Files
Michał Pierzchała d8b309c6db refactor(contracts): name façade exports explicitly and retire the pin table (#1614)
* 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.
2026-08-05 15:58:02 +02:00

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']);
});