mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
873 lines
39 KiB
TypeScript
873 lines
39 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';
|
|
|
|
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];
|
|
const CONTRACT_EXPORTS = [
|
|
'@agent-device/contracts/alert-contract',
|
|
'@agent-device/contracts/alert-runtime',
|
|
'@agent-device/contracts/android-clipboard-support',
|
|
'@agent-device/contracts/android-helper-artifacts',
|
|
'@agent-device/contracts/android-input-ownership',
|
|
'@agent-device/contracts/android-observation',
|
|
'@agent-device/contracts/android-snapshot-quality',
|
|
'@agent-device/contracts/android-system-chrome',
|
|
'@agent-device/contracts/android-touch-plan',
|
|
'@agent-device/contracts/app-deployment-runtime',
|
|
'@agent-device/contracts/app-deployment-runtime-plan',
|
|
'@agent-device/contracts/app-event-runtime',
|
|
'@agent-device/contracts/app-inventory-runtime',
|
|
'@agent-device/contracts/app-log-runtime',
|
|
'@agent-device/contracts/app-state-runtime',
|
|
'@agent-device/contracts/app-switcher-runtime',
|
|
'@agent-device/contracts/apple-multitouch-support',
|
|
'@agent-device/contracts/apple-runner-request',
|
|
'@agent-device/contracts/application-lifecycle-interaction',
|
|
'@agent-device/contracts/application-lifecycle-runtime',
|
|
'@agent-device/contracts/application-lifecycle-runtime-plan',
|
|
'@agent-device/contracts/async-lifecycle',
|
|
'@agent-device/contracts/audio-probe-result',
|
|
'@agent-device/contracts/audio-probe-runtime',
|
|
'@agent-device/contracts/audio-probe-runtime-host',
|
|
'@agent-device/contracts/audio-probe-support',
|
|
'@agent-device/contracts/audio-runtime-plan',
|
|
'@agent-device/contracts/back-mode',
|
|
'@agent-device/contracts/backend-diagnostics',
|
|
'@agent-device/contracts/back-runtime',
|
|
'@agent-device/contracts/boot-failure',
|
|
'@agent-device/contracts/capture',
|
|
'@agent-device/contracts/click-button',
|
|
'@agent-device/contracts/client',
|
|
'@agent-device/contracts/clipboard',
|
|
'@agent-device/contracts/clipboard-runtime',
|
|
'@agent-device/contracts/command',
|
|
'@agent-device/contracts/command-platform-execution',
|
|
'@agent-device/contracts/device',
|
|
'@agent-device/contracts/device-readiness-runtime',
|
|
'@agent-device/contracts/device-shutdown-runtime',
|
|
'@agent-device/contracts/divergence',
|
|
'@agent-device/contracts/durable-resource',
|
|
'@agent-device/contracts/durable-resource-envelope',
|
|
'@agent-device/contracts/element-text-runtime',
|
|
'@agent-device/contracts/focus-runtime',
|
|
'@agent-device/contracts/gesture-admission',
|
|
'@agent-device/contracts/gesture-input',
|
|
'@agent-device/contracts/gesture-normalization',
|
|
'@agent-device/contracts/gesture-plan',
|
|
'@agent-device/contracts/gesture-plan-types',
|
|
'@agent-device/contracts/gesture-runtime',
|
|
'@agent-device/contracts/home-runtime',
|
|
'@agent-device/contracts/host-diagnostics',
|
|
'@agent-device/contracts/daemon-owner-cleanup',
|
|
'@agent-device/contracts/interaction',
|
|
'@agent-device/contracts/interaction-error',
|
|
'@agent-device/contracts/interaction-guarantees',
|
|
'@agent-device/contracts/interactor-operation-catalog',
|
|
'@agent-device/contracts/interactor-types',
|
|
'@agent-device/contracts/ios-snapshot',
|
|
'@agent-device/contracts/keyboard',
|
|
'@agent-device/contracts/keyboard-runtime',
|
|
'@agent-device/contracts/local-interactor-operation-set',
|
|
'@agent-device/contracts/logs-runtime-plan',
|
|
'@agent-device/contracts/managed-web-backend',
|
|
'@agent-device/contracts/navigation',
|
|
'@agent-device/contracts/network-runtime',
|
|
'@agent-device/contracts/network-runtime-plan',
|
|
'@agent-device/contracts/network-traffic',
|
|
'@agent-device/contracts/observability',
|
|
'@agent-device/contracts/orientation-runtime',
|
|
'@agent-device/contracts/perf-runtime',
|
|
'@agent-device/contracts/perf-runtime-host',
|
|
'@agent-device/contracts/perf-runtime-operation-builder',
|
|
'@agent-device/contracts/perf-runtime-plan',
|
|
'@agent-device/contracts/platform-module',
|
|
'@agent-device/contracts/platform-plugin',
|
|
'@agent-device/contracts/platform-providers',
|
|
'@agent-device/contracts/platform-resource-cleanup',
|
|
'@agent-device/contracts/platform-runtime',
|
|
'@agent-device/contracts/platform-runtime-host',
|
|
'@agent-device/contracts/platform-runtime-operations',
|
|
'@agent-device/contracts/platform-runtime-unavailable',
|
|
'@agent-device/contracts/progress',
|
|
'@agent-device/contracts/record-runtime-execution',
|
|
'@agent-device/contracts/recording',
|
|
'@agent-device/contracts/remote',
|
|
'@agent-device/contracts/replay',
|
|
'@agent-device/contracts/react-native-overlay',
|
|
'@agent-device/contracts/runner-lease-context',
|
|
'@agent-device/contracts/screen-recording-runtime',
|
|
'@agent-device/contracts/screen-recording-runtime-host',
|
|
'@agent-device/contracts/screen-recording-runtime-plan',
|
|
'@agent-device/contracts/screenshot-runtime',
|
|
'@agent-device/contracts/scroll-command',
|
|
'@agent-device/contracts/scroll-gesture',
|
|
'@agent-device/contracts/scroll-runtime',
|
|
'@agent-device/contracts/selector-observation-runtime',
|
|
'@agent-device/contracts/session',
|
|
'@agent-device/contracts/settings',
|
|
'@agent-device/contracts/settings-runtime',
|
|
'@agent-device/contracts/snapshot',
|
|
'@agent-device/contracts/snapshot-presentation',
|
|
'@agent-device/contracts/snapshot-runtime',
|
|
'@agent-device/contracts/snapshot-scope',
|
|
'@agent-device/contracts/snapshot-timeout-evidence',
|
|
'@agent-device/contracts/startup-recovery-fence',
|
|
'@agent-device/contracts/touch-runtime',
|
|
'@agent-device/contracts/tv-remote',
|
|
'@agent-device/contracts/tv-remote-runtime',
|
|
'@agent-device/contracts/type-text-runtime',
|
|
'@agent-device/contracts/viewport-runtime',
|
|
'@agent-device/contracts/wait',
|
|
'@agent-device/contracts/wait-runtime-plan',
|
|
] as const;
|
|
|
|
function rules(violations: { rule: string }[]): string[] {
|
|
return violations.map((violation) => violation.rule);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
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', () => {
|
|
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');
|
|
assert.deepEqual([...contractsPackage.exportTargets.keys()].sort(), [...CONTRACT_EXPORTS].sort());
|
|
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/ios-snapshot-engine',
|
|
'@agent-device/capture-kit/ios-snapshot-planning',
|
|
'@agent-device/capture-kit/mobile-snapshot-semantics',
|
|
'@agent-device/capture-kit/png',
|
|
'@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/screenshot-density',
|
|
'@agent-device/capture-kit/screenshot-diff-pixels',
|
|
'@agent-device/capture-kit/snapshot-desktop-projection',
|
|
'@agent-device/capture-kit/snapshot-occlusion',
|
|
'@agent-device/capture-kit/snapshot-quality-backend-capabilities',
|
|
'@agent-device/capture-kit/snapshot-quality-verdict',
|
|
]);
|
|
|
|
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/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',
|
|
'@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/request',
|
|
'@agent-device/host-kit/retry',
|
|
'@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/simctl',
|
|
'@agent-device/platform-apple/simulator',
|
|
'@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/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 "exports only `.`" boundary: the stage-A wide façade and the
|
|
// `./testing` subpath (the deleted in-memory selector adapter) are both gone
|
|
// as of the direct selectors-package cutover — a future
|
|
// `./testing` (or any other) subpath widens this key list and fails the
|
|
// assertion.
|
|
assert.deepEqual([...adReplayPackage.exportTargets.keys()], ['@agent-device/ad-replay']);
|
|
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');
|
|
// Three 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, and `./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. A fourth subpath, or the AST
|
|
// leaking into `.`, fails here.
|
|
assert.deepEqual([...selectorsPackage.exportTargets.keys()].sort(), [
|
|
'@agent-device/selectors',
|
|
'@agent-device/selectors/ast',
|
|
'@agent-device/selectors/engine',
|
|
]);
|
|
assert.deepEqual([...selectorsPackage.workspaceDependencies].sort(), [
|
|
'@agent-device/ad-script',
|
|
'@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()],
|
|
['@agent-device/provider-webdriver'],
|
|
);
|
|
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 contractsSnapshotPresentationResolved = import.meta
|
|
.resolve('@agent-device/contracts/snapshot-presentation');
|
|
assert.ok(
|
|
contractsSnapshotPresentationResolved.endsWith(
|
|
'packages/contracts/src/snapshot-presentation.ts',
|
|
),
|
|
contractsSnapshotPresentationResolved,
|
|
);
|
|
const contractsReactNativeOverlayResolved = import.meta
|
|
.resolve('@agent-device/contracts/react-native-overlay');
|
|
assert.ok(
|
|
contractsReactNativeOverlayResolved.endsWith('packages/contracts/src/react-native-overlay.ts'),
|
|
contractsReactNativeOverlayResolved,
|
|
);
|
|
const contractsSnapshotTimeoutEvidenceResolved = import.meta
|
|
.resolve('@agent-device/contracts/snapshot-timeout-evidence');
|
|
assert.ok(
|
|
contractsSnapshotTimeoutEvidenceResolved.endsWith(
|
|
'packages/contracts/src/snapshot-timeout-evidence.ts',
|
|
),
|
|
contractsSnapshotTimeoutEvidenceResolved,
|
|
);
|
|
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);
|
|
});
|