mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
d8b309c6db
* refactor(contracts): name façade exports explicitly and retire the pin table Thirteen of the fourteen `@agent-device/contracts` façades were bare `export *` barrels. `facades/snapshot.ts`, added by #1582, was the one exception — explicit named re-exports — and that is now the rule. Everything #1574 built to cope with `export *` goes with them: scripts/layering/facade-symbols.ts -980 (816 pinned names) scripts/layering/facade-exports.ts -192 (readFacadeExports) scripts/layering/facade-exports.test.ts -234 (star semantics) scripts/layering/package-boundaries.test.ts -55 `readFacadeExports` re-implemented ESM `GetExportedNames`/`ResolveExport` — star-chain resolution, ambiguity rejection, diamond binding identity, cycle guards, spec-accurate `default` filtering at the star rather than the source. All of it existed to enumerate what `export *` hides. 523 of the 816 pinned names belonged to contracts, i.e. to those thirteen files. Once a façade names its exports, the façade file IS the pin, and it is visible in the diff of the file that widened rather than in a separate table a reviewer has to cross-check. `readNamedExports` (20 lines) stays and is enough: it already throws on bare `export *` and on `export default`. The pin is replaced by one structural gate — no façade may contain a bare star — which reuses that rejection rather than adding a regex. Surface equivalence verified independently, not asserted: main's own `readFacadeExports` run over the new façades, compared against main's own `FACADE_SYMBOLS` table — 31 subpaths, 0 added, 0 removed. Red evidence for the new gate: planting `export * from '../request-progress.ts'` back into facades/progress.ts fails it with the file named and the reason quoted; 12 pass / 0 fail once reverted. Not included: the `lowerAndroidTouchPlan` tuple-assertion drive-by. It needs `sampleGestureOffsets` to carry a min-arity tuple through `.map()`, which TypeScript will not infer without a typed helper — a real change to the gesture-plan contract rather than a drive-by, so it stays out. * test(layering): assert façades stay exhaustive over their sources Review on #1614 caught this conversion silently narrowing the public surface. The explicit lists were generated against the surface at fork time; #1567 landed 13 exports meanwhile — `DragOptions`, the drag-gesture vocabulary (`COORDINATE_GESTURE_KINDS`, `CoordinateGesturePayload`, the three `DEFAULT_DRAG_*` constants, `DragGestureInput`, `DragGesturePayload`, `GestureCommandInput`, `buildDragGesturePlan`, `dragGesturePayloadFromPositionals`, `normalizeGestureCommandInput`) and `MultiTargetAnnotationV1`. The `export *` barrels had been forwarding all 13 automatically; the rebase dropped every one, and only a human diff caught it. The star-rejection gate could not: it only proves a façade does not WIDEN invisibly. Narrowing is the failure an explicit list newly makes possible, because `export *` could not narrow by construction. So the property the stars gave for free is now asserted directly — every name a re-exported source declares must appear in the façade. Scoped to `packages/*/src/facades/`, the barrels this PR converted. A hand-curated package `index.ts` is a different thing: `ad-replay` deliberately publishes two values out of a much larger `internal/`, and forcing exhaustiveness there would widen a surface its owner narrowed on purpose (#1555). A source that itself carries a bare `export *` is skipped — unknowable from that file alone, and reachable because the façade re-exports the starred module directly too, which IS checked. Red evidence: dropping `MultiTargetAnnotationV1` from facades/replay.ts — one of the 13 the old gate was blind to — fails with the file, the source and the symbol named. 13 pass / 0 fail once restored. * fix(layering): close the exhaustiveness gate's starred-source hole Two review findings, plus a third the gate caught on itself. P1 — the three `DEFAULT_DRAG_*` constants join the existing public-façade suppression, alongside `COORDINATE_GESTURE_KINDS` and `normalizePublicGesture` which the same conversion surfaced. All five are #1567's drag vocabulary, made individually visible to `--production` analysis for the first time because a bare star used to hide them from that exact check. Kept rather than narrowed, for the reason the existing entry already states: the façade's surface stays byte-identical to what the retired pin table asserted, and narrowing is a follow-up with its own review. P2 — the exhaustiveness gate skipped any source carrying a bare `export *`, which dropped that module's DIRECT exports from the check too. `gesture-plan.ts` stars `gesture-plan-types.ts`, so removing `buildDragGesturePlan` from the façade narrowed the public surface and still passed. `readDirectNamedExports` now reads exactly the names a module declares or re-exports BY NAME and ignores the star, so direct exports are checked while the starred set stays covered by the façade's own direct re-export of that module. Red evidence: removing `buildDragGesturePlan` from facades/interaction.ts now fails naming file, source and symbol; 13 pass / 0 fail restored. Third, and the reason the gate is worth having: rebasing onto main after #1612 merged silently dropped `TEXT_ENTRY_ROUTES`, `TextEntryRoute` and `TypeTextBackendResult` from the interaction façade — the same narrowing class as the #1567 one review caught by hand, one merge later. The gate failed on it before CI did. Restored.
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
// Named-export enumeration, tested directly: what `readNamedExports` reports
|
|
// for each export FORM, independently of the R11 boundary rules that consume
|
|
// it.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { readNamedExports } from './facade-exports.ts';
|
|
|
|
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']);
|
|
});
|