mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
03c3984066
* perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump
`@agent-device/contracts/platform` unions 32 vocabulary modules and
`/interaction` another 18. A file that value-imports either evaluates the whole
union to reach one function, and because permanent hubs sat behind them —
`command-descriptor/registry.ts`, `core/capabilities.ts`,
`interactors/register-builtins.ts`, `command-descriptor/platform-execution-entry.ts` —
that union rode into roughly half the unit suite's test graphs.
Give every vocabulary module its own entry subpath and move all value-importers
onto the module that owns the symbol. Type-only importers are left alone: `import
type` is erased, so it already evaluated nothing.
Measured with the #1950 eager-import-closure walker over all 974 unit-core test
files, against base e5bfde3d1:
aggregate eager module evaluations 143,248 -> 129,738 (-9.4%)
facades/platform.ts carried by 466 -> 1 test graphs
facades/interaction.ts carried by 451 -> 0 test graphs
registry.ts 105 -> 66
capabilities.ts 113 -> 76
register-builtins.ts 111 -> 73
platform-execution-entry.ts 43 -> 3
dispatch.ts 134 -> 100
Three gate adjustments the split forces:
- R11's pinned contracts subpath list grows to the new entries, and the resolver
test's "must not resolve" example moves to `./clipboard`, since `./gesture-plan`
is now a real entry.
- R16 anchored the record-runtime join on the literal `contracts/platform`
specifier. It now accepts any contracts entry — the assertion's provenance is
what the rule pins, not which subpath carried it.
- `gesture-plan.ts` became an entry target, and the no-bare-star rule rejects the
`export * from './gesture-plan-types.ts'` it carried. Its one internal consumer
now imports the owning module directly.
Both facades keep their type re-exports for the ~490 type-only importers, so
every symbol on them now reads as value-unused; one fallow entry records that
and names retiring them as the follow-up.
Closes #1959
* test(contracts): text-filter the facade scan before parsing
The repo-wide scan parsed all ~3000 sources, which the coverage lane's
instrumentation pushed past both the 5s test timeout and the 2.5s slow-test
budget. A file that never names the specifier cannot import it, so filter on the
text first and parse only the ~490 candidates.
Non-vacuity moves with it: instead of counting narrow imports across every file,
require that the surviving type-only importers were seen and classified as
erased — which an empty scan cannot satisfy.
654 lines
29 KiB
TypeScript
654 lines
29 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 fs from 'node:fs';
|
|
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,
|
|
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/facades/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/android-input-ownership',
|
|
'@agent-device/contracts/android-snapshot-quality',
|
|
'@agent-device/contracts/android-system-chrome',
|
|
'@agent-device/contracts/app-deployment-runtime',
|
|
'@agent-device/contracts/app-deployment-runtime-plan',
|
|
'@agent-device/contracts/app-inventory-runtime',
|
|
'@agent-device/contracts/app-log-runtime',
|
|
'@agent-device/contracts/app-state-runtime',
|
|
'@agent-device/contracts/apple-multitouch-support',
|
|
'@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-support',
|
|
'@agent-device/contracts/back-mode',
|
|
'@agent-device/contracts/capture',
|
|
'@agent-device/contracts/click-button',
|
|
'@agent-device/contracts/client',
|
|
'@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-input',
|
|
'@agent-device/contracts/gesture-normalization',
|
|
'@agent-device/contracts/gesture-plan',
|
|
'@agent-device/contracts/gesture-plan-types',
|
|
'@agent-device/contracts/interaction',
|
|
'@agent-device/contracts/interaction-error',
|
|
'@agent-device/contracts/interaction-guarantees',
|
|
'@agent-device/contracts/interactor-types',
|
|
'@agent-device/contracts/logs-runtime-plan',
|
|
'@agent-device/contracts/navigation',
|
|
'@agent-device/contracts/network-runtime',
|
|
'@agent-device/contracts/network-runtime-plan',
|
|
'@agent-device/contracts/observability',
|
|
'@agent-device/contracts/platform',
|
|
'@agent-device/contracts/platform-module',
|
|
'@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-cutover',
|
|
'@agent-device/contracts/recording',
|
|
'@agent-device/contracts/remote',
|
|
'@agent-device/contracts/replay',
|
|
'@agent-device/contracts/screen-recording-runtime',
|
|
'@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/selector-observation-runtime',
|
|
'@agent-device/contracts/session',
|
|
'@agent-device/contracts/settings',
|
|
'@agent-device/contracts/snapshot',
|
|
'@agent-device/contracts/snapshot-runtime',
|
|
'@agent-device/contracts/startup-recovery-fence',
|
|
'@agent-device/contracts/tv-remote',
|
|
'@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('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 file under a `packages/*/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.
|
|
const packages = readWorkspacePackages(repoRoot);
|
|
const facadeFiles = new Set<string>(packages.flatMap((pkg) => [...pkg.exportTargets.values()]));
|
|
for (const file of listSourceFiles()) {
|
|
if (file.includes('/src/facades/')) facadeFiles.add(file);
|
|
}
|
|
assert.ok(facadeFiles.size > 0, 'expected at least one workspace package façade to check');
|
|
for (const file of [...facadeFiles].sort()) {
|
|
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()], ['@agent-device/capture-kit']);
|
|
assert.deepEqual([...captureKitPackage.workspaceDependencies].sort(), [
|
|
'@agent-device/contracts',
|
|
'@agent-device/kernel',
|
|
]);
|
|
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/clipboard',
|
|
'@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/facades/interaction.ts'),
|
|
contractsResolved,
|
|
);
|
|
const contractsSnapshotResolved = import.meta.resolve('@agent-device/contracts/snapshot');
|
|
assert.ok(
|
|
contractsSnapshotResolved.endsWith('packages/contracts/src/facades/snapshot.ts'),
|
|
contractsSnapshotResolved,
|
|
);
|
|
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);
|
|
});
|