Files
callstack__agent-device/scripts/layering/record-runtime-mechanics-policy.ts
Michał Pierzchała 35362fe517 refactor(cli): let help resolve command aliases itself and retire R12 (#2293)
* refactor(cli): let cli-help resolve the --help alias itself

bin.ts's --help fast path composed
buildCommandUsageText(normalizeCliCommandAlias(helpTarget)) inline, which
let a future edit call buildCommandUsageText raw without anyone noticing
until an alias's help silently dropped back to a full CLI bootstrap (the
regression #1641 fixed). Move the composition into cli-schema/cli-help.ts
as resolveHelpTargetUsageText, so bin.ts just calls one function that owns
its own alias normalization; bin.ts no longer imports the alias registry
at all.

Retargets cli-help-alias-fast-path.test.ts at the new function (same three
cases) and adds a process-level smoke test asserting `tap --help`/`launch
--help` stdout is byte-identical to `press --help`/`open --help`. Seen red
by temporarily removing the `tap` alias from CLI_COMMAND_ALIASES (both
fast and slow paths lose the alias, producing an "Unknown command: tap"
mismatch); green again after restoring it.

Verified manually: `node --experimental-strip-types src/bin.ts tap --help`
stays byte-identical to `press --help`, and `launch --help` to `open
--help`; `rotate --help` still falls through to the retired-command error.

* chore(gates): retire R12 now that cli-help owns its own alias resolution

bin.ts can no longer compose buildCommandUsageText and
normalizeCliCommandAlias incorrectly because it doesn't hold either import
any more — resolveHelpTargetUsageText in cli-schema/cli-help.ts is the only
call site, and cli-help-alias-fast-path.test.ts plus the new smoke-cli
process test pin it. The static R12 checker existed only to prove that
composition from source text; delete it along with its rule wiring in
check.ts (rule function, import, LAYERING_RULE_IDS/LAYERING_RULES entries,
header comment, summary string).

Drops scripts/layering/bin-alias-fast-path.ts (352 lines) and its test
(311 lines). Updates the two stale references left behind:
record-runtime-mechanics-policy.ts's comparison to R12's "delegate to your
single owner" shape, and check-wiring.test.ts's header, which named
bin-alias-fast-path.test.ts as the seam it protects.

rule-ids.ts discovers rule ids by scanning source text rather than a
hand-maintained list, so no entry there needed updating.

Verified: pnpm check:layering green (175/175), including
check-wiring.test.ts and rule-ids.test.ts; pnpm check:quick (lint +
typecheck) clean; scripts/__tests__/eager-closure-budgets.test.ts
(418/418) unaffected, since neither bin.ts nor cli-help.ts sits in any
HUB_ENTRY_FILES or facade closure — both files reach cli-help.ts only
through a dynamic import.

* test(cli): pin the alias help fast path with a coverage-based oracle

The byte-identical stdout test cannot fail when the fast path is bypassed:
src/cli.ts's slow path resolves the same alias and writes the identical
string, so a reintroduced hand-written table in bin.ts (the exact shape of
#1641) would still pass it. Add a second process-level test that runs
`tap`/`launch --help` and `rotate --help` with NODE_V8_COVERAGE set and reads
the subprocess's own coverage report for src/cli/process-entry.ts, the one
module runCli's slow path loads and the fast path never does.

Seen red: forcing the fast path to always fall through to runCli (simulating
the reintroduced-table bug) failed this test (bootstrappedFullCli true where
false was expected) while the byte-identical test stayed green; reverted and
confirmed both green.

* test(cli): restore an independent oracle for alias help parity

The canonical side of "alias help output matches its canonical command" also
called resolveHelpTargetUsageText, so the assertion became self-consistency:
a degenerate normalizer that maps every input to one canonical command would
make aliasHelp and canonicalHelp equal for every case. Compare
resolveHelpTargetUsageText(alias) against buildCommandUsageText(canonical)
(no alias normalization on the canonical side) instead, restoring the
original two-source oracle.

Seen red: pointing resolveHelpTargetUsageText at a degenerate
`return buildCommandUsageText('press')` failed this test
("launch --help" no longer byte-identical to "open --help"); reverted and
confirmed green.

* refactor(mcp): route the help tool through resolveHelpTargetUsageText

server-guide.ts's help tool composed
buildCommandUsageText(normalizeCliCommandAlias(topic)) inline, the same
composition bin.ts held before this PR moved it into cli-help.ts. That left
a second hand-written call site the R12 gate's own kill criterion said had
to be gone before retirement was moot. Call resolveHelpTargetUsageText(topic)
instead; behavior is unchanged (manually confirmed tap/press and rotate
topics still match) since it's the same composition, and no closure/layering
change since server-guide.ts already imports cli-help.ts statically.

* style: apply oxfmt

* test(cli): prove the help fast path for every registered alias

* refactor(cli): make the process entry importable and test it directly

bin.ts ran its dispatch at import time, so the only way to prove that an
alias --help never loads the full CLI was to spawn the process under
NODE_V8_COVERAGE and grep the report for process-entry.ts. That oracle
needed a paragraph to justify; the code was wrong, not the comment.

The dispatch now lives in src/cli/entry.ts as runEntry(argv, modules, io),
with the five lazy imports injected by bin.ts. entry.test.ts drives it with
recording loaders and the real help module: every registry alias prints its
canonical help with only the help module loaded, an unknown topic falls
through to the CLI loader, --version, bare usage, mcp, and startup failures
each have one case. The subprocess coverage machinery, the alias table pin,
and the multi-line comments are gone; the smoke test keeps one
registry-derived byte-identical alias --help check against the real bin.ts.

Seen red: hand-routing long-press and relaunch to the CLI loader inside
entry.ts failed "every registered alias prints its canonical help without
loading the CLI"; restored.
2026-09-05 20:01:21 +02:00

126 lines
4.9 KiB
TypeScript

// Catches: the daemon record owner reaching past its declared coordinators to spawn or poll a
// native process/timer directly — the same "delegate to your single owner" shape as R7,
// applied to record's runtime mechanics; a type check cannot see this because runCmd and
// setInterval are both fully typed, legal calls from anywhere.
// Evidence: 1b2e786128 (#1724) moved screen recording onto the platform runtime, the migration
// this ownership boundary protects against regressing.
// Cost: 165 LOC (112 rule + 53 test); shares rule id R16 with record-runtime-registry-policy.ts
// (137 LOC).
// Kill criterion: none enforced today; retire only by maintainer decision that the daemon record
// owner staying free of native mechanics (runCmd*/spawn*/timer calls, child kill, platform
// comparisons and switches) no longer matters. A typed capture-kit port would not replace it:
// setTimeout is a global and spawn stays importable from the owner whatever port it holds.
import { parseSync } from 'oxc-parser';
import { memberName, type ProductionSource, visitAst } from './layering-ast.ts';
const DAEMON_RECORD_MECHANIC_CALLS = new Set([
'runCmd',
'runCmdBackground',
'runCmdDetached',
'runCmdStreaming',
'runCmdSync',
'setInterval',
'setTimeout',
'spawn',
'spawnSync',
]);
/** The daemon record owner coordinates runtime operations; native mechanics stay behind owners. */
export function recordRuntimeDaemonMechanicsViolations(
sources: readonly ProductionSource[],
): string[] {
const violations: string[] = [];
for (const file of sources.filter(({ path }) => isDaemonRecordOwner(path))) {
const parsed = parseSync(file.path, file.source);
const platformAliases = collectPlatformAliases(parsed.program);
visitAst(parsed.program, (node) => {
if (node.type === 'ImportDeclaration') {
const source = node.source as Record<string, unknown> | undefined;
const imported = source?.type === 'Literal' ? String(source.value) : '';
if (isDaemonRecordMechanicImport(imported)) {
violations.push(`${file.path}: daemon record owner imports native mechanic ${imported}`);
}
}
if (node.type !== 'CallExpression') return;
const callee = node.callee as Record<string, unknown> | undefined;
if (callee?.type === 'Identifier' && DAEMON_RECORD_MECHANIC_CALLS.has(String(callee.name))) {
violations.push(
`${file.path}: daemon record owner calls native mechanic ${String(callee.name)}`,
);
}
if (callee?.type === 'MemberExpression' && memberName(callee) === 'kill') {
violations.push(`${file.path}: daemon record owner calls child termination kill`);
}
});
visitAst(parsed.program, (node) => {
if (isPlatformComparison(node, platformAliases)) {
violations.push(`${file.path}: daemon record owner contains platform comparison`);
}
if (
node.type === 'SwitchStatement' &&
isPlatformSelector(node.discriminant, platformAliases)
) {
violations.push(`${file.path}: daemon record owner contains platform switch`);
}
});
}
return violations;
}
function isDaemonRecordOwner(filePath: string): boolean {
return /^src\/daemon\/handlers\/record-runtime(?:-[^/]+)?\.ts$/.test(filePath);
}
function isDaemonRecordMechanicImport(imported: string): boolean {
return (
imported === 'node:child_process' ||
imported === 'node:fs' ||
imported.includes('/platforms/') ||
imported.includes('/provider-') ||
imported === '@agent-device/host-kit/command' ||
imported.includes('/recording/overlay')
);
}
function isPlatformComparison(
node: Record<string, unknown>,
platformAliases: ReadonlySet<string>,
): boolean {
if (node.type !== 'BinaryExpression') return false;
if (!['===', '!==', '==', '!='].includes(String(node.operator))) return false;
return (
isPlatformSelector(node.left, platformAliases) ||
isPlatformSelector(node.right, platformAliases)
);
}
function collectPlatformAliases(node: unknown): Set<string> {
const aliases = new Set<string>();
visitAst(node, (candidate) => {
if (candidate.type !== 'VariableDeclarator' || !isPlatformAccess(candidate.init)) return;
const id = candidate.id as Record<string, unknown> | undefined;
if (id?.type === 'Identifier') aliases.add(String(id.name));
});
return aliases;
}
function isPlatformSelector(node: unknown, aliases: ReadonlySet<string>): boolean {
if (isPlatformAccess(node)) return true;
return (
node !== null &&
typeof node === 'object' &&
(node as Record<string, unknown>).type === 'Identifier' &&
aliases.has(String((node as Record<string, unknown>).name))
);
}
function isPlatformAccess(node: unknown): boolean {
return (
node !== null &&
typeof node === 'object' &&
(node as Record<string, unknown>).type === 'MemberExpression' &&
memberName(node as Record<string, unknown>) === 'platform'
);
}