From 2f97049a1305e687bc2a69172e1da78e1fe9d874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 18:48:39 +0200 Subject: [PATCH 1/2] chore(gates): derive the contracts export inventory from package.json R11's package-boundaries test pinned every packages/contracts export subpath by hand (~115 entries, compared with deepEqual), so every merge that touched packages/contracts/package.json also had to hand-edit the pin. Replace it with a structural check derived from the manifest itself: every exports target must resolve to an existing, tracked source file, and the number of resolved targets must equal Object.keys(manifest.exports).length. The must-not-resolve negative list (Node resolution enforces the exports map at runtime) is untouched. --- scripts/layering/package-boundaries.test.ts | 147 ++++---------------- 1 file changed, 27 insertions(+), 120 deletions(-) diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 7c7eb2fe5..9992ff013 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -21,6 +21,7 @@ import { specifierSites, type WorkspacePackage, } from './package-boundaries.ts'; +import { listTrackedTypeScriptFiles } from './tracked-sources.ts'; const repoRoot = path.resolve(import.meta.dirname, '../..'); @@ -46,130 +47,36 @@ const contracts: WorkspacePackage = { }; const ALL = [kernel, contracts]; -const CONTRACT_EXPORTS = [ - '@agent-device/contracts/alert-contract', - '@agent-device/contracts/alert-runtime', - '@agent-device/contracts/android-clipboard-support', - '@agent-device/contracts/android-helper-artifacts', - '@agent-device/contracts/android-input-ownership', - '@agent-device/contracts/android-observation', - '@agent-device/contracts/android-snapshot-quality', - '@agent-device/contracts/android-system-chrome', - '@agent-device/contracts/android-touch-plan', - '@agent-device/contracts/app-deployment-runtime', - '@agent-device/contracts/app-deployment-runtime-plan', - '@agent-device/contracts/app-event-runtime', - '@agent-device/contracts/app-inventory-runtime', - '@agent-device/contracts/app-log-runtime', - '@agent-device/contracts/app-state-runtime', - '@agent-device/contracts/app-switcher-runtime', - '@agent-device/contracts/apple-multitouch-support', - '@agent-device/contracts/apple-runner-request', - '@agent-device/contracts/application-lifecycle-interaction', - '@agent-device/contracts/application-lifecycle-runtime', - '@agent-device/contracts/application-lifecycle-runtime-plan', - '@agent-device/contracts/async-lifecycle', - '@agent-device/contracts/audio-probe-result', - '@agent-device/contracts/audio-probe-runtime', - '@agent-device/contracts/audio-probe-runtime-host', - '@agent-device/contracts/audio-probe-support', - '@agent-device/contracts/audio-runtime-plan', - '@agent-device/contracts/back-mode', - '@agent-device/contracts/backend-diagnostics', - '@agent-device/contracts/back-runtime', - '@agent-device/contracts/boot-failure', - '@agent-device/contracts/capture', - '@agent-device/contracts/click-button', - '@agent-device/contracts/client', - '@agent-device/contracts/clipboard', - '@agent-device/contracts/clipboard-runtime', - '@agent-device/contracts/command', - '@agent-device/contracts/command-platform-execution', - '@agent-device/contracts/device', - '@agent-device/contracts/device-readiness-runtime', - '@agent-device/contracts/device-shutdown-runtime', - '@agent-device/contracts/divergence', - '@agent-device/contracts/durable-resource', - '@agent-device/contracts/durable-resource-envelope', - '@agent-device/contracts/element-text-runtime', - '@agent-device/contracts/focus-runtime', - '@agent-device/contracts/gesture-admission', - '@agent-device/contracts/gesture-input', - '@agent-device/contracts/gesture-normalization', - '@agent-device/contracts/gesture-plan', - '@agent-device/contracts/gesture-plan-types', - '@agent-device/contracts/gesture-runtime', - '@agent-device/contracts/home-runtime', - '@agent-device/contracts/host-diagnostics', - '@agent-device/contracts/daemon-owner-cleanup', - '@agent-device/contracts/interaction', - '@agent-device/contracts/interaction-error', - '@agent-device/contracts/interaction-guarantees', - '@agent-device/contracts/interactor-operation-catalog', - '@agent-device/contracts/interactor-types', - '@agent-device/contracts/ios-snapshot', - '@agent-device/contracts/is-predicate', - '@agent-device/contracts/keyboard', - '@agent-device/contracts/keyboard-runtime', - '@agent-device/contracts/local-interactor-operation-set', - '@agent-device/contracts/logs-runtime-plan', - '@agent-device/contracts/managed-device-allocation', - '@agent-device/contracts/managed-web-backend', - '@agent-device/contracts/navigation', - '@agent-device/contracts/network-runtime', - '@agent-device/contracts/network-runtime-plan', - '@agent-device/contracts/network-traffic', - '@agent-device/contracts/observability', - '@agent-device/contracts/orientation-runtime', - '@agent-device/contracts/perf-runtime', - '@agent-device/contracts/perf-runtime-host', - '@agent-device/contracts/perf-runtime-operation-builder', - '@agent-device/contracts/perf-runtime-plan', - '@agent-device/contracts/platform-module', - '@agent-device/contracts/platform-plugin', - '@agent-device/contracts/platform-providers', - '@agent-device/contracts/platform-resource-cleanup', - '@agent-device/contracts/platform-runtime', - '@agent-device/contracts/platform-runtime-host', - '@agent-device/contracts/platform-runtime-operations', - '@agent-device/contracts/platform-runtime-unavailable', - '@agent-device/contracts/progress', - '@agent-device/contracts/record-runtime-execution', - '@agent-device/contracts/recording', - '@agent-device/contracts/remote', - '@agent-device/contracts/replay', - '@agent-device/contracts/react-native-overlay', - '@agent-device/contracts/runner-lease-context', - '@agent-device/contracts/screen-recording-runtime', - '@agent-device/contracts/screen-recording-runtime-host', - '@agent-device/contracts/screen-recording-runtime-plan', - '@agent-device/contracts/screenshot-runtime', - '@agent-device/contracts/scroll-command', - '@agent-device/contracts/scroll-gesture', - '@agent-device/contracts/scroll-runtime', - '@agent-device/contracts/selector-observation-runtime', - '@agent-device/contracts/session', - '@agent-device/contracts/settings', - '@agent-device/contracts/settings-runtime', - '@agent-device/contracts/snapshot', - '@agent-device/contracts/snapshot-presentation', - '@agent-device/contracts/snapshot-runtime', - '@agent-device/contracts/snapshot-scope', - '@agent-device/contracts/snapshot-timeout-evidence', - '@agent-device/contracts/startup-recovery-fence', - '@agent-device/contracts/touch-runtime', - '@agent-device/contracts/tv-remote', - '@agent-device/contracts/tv-remote-runtime', - '@agent-device/contracts/type-text-runtime', - '@agent-device/contracts/viewport-runtime', - '@agent-device/contracts/wait', - '@agent-device/contracts/wait-runtime-plan', -] as const; function rules(violations: { rule: string }[]): string[] { return violations.map((violation) => violation.rule); } +/** + * The manifest is its own inventory: every `exports` target must resolve to an existing, + * TRACKED source file, and every manifest entry must have produced exactly one `exportTargets` + * entry (nothing dropped, nothing collapsed by a duplicate key). This replaces a ~120-line pinned + * subpath list that every `packages/contracts/package.json` change also had to hand-edit; the + * manifest is now the only place the export surface is declared. + */ +function assertExportTargetsMatchManifest(pkg: WorkspacePackage, manifestFile: string): void { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as { + exports?: Record; + }; + const trackedSources = new Set(listTrackedTypeScriptFiles(repoRoot)); + for (const [specifier, target] of pkg.exportTargets) { + assert.ok( + trackedSources.has(target) && fs.existsSync(path.join(repoRoot, target)), + `${specifier} -> ${target} must resolve to an existing, tracked file`, + ); + } + assert.equal( + pkg.exportTargets.size, + Object.keys(manifest.exports ?? {}).length, + `${pkg.name} exports map entries must each produce one resolved export target`, + ); +} + test('specifier sites carry 1-based lines for static and dynamic imports', () => { const sites = specifierSites( 'src/a.ts', @@ -451,7 +358,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(kernelPackage.exportTargets.size >= 8, 'kernel exports its vocabulary subpaths'); const contractsPackage = packages.find((pkg) => pkg.name === '@agent-device/contracts'); assert.ok(contractsPackage, 'contracts package must exist'); - assert.deepEqual([...contractsPackage.exportTargets.keys()].sort(), [...CONTRACT_EXPORTS].sort()); + assertExportTargetsMatchManifest(contractsPackage, 'packages/contracts/package.json'); assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']); const captureKitPackage = packages.find((pkg) => pkg.name === '@agent-device/capture-kit'); assert.ok(captureKitPackage, 'capture-kit package must exist'); From 53c925fa4912879665b82e9dd18fe6263c312c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 18:57:39 +0200 Subject: [PATCH 2/2] 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. --- .../layering/contracts-exports.snapshot.json | 119 ++++++++++++++++++ .../generate-contracts-exports-snapshot.ts | 33 +++++ scripts/layering/package-boundaries.test.ts | 36 ++++-- 3 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 scripts/layering/contracts-exports.snapshot.json create mode 100644 scripts/layering/generate-contracts-exports-snapshot.ts diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json new file mode 100644 index 000000000..65147a854 --- /dev/null +++ b/scripts/layering/contracts-exports.snapshot.json @@ -0,0 +1,119 @@ +[ + "@agent-device/contracts/alert-contract", + "@agent-device/contracts/alert-runtime", + "@agent-device/contracts/android-clipboard-support", + "@agent-device/contracts/android-helper-artifacts", + "@agent-device/contracts/android-input-ownership", + "@agent-device/contracts/android-observation", + "@agent-device/contracts/android-snapshot-quality", + "@agent-device/contracts/android-system-chrome", + "@agent-device/contracts/android-touch-plan", + "@agent-device/contracts/app-deployment-runtime", + "@agent-device/contracts/app-deployment-runtime-plan", + "@agent-device/contracts/app-event-runtime", + "@agent-device/contracts/app-inventory-runtime", + "@agent-device/contracts/app-log-runtime", + "@agent-device/contracts/app-state-runtime", + "@agent-device/contracts/app-switcher-runtime", + "@agent-device/contracts/apple-multitouch-support", + "@agent-device/contracts/apple-runner-request", + "@agent-device/contracts/application-lifecycle-interaction", + "@agent-device/contracts/application-lifecycle-runtime", + "@agent-device/contracts/application-lifecycle-runtime-plan", + "@agent-device/contracts/async-lifecycle", + "@agent-device/contracts/audio-probe-result", + "@agent-device/contracts/audio-probe-runtime", + "@agent-device/contracts/audio-probe-runtime-host", + "@agent-device/contracts/audio-probe-support", + "@agent-device/contracts/audio-runtime-plan", + "@agent-device/contracts/back-mode", + "@agent-device/contracts/back-runtime", + "@agent-device/contracts/backend-diagnostics", + "@agent-device/contracts/boot-failure", + "@agent-device/contracts/capture", + "@agent-device/contracts/click-button", + "@agent-device/contracts/client", + "@agent-device/contracts/clipboard", + "@agent-device/contracts/clipboard-runtime", + "@agent-device/contracts/command", + "@agent-device/contracts/command-platform-execution", + "@agent-device/contracts/daemon-owner-cleanup", + "@agent-device/contracts/device", + "@agent-device/contracts/device-readiness-runtime", + "@agent-device/contracts/device-shutdown-runtime", + "@agent-device/contracts/divergence", + "@agent-device/contracts/durable-resource", + "@agent-device/contracts/durable-resource-envelope", + "@agent-device/contracts/element-text-runtime", + "@agent-device/contracts/focus-runtime", + "@agent-device/contracts/gesture-admission", + "@agent-device/contracts/gesture-input", + "@agent-device/contracts/gesture-normalization", + "@agent-device/contracts/gesture-plan", + "@agent-device/contracts/gesture-plan-types", + "@agent-device/contracts/gesture-runtime", + "@agent-device/contracts/home-runtime", + "@agent-device/contracts/host-diagnostics", + "@agent-device/contracts/interaction", + "@agent-device/contracts/interaction-error", + "@agent-device/contracts/interaction-guarantees", + "@agent-device/contracts/interactor-operation-catalog", + "@agent-device/contracts/interactor-types", + "@agent-device/contracts/ios-snapshot", + "@agent-device/contracts/is-predicate", + "@agent-device/contracts/keyboard", + "@agent-device/contracts/keyboard-runtime", + "@agent-device/contracts/local-interactor-operation-set", + "@agent-device/contracts/logs-runtime-plan", + "@agent-device/contracts/managed-device-allocation", + "@agent-device/contracts/managed-web-backend", + "@agent-device/contracts/navigation", + "@agent-device/contracts/network-runtime", + "@agent-device/contracts/network-runtime-plan", + "@agent-device/contracts/network-traffic", + "@agent-device/contracts/observability", + "@agent-device/contracts/orientation-runtime", + "@agent-device/contracts/perf-runtime", + "@agent-device/contracts/perf-runtime-host", + "@agent-device/contracts/perf-runtime-operation-builder", + "@agent-device/contracts/perf-runtime-plan", + "@agent-device/contracts/platform-module", + "@agent-device/contracts/platform-plugin", + "@agent-device/contracts/platform-providers", + "@agent-device/contracts/platform-resource-cleanup", + "@agent-device/contracts/platform-runtime", + "@agent-device/contracts/platform-runtime-host", + "@agent-device/contracts/platform-runtime-operations", + "@agent-device/contracts/platform-runtime-unavailable", + "@agent-device/contracts/progress", + "@agent-device/contracts/react-native-overlay", + "@agent-device/contracts/record-runtime-execution", + "@agent-device/contracts/recording", + "@agent-device/contracts/remote", + "@agent-device/contracts/replay", + "@agent-device/contracts/runner-lease-context", + "@agent-device/contracts/screen-recording-runtime", + "@agent-device/contracts/screen-recording-runtime-host", + "@agent-device/contracts/screen-recording-runtime-plan", + "@agent-device/contracts/screenshot-runtime", + "@agent-device/contracts/scroll-command", + "@agent-device/contracts/scroll-gesture", + "@agent-device/contracts/scroll-runtime", + "@agent-device/contracts/selector-observation-runtime", + "@agent-device/contracts/session", + "@agent-device/contracts/settings", + "@agent-device/contracts/settings-runtime", + "@agent-device/contracts/snapshot", + "@agent-device/contracts/snapshot-presentation", + "@agent-device/contracts/snapshot-runtime", + "@agent-device/contracts/snapshot-scope", + "@agent-device/contracts/snapshot-timeout-evidence", + "@agent-device/contracts/startup-recovery-fence", + "@agent-device/contracts/touch-runtime", + "@agent-device/contracts/tv-remote", + "@agent-device/contracts/tv-remote-runtime", + "@agent-device/contracts/type-text-runtime", + "@agent-device/contracts/viewport-runtime", + "@agent-device/contracts/wait", + "@agent-device/contracts/wait-runtime-plan" +] diff --git a/scripts/layering/generate-contracts-exports-snapshot.ts b/scripts/layering/generate-contracts-exports-snapshot.ts new file mode 100644 index 000000000..d1245be91 --- /dev/null +++ b/scripts/layering/generate-contracts-exports-snapshot.ts @@ -0,0 +1,33 @@ +// Regenerates contracts-exports.snapshot.json from packages/contracts/package.json#exports. +// +// package-boundaries.test.ts diffs the live manifest's export specifiers against this committed +// snapshot so widening or shrinking `@agent-device/contracts`'s export surface fails the gate +// (the property the deleted ~120-line inline CONTRACT_EXPORTS pin used to give). The snapshot is a +// separate file rather than an inline list so an editor who adds or removes a contracts subpath +// runs this script and reviews the diff, instead of hand-retyping an alphabetized array. +// +// Run after any packages/contracts/package.json#exports change: +// node --experimental-strip-types scripts/layering/generate-contracts-exports-snapshot.ts +// then commit the resulting snapshot diff alongside the manifest change. + +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const manifestFile = 'packages/contracts/package.json'; +const snapshotFile = path.join(import.meta.dirname, 'contracts-exports.snapshot.json'); + +const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as { + name?: string; + exports?: Record; +}; +if (!manifest.name) throw new Error(`${manifestFile} has no "name"`); + +const specifiers = Object.keys(manifest.exports ?? {}) + .map((subpath) => path.posix.join(manifest.name!, subpath)) + .sort(); + +fs.writeFileSync(snapshotFile, `${JSON.stringify(specifiers, null, 2)}\n`); +console.log( + `Wrote ${specifiers.length} export specifiers to ${path.relative(repoRoot, snapshotFile)}`, +); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 9992ff013..fe89d9cf8 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -53,13 +53,21 @@ function rules(violations: { rule: string }[]): string[] { } /** - * The manifest is its own inventory: every `exports` target must resolve to an existing, - * TRACKED source file, and every manifest entry must have produced exactly one `exportTargets` - * entry (nothing dropped, nothing collapsed by a duplicate key). This replaces a ~120-line pinned - * subpath list that every `packages/contracts/package.json` change also had to hand-edit; the - * manifest is now the only place the export surface is declared. + * The manifest is its own inventory for two checks: every `exports` target must resolve to an + * existing, TRACKED source file, and every manifest entry must have produced exactly one + * `exportTargets` entry (nothing dropped, nothing collapsed by a duplicate key). Neither check + * catches the export SURFACE itself widening or shrinking -- `pkg.exportTargets` and + * `manifest.exports` are read from the same file, so adding or removing a subpath moves both + * counts together and the equality holds regardless (#2297 review). `snapshotFile`, an + * independently committed baseline regenerated by `generate-contracts-exports-snapshot.ts`, + * restores that guarantee: it changes only when a contributor deliberately reruns the generator + * and reviews the diff, so a subpath added or removed without doing so fails here. */ -function assertExportTargetsMatchManifest(pkg: WorkspacePackage, manifestFile: string): void { +function assertExportTargetsMatchManifest( + pkg: WorkspacePackage, + manifestFile: string, + snapshotFile: string, +): void { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, manifestFile), 'utf8')) as { exports?: Record; }; @@ -75,6 +83,16 @@ function assertExportTargetsMatchManifest(pkg: WorkspacePackage, manifestFile: s Object.keys(manifest.exports ?? {}).length, `${pkg.name} exports map entries must each produce one resolved export target`, ); + const snapshot = JSON.parse( + fs.readFileSync(path.join(repoRoot, snapshotFile), 'utf8'), + ) as string[]; + assert.deepEqual( + [...pkg.exportTargets.keys()].sort(), + [...snapshot].sort(), + `${pkg.name}'s export surface no longer matches ${snapshotFile} -- regenerate it with ` + + `'node --experimental-strip-types scripts/layering/generate-contracts-exports-snapshot.ts' ` + + 'and review the diff', + ); } 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', () => { assert.ok(kernelPackage.exportTargets.size >= 8, 'kernel exports its vocabulary subpaths'); const contractsPackage = packages.find((pkg) => pkg.name === '@agent-device/contracts'); assert.ok(contractsPackage, 'contracts package must exist'); - assertExportTargetsMatchManifest(contractsPackage, 'packages/contracts/package.json'); + assertExportTargetsMatchManifest( + contractsPackage, + 'packages/contracts/package.json', + 'scripts/layering/contracts-exports.snapshot.json', + ); assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']); const captureKitPackage = packages.find((pkg) => pkg.name === '@agent-device/capture-kit'); assert.ok(captureKitPackage, 'capture-kit package must exist');