Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/babysit/scripts/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
3 changes: 3 additions & 0 deletions .claude/skills/babysit/scripts/scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
110 changes: 110 additions & 0 deletions scripts/ci/check-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}("<name>");`,
"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([]);",
Expand Down
160 changes: 150 additions & 10 deletions scripts/ci/check-tool-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tool>` entry (letter case ignored, as the macOS dispatcher
* does), or an alias in the root `tools` dispatcher;
Expand Down Expand Up @@ -300,20 +300,24 @@ interface Registrations {
const REGISTERING_CALLS = new Set(["command", "alias", "buildGroup"]);

/** The `.command("<word>")` 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;

Expand All @@ -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<ts.Symbol, string[]>();

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<typeof registrationValues> | 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];
Expand Down Expand Up @@ -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));
Expand All @@ -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);
}
}
}

Expand Down
Loading