Files
Michał Pierzchała 6db1a4270f fix(android): report the clip an Android recording really captured (#2566)
* fix(android): report the clip an Android recording really captured

Android `screenrecord` encodes a frame only when the screen changes, so a window that ends on an
unchanged screen returns a video far shorter than the requested duration, and `record stop` had
nothing to say about it: the reported `durationMs` is host wall clock from `record start` until the
export finished, which is not the length of the file that was just pulled.

`record stop` now measures the pulled MP4 timelines and reports them as `capturedDurationMs`, and
warns with the clip length against the window when the video is two or more seconds short. The
window is measured on the device's own elapsed clock, read before the stop signal and at launch,
because host wall clock drifts against the clock the encoder timestamps frames with; an unreadable
clock or a chunk that answers no duration costs the caller the claim, never the recording.

Measuring the timeline needed an ISO-BMFF box walk, which now lives in
`@agent-device/capture-kit/recording-mp4-duration` and replaces the private top-level atom scan
that MP4 container detection was doing. Stop replay through daemon recovery carries the field too,
so a completion read back from the session resource reports the same numbers it did live.

* chore(gates): enumerate the capture-kit MP4 subpaths the layering scan holds

`@agent-device/capture-kit/recording-mp4-duration` and its fixture sibling are new declared package
subpaths, so the boundary enumeration that holds every exported workspace subpath has to name them
for the layering scan to accept the Android recorder's read of a pulled clip's timeline.

* fix(capture-kit): evaluate the MP4 box scan only when a file is validated

The Coverage job's ADR-0019 eager-closure probe failed: `recording/video.ts` evaluated 25 modules on
import where the merge-base evaluated 24, because the MP4 container gate statically imported the box
walk it now shares with the clip-duration read, and `recording/overlay.ts` grew by the same module.
An entry the merge-base already carries gets no growth budget, so the edge moves behind a
function-scoped `await import`: the scan is something recording completion asks for, and importing
this module for `waitForStableFile` or WebM detection should not evaluate a box walker.

The alternative the probe offered -- hosting the walker in a module both growing entries already
evaluate -- would have put an ISO-BMFF walk in `swift-cache.ts` or `video-webm.ts`, or made the
duration read import the Swift validator machinery that sits behind `video.ts`.

* refactor(android): bracket the recording window with the host clock

Human review of #2566: the device-clock read defended against host-vs-encoder drift that does not
matter at this threshold. Quartz drifts by tens of ppm, so a 30-minute chunked recording moves the
window under 100 ms against a 2s warning threshold, while the read cost a transport operation, its
own probe budget, and two adb round trips per recording. The window is now the host elapsed time
between `Date.now()` immediately before the recorder launches and `Date.now()` immediately before the
stop signal, so the contract change and the extra device I/O are gone, and the one case where the
clocks genuinely diverge -- a host that sleeps mid-recording -- reports a shorter window and misses
the warning rather than inventing one.

The surviving clock arithmetic is one subtraction, so it lives in the window module that already owns
that concern instead of a module of its own. A stop recovered through daemon recovery now passes the
manifest's own start instant, which is the first host timestamp the recording ever had, so a recovered
stop gets the same comparison a live stop gets.
2026-09-14 13:54:30 +02:00

865 lines
40 KiB
TypeScript

// R11 package-boundaries fires on every bypass and holds on the one exception.
// Necessary for the same reason as zone-policy.test.ts: the real tree is clean,
// so a rule that stopped matching would look exactly like a rule being obeyed.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { listSourceFiles } from './check.ts';
import { readDirectNamedExports, readNamedExports, readReExportSources } from './facade-exports.ts';
import {
checkPackageBoundaries,
facadeEntryFiles,
checkPackageInternalSites,
checkRootSites,
readWorkspacePackages,
rootExternalDependencyRanges,
rootWorkspaceDependencyNames,
specifierSites,
type WorkspacePackage,
} from './package-boundaries.ts';
import { listTrackedTypeScriptFiles } from './tracked-sources.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const kernel: WorkspacePackage = {
dir: 'packages/kernel',
name: '@agent-device/kernel',
exportTargets: new Map([
['@agent-device/kernel/errors', 'packages/kernel/src/errors.ts'],
['@agent-device/kernel/device', 'packages/kernel/src/device.ts'],
]),
workspaceDependencies: new Set(),
externalDependencies: new Map(),
};
const contracts: WorkspacePackage = {
dir: 'packages/contracts',
name: '@agent-device/contracts',
exportTargets: new Map([
['@agent-device/contracts/interaction', 'packages/contracts/src/interaction.ts'],
]),
workspaceDependencies: new Set(['@agent-device/kernel']),
externalDependencies: new Map(),
};
const ALL = [kernel, contracts];
function rules(violations: { rule: string }[]): string[] {
return violations.map((violation) => violation.rule);
}
/**
* The manifest is its own inventory for two checks: every `exports` target must resolve to an
* existing, TRACKED source file, and every manifest entry must have produced exactly one
* `exportTargets` entry (nothing dropped, nothing collapsed by a duplicate key). Neither check
* catches the export SURFACE itself widening or shrinking -- `pkg.exportTargets` and
* `manifest.exports` are read from the same file, so adding or removing a subpath moves both
* counts together and the equality holds regardless (#2297 review). `snapshotFile`, an
* independently committed baseline regenerated by `generate-contracts-exports-snapshot.ts`,
* restores that guarantee: it changes only when a contributor deliberately reruns the generator
* and reviews the diff, so a subpath added or removed without doing so fails here.
*/
function assertExportTargetsMatchManifest(
pkg: WorkspacePackage,
manifestFile: string,
snapshotFile: string,
): void {
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as {
exports?: Record<string, unknown>;
};
const trackedSources = new Set(listTrackedTypeScriptFiles(repoRoot));
for (const [specifier, target] of pkg.exportTargets) {
assert.ok(
trackedSources.has(target) && fs.existsSync(path.join(repoRoot, target)),
`${specifier} -> ${target} must resolve to an existing, tracked file`,
);
}
assert.equal(
pkg.exportTargets.size,
Object.keys(manifest.exports ?? {}).length,
`${pkg.name} exports map entries must each produce one resolved export target`,
);
const snapshot = JSON.parse(
fs.readFileSync(path.join(repoRoot, snapshotFile), 'utf8'),
) as string[];
assert.deepEqual(
[...pkg.exportTargets.keys()].sort(),
[...snapshot].sort(),
`${pkg.name}'s export surface no longer matches ${snapshotFile} -- regenerate it with ` +
`'node --experimental-strip-types scripts/layering/generate-contracts-exports-snapshot.ts' ` +
'and review the diff',
);
}
test('specifier sites carry 1-based lines for static and dynamic imports', () => {
const sites = specifierSites(
'src/a.ts',
["import { x } from './b.ts';", '', "void import('../c.ts');"].join('\n'),
);
assert.deepEqual(
sites.map(({ specifier, line }) => `${line}:${specifier}`),
['1:./b.ts', '3:../c.ts'],
);
});
test('readWorkspacePackages reads tracked manifests only', () => {
// R11's own committed-state property, and the source-level half of the #1965 review finding.
// `readWorkspacePackages` used to enumerate `packages/` with `readdirSync`, so an uncommitted
// scratch package contributed a name, export targets, and dependency edges to every rule built
// on it — R11 could fail on work a contributor had not committed. Filtering the OUTPUT of
// façade discovery hides that from one caller; this closes it for all of them.
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'package-boundaries-tracked-manifests-'));
const committed = path.join(repo, 'packages/committed');
fs.mkdirSync(path.join(committed, 'src'), { recursive: true });
fs.writeFileSync(
path.join(committed, 'package.json'),
JSON.stringify({ name: '@agent-device/committed', exports: { '.': './src/index.ts' } }),
);
fs.writeFileSync(path.join(committed, 'src/index.ts'), 'export const a = 1;\n');
execFileSync('git', ['init', '-q'], { cwd: repo });
execFileSync('git', ['add', '.'], { cwd: repo });
execFileSync(
'git',
['-c', 'user.name=Gate', '-c', 'user.email=gate@example.test', 'commit', '-qm', 'base'],
{ cwd: repo },
);
const scratch = path.join(repo, 'packages/scratch');
fs.mkdirSync(path.join(scratch, 'src'), { recursive: true });
fs.writeFileSync(
path.join(scratch, 'package.json'),
JSON.stringify({
name: '@agent-device/scratch',
exports: { '.': './src/index.ts' },
dependencies: { '@agent-device/committed': 'workspace:*' },
}),
);
fs.writeFileSync(path.join(scratch, 'src/index.ts'), 'export const b = 2;\n');
const names = readWorkspacePackages(repo).map((pkg) => pkg.name);
assert.deepEqual(names, ['@agent-device/committed']);
assert.ok(
!names.includes('@agent-device/scratch'),
'an uncommitted package directory is not part of the committed state R11 describes',
);
});
test('every workspace package façade names its exports explicitly (no bare `export *`)', () => {
// #1574 built a hand-maintained pin table (`facade-symbols.ts`, 816 symbols across every
// workspace-package façade) plus a ~200-line star-chain resolver (`readFacadeExports`) whose
// entire job was enumerating what `export *` hides. Once a façade names its exports explicitly,
// the façade file itself IS the pin — a widening shows up in the diff of the file that grew,
// not in a table two files away that only a gate failure would surface. This structural gate is
// what keeps that property true: every façade a package manifest declares (`exportTargets`),
// plus every production file under a `src/facades/` directory, must parse through
// `readNamedExports` without hitting the bare-`export *`/`export default` rejection it already
// implements — reusing that check rather than writing a second, regex-based one that would have
// to independently rediscover every export form to be trustworthy.
//
// The façade set comes from `facadeEntryFiles`, the single owner of "what is an entry surface".
// The ADR-0019 eager-closure budget table consumes the same function, so a file this gate holds
// to an explicit export list is necessarily a file that gate holds to a loading-shape budget.
const facadeFiles = facadeEntryFiles(repoRoot);
assert.ok(facadeFiles.length > 0, 'expected at least one workspace package façade to check');
for (const file of facadeFiles) {
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
try {
readNamedExports(source);
} catch (error) {
assert.fail(
`${file} must name its exports explicitly instead of a bare \`export *\` (or an ` +
'`export default`) — a façade widened by a star re-export hides the new symbol from ' +
'its own diff, exactly what the retired symbol-pin table (#1574) used to catch by ' +
`hand. Underlying error: ${(error as Error).message}`,
);
}
}
});
test('every façade re-exports its sources exhaustively (no silent narrowing)', () => {
// The star-rejection above catches a façade WIDENING invisibly. This catches the
// opposite, which is the failure an explicit list makes newly possible: a symbol
// added to a source module simply never reaches the façade, and nothing notices.
// `export *` could not narrow by construction; an explicit list can, so the
// property `export *` gave for free is asserted here instead.
//
// Found by review on #1614: this conversion was generated against the surface at
// fork time, and #1567 landed 13 new exports meanwhile (`DragOptions`, the drag
// gesture vocabulary, `MultiTargetAnnotationV1`). The rebase silently dropped all
// 13 and only a human diff caught it. Exhaustiveness is what makes that mechanical.
//
// Scoped to `packages/*/src/facades/` — the barrels this PR converted, which were
// exhaustive by construction because `export *` cannot narrow. A hand-curated
// package `index.ts` is a different thing: `@agent-device/ad-replay` deliberately
// publishes two values out of a much larger `internal/`, and forcing it exhaustive
// would widen a surface its owner narrowed on purpose (#1555).
const facadeFiles = listSourceFiles().filter((file) => file.includes('/src/facades/'));
assert.ok(facadeFiles.length > 0, 'expected at least one converted façade to check');
for (const file of [...facadeFiles].sort()) {
const absolute = path.join(repoRoot, file);
const exported = new Set(readNamedExports(fs.readFileSync(absolute, 'utf8')));
for (const specifier of reExportSources(fs.readFileSync(absolute, 'utf8'))) {
const sourcePath = path.resolve(path.dirname(absolute), specifier);
if (!fs.existsSync(sourcePath)) continue;
// A source may itself carry a bare `export *` (contracts' `gesture-plan.ts`
// stars `gesture-plan-types.ts`). Read its DIRECT exports rather than skipping
// the file: skipping would also drop `buildDragGesturePlan` and friends from
// this check, so removing one from a façade would narrow the surface silently
// (#1614 review P2). The starred names are covered because the façade
// re-exports the starred module directly too, and that path is checked here on
// its own turn.
const sourceNames = readDirectNamedExports(fs.readFileSync(sourcePath, 'utf8'));
const dropped = sourceNames.filter((name) => name !== 'default' && !exported.has(name));
assert.deepEqual(
dropped,
[],
`${file} re-exports from ${specifier} but omits ${dropped.join(', ')} — an explicit ` +
'façade list must stay exhaustive over its sources, or a symbol added upstream ' +
'silently never becomes public. Add the names, or move them out of that module.',
);
}
}
});
/** The relative specifiers a façade re-exports from, in source order. */
function reExportSources(source: string): string[] {
const found = new Set<string>();
for (const match of source.matchAll(/\bfrom\s+'(\.[^']*)'/g)) {
const specifier = match[1];
if (specifier) found.add(specifier);
}
return [...found];
}
// These `src/utils` paths are in-memory arbitrary parser fixtures, not live repository paths.
test('double-quoted and re-export routes into packages are not invisible to R11', () => {
// The scanner is the layering parser, so quote style and statement form
// cannot carve out a bypass: a double-quoted import, a re-export, and a
// double-quoted dynamic import into packages/*/src all reach the rule.
const doubleQuoted = specifierSites(
'src/utils/exec.ts',
'import { AppError } from "../../packages/kernel/src/errors.ts";',
);
assert.equal(checkRootSites(doubleQuoted, ALL, new Set([kernel.name])).length, 1);
const reExport = specifierSites(
'src/utils/exec.ts',
'export { AppError } from "../../packages/kernel/src/errors.ts";',
);
assert.equal(checkRootSites(reExport, ALL, new Set([kernel.name])).length, 1);
const dynamic = specifierSites(
'src/utils/exec.ts',
'void import("../../packages/kernel/src/errors.ts");',
);
assert.equal(checkRootSites(dynamic, ALL, new Set([kernel.name])).length, 1);
const packageEscape = specifierSites(
'packages/kernel/src/errors.ts',
'export * from "../../../src/utils/exec.ts";',
);
assert.equal(checkPackageInternalSites(kernel, packageEscape, ALL).length, 1);
});
// This `src/utils` path is another in-memory arbitrary parser fixture, not a live repository path.
test('a package file importing root src is a violation', () => {
const sites = specifierSites(
'packages/kernel/src/errors.ts',
"import { helper } from '../../../src/utils/exec.ts';",
);
assert.deepEqual(rules(checkPackageInternalSites(kernel, sites, ALL)), [
'R11 package-boundaries',
]);
});
test('intra-package relative imports hold', () => {
const sites = specifierSites(
'packages/kernel/src/daemon-error.ts',
"import { AppError } from './errors.ts';",
);
assert.deepEqual(checkPackageInternalSites(kernel, sites, ALL), []);
});
test('an exported package self-reference holds while a deep self-import fails', () => {
const exported = specifierSites(
'packages/kernel/src/errors.test.ts',
"import { AppError } from '@agent-device/kernel/errors';",
);
assert.deepEqual(checkPackageInternalSites(kernel, exported, ALL), []);
const deep = specifierSites(
'packages/kernel/src/errors.test.ts',
"import { AppError } from '@agent-device/kernel/src/errors.ts';",
);
assert.equal(checkPackageInternalSites(kernel, deep, ALL).length, 1);
});
test('a cross-package import needs a workspace:* declaration and an exported subpath', () => {
const declared = specifierSites(
'packages/contracts/src/gesture.ts',
"import { AppError } from '@agent-device/kernel/errors';",
);
assert.deepEqual(checkPackageInternalSites(contracts, declared, ALL), []);
const undeclared = specifierSites(
'packages/kernel/src/errors.ts',
"import { g } from '@agent-device/contracts/interaction';",
);
assert.equal(checkPackageInternalSites(kernel, undeclared, ALL).length, 1);
const deep = specifierSites(
'packages/contracts/src/gesture.ts',
"import { internal } from '@agent-device/kernel/internal/secret';",
);
assert.equal(checkPackageInternalSites(contracts, deep, ALL).length, 1);
});
test('a root src file tunnelling into packages/*/src relatively is a violation', () => {
// This `src/utils` path is an in-memory arbitrary parser fixture, not a live repository path.
const sites = specifierSites(
'src/utils/exec.ts',
"import { AppError } from '../../packages/kernel/src/errors.ts';",
);
const violations = checkRootSites(sites, ALL, new Set([kernel.name]));
assert.deepEqual(rules(violations), ['R11 package-boundaries']);
assert.match(violations[0]!.message, /instantiate the module twice/);
});
// The R8 zero-dep exception retired with R8 (#1781 A6). `scripts/` placement never
// proved anything about dual instantiation on its own, and there is no longer a job
// whose absent node_modules could prove it, so the route is closed to every caller —
// exports-named target or not.
test('a scripts/ file gets no relative route into a package either', () => {
const exportsNamed = specifierSites(
'scripts/some-tool/run.ts',
"import { AppError } from '../../packages/kernel/src/errors.ts';",
);
assert.equal(checkRootSites(exportsNamed, ALL, new Set([kernel.name])).length, 1);
const nonExported = specifierSites(
'scripts/some-tool/run.ts',
"import { hidden } from '../../packages/kernel/src/internal.ts';",
);
assert.equal(checkRootSites(nonExported, ALL, new Set([kernel.name])).length, 1);
});
test('root workspace specifiers need a root workspace:* entry and an exported subpath', () => {
const fine = specifierSites(
'src/cli.ts',
"import { AppError } from '@agent-device/kernel/errors';",
);
assert.deepEqual(checkRootSites(fine, ALL, new Set([kernel.name])), []);
const undeclared = checkRootSites(fine, ALL, new Set());
assert.equal(undeclared.length, 1);
assert.match(undeclared[0]!.message, /workspace:\*/);
const deep = specifierSites(
'src/cli.ts',
"import { x } from '@agent-device/kernel/src/errors.ts';",
);
assert.equal(checkRootSites(deep, ALL, new Set([kernel.name])).length, 1);
const unknown = specifierSites('src/cli.ts', "import { x } from '@agent-device/nope/thing';");
assert.equal(checkRootSites(unknown, ALL, new Set([kernel.name])).length, 1);
});
test('the real tree parses, declares, and passes R11', () => {
const packages = readWorkspacePackages(repoRoot);
assert.ok(packages.length >= 1, 'expected at least the kernel package');
const kernelPackage = packages.find((pkg) => pkg.name === '@agent-device/kernel');
assert.ok(kernelPackage, 'kernel package must exist');
assert.ok(kernelPackage.exportTargets.size >= 8, 'kernel exports its vocabulary subpaths');
const contractsPackage = packages.find((pkg) => pkg.name === '@agent-device/contracts');
assert.ok(contractsPackage, 'contracts package must exist');
assertExportTargetsMatchManifest(
contractsPackage,
'packages/contracts/package.json',
'scripts/layering/contracts-exports.snapshot.json',
);
assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']);
const captureKitPackage = packages.find((pkg) => pkg.name === '@agent-device/capture-kit');
assert.ok(captureKitPackage, 'capture-kit package must exist');
assert.equal(
JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/capture-kit/package.json'), 'utf8'))
.private,
true,
'capture-kit stays a private implementation package',
);
assert.deepEqual([...captureKitPackage.exportTargets.keys()].sort(), [
'@agent-device/capture-kit',
'@agent-device/capture-kit/android-replacement-surface-occlusion',
'@agent-device/capture-kit/durable-capture',
'@agent-device/capture-kit/durable-json',
'@agent-device/capture-kit/ios-snapshot-acquisition',
'@agent-device/capture-kit/ios-snapshot-engine',
'@agent-device/capture-kit/ios-snapshot-planning',
'@agent-device/capture-kit/ios-snapshot-runtime',
'@agent-device/capture-kit/mobile-snapshot-semantics',
'@agent-device/capture-kit/png',
'@agent-device/capture-kit/png-crop',
'@agent-device/capture-kit/png-resize',
'@agent-device/capture-kit/png-rgb-difference',
'@agent-device/capture-kit/png-size',
'@agent-device/capture-kit/png-worker-client',
'@agent-device/capture-kit/post-gesture-stability',
'@agent-device/capture-kit/quality-warnings',
'@agent-device/capture-kit/react-native-overlay',
'@agent-device/capture-kit/recording-mp4-duration',
'@agent-device/capture-kit/recording-mp4-fixtures',
'@agent-device/capture-kit/recording-output-path',
'@agent-device/capture-kit/recording-overlay',
'@agent-device/capture-kit/recording-telemetry',
'@agent-device/capture-kit/recording-video',
'@agent-device/capture-kit/repeated-nav-subtree',
'@agent-device/capture-kit/screenshot-density',
'@agent-device/capture-kit/screenshot-diff-pixels',
'@agent-device/capture-kit/screenshot-overlay',
'@agent-device/capture-kit/scroll-edge-state',
'@agent-device/capture-kit/snapshot-chrome',
'@agent-device/capture-kit/snapshot-desktop-projection',
'@agent-device/capture-kit/snapshot-desktop-surface',
'@agent-device/capture-kit/snapshot-diff',
'@agent-device/capture-kit/snapshot-evidence',
'@agent-device/capture-kit/snapshot-freshness',
'@agent-device/capture-kit/snapshot-label-dedup',
'@agent-device/capture-kit/snapshot-lines',
'@agent-device/capture-kit/snapshot-node-lookup',
'@agent-device/capture-kit/snapshot-occlusion',
'@agent-device/capture-kit/snapshot-presentation-android-helper',
'@agent-device/capture-kit/snapshot-presentation-ios-transitions-fixtures',
'@agent-device/capture-kit/snapshot-quality-backend-capabilities',
'@agent-device/capture-kit/snapshot-quality-verdict',
'@agent-device/capture-kit/snapshot-rect-projection',
'@agent-device/capture-kit/snapshot-state',
'@agent-device/capture-kit/snapshot-state-fixtures',
'@agent-device/capture-kit/snapshot-timeout-policy',
'@agent-device/capture-kit/snapshot-visibility',
'@agent-device/capture-kit/text-surface',
]);
const provisionKitPackage = packages.find((pkg) => pkg.name === '@agent-device/provision-kit');
assert.ok(provisionKitPackage, 'provision-kit package must exist');
assert.equal(
JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/provision-kit/package.json'), 'utf8'))
.private,
true,
'provision-kit stays a private implementation package',
);
assert.deepEqual([...provisionKitPackage.exportTargets.keys()].sort(), [
'@agent-device/provision-kit/app-resolution-cache',
'@agent-device/provision-kit/boot-diagnostics',
'@agent-device/provision-kit/install-artifact-archive-context',
'@agent-device/provision-kit/install-source',
'@agent-device/provision-kit/install-source-config',
'@agent-device/provision-kit/install-source-network',
'@agent-device/provision-kit/install-source-network-transport',
'@agent-device/provision-kit/managed-device-scope',
'@agent-device/provision-kit/toolchain-probe',
]);
assert.deepEqual([...provisionKitPackage.workspaceDependencies].sort(), [
'@agent-device/contracts',
'@agent-device/host-kit',
'@agent-device/kernel',
]);
assert.deepEqual([...captureKitPackage.workspaceDependencies].sort(), [
'@agent-device/contracts',
'@agent-device/host-kit',
'@agent-device/kernel',
]);
const hostKitPackage = packages.find((pkg) => pkg.name === '@agent-device/host-kit');
assert.ok(hostKitPackage, 'host-kit package must exist');
assert.equal(
JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/host-kit/package.json'), 'utf8'))
.private,
true,
'host-kit stays a private implementation package',
);
assert.deepEqual([...hostKitPackage.exportTargets.keys()].sort(), [
'@agent-device/host-kit/archive',
// Test-only entry: the one inert audio-probe double the platform runtime fixtures share.
'@agent-device/host-kit/audio-probe-fixtures',
'@agent-device/host-kit/code-signature',
'@agent-device/host-kit/code-signature-cache',
'@agent-device/host-kit/command',
'@agent-device/host-kit/diagnostics',
'@agent-device/host-kit/file',
'@agent-device/host-kit/host-file',
'@agent-device/host-kit/process',
'@agent-device/host-kit/project-runtime',
'@agent-device/host-kit/request',
'@agent-device/host-kit/retry',
'@agent-device/host-kit/runtime-transport-hints',
'@agent-device/host-kit/transport',
'@agent-device/host-kit/version',
]);
assert.deepEqual([...hostKitPackage.workspaceDependencies].sort(), [
'@agent-device/contracts',
'@agent-device/kernel',
]);
const platformApplePackage = packages.find((pkg) => pkg.name === '@agent-device/platform-apple');
assert.ok(platformApplePackage, 'platform-apple package must exist');
assert.deepEqual([...platformApplePackage.exportTargets.keys()].sort(), [
'@agent-device/platform-apple',
'@agent-device/platform-apple/app-lifecycle',
'@agent-device/platform-apple/app-resolution',
'@agent-device/platform-apple/debug-symbols',
'@agent-device/platform-apple/doctor',
'@agent-device/platform-apple/install-artifact',
'@agent-device/platform-apple/macos',
'@agent-device/platform-apple/perf',
'@agent-device/platform-apple/physical-device',
'@agent-device/platform-apple/runner',
'@agent-device/platform-apple/runner-owner',
'@agent-device/platform-apple/runner/operations',
'@agent-device/platform-apple/runner/test-host',
'@agent-device/platform-apple/session-observation',
'@agent-device/platform-apple/simctl',
'@agent-device/platform-apple/simulator',
'@agent-device/platform-apple/simulator-boot',
'@agent-device/platform-apple/snapshot-source',
'@agent-device/platform-apple/tool-provider',
]);
assert.deepEqual([...platformApplePackage.workspaceDependencies].sort(), [
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/host-kit',
'@agent-device/kernel',
'@agent-device/provision-kit',
'@agent-device/xml',
]);
const platformAndroidPackage = packages.find(
(pkg) => pkg.name === '@agent-device/platform-android',
);
assert.ok(platformAndroidPackage, 'platform-android package must exist');
assert.deepEqual([...platformAndroidPackage.exportTargets.keys()].sort(), [
'@agent-device/platform-android',
'@agent-device/platform-android/adb-host',
'@agent-device/platform-android/device-boot',
'@agent-device/platform-android/mechanics',
]);
assert.deepEqual([...platformAndroidPackage.workspaceDependencies].sort(), [
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/host-kit',
'@agent-device/kernel',
'@agent-device/provision-kit',
'@agent-device/xml',
]);
const maestroPackage = packages.find((pkg) => pkg.name === '@agent-device/maestro');
assert.ok(maestroPackage, 'maestro package must exist');
assert.deepEqual([...maestroPackage.exportTargets.keys()], ['@agent-device/maestro']);
assert.deepEqual([...maestroPackage.workspaceDependencies].sort(), [
'@agent-device/contracts',
'@agent-device/kernel',
'@agent-device/selectors',
]);
const adScriptPackage = packages.find((pkg) => pkg.name === '@agent-device/ad-script');
assert.ok(adScriptPackage, 'ad-script package must exist');
// Locks the "exports only `.`" boundary: a future `/codec` (or any other)
// subpath widens this key list and fails the assertion (#1478 P5 dossier).
assert.deepEqual([...adScriptPackage.exportTargets.keys()], ['@agent-device/ad-script']);
assert.deepEqual([...adScriptPackage.workspaceDependencies].sort(), [
'@agent-device/contracts',
'@agent-device/kernel',
]);
const adReplayPackage = packages.find((pkg) => pkg.name === '@agent-device/ad-replay');
assert.ok(adReplayPackage, 'ad-replay package must exist');
// Locks the export surface: `.` (the engine) and `./divergence` (the
// divergence report/sanitization vocabulary, off the engine entry so CLI/MCP
// surfaces don't load the step loop). Any other subpath widens this key
// list and fails the assertion.
assert.deepEqual(
[...adReplayPackage.exportTargets.keys()],
['@agent-device/ad-replay', '@agent-device/ad-replay/divergence'],
);
assert.deepEqual([...adReplayPackage.workspaceDependencies].sort(), [
'@agent-device/ad-script',
'@agent-device/contracts',
'@agent-device/kernel',
'@agent-device/selectors',
]);
// #1555 review P1 ("add the reviewer-required exact exported-symbol
// gate"; second pass, "enforce the accepted two-entrypoint facade"; the
// structural-quality review, "typed façade replaces the zero-type rule"):
// the exports-subpath assertion above only proves the package exposes one
// `.` entry point — it says nothing about what that entry point actually
// NAMES. This pins the exact symbol list `packages/ad-replay/src/index.ts`
// exports: the binding design's two VALUE entrypoints, `inspectAdReplay`
// and `runAdReplay` (never a third value), plus the neutral vocabulary
// their signatures are built from, named explicitly instead of every root
// consumer hand-deriving `Parameters<...>`/`ReturnType<...>` off them (the
// shim `src/daemon/ad-replay-facade-types.ts` used to centralize — since
// deleted). `formatReplaySuccessMessage` (presentation, not engine policy)
// stays out on purpose — it sits daemon-side beside its one caller. A
// stray export — intentional or not, including a form `readNamedExports`
// cannot enumerate a name for (`export *`, `export default` — see the
// rejection tests below) — must edit this list too, not just slip through
// the exports-subpath check.
assert.deepEqual(
readNamedExports(
fs.readFileSync(path.join(repoRoot, 'packages/ad-replay/src/index.ts'), 'utf8'),
),
[
'AdReplayDispatchGuard',
'AdReplayDispatchOutcome',
'AdReplayGuardMismatchEvidence',
'AdReplayLandmarkMismatchEvidence',
'AdReplayManifest',
'AdReplayScrubValue',
'AdReplayStepFailure',
'AdReplayStepRuntime',
'AdReplayTargetBindingEvidence',
'AdReplayTargetClassification',
'AdReplayVarSources',
'AdReplayVerificationEntry',
'inspectAdReplay',
'runAdReplay',
],
);
const selectorsPackage = packages.find((pkg) => pkg.name === '@agent-device/selectors');
assert.ok(selectorsPackage, 'selectors package must exist');
// Subpaths, and each split is the point: `.` is the string-only façade
// every in-repo consumer uses, `./ast` is the published parser surface that
// `agent-device/selectors` has shipped since before the engine moved into
// this package, `./engine` is the resolve/list surface reserved for the
// selector-pipeline owner (R19, #1656) — a route reaching it skips the
// structural stages its policy row declares — and
// `./parameterized-recorded-fill` is the recorded-fill parameterization the
// daemon used to own (#2340). The per-file subpaths under `./interaction-*`,
// `./selector-pipeline*`, `./press-retarget`, `./touch-semantics` are the
// execution surface the core selector pipeline moved into this package —
// one subpath per module so consumers pull only the stage they run; the
// `-fixtures` entries are the test-fixture surface (host-kit's
// `./audio-probe-fixtures` precedent) — `./interaction-targeting-fixtures`
// for the interaction-targeting node trees and `./snapshot-geometry-fixtures`
// for the geometry/touch-point builders both this package's and root's
// tests build on (#2402, replacing the copy that used to live under root's
// `src/__tests__/test-utils/`; it re-exports `makeSnapshotState` from
// capture-kit's own `./snapshot-state-fixtures`, its canonical home). Any
// other subpath, or the AST leaking into `.`, fails here.
assert.deepEqual([...selectorsPackage.exportTargets.keys()].sort(), [
'@agent-device/selectors',
'@agent-device/selectors/absence-observation',
'@agent-device/selectors/absence-observation-errors',
'@agent-device/selectors/absence-observation-resolution',
'@agent-device/selectors/ast',
'@agent-device/selectors/engine',
'@agent-device/selectors/interaction-error',
'@agent-device/selectors/interaction-positionals',
'@agent-device/selectors/interaction-targeting',
'@agent-device/selectors/interaction-targeting-fixtures',
'@agent-device/selectors/interaction-touch-point',
'@agent-device/selectors/parameterized-recorded-fill',
'@agent-device/selectors/press-retarget',
'@agent-device/selectors/selector-pipeline',
'@agent-device/selectors/selector-pipeline-policy',
'@agent-device/selectors/snapshot-geometry-fixtures',
'@agent-device/selectors/touch-semantics',
]);
assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [
'@agent-device/ad-script',
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/kernel',
]);
assert.deepEqual(
readNamedExports(
fs.readFileSync(path.join(repoRoot, 'packages/selectors/src/index.ts'), 'utf8'),
).filter((name) =>
['Selector', 'SelectorChain', 'SelectorTerm', 'SelectorKey', 'parseSelectorChain'].includes(
name,
),
),
[],
'selectors façade keeps AST and grammar internals private',
);
// Named exports are not the whole boundary. A parser-side type reached
// through a NESTED field — `PolicyResolutionOutcome.resolution` typed as
// `AstSelectorResolution` — leaks the same objects while exporting none of
// their names, and the assertion above stays green on it (#1649). What
// separates the two is which module the type is re-exported FROM:
// `public-resolution-types.ts` holds the string-flattened shapes,
// `resolve-with-policy.ts` and `resolve.ts` hold the parser-side ones. A
// resolution type re-exported from either of the latter means a flattening
// step at the façade was skipped.
const selectorsReExports = readReExportSources(
fs.readFileSync(path.join(repoRoot, 'packages/selectors/src/index.ts'), 'utf8'),
);
assert.deepEqual(
['PolicyResolutionOutcome', 'SelectorResolution', 'SelectorChainMatchList'].filter(
(name) => selectorsReExports.get(name) !== './internal/public-resolution-types.ts',
),
[],
'selectors façade must publish resolution shapes from public-resolution-types.ts, not from the parser-side modules',
);
// The AST subpath's one in-repo consumer is the published SDK re-export.
// Anything else importing it means the string-only façade was bypassed.
assert.deepEqual(
listSourceFiles()
.filter((file) => !file.startsWith('packages/selectors/'))
.filter((file) => fs.existsSync(path.join(repoRoot, file)))
.filter((file) =>
fs.readFileSync(path.join(repoRoot, file), 'utf8').includes('@agent-device/selectors/ast'),
),
['src/sdk/selectors.ts'],
'only the published SDK subpath may import the selector AST',
);
const providerWebDriverPackage = packages.find(
(pkg) => pkg.name === '@agent-device/provider-webdriver',
);
assert.ok(providerWebDriverPackage, 'provider-webdriver package must exist');
assert.deepEqual([...providerWebDriverPackage.exportTargets.keys()].sort(), [
'@agent-device/provider-webdriver',
'@agent-device/provider-webdriver/providers',
]);
assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/kernel',
'@agent-device/xml',
]);
const providerLimrunPackage = packages.find(
(pkg) => pkg.name === '@agent-device/provider-limrun',
);
assert.ok(providerLimrunPackage, 'provider-limrun package must exist');
assert.deepEqual(
[...providerLimrunPackage.exportTargets.keys()],
['@agent-device/provider-limrun'],
);
assert.deepEqual([...providerLimrunPackage.workspaceDependencies].sort(), [
'@agent-device/capture-kit',
'@agent-device/contracts',
'@agent-device/kernel',
]);
const rootExternalDependencies = rootExternalDependencyRanges(repoRoot);
for (const pkg of packages) {
for (const [name, range] of pkg.externalDependencies) {
assert.equal(
rootExternalDependencies.get(name),
range,
`${pkg.name} external dependency ${name} must match the root dependency range`,
);
}
}
const xmlPackage = packages.find((pkg) => pkg.name === '@agent-device/xml');
assert.ok(xmlPackage, 'xml package must exist');
assert.deepEqual([...xmlPackage.exportTargets.keys()], ['@agent-device/xml']);
assert.deepEqual([...xmlPackage.workspaceDependencies], []);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/capture-kit'),
'root must declare the capture-kit workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/kernel'),
'root must declare the kernel workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/contracts'),
'root must declare the contracts workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/maestro'),
'root must declare the maestro workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/ad-script'),
'root must declare the ad-script workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/ad-replay'),
'root must declare the ad-replay workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/selectors'),
'root must declare the selectors workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-webdriver'),
'root must declare the provider-webdriver workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-limrun'),
'root must declare the provider-limrun workspace dependency',
);
assert.ok(
rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'),
'root must declare the xml workspace dependency',
);
assert.deepEqual(checkPackageBoundaries(repoRoot, new Set()), []);
});
test('Node resolution enforces the exports map at runtime', () => {
// Real resolver, not the gate's model: a deep import is a resolution error,
// and the legal subpath realpaths OUTSIDE node_modules (pnpm symlink), which
// is what lets --experimental-strip-types load package sources in dev.
const resolved = import.meta.resolve('@agent-device/kernel/errors');
assert.ok(resolved.endsWith('packages/kernel/src/errors.ts'), resolved);
for (const deep of [
'@agent-device/kernel/src/errors.ts',
'@agent-device/kernel/internal-not-exported',
'@agent-device/kernel',
'@agent-device/contracts/not-an-export',
'@agent-device/contracts/src/gesture-plan.ts',
'@agent-device/contracts',
'@agent-device/contracts/src/snapshot-text.ts',
'@agent-device/contracts/src/facades/snapshot.ts',
'@agent-device/provider-webdriver/runtime',
'@agent-device/provider-webdriver/src/runtime.ts',
'@agent-device/provider-limrun/runtime',
'@agent-device/provider-limrun/src/runtime.ts',
'@agent-device/xml/internal/parser',
'@agent-device/xml/src/index.ts',
'@agent-device/ad-script/codec',
'@agent-device/ad-script/internal/script.ts',
'@agent-device/ad-script/src/index.ts',
'@agent-device/ad-replay/testing',
'@agent-device/ad-replay/internal/target-verification.ts',
'@agent-device/ad-replay/src/index.ts',
'@agent-device/selectors/internal/parse.ts',
'@agent-device/selectors/src/index.ts',
]) {
assert.throws(
() => import.meta.resolve(deep),
/ERR_PACKAGE_PATH_NOT_EXPORTED|Package subpath|No "exports" main/,
`${deep} must not resolve`,
);
}
const contractsResolved = import.meta.resolve('@agent-device/contracts/interaction');
assert.ok(contractsResolved.endsWith('packages/contracts/src/interaction.ts'), contractsResolved);
const contractsSnapshotResolved = import.meta.resolve('@agent-device/contracts/snapshot');
assert.ok(
contractsSnapshotResolved.endsWith('packages/contracts/src/facades/snapshot.ts'),
contractsSnapshotResolved,
);
const contractsReactNativeOverlayResolved = import.meta
.resolve('@agent-device/contracts/react-native-overlay');
assert.ok(
contractsReactNativeOverlayResolved.endsWith('packages/contracts/src/react-native-overlay.ts'),
contractsReactNativeOverlayResolved,
);
const providerWebDriverResolved = import.meta.resolve('@agent-device/provider-webdriver');
assert.ok(
providerWebDriverResolved.endsWith('packages/provider-webdriver/src/index.ts'),
providerWebDriverResolved,
);
const providerLimrunResolved = import.meta.resolve('@agent-device/provider-limrun');
assert.ok(
providerLimrunResolved.endsWith('packages/provider-limrun/src/index.ts'),
providerLimrunResolved,
);
const xmlResolved = import.meta.resolve('@agent-device/xml');
assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved);
const adScriptResolved = import.meta.resolve('@agent-device/ad-script');
assert.ok(adScriptResolved.endsWith('packages/ad-script/src/index.ts'), adScriptResolved);
const adReplayResolved = import.meta.resolve('@agent-device/ad-replay');
assert.ok(adReplayResolved.endsWith('packages/ad-replay/src/index.ts'), adReplayResolved);
const selectorsResolved = import.meta.resolve('@agent-device/selectors');
assert.ok(selectorsResolved.endsWith('packages/selectors/src/index.ts'), selectorsResolved);
});