Files
Michał Pierzchała 2ec4e91b11 refactor(core): move the command descriptor registry into its own workspace package (#2348)
* refactor(core): move the command descriptor registry into its own package

`src/core/command-descriptor/`, `src/command-catalog.ts`, `src/core/wait-positionals.ts`
and `src/core/parse-timeout.ts` move as git renames into a new private package
`@agent-device/command-registry` (deps: contracts, selectors). One subpath per module
points straight at the moved file; no `index.ts`, no re-export at the old path. Every
consumer switches to the owning specifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jqfa11D8QsCMuL17SsLvDz

* test(host-kit): pin the command-registry package inside the daemon code graph

The daemon reaches the registry and its catalog only by workspace specifier. A walk
that stopped at the package boundary would report an unchanged signature after a
descriptor edit, and the client would keep reusing a daemon running the superseded
policy. The manifest is asserted beside the sources because its `exports` map is what
chose them. The cache doc comment quoting the old ~800-module graph is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jqfa11D8QsCMuL17SsLvDz

* chore(gates): point the descriptor-registry gates at the package path

R66's `COMMAND_DESCRIPTOR_MODULE`, R16's record-runtime join subject and the Fallow
`AssertTrue` totality-guard key follow the registry to its package. The two descriptor
hubs leave `HUB_ENTRY_FILES` because the package manifest now publishes them, so the
eager-closure gate discovers them as facades and one entry gets one rule; this also
flips `denyPlatformImplementations` from false (hub) to true (package entry) for both,
which is intentional and stricter. `command-registry` joins the ranked spine at rank 1.

No `APPROVED_OVER_CEILING` row: rename detection carries every moved entry's merge-base
baseline, so all twelve fall under the no-growth rule rather than a ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jqfa11D8QsCMuL17SsLvDz

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00

203 lines
9.6 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import assert from 'node:assert/strict';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { runCmdSync } from '@agent-device/host-kit/command';
// Compiles every fenced ```ts snippet in website/docs/docs/client-api.md against
// agent-device's real `agent-device/*` subpath sources, so a doc snippet that no
// longer compiles — wrong property, renamed export, changed signature — fails
// CI instead of only being caught if an examples/sdk/*.ts file happens to drift
// the same way. src/__tests__/client-api-examples-drift.test.ts covers the
// doc's bullet-list API manifest against examples/sdk/*.ts; this is the
// complementary check the reviewer asked for on #1463's drift guard (checking
// the doc's actual code, not just its symbol-name manifest).
//
// Lives in the Node integration lane, not vitest's unit-core: it spawns a real
// tsc Program over a chunk of src/, which comfortably exceeds the unit suite's
// 2.5s slow-test budget (see docs/agents/testing.md).
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const CLIENT_API_DOC_PATH = path.join(repoRoot, 'website/docs/docs/client-api.md');
const EXAMPLES_SDK_TSCONFIG = path.join(repoRoot, 'examples/sdk/tsconfig.json');
const TSC_BIN = path.join(repoRoot, 'node_modules/.bin/tsc');
// Extracts every fenced ```ts code block from the doc, in document order. Each
// block is compiled standalone.
function extractTsSnippets(markdown: string): string[] {
return [...markdown.matchAll(/```ts\n([\s\S]*?)```/g)].map((match) => match[1] ?? '');
}
// examples/sdk/tsconfig.json's `paths` is the existing, CI-proven mechanism that
// resolves `agent-device/...` specifiers straight to `src/sdk/*.ts` so examples
// typecheck without a build (see that file's own comment). `tsc --showConfig`
// returns it fully resolved and comment-free, so this reads the one true copy of
// the subpath map instead of keeping a second, driftable one here.
function resolveExamplesSdkPaths(): Record<string, string[]> {
const raw = runCmdSync(TSC_BIN, ['-p', EXAMPLES_SDK_TSCONFIG, '--showConfig']).stdout;
const configDir = path.dirname(EXAMPLES_SDK_TSCONFIG);
const paths: Record<string, string[]> = {};
for (const [specifier, targets] of Object.entries(
JSON.parse(raw).compilerOptions.paths as Record<string, string[]>,
)) {
paths[specifier] = targets.map((target) => path.resolve(configDir, target));
}
return paths;
}
// Free identifiers a snippet references without declaring, because the doc's
// prose treats them as continuing from an earlier snippet (`client`,
// `androidClient`, `snapshot` — typed against the real SDK return type, not
// `any`, so continuation snippets still get meaningful checking) or as
// illustrative host-glue the doc invents on purpose (a bridge's own transport
// function, never part of the SDK). This is a closed, explicit allowlist, not
// a catch-all: any free identifier NOT listed here — including a typo of one
// that IS, like `cliet` for `client` — is left as a real "Cannot find name"
// failure. See the "rejects an unrecognized free identifier" test below for
// the regression this guards against.
const KNOWN_FREE_NAME_STUB_TYPES: Record<string, string> = {
client: `ReturnType<typeof import('agent-device').createAgentDeviceClient>`,
androidClient: `ReturnType<typeof import('agent-device').createAgentDeviceClient>`,
snapshot: `Awaited<ReturnType<ReturnType<typeof import('agent-device').createAgentDeviceClient>['capture']['snapshot']>>`,
// "Android ADB providers": the doc's own invented remote-transport glue, not
// part of agent-device — typed as the real `AndroidAdbExecutor` function
// shape so the snippet's `exec: async (args, options) => ...` still has to
// return something assignable to it.
runAdbThroughRemoteTunnel: `import('agent-device/android-adb').AndroidAdbExecutor`,
// "Batch orchestration for custom transports": the doc's own invented
// command dispatcher and error mapper.
dispatch: `(stepReq: unknown) => Promise<import('agent-device/contracts').DaemonResponseData>`,
bridgeErrorToDaemonResponse: `(error: unknown) => import('agent-device/contracts').DaemonResponse`,
};
function writeSnippetProgram(snippets: string[]): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'client-api-doc-snippets-'));
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"type":"module"}\n');
fs.writeFileSync(
path.join(tmpDir, 'tsconfig.json'),
JSON.stringify({
extends: path.join(repoRoot, 'tsconfig.json'),
compilerOptions: {
typeRoots: [path.join(repoRoot, 'node_modules/@types')],
// Doc snippets are illustrative fragments, not production code: an
// unused destructured result is normal and not a real defect here.
noUnusedLocals: false,
noUnusedParameters: false,
paths: resolveExamplesSdkPaths(),
},
// packages/command-registry/src/global.d.ts declares build-time ambient globals
// (e.g. __OWNER_FILES__) that the descriptor registry references;
// examples/sdk/tsconfig.json includes it for the same reason.
include: ['*.ts', path.join(repoRoot, 'packages/command-registry/src/global.d.ts')],
}),
);
snippets.forEach((code, index) => {
// `export {}` forces module scope so each snippet's declarations (and any
// stubs injected below) can't collide with another snippet's globals.
fs.writeFileSync(path.join(tmpDir, `snippet-${index}.ts`), `export {};\n${code}`);
});
return tmpDir;
}
function runTsc(tmpDir: string): string {
const result = runCmdSync(
TSC_BIN,
['--noEmit', '-p', path.join(tmpDir, 'tsconfig.json'), '--pretty', 'false'],
{ cwd: tmpDir, allowFailure: true },
);
return `${result.stdout}${result.stderr}`;
}
// Finds every "Cannot find name 'X'" diagnostic where `X` is in
// KNOWN_FREE_NAME_STUB_TYPES, grouped by snippet file. Any other free name —
// anything not in that explicit allowlist — is deliberately left out here, so
// its diagnostic survives untouched into the final output and fails the
// check. A missing/renamed *import* binding is a different diagnostic code
// and is never matched by this regex either way.
function findKnownFreeNamesByFile(tscOutput: string): Map<string, Set<string>> {
const freeNamesByFile = new Map<string, Set<string>>();
for (const line of tscOutput.split('\n')) {
const match =
/^(?:.*[/\\])?(snippet-\d+\.ts)\(\d+,\d+\): error TS2304: Cannot find name '([^']+)'/.exec(
line,
);
const name = match?.[2];
if (match?.[1] && name && name in KNOWN_FREE_NAME_STUB_TYPES) {
const names = freeNamesByFile.get(match[1]) ?? new Set<string>();
names.add(name);
freeNamesByFile.set(match[1], names);
}
}
return freeNamesByFile;
}
// One auto-stub pass: declare each known free name at the top of its snippet
// file (typed per KNOWN_FREE_NAME_STUB_TYPES) and recompile.
function stubFreeNamesAndRecompile(tmpDir: string, firstPassOutput: string): string {
const freeNamesByFile = findKnownFreeNamesByFile(firstPassOutput);
if (freeNamesByFile.size === 0) return firstPassOutput;
for (const [fileName, names] of freeNamesByFile) {
const filePath = path.join(tmpDir, fileName);
const stubs = [...names]
.map((name) => `declare const ${name}: ${KNOWN_FREE_NAME_STUB_TYPES[name]};`)
.join('\n');
fs.writeFileSync(
filePath,
fs.readFileSync(filePath, 'utf8').replace('export {};\n', `export {};\n${stubs}\n`),
);
}
return runTsc(tmpDir);
}
function compileDocSnippets(snippets: string[]): string[] {
const tmpDir = writeSnippetProgram(snippets);
try {
const output = stubFreeNamesAndRecompile(tmpDir, runTsc(tmpDir));
return output.split('\n').filter((line) => /error TS\d+:/.test(line));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
test('recognizes known free names when TypeScript prints a relative snippet path', () => {
const freeNames = findKnownFreeNamesByFile(
"../../../../tmp/client-api-doc-snippets/snippet-3.ts(2,7): error TS2304: Cannot find name 'client'.",
);
assert.deepEqual([...(freeNames.get('snippet-3.ts') ?? [])], ['client']);
});
test("every fenced ```ts snippet in client-api.md compiles against agent-device's real exports", () => {
const snippets = extractTsSnippets(fs.readFileSync(CLIENT_API_DOC_PATH, 'utf8'));
assert.ok(
snippets.length > 0,
`${CLIENT_API_DOC_PATH} has no fenced \`\`\`ts snippets to check.`,
);
const failures = compileDocSnippets(snippets);
assert.deepEqual(
failures,
[],
`A \`\`\`ts snippet in ${CLIENT_API_DOC_PATH} no longer compiles against agent-device's real ` +
`exports:\n${failures.join('\n')}\n` +
'Fix the snippet to match the current API, or fix the actual exported contract if the doc ' +
'was right and the API regressed.',
);
});
test('rejects an unrecognized free identifier instead of silently stubbing it (e.g. a typo of `client`)', () => {
const failures = compileDocSnippets([
"import { createAgentDeviceClient } from 'agent-device';\n\n" +
"const client = createAgentDeviceClient({ session: 'qa-ios' });\n" +
"await cliet.apps.open({ app: 'com.example.app', platform: 'ios' });\n",
]);
assert.ok(
failures.some((line) => line.includes("Cannot find name 'cliet'")),
`Expected a "Cannot find name 'cliet'" failure for an unrecognized free identifier, got: ` +
`${JSON.stringify(failures)}. If this fails, stubFreeNamesAndRecompile is stubbing names ` +
'outside its explicit allowlist again.',
);
});