Skip to content

Commit 53c925f

Browse files
committed
chore(gates): restore exact-membership for the contracts export surface
assertExportTargetsMatchManifest's two checks (targets resolve to tracked files, count equals manifest.exports keys) both read from the same manifest.exports object, so a subpath added or removed moves both sides together and the equality holds regardless -- the widen/shrink guarantee the deleted ~120-line CONTRACT_EXPORTS pin gave was silently gone (#2297 review). Add scripts/layering/contracts-exports.snapshot.json, an independently committed baseline regenerated by the new generate-contracts-exports-snapshot.ts, and deepEqual the live export specifiers against it. A subpath change now fails until the contributor reruns the generator and reviews the snapshot diff -- "run a script and commit its output" instead of hand-retyping an alphabetized array, so the original maintenance-burden goal holds too. Planted red: widened packages/contracts/package.json#exports with a throwaway "./planted-red-widen" subpath, then separately deleted the "./wait" subpath; `node --test --experimental-strip-types scripts/layering/package-boundaries.test.ts` failed both times on the new deepEqual with the regenerate-and-review message. Reverted before committing.
1 parent 2f97049 commit 53c925f

3 files changed

Lines changed: 181 additions & 7 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
[
2+
"@agent-device/contracts/alert-contract",
3+
"@agent-device/contracts/alert-runtime",
4+
"@agent-device/contracts/android-clipboard-support",
5+
"@agent-device/contracts/android-helper-artifacts",
6+
"@agent-device/contracts/android-input-ownership",
7+
"@agent-device/contracts/android-observation",
8+
"@agent-device/contracts/android-snapshot-quality",
9+
"@agent-device/contracts/android-system-chrome",
10+
"@agent-device/contracts/android-touch-plan",
11+
"@agent-device/contracts/app-deployment-runtime",
12+
"@agent-device/contracts/app-deployment-runtime-plan",
13+
"@agent-device/contracts/app-event-runtime",
14+
"@agent-device/contracts/app-inventory-runtime",
15+
"@agent-device/contracts/app-log-runtime",
16+
"@agent-device/contracts/app-state-runtime",
17+
"@agent-device/contracts/app-switcher-runtime",
18+
"@agent-device/contracts/apple-multitouch-support",
19+
"@agent-device/contracts/apple-runner-request",
20+
"@agent-device/contracts/application-lifecycle-interaction",
21+
"@agent-device/contracts/application-lifecycle-runtime",
22+
"@agent-device/contracts/application-lifecycle-runtime-plan",
23+
"@agent-device/contracts/async-lifecycle",
24+
"@agent-device/contracts/audio-probe-result",
25+
"@agent-device/contracts/audio-probe-runtime",
26+
"@agent-device/contracts/audio-probe-runtime-host",
27+
"@agent-device/contracts/audio-probe-support",
28+
"@agent-device/contracts/audio-runtime-plan",
29+
"@agent-device/contracts/back-mode",
30+
"@agent-device/contracts/back-runtime",
31+
"@agent-device/contracts/backend-diagnostics",
32+
"@agent-device/contracts/boot-failure",
33+
"@agent-device/contracts/capture",
34+
"@agent-device/contracts/click-button",
35+
"@agent-device/contracts/client",
36+
"@agent-device/contracts/clipboard",
37+
"@agent-device/contracts/clipboard-runtime",
38+
"@agent-device/contracts/command",
39+
"@agent-device/contracts/command-platform-execution",
40+
"@agent-device/contracts/daemon-owner-cleanup",
41+
"@agent-device/contracts/device",
42+
"@agent-device/contracts/device-readiness-runtime",
43+
"@agent-device/contracts/device-shutdown-runtime",
44+
"@agent-device/contracts/divergence",
45+
"@agent-device/contracts/durable-resource",
46+
"@agent-device/contracts/durable-resource-envelope",
47+
"@agent-device/contracts/element-text-runtime",
48+
"@agent-device/contracts/focus-runtime",
49+
"@agent-device/contracts/gesture-admission",
50+
"@agent-device/contracts/gesture-input",
51+
"@agent-device/contracts/gesture-normalization",
52+
"@agent-device/contracts/gesture-plan",
53+
"@agent-device/contracts/gesture-plan-types",
54+
"@agent-device/contracts/gesture-runtime",
55+
"@agent-device/contracts/home-runtime",
56+
"@agent-device/contracts/host-diagnostics",
57+
"@agent-device/contracts/interaction",
58+
"@agent-device/contracts/interaction-error",
59+
"@agent-device/contracts/interaction-guarantees",
60+
"@agent-device/contracts/interactor-operation-catalog",
61+
"@agent-device/contracts/interactor-types",
62+
"@agent-device/contracts/ios-snapshot",
63+
"@agent-device/contracts/is-predicate",
64+
"@agent-device/contracts/keyboard",
65+
"@agent-device/contracts/keyboard-runtime",
66+
"@agent-device/contracts/local-interactor-operation-set",
67+
"@agent-device/contracts/logs-runtime-plan",
68+
"@agent-device/contracts/managed-device-allocation",
69+
"@agent-device/contracts/managed-web-backend",
70+
"@agent-device/contracts/navigation",
71+
"@agent-device/contracts/network-runtime",
72+
"@agent-device/contracts/network-runtime-plan",
73+
"@agent-device/contracts/network-traffic",
74+
"@agent-device/contracts/observability",
75+
"@agent-device/contracts/orientation-runtime",
76+
"@agent-device/contracts/perf-runtime",
77+
"@agent-device/contracts/perf-runtime-host",
78+
"@agent-device/contracts/perf-runtime-operation-builder",
79+
"@agent-device/contracts/perf-runtime-plan",
80+
"@agent-device/contracts/platform-module",
81+
"@agent-device/contracts/platform-plugin",
82+
"@agent-device/contracts/platform-providers",
83+
"@agent-device/contracts/platform-resource-cleanup",
84+
"@agent-device/contracts/platform-runtime",
85+
"@agent-device/contracts/platform-runtime-host",
86+
"@agent-device/contracts/platform-runtime-operations",
87+
"@agent-device/contracts/platform-runtime-unavailable",
88+
"@agent-device/contracts/progress",
89+
"@agent-device/contracts/react-native-overlay",
90+
"@agent-device/contracts/record-runtime-execution",
91+
"@agent-device/contracts/recording",
92+
"@agent-device/contracts/remote",
93+
"@agent-device/contracts/replay",
94+
"@agent-device/contracts/runner-lease-context",
95+
"@agent-device/contracts/screen-recording-runtime",
96+
"@agent-device/contracts/screen-recording-runtime-host",
97+
"@agent-device/contracts/screen-recording-runtime-plan",
98+
"@agent-device/contracts/screenshot-runtime",
99+
"@agent-device/contracts/scroll-command",
100+
"@agent-device/contracts/scroll-gesture",
101+
"@agent-device/contracts/scroll-runtime",
102+
"@agent-device/contracts/selector-observation-runtime",
103+
"@agent-device/contracts/session",
104+
"@agent-device/contracts/settings",
105+
"@agent-device/contracts/settings-runtime",
106+
"@agent-device/contracts/snapshot",
107+
"@agent-device/contracts/snapshot-presentation",
108+
"@agent-device/contracts/snapshot-runtime",
109+
"@agent-device/contracts/snapshot-scope",
110+
"@agent-device/contracts/snapshot-timeout-evidence",
111+
"@agent-device/contracts/startup-recovery-fence",
112+
"@agent-device/contracts/touch-runtime",
113+
"@agent-device/contracts/tv-remote",
114+
"@agent-device/contracts/tv-remote-runtime",
115+
"@agent-device/contracts/type-text-runtime",
116+
"@agent-device/contracts/viewport-runtime",
117+
"@agent-device/contracts/wait",
118+
"@agent-device/contracts/wait-runtime-plan"
119+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Regenerates contracts-exports.snapshot.json from packages/contracts/package.json#exports.
2+
//
3+
// package-boundaries.test.ts diffs the live manifest's export specifiers against this committed
4+
// snapshot so widening or shrinking `@agent-device/contracts`'s export surface fails the gate
5+
// (the property the deleted ~120-line inline CONTRACT_EXPORTS pin used to give). The snapshot is a
6+
// separate file rather than an inline list so an editor who adds or removes a contracts subpath
7+
// runs this script and reviews the diff, instead of hand-retyping an alphabetized array.
8+
//
9+
// Run after any packages/contracts/package.json#exports change:
10+
// node --experimental-strip-types scripts/layering/generate-contracts-exports-snapshot.ts
11+
// then commit the resulting snapshot diff alongside the manifest change.
12+
13+
import fs from 'node:fs';
14+
import path from 'node:path';
15+
16+
const repoRoot = path.resolve(import.meta.dirname, '../..');
17+
const manifestFile = 'packages/contracts/package.json';
18+
const snapshotFile = path.join(import.meta.dirname, 'contracts-exports.snapshot.json');
19+
20+
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as {
21+
name?: string;
22+
exports?: Record<string, unknown>;
23+
};
24+
if (!manifest.name) throw new Error(`${manifestFile} has no "name"`);
25+
26+
const specifiers = Object.keys(manifest.exports ?? {})
27+
.map((subpath) => path.posix.join(manifest.name!, subpath))
28+
.sort();
29+
30+
fs.writeFileSync(snapshotFile, `${JSON.stringify(specifiers, null, 2)}\n`);
31+
console.log(
32+
`Wrote ${specifiers.length} export specifiers to ${path.relative(repoRoot, snapshotFile)}`,
33+
);

scripts/layering/package-boundaries.test.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,21 @@ function rules(violations: { rule: string }[]): string[] {
5353
}
5454

5555
/**
56-
* The manifest is its own inventory: every `exports` target must resolve to an existing,
57-
* TRACKED source file, and every manifest entry must have produced exactly one `exportTargets`
58-
* entry (nothing dropped, nothing collapsed by a duplicate key). This replaces a ~120-line pinned
59-
* subpath list that every `packages/contracts/package.json` change also had to hand-edit; the
60-
* manifest is now the only place the export surface is declared.
56+
* The manifest is its own inventory for two checks: every `exports` target must resolve to an
57+
* existing, TRACKED source file, and every manifest entry must have produced exactly one
58+
* `exportTargets` entry (nothing dropped, nothing collapsed by a duplicate key). Neither check
59+
* catches the export SURFACE itself widening or shrinking -- `pkg.exportTargets` and
60+
* `manifest.exports` are read from the same file, so adding or removing a subpath moves both
61+
* counts together and the equality holds regardless (#2297 review). `snapshotFile`, an
62+
* independently committed baseline regenerated by `generate-contracts-exports-snapshot.ts`,
63+
* restores that guarantee: it changes only when a contributor deliberately reruns the generator
64+
* and reviews the diff, so a subpath added or removed without doing so fails here.
6165
*/
62-
function assertExportTargetsMatchManifest(pkg: WorkspacePackage, manifestFile: string): void {
66+
function assertExportTargetsMatchManifest(
67+
pkg: WorkspacePackage,
68+
manifestFile: string,
69+
snapshotFile: string,
70+
): void {
6371
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as {
6472
exports?: Record<string, unknown>;
6573
};
@@ -75,6 +83,16 @@ function assertExportTargetsMatchManifest(pkg: WorkspacePackage, manifestFile: s
7583
Object.keys(manifest.exports ?? {}).length,
7684
`${pkg.name} exports map entries must each produce one resolved export target`,
7785
);
86+
const snapshot = JSON.parse(
87+
fs.readFileSync(path.join(repoRoot, snapshotFile), 'utf8'),
88+
) as string[];
89+
assert.deepEqual(
90+
[...pkg.exportTargets.keys()].sort(),
91+
[...snapshot].sort(),
92+
`${pkg.name}'s export surface no longer matches ${snapshotFile} -- regenerate it with ` +
93+
`'node --experimental-strip-types scripts/layering/generate-contracts-exports-snapshot.ts' ` +
94+
'and review the diff',
95+
);
7896
}
7997

8098
test('specifier sites carry 1-based lines for static and dynamic imports', () => {
@@ -358,7 +376,11 @@ test('the real tree parses, declares, and passes R11', () => {
358376
assert.ok(kernelPackage.exportTargets.size >= 8, 'kernel exports its vocabulary subpaths');
359377
const contractsPackage = packages.find((pkg) => pkg.name === '@agent-device/contracts');
360378
assert.ok(contractsPackage, 'contracts package must exist');
361-
assertExportTargetsMatchManifest(contractsPackage, 'packages/contracts/package.json');
379+
assertExportTargetsMatchManifest(
380+
contractsPackage,
381+
'packages/contracts/package.json',
382+
'scripts/layering/contracts-exports.snapshot.json',
383+
);
362384
assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']);
363385
const captureKitPackage = packages.find((pkg) => pkg.name === '@agent-device/capture-kit');
364386
assert.ok(captureKitPackage, 'capture-kit package must exist');

0 commit comments

Comments
 (0)