diff --git a/package.json b/package.json index fc7210de4..c5d436aec 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/facade-exports.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", diff --git a/scripts/layering/facade-exports.test.ts b/scripts/layering/facade-exports.test.ts new file mode 100644 index 000000000..cc5b2a78b --- /dev/null +++ b/scripts/layering/facade-exports.test.ts @@ -0,0 +1,307 @@ +// Façade export enumeration, tested directly: what `readNamedExports` and +// `readFacadeExports` report for each export FORM, independently of the R11 +// boundary rules that consume them. +// +// The `readFacadeExports` cases write throwaway modules under a real package +// rather than using committed fixtures — a fixture would pin the walker +// against a file shape this repo never actually ships. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { readFacadeExports, readNamedExports } from './facade-exports.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +test('readNamedExports collects re-export and direct-declaration forms, resolving aliases', () => { + const source = [ + "export { a, b } from './x.ts';", + "export type { C, D } from './y.ts';", + "export { e as f } from './z.ts';", + "export type { g as h } from './z.ts';", + 'export function i() {}', + 'export const j = 1;', + 'export type K = string;', + 'export interface L {}', + "export {\n m,\n n,\n} from './multi.ts';", + ].join('\n'); + assert.deepEqual( + readNamedExports(source), + ['D', 'C', 'K', 'L', 'a', 'b', 'f', 'h', 'i', 'j', 'm', 'n'].sort(), + ); +}); + +test('readNamedExports never reports the original name behind an `as` alias', () => { + const source = "export { internalOnly as publicName } from './x.ts';"; + const names = readNamedExports(source); + assert.deepEqual(names, ['publicName']); + assert.ok(!names.includes('internalOnly')); +}); + +test('readNamedExports resolves `export * as ns` to its one real bound name', () => { + // Unlike bare `export *`, this binds exactly one importable name (`ns`) — + // enumerable, not a widening blind spot. + const source = "export * as ns from './x.ts';"; + assert.deepEqual(readNamedExports(source), ['ns']); +}); + +// #1555 review P1 (second pass, "the gate also ignores export-star +// declarations, so it can miss future widening"): a facade pinned to an +// exact named-export list must not silently accept a form that widens its +// real surface with no enumerable name at all. These two forms throw instead +// of contributing nothing to the list — plant-verified (temporarily reverted +// to a no-op, confirmed both tests failed, restored) rather than merely +// asserted. +test('readNamedExports rejects a bare `export *` re-export', () => { + const source = "export { runAdReplay } from './step-loop.ts';\nexport * from './leak.ts';\n"; + assert.throws(() => readNamedExports(source), /export \* from/); +}); + +test('readNamedExports rejects a default export', () => { + assert.throws(() => readNamedExports('export default function leak() {}'), /export default/); + assert.throws(() => readNamedExports('export default 42;'), /export default/); +}); + +test('readNamedExports reports `export { default as x }` as the named symbol x', () => { + // The one form that sits between the two rejection rules above: the LOCAL + // name is `default`, but what it binds in this module — and the only thing + // a consumer can import — is `x`. Enumerable, so it must be reported, not + // thrown; and `default` must never appear in the list. + const names = readNamedExports("export { default as x, b } from './y.ts';"); + assert.deepEqual(names, ['b', 'x']); + assert.ok(!names.includes('default')); +}); + +test('readNamedExports collects a local `export { … }` list with no `from`', () => { + // The re-export tests above all carry a `from`; a façade that declares + // first and exports at the bottom is the same public surface. + assert.deepEqual( + readNamedExports('const a = 1;\ntype T = string;\nexport { a };\nexport type { T };'), + ['T', 'a'], + ); +}); + +test('readNamedExports collects every declarator of a multi-declarator export', () => { + // Documented in the helper's contract; the direct-declaration test above + // only exercises a single declarator, so the second name went unpinned. + assert.deepEqual(readNamedExports('export const a = 1, b = 2;'), ['a', 'b']); +}); + +// `readFacadeExports` is the same enumeration widened from one source string +// to the re-export CHAIN behind a file — the form every `contracts` façade +// is built from. These use the real tree's own barrels rather than fixtures: +// a fixture would pin the walker against a file this repo never ships. +test('readFacadeExports resolves a bare `export *` chain the source-only reader refuses', () => { + const barrel = path.join(repoRoot, 'packages/contracts/src/facades/session.ts'); + // Source-only: unknowable, so it throws (the merged contract, unchanged). + assert.throws(() => readNamedExports(fs.readFileSync(barrel, 'utf8')), /export \* from/); + // Given the FILE, the same barrel is fully enumerable. + assert.deepEqual(readFacadeExports(barrel), [ + 'SESSION_SURFACES', + 'SessionAction', + 'SessionSurface', + 'parseSessionSurface', + ]); +}); + +test('readFacadeExports refuses a bare `export *` across a package specifier', () => { + // A relative star names a module this gate can read; a package star means + // resolving node_modules into another package's exports map — unbounded + // widening, the exact thing the gate refuses. + const scratch = path.join(repoRoot, 'packages/contracts/src/facades/.export-star-probe.ts'); + fs.writeFileSync(scratch, "export * from '@agent-device/kernel/errors';\n"); + try { + assert.throws(() => readFacadeExports(scratch), /only a relative re-export/); + } finally { + fs.rmSync(scratch); + } +}); + +/** Write throwaway modules next to a real façade; always clean them up. */ +function withProbeModules(files: Record, run: (dir: string) => void): void { + const dir = path.join(repoRoot, 'packages/contracts/src/facades'); + const written = Object.entries(files).map(([name, source]) => { + const file = path.join(dir, name); + fs.writeFileSync(file, source); + return file; + }); + try { + run(dir); + } finally { + for (const file of written) fs.rmSync(file, { force: true }); + } +} + +test('readFacadeExports excludes a default that a star export cannot reach', () => { + // #1574 review P1: `export *` skips the child's default per + // GetExportedNames, so a private default in a leaf is NOT part of the + // barrel's surface. Counterfactual: the named sibling still comes through, + // proving the leaf is genuinely being read and the default specifically — + // not the whole module — is what got dropped. + withProbeModules( + { + '.leaf-probe.ts': 'export default function hidden() {}\nexport const reachable = 1;\n', + '.barrel-probe.ts': "export * from './.leaf-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.barrel-probe.ts')), ['reachable']); + }, + ); +}); + +test('readFacadeExports still rejects a default on the façade entry itself', () => { + // The other side of the same rule: the entry's own default IS a default + // export of the façade, and a façade pinned to a named list must not carry + // one. Same source text as the leaf above — only its position changed. + withProbeModules({ '.entry-default-probe.ts': 'export default function leak() {}\n' }, (dir) => { + assert.throws( + () => readFacadeExports(path.join(dir, '.entry-default-probe.ts')), + /export default/, + ); + }); +}); + +test('readFacadeExports rejects a name two star sources resolve differently', () => { + // ESM resolves this to `ambiguous`, so `clash` is not importable at all; + // unioning would pin a symbol no consumer can reach. + withProbeModules( + { + '.clash-a-probe.ts': 'export const clash = 1;\nexport const onlyA = 1;\n', + '.clash-b-probe.ts': 'export const clash = 2;\n', + '.clash-barrel-probe.ts': + "export * from './.clash-a-probe.ts';\nexport * from './.clash-b-probe.ts';\n", + }, + (dir) => { + assert.throws(() => readFacadeExports(path.join(dir, '.clash-barrel-probe.ts')), /ambiguous/); + }, + ); +}); + +test('readFacadeExports resolves a diamond and lets an explicit export shadow a star', () => { + // The two counterfactuals to the ambiguity rule, both of which a naive + // "two paths reached this name" check would wrongly reject. One shared + // declaration reached by two barrels is ONE binding, not a clash; and an + // explicit re-export of a name a star also provides is the spec's own + // precedence, not ambiguity. + withProbeModules( + { + '.shared-probe.ts': 'export const shared = 1;\n', + '.mid-one-probe.ts': "export * from './.shared-probe.ts';\n", + '.mid-two-probe.ts': "export * from './.shared-probe.ts';\n", + '.diamond-probe.ts': + "export * from './.mid-one-probe.ts';\nexport * from './.mid-two-probe.ts';\n", + '.shadow-src-probe.ts': 'export const shadowed = 1;\nexport const other = 2;\n', + '.shadow-probe.ts': + "export * from './.shadow-src-probe.ts';\nexport { shadowed } from './.shared-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.diamond-probe.ts')), ['shared']); + assert.deepEqual(readFacadeExports(path.join(dir, '.shadow-probe.ts')), [ + 'other', + 'shadowed', + ]); + }, + ); +}); + +test('readFacadeExports follows a named re-export chain to its ultimate binding', () => { + // #1574 review P1: `a` re-exports `x` from `b`, `c` re-exports `x` from + // `a`, and the façade stars both. ESM resolves ONE binding (`b`'s `x`), so + // this is a diamond, not a clash. Identifying a re-export by its immediate + // source would see `b#x` vs `a#x` and falsely reject the façade — the + // counterfactual that fails without transitive origin resolution. + withProbeModules( + { + '.chain-b-probe.ts': 'export const x = 1;\n', + '.chain-a-probe.ts': "export { x } from './.chain-b-probe.ts';\n", + '.chain-c-probe.ts': "export { x } from './.chain-a-probe.ts';\n", + '.chain-facade-probe.ts': + "export * from './.chain-a-probe.ts';\nexport * from './.chain-c-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.chain-facade-probe.ts')), ['x']); + }, + ); +}); + +test('readFacadeExports keeps rejecting two genuinely distinct bindings behind a chain', () => { + // The guard against over-correcting the above: resolving through chains + // must not collapse two REAL declarations into one. Same chain depth as the + // diamond, but the two branches bottom out in different modules. + withProbeModules( + { + '.split-one-probe.ts': 'export const y = 1;\n', + '.split-two-probe.ts': 'export const y = 2;\n', + '.split-a-probe.ts': "export { y } from './.split-one-probe.ts';\n", + '.split-c-probe.ts': "export { y } from './.split-two-probe.ts';\n", + '.split-facade-probe.ts': + "export * from './.split-a-probe.ts';\nexport * from './.split-c-probe.ts';\n", + }, + (dir) => { + assert.throws(() => readFacadeExports(path.join(dir, '.split-facade-probe.ts')), /ambiguous/); + }, + ); +}); + +// #1574 review, third round. `export { default } from './x.ts'` is reported +// by oxc as kind `Name` with the name `default` — the same fact as +// `export default …` wearing a different parse shape. It has to stay in a +// module's map so a later `export { default as x }` can resolve its binding, +// while never being reachable through a star. These three pin that split. +test('a star export does not re-export a name called `default`', () => { + // Per GetExportedNames a star skips `default` — oxc names the star's own + // import `AllButDefault`. Counterfactual: the ordinary sibling name in the + // same module still comes through, so this is `default` being filtered and + // not the whole module being dropped. + withProbeModules( + { + '.dstar-leaf-probe.ts': 'export default function hidden() {}\nexport const kept = 1;\n', + '.dstar-mid-probe.ts': + "export { default } from './.dstar-leaf-probe.ts';\n" + + "export { kept } from './.dstar-leaf-probe.ts';\n", + '.dstar-facade-probe.ts': "export * from './.dstar-mid-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.dstar-facade-probe.ts')), ['kept']); + }, + ); +}); + +test('a façade re-exporting a default under the name `default` is rejected', () => { + // The entry carries a default export either way; only the parse shape + // differs from the `export default …` case above. + withProbeModules( + { + '.dentry-leaf-probe.ts': 'export default function leak() {}\n', + '.dentry-facade-probe.ts': "export { default } from './.dentry-leaf-probe.ts';\n", + }, + (dir) => { + assert.throws( + () => readFacadeExports(path.join(dir, '.dentry-facade-probe.ts')), + /must not carry one/, + ); + }, + ); +}); + +test('two paths to one default binding resolve to a single name, not ambiguity', () => { + // `leaf` declares a default; `a` re-exports it; `b` names a's default `x` + // while `c` names leaf's default `x`; a façade stars both. ESM resolves ONE + // `leaf#default` binding, so `x` is exported rather than ambiguous — the + // intermediate `export { default } from` link has to carry identity through + // for the two paths to agree. + withProbeModules( + { + '.dchain-leaf-probe.ts': 'export default function shared() {}\n', + '.dchain-a-probe.ts': "export { default } from './.dchain-leaf-probe.ts';\n", + '.dchain-b-probe.ts': "export { default as x } from './.dchain-a-probe.ts';\n", + '.dchain-c-probe.ts': "export { default as x } from './.dchain-leaf-probe.ts';\n", + '.dchain-facade-probe.ts': + "export * from './.dchain-b-probe.ts';\nexport * from './.dchain-c-probe.ts';\n", + }, + (dir) => { + assert.deepEqual(readFacadeExports(path.join(dir, '.dchain-facade-probe.ts')), ['x']); + }, + ); +}); diff --git a/scripts/layering/facade-exports.ts b/scripts/layering/facade-exports.ts new file mode 100644 index 000000000..5f696682b --- /dev/null +++ b/scripts/layering/facade-exports.ts @@ -0,0 +1,242 @@ +// Façade export enumeration: what names a module actually exposes to a +// consumer, for the R11 exact-symbol pins in `package-boundaries.test.ts`. +// +// Split out of `package-boundaries.ts` (#1574 review): the boundary rules +// answer "may this file import that one?", while this module answers "what +// does this façade name?" — different questions, so per AGENTS.md they are +// different bounded reads. + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseSync } from 'oxc-parser'; + +/** + * Every name a façade module exports, value or type-only, sorted — the exact + * "named-export-list" a package-boundaries gate can pin (#1555 review P1, + * "add the reviewer-required exact exported-symbol gate"). Covers both + * re-export forms (`export { a, b } from './x.ts'`, + * `export type { a, b } from './x.ts'`, with or without `as` aliasing — the + * alias is reported, since that is the name a consumer actually imports), + * `export * as ns from './x.ts'` (one real name, `ns`), and direct + * declarations (`export function`/`const`/`class`/`type`/`interface`, + * including `export const a = 1, b = 2`'s multiple declarators). A stray + * export — intentional or not — changes this list, so a test that pins it + * exactly turns "the façade grew a symbol" into a loud failure instead of a + * silent widening only a PR diff review would catch. + * + * AST-based (`oxc-parser`, already a devDependency — `session-state.ts` is + * the existing precedent for using it in this gate), not a regex, for the + * SAME reason `session-state.ts` gives: a regex has to enumerate every + * export FORM by hand, and the one it forgets is exactly the one that slips + * through. That is precisely what happened here (#1555 review, second pass, + * "the gate also ignores export-star declarations, so it can miss future + * widening"): `export * from './x.ts'` re-exports an unbounded, statically + * unknowable set of names — the old regex scanner had no case for it at all, + * so it silently contributed NOTHING to the list instead of failing loudly. + * `parsed.module.staticExports` is oxc's own resolved export-entry table + * (built for exactly this purpose, not re-derived from a manual AST walk), + * and its `exportName.kind` already draws the line this function needs: + * `'None'` is bare `export *` (unenumerable — thrown), `'Default'` is + * `export default …` (also thrown — a facade pinned to an exact named-export + * list must not carry one), and `'Name'` is every enumerable form above, + * `export * as ns` included (oxc reports its one real bound name, `ns`). + */ +export function readNamedExports(source: string): string[] { + const parsed = parseSync('package-boundaries-export-scan.ts', source); + const names = new Set(); + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + if (entry.exportName.kind === 'None') { + throw new Error( + "readNamedExports cannot enumerate 'export * from …' — it re-exports an unknown set " + + 'of names, exactly the widening an exact-export-list gate exists to catch. Name the ' + + 're-exported symbols explicitly instead of re-exporting the whole module.', + ); + } + if (entry.exportName.kind === 'Default') { + throw new Error( + "readNamedExports cannot enumerate 'export default …' as a named symbol — a facade a " + + 'caller pins to an exact named-export list must not carry a default export.', + ); + } + if (entry.exportName.name) names.add(entry.exportName.name); + } + } + return [...names].sort(); +} + +/** + * Every name a façade subpath exports, sorted — `readNamedExports` widened + * from one source string to the re-export CHAIN behind it, so a barrel + * façade is pinnable too. + * + * `readNamedExports` throws on bare `export * from './x.ts'` because, given + * only a source string, the set it contributes is genuinely unknowable. Given + * the FILE, it is not: the specifier names a sibling module the gate can read + * and enumerate in turn. That is the whole difference here — every + * `@agent-device/contracts` façade (`src/facades/*.ts`) is exactly such a + * barrel, 13 of the 14 subpaths being nothing but bare re-export lines, so + * without chain resolution the package with the largest and fastest-growing + * public surface in the workspace is the one package that cannot be pinned. + * + * The walk models what `export *` ACTUALLY re-exports, which is narrower than + * "every name in the child" in two ways a naive union gets wrong (#1574 + * review P1): + * + * 1. **`default` is excluded.** Per `GetExportedNames`, a star export skips + * the child's default entirely. A private `export default` in a leaf is + * not reachable through the barrel and does not widen the façade, so it is + * passed over rather than rejected. A default on the ENTRY file is still a + * real default export of the façade itself, and still throws. + * 2. **A name from two different star sources is ambiguous, not exported.** + * `ResolveExport` returns `ambiguous` when star resolution finds two + * distinct bindings for one name, and importing it is then a `SyntaxError` + * — the name is not part of the surface at all. Unioning would silently + * pin a symbol no consumer can import, so ambiguity throws instead, naming + * both origins. A diamond (two barrels reaching the SAME declaration) is + * not ambiguous and resolves normally, which is why origins are tracked by + * the binding a name ultimately resolves to — through a chain of named + * re-exports, not just the immediate source — rather than by path taken. + * An explicit export in a module shadows any star-provided name of the + * same name, exactly as the spec's own precedence does. + * + * Resolution is deliberately narrow — a RELATIVE specifier only, and the + * repo's explicit-`.ts`-extension convention means the specifier is already + * the path. A bare star across a PACKAGE specifier still throws: enumerating + * it means resolving `node_modules` and re-entering another package's + * `exports` map, and a façade that re-exports a whole other package wholesale + * is precisely the unbounded widening this gate exists to refuse. Cycles are + * visit-guarded (a barrel pair that re-exported each other would otherwise + * recurse forever). + */ +export function readFacadeExports(entryFile: string): string[] { + // `${declaringFile}#${name}` — binding identity, so the same declaration + // reached by two different barrel paths is one origin, not two. + const cache = new Map>(); + const walking = new Set(); + + /** + * The binding a named re-export ULTIMATELY names, not the module it was + * written against (#1574 review P1). `a` re-exports `x` from `b`, `c` + * re-exports `x` from `a`, and a façade stars both: ESM resolves one `b#x` + * binding, so that is a diamond, not a clash. Stopping at the immediate + * source would identify the two paths as `b#x` and `a#x` and falsely reject + * the façade as ambiguous. The child's own map already carries + * fully-resolved origins, so asking it is the whole fix. + */ + const reExportOrigin = ( + from: string, + specifier: string | undefined, + importedName: string, + localName: string, + ): string => { + if (!specifier) return `${from}#${localName}`; + // A package specifier is not a file this gate reads; its name is a stable + // identity of its own, so two façades re-exporting the same symbol from + // the same package still agree. + if (!specifier.startsWith('.')) return `${specifier}#${importedName}`; + const childPath = path.resolve(path.dirname(from), specifier); + // Falls back to the immediate source when the child does not name it — a + // cycle in progress, or a name oxc cannot attribute. + return exportedNames(childPath).get(importedName) ?? `${childPath}#${importedName}`; + }; + + const exportedNames = (file: string): Map => { + const resolved = path.resolve(file); + const cached = cache.get(resolved); + if (cached) return cached; + // A cycle contributes nothing further; whatever it exports is reached by + // the path that entered it first. + if (walking.has(resolved)) return new Map(); + walking.add(resolved); + + const parsed = parseSync(resolved, fs.readFileSync(resolved, 'utf8')); + const explicit = new Map(); + const starOrigins = new Map>(); + + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + const specifier = entry.moduleRequest?.value; + + if (entry.exportName.kind === 'Default') { + // Only the façade's own default is a default export of the façade. + if (resolved === path.resolve(entryFile)) { + throw new Error( + `readFacadeExports cannot enumerate 'export default …' as a named symbol ` + + `(${resolved}) — a facade a caller pins to an exact named-export list must not ` + + 'carry one.', + ); + } + continue; + } + + if (entry.exportName.kind === 'None') { + if (!specifier || !specifier.startsWith('.')) { + throw new Error( + `readFacadeExports cannot enumerate 'export * from ${specifier ?? '…'}' ` + + `(${resolved}) — only a relative re-export names a module this gate can read ` + + 'and enumerate in turn. Name the re-exported symbols explicitly instead.', + ); + } + const childPath = path.resolve(path.dirname(resolved), specifier); + for (const [name, origin] of exportedNames(childPath)) { + // `default` is filtered HERE, at the star, not at the source + // (#1574 review, third round). A child's `export { default } from + // './leaf.ts'` is a NAMED export whose name happens to be + // `default` — oxc reports it as kind `Name`, and it must stay in + // the child's map so a chain like `export { default as x } from + // './that-child.ts'` can resolve its binding. But a star must not + // re-export it: `GetExportedNames` skips `default`, and oxc names + // the star's own import `AllButDefault`. Filtering at the source + // would break identity resolution; filtering here is the spec's + // own split. + if (name === 'default') continue; + let origins = starOrigins.get(name); + if (!origins) starOrigins.set(name, (origins = new Map())); + origins.set(origin, specifier); + } + continue; + } + + const name = entry.exportName.name; + if (!name) continue; + explicit.set( + name, + reExportOrigin(resolved, specifier, entry.importName?.name ?? name, name), + ); + } + } + + const names = new Map(explicit); + for (const [name, origins] of starOrigins) { + if (explicit.has(name)) continue; // explicit export shadows the star + if (origins.size > 1) { + throw new Error( + `readFacadeExports found '${name}' re-exported by ${origins.size} different ` + + `'export *' sources in ${resolved} (${[...origins.values()].sort().join(', ')}). ` + + 'ESM resolves that to `ambiguous`, so the name is not importable at all and must ' + + 'not be pinned as part of the surface. Re-export it explicitly from one source.', + ); + } + names.set(name, [...origins.keys()][0]!); + } + + walking.delete(resolved); + cache.set(resolved, names); + return names; + }; + + const names = exportedNames(entryFile); + // The entry's own default, in EITHER form. `export default …` throws above + // as it is parsed; `export { default } from './x.ts'` reaches here instead, + // because oxc reports it as a named export called `default` — a different + // parse shape for the same fact, that the façade carries a default export. + if (names.has('default')) { + throw new Error( + `readFacadeExports cannot enumerate a default export as a named symbol ` + + `(${path.resolve(entryFile)}) — a facade a caller pins to an exact named-export list ` + + 'must not carry one, whether declared or re-exported under the name `default`.', + ); + } + return [...names.keys()].sort(); +} diff --git a/scripts/layering/facade-symbols.ts b/scripts/layering/facade-symbols.ts new file mode 100644 index 000000000..c4d402f1a --- /dev/null +++ b/scripts/layering/facade-symbols.ts @@ -0,0 +1,887 @@ +// The exact exported-symbol list of every workspace package façade — the data +// half of the R11 gate, kept beside `package-boundaries.test.ts` so the +// behavioral tests there stay one bounded read (AGENTS.md module-size +// tripwires; a generated table is data, not behavior, and the two answer +// different questions). +// +// #1555 added the first such pin, for `@agent-device/ad-replay`; that one +// stays asserted inline beside the design rationale for its two-value façade. +// This table covers every other exported subpath. The exports-subpath checks +// prove WHICH files a package exposes; these lists prove WHAT those files +// name, so a façade cannot grow a symbol without editing the gate. +// +// The lists are the honest current surface, deliberately untrimmed: several +// are wider than their owners would design today (`contracts/interaction` +// alone names 140 symbols), and pinning the real number is what makes the +// next widening visible. Narrowing a façade is a change to that package, with +// its own consumers to fix — not a silent edit to this table. +// +// Maintaining it is mechanical: run `pnpm check:layering` and the assertion +// prints the exact added/removed names. +export const FACADE_SYMBOLS: readonly (readonly [string, readonly string[]])[] = [ + [ + '@agent-device/ad-script', + [ + 'LocalIdentity', + 'ParsedReplayScript', + 'REPLAY_VAR_KEY_RE', + 'ReplayScriptMetadata', + 'ReplayVarScope', + 'TARGET_ANNOTATION_MAX_ANCESTRY', + 'TARGET_ANNOTATION_MAX_FIELD_BYTES', + 'TARGET_ANNOTATION_MAX_PAYLOAD_BYTES', + 'TargetBindingClassification', + 'TargetBindingClassificationInput', + 'annotationLocalIdentity', + 'appendScriptSeriesFlags', + 'buildReplayVarScope', + 'classifyTargetBindingMatch', + 'collectReplayScrubbableVarValues', + 'collectReplayShellEnv', + 'firstAncestryMismatch', + 'formatDivergenceActionLabel', + 'formatPortableActionLine', + 'formatScriptArg', + 'formatScriptStringLiteral', + 'formatTargetAnnotationLines', + 'identityFieldMismatches', + 'isClickLikeCommand', + 'isTouchTargetCommand', + 'matchesAncestryPrefix', + 'matchesLocalIdentity', + 'normalizeIdentifierField', + 'normalizeLabelField', + 'normalizeRoleField', + 'parseReplayCliEnvEntries', + 'parseReplayScriptDetailed', + 'parseTargetAnnotationV1Payload', + 'readReplayCliEnvEntries', + 'readReplayScriptMetadata', + 'readReplayShellEnvSource', + 'resolveDeclaredScriptPlatform', + 'resolveReplayAction', + 'serializeTargetAnnotationV1', + 'stripRecordedRefGeneration', + 'truncateToUtf8Bytes', + 'utf8ByteLength', + ], + ], + [ + '@agent-device/contracts/client', + [ + 'AgentDeviceCapabilitiesResult', + 'AgentDeviceClientConfig', + 'AgentDeviceDaemonTransport', + 'AgentDeviceDaemonTransportContext', + 'AgentDeviceDevice', + 'AgentDeviceIdentifiers', + 'AgentDeviceRequestOverrides', + 'AgentDeviceSelectionOptions', + 'AgentDeviceSession', + 'AgentDeviceSessionDevice', + 'AlertCommandOptions', + 'AppCloseOptions', + 'AppCloseResult', + 'AppDeployOptions', + 'AppDeployResult', + 'AppInstallFromSourceOptions', + 'AppInstallFromSourceResult', + 'AppInstallOptions', + 'AppListOptions', + 'AppOpenOptions', + 'AppOpenResult', + 'AppPushOptions', + 'AppStateCommandOptions', + 'AppTriggerEventOptions', + 'AudioOptions', + 'BatchRunOptions', + 'BatchStep', + 'CaptureDiffOptions', + 'CaptureScreenshotOptions', + 'CaptureScreenshotResult', + 'CaptureSnapshotOptions', + 'CaptureSnapshotResult', + 'ClickOptions', + 'ClipboardCommandOptions', + 'CloudArtifactsOptions', + 'CommandExecutionOptions', + 'CommandRequestResult', + 'DeviceBootOptions', + 'DeviceCommandBaseOptions', + 'DeviceShutdownOptions', + 'DoctorCommandOptions', + 'ElementTarget', + 'EventsOptions', + 'FillOptions', + 'FindBaseOptions', + 'FindOptions', + 'FindSnapshotCommandOptions', + 'FlingOptions', + 'FocusOptions', + 'GetOptions', + 'InteractionTarget', + 'InternalRequestOptions', + 'IsOptions', + 'IsStatePredicateOptions', + 'IsTextPredicateOptions', + 'JsonObject', + 'JsonPrimitive', + 'JsonValue', + 'KeyboardCommandOptions', + 'Lease', + 'LeaseAllocateOptions', + 'LeaseOptions', + 'LeaseScopedOptions', + 'LogsOptions', + 'LongPressOptions', + 'MaterializationReleaseOptions', + 'MaterializationReleaseResult', + 'NetworkOptions', + 'PanOptions', + 'PerfOptions', + 'PermissionTarget', + 'PinchOptions', + 'PointTarget', + 'PrepareCommandOptions', + 'PressOptions', + 'ReactNativeCommandOptions', + 'RecordControlOptions', + 'RecordOptions', + 'RefTarget', + 'RepeatedPressOptions', + 'ReplayRunOptions', + 'ReplayTestOptions', + 'RotateGestureOptions', + 'ScrollOptions', + 'SelectorSnapshotCommandOptions', + 'SelectorTarget', + 'SessionCloseResult', + 'SessionSaveScriptOptions', + 'SessionSaveScriptResult', + 'SettingsUpdateOptions', + 'SettleCommandOptions', + 'StartupPerfSample', + 'SwipeGestureOptions', + 'SwipeOptions', + 'TraceOptions', + 'TransformGestureOptions', + 'TypeTextOptions', + 'ViewportCommandOptions', + 'WaitCommandOptions', + 'WaitCommandTarget', + 'isRecord', + ], + ], + [ + '@agent-device/contracts/command', + [ + 'CliFlags', + 'CommandFlags', + 'DEFAULT_BATCH_MAX_STEPS', + 'DaemonBatchStep', + 'DaemonExcludedCliFlag', + 'DispatchedCommand', + 'IOS_SAFARI_BUNDLE_ID', + 'MaestroRuntimeFlags', + 'PrepareCommandResult', + 'PrepareIosRunnerArtifactState', + 'PrepareIosRunnerCacheKind', + 'PrepareIosRunnerTiming', + 'PushCommandResult', + 'assertBatchStepCount', + 'isDeepLinkTarget', + 'isValidBatchMaxSteps', + 'isWebUrl', + 'parseBatchStepRuntime', + 'readBatchStepInputObject', + 'readBatchStepRecord', + 'readOptionalInteger', + 'resolveIosDeviceDeepLinkBundleId', + ], + ], + [ + '@agent-device/contracts/device', + [ + 'AppStateCommandResult', + 'AppsFilter', + 'BootCommandResult', + 'DEFAULT_APPS_FILTER', + 'DEVICE_ROTATIONS', + 'DEVICE_ROTATION_SURFACE_INDEX', + 'DeviceInventoryGroup', + 'DeviceInventoryGroupCounts', + 'DeviceInventoryProvider', + 'DeviceInventoryRequest', + 'DeviceLease', + 'DeviceRotation', + 'LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS', + 'LeaseLifecycleContext', + 'LeaseLifecycleProvider', + 'ProviderDeviceInstallOptions', + 'ProviderDeviceInstallResult', + 'ProviderDeviceRuntime', + 'ProviderExpiredLeaseRecovery', + 'ProviderPortReverseOptions', + 'ShutdownCommandResult', + 'TargetShutdownResult', + 'TriggerAppEventCommandResult', + 'WEB_DESKTOP_DEVICE', + 'assertResolvedAppsFilter', + 'countDeviceInventoryByGroup', + 'deviceRotationOrientation', + 'deviceRotationSurfaceDegrees', + 'parseDeviceRotation', + 'resolveAppsFilter', + 'shouldUseHostMacFastPath', + ], + ], + [ + '@agent-device/contracts/interaction', + [ + 'ALERT_ACTIONS', + 'ALERT_ACTION_RETRY_MS', + 'ALERT_POLL_INTERVAL_MS', + 'AlertAction', + 'AlertInfo', + 'AlertPlatform', + 'AlertSource', + 'AppSwitcherCommandResult', + 'AppleTvRemoteButton', + 'BACK_MODES', + 'BackCommandResult', + 'BackMode', + 'CLICK_BUTTONS', + 'ClickButton', + 'ClickCommandResponseData', + 'ClipboardCommandResult', + 'DEFAULT_ALERT_TIMEOUT_MS', + 'DisambiguationTiebreak', + 'ElementSelectorKey', + 'ElementSelectorTapOptions', + 'ElementTarget', + 'FillCommandResponseData', + 'FillCommandResult', + 'FindCommandResponseData', + 'FlingGesturePayload', + 'GESTURE_DURATION_MAX_MS', + 'GESTURE_DURATION_MIN_MS', + 'GESTURE_FLING_DURATION_MS', + 'GESTURE_INITIAL_ANGLE_DEGREES', + 'GESTURE_KINDS', + 'GESTURE_SAMPLE_INTERVAL_MS', + 'GestureExecutionProfile', + 'GestureIntent', + 'GesturePayload', + 'GesturePlan', + 'GesturePointerCount', + 'GestureReferenceFrame', + 'GestureSemanticInput', + 'GuaranteeEnforcement', + 'HomeCommandResult', + 'INTERACTION_DISPATCH_PATHS', + 'INTERACTION_GUARANTEES', + 'INTERACTION_PATH_IDS', + 'InPageSwipeGesturePlan', + 'InteractionEvidence', + 'InteractionGuarantee', + 'InteractionPathContract', + 'InteractionPathId', + 'InteractionTarget', + 'Interactor', + 'KeyboardCommandResult', + 'LongPressCommandResponseData', + 'LongPressCommandResult', + 'MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE', + 'MultiTouchGesturePlan', + 'NormalizedPublicGesture', + 'OrientationCommandResult', + 'PanGesturePayload', + 'PinchGesturePayload', + 'PointTarget', + 'PointerTrajectory', + 'PointerTrajectorySample', + 'PressCommandResponseData', + 'PressCommandResult', + 'RecordingTargetOverride', + 'RefTarget', + 'ResolutionDiagnosticEntry', + 'ResolutionDisclosure', + 'ResolvedInteractionTarget', + 'ResolvedTarget', + 'RotateCommandResult', + 'RotateGesturePayload', + 'RunnerCallOptions', + 'RunnerContext', + 'SCROLL_DIRECTIONS', + 'SCROLL_DURATION_MAX_MS', + 'SCROLL_INPUT_DIRECTIONS', + 'SWIPE_PATTERNS', + 'SWIPE_PAUSE_MAX_MS', + 'SWIPE_PRESETS', + 'SWIPE_REPETITION_MAX', + 'SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS', + 'ScreenshotOptions', + 'ScrollCommandOptions', + 'ScrollDirection', + 'ScrollDistanceOptions', + 'ScrollGestureOptions', + 'ScrollGesturePlan', + 'ScrollInputDirection', + 'ScrollTimingOptions', + 'SelectorTarget', + 'SettleDiffLine', + 'SettleObservation', + 'SettleParams', + 'SettleTailEntry', + 'SinglePointerGesturePlan', + 'SnapshotOptions', + 'SnapshotResult', + 'SwipeGesturePayload', + 'SwipePattern', + 'SwipePayload', + 'SwipePreset', + 'SwipePresetGesturePlan', + 'TV_REMOTE_BUTTONS', + 'TV_REMOTE_BUTTON_USAGE', + 'TransformGestureParams', + 'TransformGesturePayload', + 'TvRemoteButton', + 'TvRemoteCommandResult', + 'VegaTvRemoteKey', + 'WaitCommandResult', + 'assertExclusiveScrollDistanceInputs', + 'assertNoRemovedSwipeInput', + 'assertScrollGestureInput', + 'buildGesturePlan', + 'buildInPageSwipeGesturePlan', + 'buildScrollGesturePlan', + 'buildSwipePresetGesturePlan', + 'buttonTag', + 'clampGestureCoordinate', + 'describeReplayGestureArityError', + 'gestureDirectionDelta', + 'gesturePayloadFromPositionals', + 'gesturePayloadToPositionals', + 'getClickButtonValidationError', + 'honoredScrollDurationMs', + 'inferGestureReferenceFrame', + 'normalizePublicGesture', + 'normalizePublicSwipeMotion', + 'normalizeScrollDurationMs', + 'parseScrollDirection', + 'parseTvRemoteButton', + 'readGesturePayload', + 'resolveClickButton', + 'singlePointerPlanEndpoints', + 'swipePayloadFromPositionals', + 'toAndroidTvRemoteKeyevent', + 'toAppleTvRemoteButton', + 'toVegaTvRemoteKey', + 'tvRemoteDurationMode', + ], + ], + [ + '@agent-device/contracts/capture', + [ + 'AndroidSnapshotBackendMetadata', + 'BackendSnapshotOptions', + 'BackendSnapshotResult', + 'DiffSnapshotCommandResult', + 'FindLocator', + 'PublicSnapshotCaptureAnnotations', + 'SCREENSHOT_ACTION_FLAG_KEYS', + 'SCREENSHOT_COMMAND_FLAG_KEYS', + 'SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS', + 'ScreenshotDispatchFlags', + 'ScreenshotPublicOptions', + 'ScreenshotRequestFlags', + 'ScreenshotResultData', + 'ScreenshotRuntimeFlags', + 'ScreenshotRuntimeOptions', + 'SnapshotCaptureAnalysis', + 'SnapshotCaptureAnnotations', + 'SnapshotCaptureFreshness', + 'SnapshotDiagnosticsState', + 'SnapshotDiagnosticsSummary', + 'SnapshotDiffLine', + 'SnapshotDiffSummary', + 'SnapshotTimingSample', + 'SnapshotTimingStats', + 'ViewportCommandResult', + 'appendScreenshotScriptFlags', + 'mergeSnapshotDiagnostics', + 'publicSnapshotCaptureAnnotations', + 'readScreenshotScriptFlag', + 'readSerializedSnapshotCaptureAnnotations', + 'readSnapshotDiagnosticsSummary', + 'recordSnapshotTiming', + 'screenshotFlagsFromOptions', + 'screenshotOptionsFromFlags', + 'snapshotCaptureAnnotationsFrom', + 'summarizeSnapshotDiagnostics', + 'summarizeSnapshotTimingSamples', + ], + ], + [ + '@agent-device/contracts/platform', + [ + 'ANDROID_SYSTEM_CHROME_PACKAGE', + 'AndroidInputOwner', + 'AndroidInputOwnership', + 'AndroidInputOwnershipSource', + 'AndroidSystemChromeProvenance', + 'AudioProbeResult', + 'AudioProbeSource', + 'EmptyAudioProbeResultOptions', + 'NormalizeAudioProbeRecordOptions', + 'PlatformGatedProviderResolverKey', + 'PlatformPlugin', + 'RunnerLogicalLeaseContext', + 'assertAppleMultiTouchSupported', + 'classifyAndroidInputOwner', + 'classifyAndroidInputOwnership', + 'emptyAudioProbeResult', + 'hasAndroidSystemChromeProvenance', + 'isAndroidInputMethodOwnedNode', + 'isAndroidSystemChromeWindowResourceId', + 'isAudioProbeSupportedDevice', + 'isFallbackAndroidInputMethodPackage', + 'isFallbackAndroidInputMethodResource', + 'isHostSystemAudioProbeDevice', + 'normalizeAudioProbeRecord', + 'parseAndroidInputMethodPackage', + 'readAndroidActiveInputMethodPackage', + 'stripAndroidSystemChromeProvenance', + 'stripAndroidSystemChromeProvenanceFromNode', + ], + ], + [ + '@agent-device/contracts/settings', + [ + 'PermissionAction', + 'PermissionTarget', + 'SETTINGS_INVALID_ARGS_MESSAGE', + 'SETTINGS_USAGE_OVERRIDE', + 'SettingOptions', + 'getUnsupportedMacOsSettingMessage', + 'isMacOsSettingSupported', + 'parsePermissionAction', + 'parsePermissionTarget', + ], + ], + [ + '@agent-device/contracts/session', + ['SESSION_SURFACES', 'SessionAction', 'SessionSurface', 'parseSessionSurface'], + ], + [ + '@agent-device/contracts/recording', + [ + 'DEFAULT_RECORDING_EXPORT_QUALITY', + 'RECORDING_EXPORT_QUALITIES', + 'RECORDING_SCOPE_VALUES', + 'RecordingAppIdentity', + 'RecordingBackendTag', + 'RecordingCommandResult', + 'RecordingExportQuality', + 'RecordingScope', + 'RecordingStartCommandResult', + 'RecordingStopCommandResult', + 'TraceCommandResult', + 'isRecordingExportQuality', + 'isWholeScreenRecordingScope', + 'recordingQualityInputToExportQuality', + ], + ], + [ + '@agent-device/contracts/observability', + [ + 'AgentArtifactsResult', + 'CloudArtifact', + 'CloudArtifactAvailability', + 'CloudArtifactKind', + 'CloudArtifactProvider', + 'CloudArtifactsQuery', + 'CloudArtifactsResult', + 'CloudArtifactsStatus', + 'CloudProviderSessionResult', + 'DaemonArtifactInventoryEntry', + 'DaemonArtifactsResult', + 'DebugSymbolsCrashFrame', + 'DebugSymbolsCrashSummary', + 'DebugSymbolsImage', + 'DebugSymbolsOptions', + 'DebugSymbolsResult', + 'DoctorCheck', + 'DoctorCommandResult', + 'DoctorKind', + 'DoctorStatus', + 'LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE', + 'LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE', + 'LOG_ACTION_VALUES', + 'LogAction', + 'LogBackend', + 'NetworkEntry', + 'PERF_ACTION_ERROR_MESSAGE', + 'PERF_ACTION_VALUES', + 'PERF_AREA_ERROR_MESSAGE', + 'PERF_AREA_VALUES', + 'PERF_KIND_ERROR_MESSAGE', + 'PERF_KIND_VALUES', + 'PERF_MEMORY_KIND_ERROR_MESSAGE', + 'PERF_SUBJECT_ERROR_MESSAGE', + 'PERF_SUBJECT_VALUES', + 'PerfAction', + 'PerfArea', + 'PerfKind', + 'PerfMetricsSamplerTag', + 'PerfSubject', + 'isPerfAction', + 'isPerfArea', + 'isPerfKind', + 'isPerfMemoryKind', + 'isPerfSubject', + ], + ], + [ + '@agent-device/contracts/remote', + [ + 'CloudProviderProfileFields', + 'CompanionTunnelScope', + 'MetroBridgeResult', + 'MetroBridgeScope', + 'MetroPrepareKind', + 'MetroPrepareOptions', + 'MetroPrepareResult', + 'MetroReloadOptions', + 'MetroReloadResult', + 'PROVIDER_DEVICE_ORIENTATIONS', + 'PrepareMetroRuntimeResult', + 'ProviderConnectionResource', + 'ProviderConnectionVerification', + 'ProviderDeviceOrientation', + 'ReloadMetroResult', + 'RemoteConfigMetroOptions', + 'RemoteConnectionProfileFields', + 'ResolvedMetroKind', + ], + ], + [ + '@agent-device/contracts/replay', + [ + 'RefFrameEffect', + 'ReplayCommandResult', + 'ReplaySuiteAttemptFailure', + 'ReplaySuiteResult', + 'ReplaySuiteTestFailed', + 'ReplaySuiteTestPassed', + 'ReplaySuiteTestResult', + 'ReplaySuiteTestSkipReason', + 'ReplaySuiteTestSkipped', + 'TargetAncestryEntry', + 'TargetAnnotationV1', + 'TargetRect', + 'TargetScrollRegion', + 'TargetVerification', + ], + ], + [ + '@agent-device/contracts/divergence', + [ + 'REPLAY_DIVERGENCE_DEFAULT_REF_LIMIT', + 'REPLAY_DIVERGENCE_DIGEST_REF_LIMIT', + 'REPLAY_DIVERGENCE_LEVEL_BYTE_LIMITS', + 'REPLAY_DIVERGENCE_SUGGESTION_LIMIT', + 'ReplayDivergence', + 'ReplayDivergenceCause', + 'ReplayDivergenceKind', + 'ReplayDivergenceOverflow', + 'ReplayDivergenceResume', + 'ReplayDivergenceScreen', + 'ReplayDivergenceScreenRef', + 'ReplayDivergenceStep', + 'ReplayDivergenceStepSource', + 'ReplayDivergenceSuggestion', + 'ReplayDivergenceSuggestionBasis', + 'ReplayDivergenceTargetBinding', + 'ReplayDivergenceTargetBindingKind', + 'ReplayDivergenceTargetCandidate', + 'ReplayDivergenceTargetIdentity', + 'ReplayRepairHint', + 'ReplayVarScrubEntry', + 'applyReplayDivergenceLevelCaps', + 'boundReplayDivergence', + 'createReplayDivergenceSanitizer', + 'formatReplayDivergenceReport', + 'measureReplayDivergenceBytes', + 'sanitizeReplayDivergenceField', + 'scrubReplayVarValues', + 'truncateUtf8Field', + ], + ], + [ + '@agent-device/contracts/progress', + [ + 'CommandProgressEvent', + 'ReplayTestProgressEvent', + 'ReplayTestSuiteProgressEvent', + 'RequestProgressEvent', + 'RequestProgressSink', + ], + ], + [ + '@agent-device/kernel/errors', + [ + 'AppError', + 'AppErrorCode', + 'AppErrorDetails', + 'DaemonError', + 'KNOWN_APP_ERROR_CODES', + 'KnownAppErrorCode', + 'NormalizedError', + 'asAppError', + 'defaultHintForCode', + 'isAgentDeviceError', + 'normalizeAgentDeviceError', + 'normalizeError', + 'retriableForErrorCode', + 'throwDaemonError', + 'toAppErrorCode', + ], + ], + [ + '@agent-device/kernel/device', + [ + 'AppleOS', + 'ApplePlatform', + 'DEVICE_TARGETS', + 'DeviceInfo', + 'DeviceKind', + 'DeviceSelector', + 'DeviceTarget', + 'PLATFORMS', + 'PLATFORM_SELECTORS', + 'PUBLIC_PLATFORMS', + 'Platform', + 'PlatformSelector', + 'PublicPlatform', + 'deviceFieldsFromPublicPlatform', + 'isAppleOs', + 'isApplePlatform', + 'isIosFamily', + 'isMacOs', + 'isMobilePlatform', + 'isPlatform', + 'isPublicPlatform', + 'isSerialAddressablePlatform', + 'isTvOsDevice', + 'matchesDeviceSelector', + 'matchesPlatformSelector', + 'publicPlatformString', + 'resolveApplePlatformName', + 'resolveAppleSimulatorSetPathForSelector', + 'resolveDevice', + 'resolveDeviceAppleOs', + 'sortAppleDevicesForSelection', + ], + ], + [ + '@agent-device/kernel/snapshot', + [ + 'HiddenContentHint', + 'Point', + 'REF_GRAMMAR_HINT', + 'RawSnapshotNode', + 'Rect', + 'ScreenshotOverlayRef', + 'SnapshotBackend', + 'SnapshotNode', + 'SnapshotOptions', + 'SnapshotPresentationFlagInput', + 'SnapshotQualityVerdict', + 'SnapshotState', + 'SnapshotUnchanged', + 'SnapshotVisibility', + 'SnapshotVisibilityReason', + 'SplitRef', + 'attachRefs', + 'buildSnapshotPresentationKey', + 'centerOfRect', + 'findNodeByRef', + 'isSnapshotBackend', + 'normalizeRef', + 'snapshotPresentationOptionsFromFlags', + 'splitRefGenerationSuffix', + 'usesMobileSnapshotPresentation', + ], + ], + [ + '@agent-device/kernel/contracts', + [ + 'AppErrorCode', + 'CommandRpcParams', + 'DaemonArtifact', + 'DaemonArtifactKnownType', + 'DaemonArtifactType', + 'DaemonInstallSource', + 'DaemonLockPolicy', + 'DaemonRequest', + 'DaemonRequestMeta', + 'DaemonResponse', + 'DaemonResponseData', + 'DaemonServerMode', + 'DaemonTransportPreference', + 'JsonRpcId', + 'JsonRpcRequestEnvelope', + 'LeaseBackend', + 'NETWORK_INCLUDE_MODES', + 'NetworkIncludeMode', + 'RESPONSE_LEVELS', + 'Rect', + 'ResponseCost', + 'ResponseLevel', + 'SessionIsolationMode', + 'SessionRuntimeHints', + 'SnapshotNode', + 'centerOfRect', + 'commandRpcParamsSchema', + 'daemonRuntimeSchema', + 'defaultHintForCode', + 'isNonDefaultResponseLevel', + 'jsonRpcRequestSchema', + 'normalizeError', + ], + ], + ['@agent-device/kernel/collections', ['uniqueStrings']], + ['@agent-device/kernel/rect', ['isPositiveFiniteRect', 'rectArea', 'rectContains']], + ['@agent-device/kernel/redaction', ['redactDiagnosticData']], + ['@agent-device/kernel/bounds', ['parseBounds']], + [ + '@agent-device/maestro', + [ + 'MAESTRO_COMPATIBILITY_ADR_URL', + 'MAESTRO_COMPATIBILITY_ISSUE_URL', + 'MAESTRO_COMPAT_LIMITATIONS', + 'MAESTRO_COMPAT_SUPPORTED_CAPABILITIES', + 'MAESTRO_RUNTIME_ADAPTER_POLICY', + 'MaestroActionEvent', + 'MaestroCompletedActionEvent', + 'MaestroDispatchSelector', + 'MaestroExecutionObserver', + 'MaestroExecutionOptions', + 'MaestroExecutionOutcome', + 'MaestroExportOptions', + 'MaestroExportResult', + 'MaestroExportWarning', + 'MaestroFailedAction', + 'MaestroFlow', + 'MaestroObservation', + 'MaestroObservationCondition', + 'MaestroObservationIdentity', + 'MaestroPlatform', + 'MaestroRuntimeCommand', + 'MaestroRuntimeMetrics', + 'MaestroRuntimeOperationContext', + 'MaestroRuntimeOperationResult', + 'MaestroRuntimeOperations', + 'MaestroRuntimePort', + 'MaestroRuntimePortLifecycle', + 'MaestroRuntimeReadContext', + 'MaestroSelector', + 'MaestroSinglePointerGestureInput', + 'MaestroSnapshotTargetQuery', + 'MaestroTargetMatch', + 'MaestroTargetQuery', + 'MaestroTargetResolution', + 'collectMaestroFailureSuggestions', + 'createMaestroRuntimePort', + 'executeMaestroFlow', + 'exportReplayActionsToMaestro', + 'formatMaestroCompatibilityReference', + 'inspectMaestroFlow', + 'literalFromMaestroRegex', + 'maestroObservationMatches', + 'maestroTestFailure', + 'resolveMaestroScrollableGesture', + 'resolveMaestroTargetFromSnapshot', + ], + ], + [ + '@agent-device/provider-limrun', + [ + 'LIMRUN_PROVIDER', + 'LimrunAndroidDeviceSession', + 'LimrunConnectionVerification', + 'LimrunConnectionVerificationOptions', + 'LimrunIosCommandExecution', + 'LimrunIosDeviceSession', + 'LimrunRuntime', + 'LimrunRuntimeDependencies', + 'LimrunRuntimeOptions', + 'createLimrunRuntime', + 'verifyLimrunConnection', + ], + ], + [ + '@agent-device/provider-webdriver', + [ + 'CLOUD_WEBDRIVER_PROVIDERS', + 'CloudWebDriverConnectionVerification', + 'CloudWebDriverConnectionVerificationOptions', + 'CloudWebDriverKnownProviderName', + 'DefaultCloudWebDriverArtifactEnv', + 'DefaultCloudWebDriverProviderRuntimeEnv', + 'ProviderWebDriver', + 'ProviderWebDriverDependencies', + 'RunHostCommand', + 'browserStackOnlyDeviceFeatureFlags', + 'createProviderWebDriver', + 'isCloudWebDriverProviderName', + 'readAwsDeviceFarmRegionFromArn', + 'rejectBrowserStackOnlyDeviceFeatures', + ], + ], + [ + '@agent-device/replay-test', + [ + 'ReplayTestAttemptCancellation', + 'ReplayTestAttemptError', + 'ReplayTestAttemptFailed', + 'ReplayTestAttemptOutcome', + 'ReplayTestAttemptPassed', + 'ReplayTestAttemptStep', + 'ReplayTestAttemptStepSink', + 'ReplayTestBindAttemptCancellation', + 'ReplayTestCleanupSession', + 'ReplayTestDiscoverSources', + 'ReplayTestEmitDiagnostic', + 'ReplayTestEmitProgress', + 'ReplayTestExecutionDependencies', + 'ReplayTestFinalizeAttempt', + 'ReplayTestIsCanceled', + 'ReplayTestManifest', + 'ReplayTestPlatform', + 'ReplayTestResolveShardTargets', + 'ReplayTestRunReplay', + 'ReplayTestRunReplayParams', + 'ReplayTestRuntimeDependencies', + 'ReplayTestShardContext', + 'ReplayTestShardMode', + 'ReplayTestShardTarget', + 'ReplayTestSource', + 'ReplayTestSuiteOutcome', + 'ReplayTestSuiteRequest', + 'ReplayTestTarget', + 'runReplayTestSuite', + ], + ], + [ + '@agent-device/xml', + [ + 'XmlNode', + 'XmlParseOptions', + 'decodeXmlCharacterReferences', + 'escapeXmlTextAndAttribute', + 'parseXmlDocumentSync', + ], + ], +]; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 932833636..24f0e643d 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -6,11 +6,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; +import { readFacadeExports, readNamedExports } from './facade-exports.ts'; +import { FACADE_SYMBOLS } from './facade-symbols.ts'; import { checkPackageBoundaries, checkPackageInternalSites, checkRootSites, - readNamedExports, readWorkspacePackages, rootExternalDependencyRanges, rootWorkspaceDependencyNames, @@ -18,6 +19,8 @@ import { type WorkspacePackage, } from './package-boundaries.ts'; +const repoRoot = path.resolve(import.meta.dirname, '../..'); + const kernel: WorkspacePackage = { dir: 'packages/kernel', name: '@agent-device/kernel', @@ -72,53 +75,32 @@ test('specifier sites carry 1-based lines for static and dynamic imports', () => ); }); -test('readNamedExports collects re-export and direct-declaration forms, resolving aliases', () => { - const source = [ - "export { a, b } from './x.ts';", - "export type { C, D } from './y.ts';", - "export { e as f } from './z.ts';", - "export type { g as h } from './z.ts';", - 'export function i() {}', - 'export const j = 1;', - 'export type K = string;', - 'export interface L {}', - "export {\n m,\n n,\n} from './multi.ts';", - ].join('\n'); +test('every workspace package façade exports exactly its pinned symbol list', () => { + const packages = readWorkspacePackages(repoRoot); + const pinned = new Map(FACADE_SYMBOLS.map(([specifier, names]) => [specifier, names])); + // The table and the manifests must agree in BOTH directions: a new package + // (or a new subpath on an existing one) that nobody pinned is exactly the + // widening this gate exists to catch, so an unpinned façade fails here + // rather than being silently skipped. + const declared = packages + .filter((pkg) => pkg.name !== '@agent-device/ad-replay') + .flatMap((pkg) => [...pkg.exportTargets.keys()]); assert.deepEqual( - readNamedExports(source), - ['D', 'C', 'K', 'L', 'a', 'b', 'f', 'h', 'i', 'j', 'm', 'n'].sort(), + declared.slice().sort(), + [...pinned.keys()].sort(), + 'every exports-map subpath needs a pinned symbol list (and vice versa)', ); -}); - -test('readNamedExports never reports the original name behind an `as` alias', () => { - const source = "export { internalOnly as publicName } from './x.ts';"; - const names = readNamedExports(source); - assert.deepEqual(names, ['publicName']); - assert.ok(!names.includes('internalOnly')); -}); - -test('readNamedExports resolves `export * as ns` to its one real bound name', () => { - // Unlike bare `export *`, this binds exactly one importable name (`ns`) — - // enumerable, not a widening blind spot. - const source = "export * as ns from './x.ts';"; - assert.deepEqual(readNamedExports(source), ['ns']); -}); - -// #1555 review P1 (second pass, "the gate also ignores export-star -// declarations, so it can miss future widening"): a facade pinned to an -// exact named-export list must not silently accept a form that widens its -// real surface with no enumerable name at all. These two forms throw instead -// of contributing nothing to the list — plant-verified (temporarily reverted -// to a no-op, confirmed both tests failed, restored) rather than merely -// asserted. -test('readNamedExports rejects a bare `export *` re-export', () => { - const source = "export { runAdReplay } from './step-loop.ts';\nexport * from './leak.ts';\n"; - assert.throws(() => readNamedExports(source), /export \* from/); -}); - -test('readNamedExports rejects a default export', () => { - assert.throws(() => readNamedExports('export default function leak() {}'), /export default/); - assert.throws(() => readNamedExports('export default 42;'), /export default/); + for (const pkg of packages) { + for (const [specifier, target] of pkg.exportTargets) { + const expected = pinned.get(specifier); + if (!expected) continue; + assert.deepEqual( + readFacadeExports(path.join(repoRoot, target)), + [...expected], + `${specifier} exports exactly its pinned symbol list`, + ); + } + } }); test('double-quoted and re-export routes into packages are not invisible to R11', () => { @@ -253,7 +235,6 @@ test('root workspace specifiers need a root workspace:* entry and an exported su }); test('the real tree parses, declares, and passes R11', () => { - const repoRoot = path.resolve(import.meta.dirname, '../..'); 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'); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index 5e060eade..d7befbb91 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -18,7 +18,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { parseSync } from 'oxc-parser'; import { parseImports } from './model.ts'; export type PackageBoundaryViolation = { @@ -56,61 +55,6 @@ export function specifierSites(file: string, source: string): SpecifierSite[] { return parseImports(source).map((edge) => ({ file, line: edge.line, specifier: edge.spec })); } -/** - * Every name a façade module exports, value or type-only, sorted — the exact - * "named-export-list" a package-boundaries gate can pin (#1555 review P1, - * "add the reviewer-required exact exported-symbol gate"). Covers both - * re-export forms (`export { a, b } from './x.ts'`, - * `export type { a, b } from './x.ts'`, with or without `as` aliasing — the - * alias is reported, since that is the name a consumer actually imports), - * `export * as ns from './x.ts'` (one real name, `ns`), and direct - * declarations (`export function`/`const`/`class`/`type`/`interface`, - * including `export const a = 1, b = 2`'s multiple declarators). A stray - * export — intentional or not — changes this list, so a test that pins it - * exactly turns "the façade grew a symbol" into a loud failure instead of a - * silent widening only a PR diff review would catch. - * - * AST-based (`oxc-parser`, already a devDependency — `session-state.ts` is - * the existing precedent for using it in this gate), not a regex, for the - * SAME reason `session-state.ts` gives: a regex has to enumerate every - * export FORM by hand, and the one it forgets is exactly the one that slips - * through. That is precisely what happened here (#1555 review, second pass, - * "the gate also ignores export-star declarations, so it can miss future - * widening"): `export * from './x.ts'` re-exports an unbounded, statically - * unknowable set of names — the old regex scanner had no case for it at all, - * so it silently contributed NOTHING to the list instead of failing loudly. - * `parsed.module.staticExports` is oxc's own resolved export-entry table - * (built for exactly this purpose, not re-derived from a manual AST walk), - * and its `exportName.kind` already draws the line this function needs: - * `'None'` is bare `export *` (unenumerable — thrown), `'Default'` is - * `export default …` (also thrown — a facade pinned to an exact named-export - * list must not carry one), and `'Name'` is every enumerable form above, - * `export * as ns` included (oxc reports its one real bound name, `ns`). - */ -export function readNamedExports(source: string): string[] { - const parsed = parseSync('package-boundaries-export-scan.ts', source); - const names = new Set(); - for (const staticExport of parsed.module.staticExports) { - for (const entry of staticExport.entries) { - if (entry.exportName.kind === 'None') { - throw new Error( - "readNamedExports cannot enumerate 'export * from …' — it re-exports an unknown set " + - 'of names, exactly the widening an exact-export-list gate exists to catch. Name the ' + - 're-exported symbols explicitly instead of re-exporting the whole module.', - ); - } - if (entry.exportName.kind === 'Default') { - throw new Error( - "readNamedExports cannot enumerate 'export default …' as a named symbol — a facade a " + - 'caller pins to an exact named-export list must not carry a default export.', - ); - } - if (entry.exportName.name) names.add(entry.exportName.name); - } - } - return [...names].sort(); -} - export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] { const packagesDir = path.join(repoRoot, 'packages'); if (!fs.existsSync(packagesDir)) return [];