Skip to content

Commit 239b705

Browse files
committed
types(mcp): narrow the CLI's closed-set guards, and discover the contract's modules (#9773)
Two things the merge with #9762 exposed, now that the action-class and autonomy-level lists are readonly literal tuples rather than `string[]`: - The CLI validated `<action>` and `<level>` with `LIST.includes(value)` and then passed the still- `string` value to a typed request. `includes` returns a boolean and narrows nothing, so the check ran and the type system learned nothing from it. `isOneOf` is the same check written as a type predicate, so a validated value arrives at the API as the union it was just proved to be. - The generator resolved a copied schema's constants against a hardcoded pair of contract modules. That is a hand-maintained list by another name, and it fails in the quietest way available: a constant that moves between modules yields a generated file referencing a name it never imported. It now reads the contract's source directory, so a constant can move -- or a module can appear -- without this script knowing anything about it. Regression test pins the discovery against wherever PUBLIC_SURFACE_SKIP_REASONS lives, rather than against the module it happens to live in today.
1 parent 6cd6fa3 commit 239b705

4 files changed

Lines changed: 60 additions & 12 deletions

File tree

packages/loopover-contract/src/api-schemas.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { z } from "zod";
1010

1111
import { checkBeforeStartSchema, slopRiskSchema, validateFocusManifestSchema, validateLinkedIssueSchema } from "./api-requests.js";
12+
import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS } from "./enums.js";
1213
import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "./limits.js";
1314

