diff --git a/.claude/skills/babysit/scripts/integrate.ts b/.claude/skills/babysit/scripts/integrate.ts index 8834659264..f65f1f2b1a 100644 --- a/.claude/skills/babysit/scripts/integrate.ts +++ b/.claude/skills/babysit/scripts/integrate.ts @@ -127,6 +127,12 @@ function gateSnapshot(options: { wt: string; sha: string; runDir: string }): str continue; } + // A dangling link (a `.bin` entry whose package is gone) has nothing to pin, and realpath throws on it. + if (!existsSync(input)) { + console.log(`gates: skipped the dangling dependency link ${relative(workerRoot, input)}`); + continue; + } + const target = realpathSync(input); const local = relative(workerRoot, target); const workspace = diff --git a/.claude/skills/babysit/scripts/scripts.test.ts b/.claude/skills/babysit/scripts/scripts.test.ts index e1b0cc2349..306164d364 100644 --- a/.claude/skills/babysit/scripts/scripts.test.ts +++ b/.claude/skills/babysit/scripts/scripts.test.ts @@ -3116,6 +3116,9 @@ describe("round 15 review regressions", () => { fs.mkdirSync(installed); fs.writeFileSync(join(installed, "fixture.txt"), "trusted dependency"); fs.symlinkSync(installed, join(modules, "fixture-dependency")); + // A `.bin` link whose package is gone must not stop the gate snapshot (it did: ENOENT from realpath). + fs.mkdirSync(join(modules, ".bin")); + fs.symlinkSync("../gone/bin/gone.mjs", join(modules, ".bin/gone")); fs.writeFileSync(join(dir, "run.json"), SafeJSON.stringify({ repo: "genesiscz/GenesisTools", main: wt })); const preload = join(dir, "preload.ts"); const gateCwd = join(dir, "gate-cwd.txt"); diff --git a/scripts/ci/check-tool-commands.test.ts b/scripts/ci/check-tool-commands.test.ts index 54df1f9e0a..a4b306ab4e 100644 --- a/scripts/ci/check-tool-commands.test.ts +++ b/scripts/ci/check-tool-commands.test.ts @@ -147,6 +147,116 @@ describe("checkTree", () => { expect(errors[0]).toContain("src/other/index.ts:2"); }); + // `tools agents hooks mods enable|disable` registers both words in one loop. + test("accepts the words of a const that is a choice of two literals, and still rejects others", () => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": + 'for (const on of [true, false]) {\n const verb = on ? "enable" : "disable";\n program.command(verb);\n}', + "src/other/index.ts": 'toolCommand("foo enable");\ntoolCommand("foo disable");\ntoolCommand("foo toggle");', + }); + + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("src/other/index.ts:3"); + }); + + test.each([ + 'const verb = "enable" as const; program.command(verb);', + 'const verb = (on ? ("enable" as const) : ("disable" as const)); program.command(verb);', + 'program.command(("enable" as const));', + ])("unwraps literal registrations: %s", (registration) => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": registration, + "src/other/index.ts": 'toolCommand("foo enable"); toolCommand("foo gone");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "gone"'); + }); + + test.each([ + 'let verb = "enable"; verb = "disable"; program.command(verb);', + 'let verb = "enable"; if (on) { verb = "disable"; } program.command(verb);', + 'let verb = "enable"; function update() { verb = "disable"; } program.command(verb);', + ])("tracks literal writes to the same binding: %s", (registration) => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": registration, + "src/other/index.ts": 'toolCommand("foo enable"); toolCommand("foo disable"); toolCommand("foo gone");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "gone"'); + }); + + test.each([ + 'let verb = "enable"; verb = dynamic; program.command(verb);', + 'let verb = "enable"; verb += "d"; program.command(verb);', + 'let verb = "enable"; ({verb} = dynamic); program.command(verb);', + 'let verb = "enable"; [verb] = dynamic; program.command(verb);', + 'let verb = "enable"; for (verb of dynamic) {} program.command(verb);', + 'const verb = on ? "enable" : dynamic; program.command(verb);', + ])("does not infer a registration after a dynamic write: %s", (registration) => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": `program.command("status"); ${registration}`, + "src/other/index.ts": 'toolCommand("foo status"); toolCommand("foo enable");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "enable"'); + }); + + test.each(["argument", "arguments"])("resolves literal choices for chained .%s()", (method) => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": `const verb = on ? "enable" : "disable"; program.command((verb)).description("toggle").${method}("");`, + "src/other/index.ts": + 'toolCommand("foo enable alice"); toolCommand("foo disable bob"); toolCommand("foo gone alice");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "gone"'); + }); + + test.each([ + 'function a() { const verb = "removed"; } function b(verb: string) { program.command(verb); }', + 'const verb = "removed"; { const verb = dynamic; program.command(verb); }', + 'const verb = "removed"; function b({verb}: Options) { program.command(verb); }', + 'const verb = "removed"; try {} catch (verb) { program.command(verb); }', + 'const verb = "removed"; { program.command(verb); const verb = dynamic; }', + ])("does not leak registrations across lexical bindings: %s", (registration) => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": `program.command("status"); ${registration}`, + "src/other/index.ts": 'toolCommand("foo status"); toolCommand("foo removed");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "removed"'); + }); + + test("keeps outer bindings after leaving a block and ignores writes to a shadow", () => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": + 'const verb = "enable"; { let verb = "unused"; verb = dynamic; } function b() { program.command(verb); }', + "src/other/index.ts": 'toolCommand("foo enable"); toolCommand("foo unused");', + }); + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no command "unused"'); + }); + test("accepts a command registered in a @genesiscz/utils module the tool imports", () => { const root = tree({ tools: "const TOOL_ALIASES = new Map([]);", diff --git a/scripts/ci/check-tool-commands.ts b/scripts/ci/check-tool-commands.ts index 4a0112b112..5fc8944d86 100644 --- a/scripts/ci/check-tool-commands.ts +++ b/scripts/ci/check-tool-commands.ts @@ -6,7 +6,7 @@ * `suggestCommand("tools macos", { replaceCommand: ["permissions", "build"] })`. A plain string such * as "Run `tools macos permissions build`" kept pointing at a command after it was renamed, and * nothing noticed until a user typed it. This guard parses every source file (the TypeScript syntax - * API, no type checker) and checks each named command against the tree: + * API, with an isolated binding checker that never resolves imports) and checks each named command against the tree: * * - the first word is a tool: a `src/` entry (letter case ignored, as the macOS dispatcher * does), or an alias in the root `tools` dispatcher; @@ -300,20 +300,24 @@ interface Registrations { const REGISTERING_CALLS = new Set(["command", "alias", "buildGroup"]); /** The `.command("")` a chained call such as `.argument(…)` hangs off, if any. */ -function chainedCommandWord(expression: ts.Expression): string | undefined { - let current = unwrap(expression); +function chainedCommandWords(options: { + expression: ts.Expression; + valuesOf: (node: ts.Expression | undefined) => string[]; +}): string[] { + let current = unwrap(options.expression); while (ts.isCallExpression(current) || ts.isPropertyAccessExpression(current)) { if (ts.isCallExpression(current) && calleeName(current) === "command") { - return stringValue(current.arguments[0])?.split(/\s+/)[0]; + return options.valuesOf(current.arguments[0]).map((value) => value.split(/\s+/)[0]); } current = unwrap(current.expression); } - return undefined; + return []; } +/** Whether a lazy registrar imports a module anywhere in its body. */ function hasDynamicImport(node: ts.Node): boolean { let found = false; @@ -326,14 +330,148 @@ function hasDynamicImport(node: ts.Node): boolean { return found; } +/** The static identifier or string key of a command registry entry. */ function propertyKey(name: ts.PropertyName): string | undefined { return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; } +/** The string literals a value can be: a literal, or both arms of a `cond ? "a" : "b"` of literals. */ +function literalValues(node: ts.Expression | undefined): string[] { + const inner = node ? unwrap(node) : undefined; + const value = stringValue(inner); + + if (value !== undefined) { + return [value]; + } + + if (inner && ts.isConditionalExpression(inner)) { + const whenTrue = stringValue(unwrap(inner.whenTrue)); + const whenFalse = stringValue(unwrap(inner.whenFalse)); + + if (whenTrue !== undefined && whenFalse !== undefined) { + return [whenTrue, whenFalse]; + } + } + + return []; +} + +/** Resolve local bindings without reading imports, libraries, tsconfig, or checking diagnostics. */ +function registrationValues(source: ts.SourceFile): (node: ts.Expression | undefined) => string[] { + const host: ts.CompilerHost = { + getSourceFile: (file) => (file === source.fileName ? source : undefined), + getDefaultLibFileName: () => "", + writeFile: () => {}, + getCurrentDirectory: () => "", + getDirectories: () => [], + fileExists: (file) => file === source.fileName, + readFile: () => undefined, + getCanonicalFileName: (file) => file, + useCaseSensitiveFileNames: () => true, + getNewLine: () => "\n", + }; + const program = ts.createProgram([source.fileName], { noLib: true, noResolve: true }, host); + const checker = program.getTypeChecker(); + const bindings = new Map(); + + walk(source, (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const symbol = checker.getSymbolAtLocation(node.name); + + if (symbol) { + const values = literalValues(node.initializer); + const previous = bindings.get(symbol); + bindings.set( + symbol, + previous ? (previous.length && values.length ? [...previous, ...values] : []) : values + ); + } + } + }); + + // Union literal writes to a binding; any dynamic or compound write invalidates inference for that binding. + const recordWrite = (target: ts.Expression, values: string[]): void => { + const inner = unwrap(target); + + if (ts.isIdentifier(inner)) { + const symbol = ts.isShorthandPropertyAssignment(inner.parent) + ? checker.getShorthandAssignmentValueSymbol(inner.parent) + : checker.getSymbolAtLocation(inner); + const previous = symbol && bindings.get(symbol); + + if (symbol && previous) { + bindings.set(symbol, previous.length && values.length ? [...previous, ...values] : []); + } + } else if (ts.isArrayLiteralExpression(inner)) { + for (const element of inner.elements) { + recordWrite(element, []); + } + } else if (ts.isObjectLiteralExpression(inner)) { + for (const property of inner.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + recordWrite(property.name, []); + } else if (ts.isPropertyAssignment(property)) { + recordWrite(property.initializer, []); + } else if (ts.isSpreadAssignment(property)) { + recordWrite(property.expression, []); + } + } + } else if (ts.isSpreadElement(inner)) { + recordWrite(inner.expression, []); + } else if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + recordWrite(inner.left, []); + } + }; + + walk(source, (node) => { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + recordWrite( + node.left, + node.operatorToken.kind === ts.SyntaxKind.EqualsToken ? literalValues(node.right) : [] + ); + } else if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken) + ) { + recordWrite(node.operand, []); + } else if ( + (ts.isForInStatement(node) || ts.isForOfStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) + ) { + recordWrite(node.initializer, []); + } + }); + + return (node) => { + const inner = node && unwrap(node); + + if (inner && ts.isIdentifier(inner)) { + const symbol = checker.getSymbolAtLocation(inner); + + return (symbol && bindings.get(symbol)) ?? []; + } + + return literalValues(node); + }; +} + /** Records the command words a source file registers; returns the modules it imports, static and dynamic. */ function scanSourceFile(path: string, into: Registrations): string[] { const { source } = parseSource(path, readFileSync(path, "utf8")); const modules: string[] = []; + let resolveBinding: ReturnType | undefined; + const valuesOf = (node: ts.Expression | undefined): string[] => { + if (node && ts.isIdentifier(unwrap(node))) { + resolveBinding ??= registrationValues(source); + return resolveBinding(node); + } + + return literalValues(node); + }; const add = (declared: string | undefined): void => { const word = declared?.split(/\s+/)[0]; @@ -373,7 +511,9 @@ function scanSourceFile(path: string, into: Registrations): string[] { const [first] = node.arguments; if (name && REGISTERING_CALLS.has(name)) { - add(stringValue(first)); + for (const value of valuesOf(first)) { + add(value); + } } else if (name === "aliases" && first && ts.isArrayLiteralExpression(first)) { for (const element of first.elements) { add(stringValue(element)); @@ -382,10 +522,10 @@ function scanSourceFile(path: string, into: Registrations): string[] { (name === "argument" || name === "arguments") && ts.isPropertyAccessExpression(node.expression) ) { - const word = chainedCommandWord(node.expression.expression); - - if (word) { - into.takesArguments.add(word); + for (const word of chainedCommandWords({ expression: node.expression.expression, valuesOf })) { + if (COMMAND_WORD.test(word)) { + into.takesArguments.add(word); + } } }