Skip to content

Commit dd0da10

Browse files
committed
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.
1 parent d831fcd commit dd0da10

4 files changed

Lines changed: 114 additions & 5 deletions

File tree

packages/contracts/src/facades/client.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,25 @@ export type {
4141
DeviceShutdownOptions,
4242
StartupPerfSample,
4343
} from '../client-device-view.ts';
44-
export type { ClickOptions, DragOptions, FillOptions, FlingOptions, FocusOptions, LongPressOptions, PanOptions, PinchOptions, PressOptions, RepeatedPressOptions, RotateGestureOptions, ScrollOptions, SettleCommandOptions, SwipeGestureOptions, SwipeOptions, TransformGestureOptions, TypeTextOptions } from '../client-gesture.ts';
44+
export type {
45+
ClickOptions,
46+
DragOptions,
47+
FillOptions,
48+
FlingOptions,
49+
FocusOptions,
50+
LongPressOptions,
51+
PanOptions,
52+
PinchOptions,
53+
PressOptions,
54+
RepeatedPressOptions,
55+
RotateGestureOptions,
56+
ScrollOptions,
57+
SettleCommandOptions,
58+
SwipeGestureOptions,
59+
SwipeOptions,
60+
TransformGestureOptions,
61+
TypeTextOptions,
62+
} from '../client-gesture.ts';
4563
export type {
4664
CloudArtifactsOptions,
4765
Lease,

packages/contracts/src/facades/interaction.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,25 @@ export type {
2525
SwipeGesturePayload,
2626
TransformGesturePayload,
2727
} from '../gesture-input.ts';
28-
export { assertNoRemovedSwipeInput, describeReplayGestureArityError, dragGesturePayloadFromPositionals, gesturePayloadFromPositionals, gesturePayloadToPositionals, normalizeGestureCommandInput, normalizePublicGesture, normalizePublicSwipeMotion, swipePayloadFromPositionals } from '../gesture-normalization.ts';
28+
export {
29+
assertNoRemovedSwipeInput,
30+
describeReplayGestureArityError,
31+
dragGesturePayloadFromPositionals,
32+
gesturePayloadFromPositionals,
33+
gesturePayloadToPositionals,
34+
normalizeGestureCommandInput,
35+
normalizePublicGesture,
36+
normalizePublicSwipeMotion,
37+
swipePayloadFromPositionals,
38+
} from '../gesture-normalization.ts';
2939
export type { NormalizedPublicGesture, SwipePayload } from '../gesture-normalization.ts';
30-
export { DEFAULT_DRAG_DESTINATION_HOLD_MS, DEFAULT_DRAG_MOVE_MS, DEFAULT_DRAG_SOURCE_HOLD_MS, GESTURE_DURATION_MAX_MS, GESTURE_DURATION_MIN_MS } from '../gesture-plan-types.ts';
40+
export {
41+
DEFAULT_DRAG_DESTINATION_HOLD_MS,
42+
DEFAULT_DRAG_MOVE_MS,
43+
DEFAULT_DRAG_SOURCE_HOLD_MS,
44+
GESTURE_DURATION_MAX_MS,
45+
GESTURE_DURATION_MIN_MS,
46+
} from '../gesture-plan-types.ts';
3147
export type {
3248
GestureExecutionProfile,
3349
GestureIntent,
@@ -40,7 +56,16 @@ export type {
4056
SinglePointerGesturePlan,
4157
SinglePointerTrajectory,
4258
} from '../gesture-plan-types.ts';
43-
export { GESTURE_FLING_DURATION_MS, GESTURE_INITIAL_ANGLE_DEGREES, GESTURE_SAMPLE_INTERVAL_MS, buildDragGesturePlan, buildGesturePlan, interpolateGesturePoint, sampleGestureOffsets, singlePointerPlanEndpoints } from '../gesture-plan.ts';
59+
export {
60+
GESTURE_FLING_DURATION_MS,
61+
GESTURE_INITIAL_ANGLE_DEGREES,
62+
GESTURE_SAMPLE_INTERVAL_MS,
63+
buildDragGesturePlan,
64+
buildGesturePlan,
65+
interpolateGesturePoint,
66+
sampleGestureOffsets,
67+
singlePointerPlanEndpoints,
68+
} from '../gesture-plan.ts';
4469
export type { GestureSamplingProfile } from '../gesture-plan.ts';
4570
export {
4671
INTERACTION_DISPATCH_PATHS,

packages/contracts/src/facades/replay.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,13 @@ export type {
1212
ReplayTargetGuardDenotation,
1313
WaitLandmarkMismatchEvidence,
1414
} from '../replay.ts';
15-
export type { LocalIdentity, MultiTargetAnnotationV1, NodeStructuralDenotation, TargetAncestryEntry, TargetAnnotationV1, TargetRect, TargetScrollRegion, TargetVerification } from '../target-annotation.ts';
15+
export type {
16+
LocalIdentity,
17+
MultiTargetAnnotationV1,
18+
NodeStructuralDenotation,
19+
TargetAncestryEntry,
20+
TargetAnnotationV1,
21+
TargetRect,
22+
TargetScrollRegion,
23+
TargetVerification,
24+
} from '../target-annotation.ts';

scripts/layering/package-boundaries.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,63 @@ test('every workspace package façade names its exports explicitly (no bare `exp
108108
}
109109
});
110110

111+
test('every façade re-exports its sources exhaustively (no silent narrowing)', () => {
112+
// The star-rejection above catches a façade WIDENING invisibly. This catches the
113+
// opposite, which is the failure an explicit list makes newly possible: a symbol
114+
// added to a source module simply never reaches the façade, and nothing notices.
115+
// `export *` could not narrow by construction; an explicit list can, so the
116+
// property `export *` gave for free is asserted here instead.
117+
//
118+
// Found by review on #1614: this conversion was generated against the surface at
119+
// fork time, and #1567 landed 13 new exports meanwhile (`DragOptions`, the drag
120+
// gesture vocabulary, `MultiTargetAnnotationV1`). The rebase silently dropped all
121+
// 13 and only a human diff caught it. Exhaustiveness is what makes that mechanical.
122+
//
123+
// Scoped to `packages/*/src/facades/` — the barrels this PR converted, which were
124+
// exhaustive by construction because `export *` cannot narrow. A hand-curated
125+
// package `index.ts` is a different thing: `@agent-device/ad-replay` deliberately
126+
// publishes two values out of a much larger `internal/`, and forcing it exhaustive
127+
// would widen a surface its owner narrowed on purpose (#1555).
128+
const facadeFiles = listSourceFiles().filter((file) => file.includes('/src/facades/'));
129+
assert.ok(facadeFiles.length > 0, 'expected at least one converted façade to check');
130+
for (const file of [...facadeFiles].sort()) {
131+
const absolute = path.join(repoRoot, file);
132+
const exported = new Set(readNamedExports(fs.readFileSync(absolute, 'utf8')));
133+
for (const specifier of reExportSources(fs.readFileSync(absolute, 'utf8'))) {
134+
const sourcePath = path.resolve(path.dirname(absolute), specifier);
135+
if (!fs.existsSync(sourcePath)) continue;
136+
// A source module may itself carry a bare `export *` (e.g. contracts'
137+
// `gesture-plan.ts` re-exports `gesture-plan-types.ts` wholesale). Its names are
138+
// then unknowable from that file alone — but they are reachable, because the
139+
// façade re-exports the starred module directly too, and THAT path is checked.
140+
let sourceNames: string[];
141+
try {
142+
sourceNames = readNamedExports(fs.readFileSync(sourcePath, 'utf8'));
143+
} catch {
144+
continue;
145+
}
146+
const dropped = sourceNames.filter((name) => name !== 'default' && !exported.has(name));
147+
assert.deepEqual(
148+
dropped,
149+
[],
150+
`${file} re-exports from ${specifier} but omits ${dropped.join(', ')} — an explicit ` +
151+
'façade list must stay exhaustive over its sources, or a symbol added upstream ' +
152+
'silently never becomes public. Add the names, or move them out of that module.',
153+
);
154+
}
155+
}
156+
});
157+
158+
/** The relative specifiers a façade re-exports from, in source order. */
159+
function reExportSources(source: string): string[] {
160+
const found = new Set<string>();
161+
for (const match of source.matchAll(/\bfrom\s+'(\.[^']*)'/g)) {
162+
const specifier = match[1];
163+
if (specifier) found.add(specifier);
164+
}
165+
return [...found];
166+
}
167+
111168
test('double-quoted and re-export routes into packages are not invisible to R11', () => {
112169
// The scanner is the layering parser, so quote style and statement form
113170
// cannot carve out a bypass: a double-quoted import, a re-export, and a

0 commit comments

Comments
 (0)