1415
export const FindingSchema = z
@@ -581,8 +582,8 @@ export const RepositorySettingsSchema = z
581582
// exact drift class #9517's enum notes warned about, republished here. The compile-time parity
582583
// assertion in src/openapi/schema-type-parity.ts is what finally caught it.
583584
autonomy: z.partialRecord(
584-
z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"]),
585-
z.enum(["observe", "auto_with_approval", "auto"]),
585+
z.enum(AGENT_ACTION_CLASSES),
586+
z.enum(AUTONOMY_LEVELS),
586587
),
587588
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
588589
agentPaused: z.boolean().optional(),

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2801,7 +2801,7 @@ export async function maintainCli(args: readonly string[]) {
28012801
if (!actionClass || !pullArg) {
28022802
throw new Error("Usage: loopover-mcp maintain propose <action-class> <pull-number> --repo owner/repo [--reason ...] [--label ...] [--review-body ...] [--merge-method merge|squash|rebase] [--close-comment ...].");
28032803
}
2804-
if (!PROPOSE_ACTION_CLASSES.includes(actionClass)) throw new Error(`Unknown action class: ${actionClass}. Use ${PROPOSE_ACTION_CLASSES.join(", ")}.`);
2804+
if (!isOneOf(PROPOSE_ACTION_CLASSES, actionClass)) throw new Error(`Unknown action class: ${actionClass}. Use ${PROPOSE_ACTION_CLASSES.join(", ")}.`);
28052805
const pullNumber = Number(pullArg);
28062806
if (!Number.isInteger(pullNumber) || pullNumber <= 0) throw new Error(`Invalid pull number: ${pullArg}. Pass a positive integer.`);
28072807
const payload = await apiPost(
@@ -2824,8 +2824,8 @@ export async function maintainCli(args: readonly string[]) {
28242824
const action = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
28252825
const level = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
28262826
if (!action || !level) throw new Error("Usage: loopover-mcp maintain set-level <action> <level> --repo owner/repo.");
2827-
if (!MAINTAIN_ACTION_CLASSES.includes(action)) throw new Error(`Unknown action: ${action}. Use ${MAINTAIN_ACTION_CLASSES.join(", ")}.`);
2828-
if (!MAINTAIN_AUTONOMY_LEVELS.includes(level)) throw new Error(`Unknown level: ${level}. Use ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}.`);
2827+
if (!isOneOf(MAINTAIN_ACTION_CLASSES, action)) throw new Error(`Unknown action: ${action}. Use ${MAINTAIN_ACTION_CLASSES.join(", ")}.`);
2828+
if (!isOneOf(MAINTAIN_AUTONOMY_LEVELS, level)) throw new Error(`Unknown level: ${level}. Use ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}.`);
28292829
// Read-merge-write so one class is updated without clearing the others.
28302830
const current = await apiGet(`${repoBase}/settings`);
28312831
const autonomy = { ...(current.autonomy ?? {}), [action]: level };
@@ -4368,6 +4368,17 @@ function printProfileHelp() {
43684368
process.stdout.write(printableUsage("profile"));
43694369
}
43704370

4371+
/**
4372+
* Whether a user-supplied string is one of a closed set (#9773).
4373+
*
4374+
* A TYPE PREDICATE, so the value narrows for whatever it is passed to next. `list.includes(value)` returns
4375+
* a boolean and narrows nothing, which is why an action class or autonomy level still arrived at the API
4376+
* as a plain `string` -- the check ran, and the type system learned nothing from it.
4377+
*/
4378+
function isOneOf<const T extends readonly string[]>(list: T, value: string): value is T[number] {
4379+
return (list as readonly string[]).includes(value);
4380+
}
4381+
43714382
/**
43724383
* An option's value as text (#9773).
43734384
*

scripts/gen-contract-api-schemas.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
// by construction -- and the contract copy is generated with the names stripped, which the CLI does not
1818
// need: it only parses and infers. `--check` in test:ci is what makes the copy safe, exactly like
1919
// gen-selfhost-env-reference.ts and gen-command-reference.ts.
20-
import { readFileSync, writeFileSync } from "node:fs";
20+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
2121
import { fileURLToPath } from "node:url";
2222

2323
const SOURCE = "src/openapi/schemas.ts";
@@ -310,11 +310,7 @@ export function renderApiSchemas(sourceText: string, documentText: string, binSo
310310
const staticCalls = cliApiCalls(binSource);
311311
const byRequest = new Map([...requestSchemaByCall(document, staticCalls), ...requestSchemaByCall(document, parameterisedCalls)]);
312312
const blocks = closure(parseSchemaBlocks(sourceText), [...new Set([...byPath.values(), ...byPattern.values(), ...byRequest.values()])]);
313-
const exportsByModule = new Map<string, ReadonlySet<string>>([
314-
["./limits.js", new Set(exportedNames(readModule("packages/loopover-contract/src/limits.ts")))],
315-
["./api-requests.js", new Set(exportedNames(readModule("packages/loopover-contract/src/api-requests.ts")))],
316-
]);
317-
const externals = referencedExternals(blocks, exportsByModule);
313+
const externals = referencedExternals(blocks, contractModuleExports());
318314
const body = blocks
319315
.map((block) =>
320316
block.source
@@ -426,16 +422,39 @@ export type ParameterisedApiResponse<Method extends string, Path extends string>
426422
>;
427423
`;
428424

425+
const CONTRACT_SRC = "packages/loopover-contract/src";
426+
427+
/**
428+
* What every sibling contract module exports, DISCOVERED rather than listed (#9773).
429+
*
430+
* A hardcoded module list is a hand-maintained list by another name, and it fails in the quietest way
431+
* there is: `PUBLIC_SURFACE_SKIP_REASONS` moved between two contract modules, and a generator that only
432+
* knew about the module it left would have emitted a file referencing a name it never imported -- valid
433+
* output, broken build. Reading the directory means a constant can move, or a new module can appear,
434+
* without this script knowing anything about it.
435+
*
436+
* `index.ts` is excluded because it re-exports the generated file (importing it back would be a cycle),
437+
* and the generated file itself because a schema cannot import its own copy.
438+
*/
439+
export function contractModuleExports(): Map<string, ReadonlySet<string>> {
440+
const modules = listModules(CONTRACT_SRC)
441+
.filter((file) => file.endsWith(".ts") && file !== "index.ts" && file !== "api-schemas.ts")
442+
.sort();
443+
return new Map(modules.map((file) => [`./${file.replace(/\.ts$/, ".js")}`, new Set(exportedNames(readModule(`${CONTRACT_SRC}/${file}`)))]));
444+
}
445+
429446
/** The names a module exports, for resolving what a copied schema references. */
430447
export function exportedNames(source: string): string[] {
431448
return [...source.matchAll(/^export (?:const|function|type) ([A-Za-z_][A-Za-z0-9_]*)/gm)].map((match) => match[1]!);
432449
}
433450

434451
let readModule: (path: string) => string = (path) => readFileSync(path, "utf8");
452+
let listModules: (dir: string) => string[] = (dir) => readdirSync(dir);
435453

436-
export function generate(deps: { readFile?: (path: string) => string } = {}): string {
454+
export function generate(deps: { readFile?: (path: string) => string; listDir?: (dir: string) => string[] } = {}): string {
437455
const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf8"));
438456
readModule = readFile;
457+
listModules = deps.listDir ?? ((dir: string) => readdirSync(dir));
439458
return renderApiSchemas(readFile(SOURCE), readFile(OPENAPI_DOCUMENT), readFile(CLI_BIN));
440459
}
441460

test/unit/mcp-api-client.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
closure,
99
declaredPathShapes,
1010
parseSchemaBlocks,
11+
contractModuleExports,
12+
referencedExternals,
1113
referencedLimits,
1214
responseSchemaByCall,
1315
responseSchemaByPath,
@@ -149,6 +151,21 @@ describe("parameterised response schemas (#9773)", () => {
149151
expect(referencedLimits(blocks), "one declared elsewhere is imported").toContain("MAX_REVIEW_NAG_COOLDOWN_DAYS");
150152
});
151153

154+
it("discovers which contract module a referenced constant lives in, so it may move", () => {
155+
// A hardcoded module list fails silently when a constant relocates: PUBLIC_SURFACE_SKIP_REASONS moved
156+
// between two contract modules, and a generator that still only knew the old one would have emitted a
157+
// file referencing a name it never imported -- valid TypeScript, broken build.
158+
const modules = contractModuleExports();
159+
expect([...modules.keys()], "index.js would import this file back").not.toContain("./index.js");
160+
expect([...modules.keys()], "the generated file cannot import its own copy").not.toContain("./api-schemas.js");
161+
162+
const owner = [...modules].find(([, names]) => names.has("PUBLIC_SURFACE_SKIP_REASONS"));
163+
expect(owner?.[0], "wherever it lives today, it is found there").toMatch(/^\.\/[a-z-]+\.js$/);
164+
expect(referencedExternals([{ name: "XSchema", exported: true, source: "const XSchema = z.enum(PUBLIC_SURFACE_SKIP_REASONS);" }], modules).get(owner![0])).toEqual([
165+
"PUBLIC_SURFACE_SKIP_REASONS",
166+
]);
167+
});
168+
152169
it("does not mistake a capitalised word in prose for a constant", () => {
153170
// The first cut scanned comments too and emitted an import for DELETE, REQUIRED, REST and friends.
154171
expect(referencedLimits([{ name: "XSchema", exported: true, source: '// DELETE and REQUIRED and REST\nconst XSchema = z.string();' }])).toEqual([]);

0 commit comments

Comments
 (0)