diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 8a398f88e..639f79e27 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -101,6 +101,11 @@ import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownersh import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts'; +import { + checkDaemonPlatformRuntimeInventory, + DAEMON_PLATFORM_RUNTIME_EDGES, +} from './daemon-platform-runtime-inventory.ts'; +import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts'; import { listTrackedPlatformZoneFiles, listTrackedProductionSources, @@ -352,6 +357,9 @@ function report( (sum, count) => sum + count, 0, ); + const measuredOverlay = handlerOwnedOverlay(ratchets.sessionAuthority); + const handlerOwnedShapeFiles = measuredOverlay.shapeFiles.length; + const handlerOwnedAuthorityFiles = measuredOverlay.authorityFiles.length; process.stdout.write( `Layering guard: OK — ${files.length} source files satisfy R2 and contain no ` + `value-import cycles (both checked globally); the ranked target spine contains no ` + @@ -363,8 +371,12 @@ function report( `${ratchets.largestTypeCycle.length} files (R9); ${daemonModularitySummary(reference)}; ` + `${packageBoundariesSummary(repoRoot)}; ${platformPackagePolicySummary()}; ` + `runtime facts remain the only device-command admission authority and daemon code cannot ` + - `manufacture narrowed runtime proof (R66); and R65 keeps production src/daemon free of ` + - `concrete platform imports in every executable and type-only form.\n`, + `manufacture narrowed runtime proof (R66); R65 keeps production src/daemon free of ` + + `concrete platform imports in every executable and type-only form; ` + + `${DAEMON_PLATFORM_RUNTIME_EDGES.length} daemon-to-root platform-runtime edges hold ` + + `their #2278 classification (R76); and the handler-owned SessionState/SessionStore ` + + `authority overlay holds at or under the merge-base (R75, ` + + `${handlerOwnedShapeFiles} shape / ${handlerOwnedAuthorityFiles} authority files).\n`, ); return 0; } @@ -437,6 +449,8 @@ export const LAYERING_RULE_IDS = [ 'ios-snapshot-engine-ownership', 'provider-snapshot-presentation-ownership', 'snapshot-assembly-presentation-neutrality', + 'daemon-platform-runtime-inventory', + 'session-authority-overlay', ] as const; export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number]; @@ -491,6 +505,13 @@ export const LAYERING_RULES: Readonly> = { providerSnapshotPresentationViolations(context.sources, context.edges), 'snapshot-assembly-presentation-neutrality': (context) => snapshotAssemblyPresentationViolations(context.sources, context.edges), + 'daemon-platform-runtime-inventory': (context) => + checkDaemonPlatformRuntimeInventory(context.edges), + 'session-authority-overlay': (context) => + checkSessionAuthorityOverlay( + context.ratchets.sessionAuthority, + context.reference.sessionAuthority, + ), }; export function main(): number { diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 1f647d654..f0533ae2c 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -20,6 +20,7 @@ function importEdge(file: string, target: string): ResolvedImportEdge { dynamic: false, typeOnly: true, symbols: [], + bindingResidue: false, fromZone: targetDagZone(file), toZone: targetDagZone(target), }; diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts new file mode 100644 index 000000000..c70563cd8 --- /dev/null +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveImportEdges, type LayeringViolation } from './model.ts'; +import { + checkDaemonPlatformRuntimeInventory, + DAEMON_PLATFORM_RUNTIME_EDGES, + DAEMON_PLATFORM_RUNTIME_RULE, + isRootPlatformRuntimeTarget, +} from './daemon-platform-runtime-inventory.ts'; + +const DEVICE_READY_TARGET = 'src/platform-runtime-device-ready.ts'; +const DEVICE_READY_STUB = + 'export async function ensureLocalPlatformDeviceReady(device: unknown) { return false; }\n'; + +function violations(sources: Record): LayeringViolation[] { + return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); +} + +function edgeViolations(sources: Record, file: string): LayeringViolation[] { + return violations(sources).filter((violation) => violation.file === file); +} + +test('R76 accepts a classified edge with the exact recorded symbols', () => { + const sources = { + [DEVICE_READY_TARGET]: DEVICE_READY_STUB, + 'src/daemon/device-ready.ts': + "import { ensureLocalPlatformDeviceReady } from '../platform-runtime-device-ready.ts';\n" + + 'void ensureLocalPlatformDeviceReady;\n', + }; + assert.deepEqual(edgeViolations(sources, 'src/daemon/device-ready.ts'), []); +}); + +test('R76 reports every classified edge missing from the tree as stale, not the other way around', () => { + const sources = { + [DEVICE_READY_TARGET]: DEVICE_READY_STUB, + 'src/daemon/device-ready.ts': + "import { ensureLocalPlatformDeviceReady } from '../platform-runtime-device-ready.ts';\n" + + 'void ensureLocalPlatformDeviceReady;\n', + }; + const stale = violations(sources).filter( + (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', + ); + assert.equal(stale.length, DAEMON_PLATFORM_RUNTIME_EDGES.length - 1); + assert.ok(stale.every((violation) => violation.message.includes('stale classified edge'))); + const deviceReadyStale = stale.find((violation) => + violation.message.includes(DEVICE_READY_TARGET), + ); + assert.equal(deviceReadyStale, undefined); +}); + +test('R76 rejects an unclassified edge with the pair and its line', () => { + const sources = { + 'src/platform-runtime-android-tool-host.ts': 'export function createAndroidToolHost() {}\n', + 'src/daemon/fixture.ts': + "import { createAndroidToolHost } from '../platform-runtime-android-tool-host.ts';\n" + + 'void createAndroidToolHost;\n', + }; + const found = edgeViolations(sources, 'src/daemon/fixture.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.equal(found[0]!.line, 1); + assert.match(found[0]!.message, /unclassified daemon-to-root platform-runtime coupling/); + assert.match( + found[0]!.message, + /src\/daemon\/fixture\.ts -> src\/platform-runtime-android-tool-host\.ts/, + ); +}); + +test('R76 rejects new symbols on a classified edge', () => { + const sources = { + [DEVICE_READY_TARGET]: DEVICE_READY_STUB + 'export function extraReadiness() {}\n', + 'src/daemon/device-ready.ts': + "import { ensureLocalPlatformDeviceReady, extraReadiness } from '../platform-runtime-device-ready.ts';\n" + + 'void [ensureLocalPlatformDeviceReady, extraReadiness];\n', + }; + const found = edgeViolations(sources, 'src/daemon/device-ready.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /classified symbols drifted/); + assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/); +}); + +test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const { recoverLegacyAppLogMarkersAfterDaemonLock } = await import('../../platform-runtime-operation-host.ts');\n" + + 'void recoverLegacyAppLogMarkersAfterDaemonLock;\n', + }; + assert.deepEqual(edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'), []); +}); + +test('R76 rejects an expanded destructured dynamic import on a classified edge', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n' + + 'export async function extraLegacyMarkerSweep() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const { recoverLegacyAppLogMarkersAfterDaemonLock, extraLegacyMarkerSweep } = await import('../../platform-runtime-operation-host.ts');\n" + + 'void [recoverLegacyAppLogMarkersAfterDaemonLock, extraLegacyMarkerSweep];\n', + }; + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /classified symbols drifted/); + assert.match( + found[0]!.message, + /extraLegacyMarkerSweep, recoverLegacyAppLogMarkersAfterDaemonLock/, + ); +}); + +test('R76 rejects a rest binding next to a recorded dynamic-import binding', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n' + + 'export async function sweepLegacyAppLogMarkers() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const { recoverLegacyAppLogMarkersAfterDaemonLock, ...operationHost } = await import('../../platform-runtime-operation-host.ts');\n" + + 'void [recoverLegacyAppLogMarkersAfterDaemonLock, operationHost];\n', + }; + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /unnameable dynamic-import binding/); + assert.match( + found[0]!.message, + /src\/daemon\/server\/daemon-runtime\.ts -> src\/platform-runtime-operation-host\.ts/, + ); +}); + +test('R76 rejects a computed destructure key on a classified dynamic import', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const markerName = 'recoverLegacyAppLogMarkersAfterDaemonLock';\n" + + 'const { [markerName]: recover } = await import("../../platform-runtime-operation-host.ts");\n' + + 'void recover;\n', + }; + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + assert.equal(found.length, 1); + assert.match(found[0]!.message, /unnameable dynamic-import binding/); +}); + +test('R76 rejects a namespace-form dynamic import that hides the recorded bindings', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const mod = await import('../../platform-runtime-operation-host.ts');\nvoid mod;\n", + }; + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /open-ended dynamic import/); +}); + +test('R76 rejects a namespace import alongside the recorded named binding on the same pair', () => { + const sources = { + 'src/platform-runtime-operation-host.ts': + 'export async function recoverLegacyAppLogMarkersAfterDaemonLock() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "const { recoverLegacyAppLogMarkersAfterDaemonLock } = await import('../../platform-runtime-operation-host.ts');\n" + + "const operationHost = await import('../../platform-runtime-operation-host.ts');\n" + + 'void [recoverLegacyAppLogMarkersAfterDaemonLock, operationHost];\n', + }; + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /open-ended dynamic import/); + assert.match( + found[0]!.message, + /src\/daemon\/server\/daemon-runtime\.ts -> src\/platform-runtime-operation-host\.ts/, + ); +}); + +test('R76 treats the import and re-export of one classified pair as one entry', () => { + const sources = { + 'src/platform-runtime-open-target.ts': + 'export async function resolveSoleForegroundIosApp() { return undefined; }\n', + 'src/daemon/ios-app-session-hint.ts': + "import { resolveSoleForegroundIosApp } from '../platform-runtime-open-target.ts';\n" + + "export { resolveSoleForegroundIosApp } from '../platform-runtime-open-target.ts';\n" + + 'void resolveSoleForegroundIosApp;\n', + }; + assert.deepEqual(edgeViolations(sources, 'src/daemon/ios-app-session-hint.ts'), []); +}); + +test('R76 ignores test-shaped and non-daemon importers', () => { + const sources = { + 'src/platform-runtime-android-tool-host.ts': 'export function createAndroidToolHost() {}\n', + 'src/daemon/__tests__/fixture.test.ts': + "import { createAndroidToolHost } from '../platform-runtime-android-tool-host.ts';\n" + + 'void createAndroidToolHost;\n', + 'src/cli.ts': + "import { createAndroidToolHost } from './platform-runtime-android-tool-host.ts';\n" + + 'void createAndroidToolHost;\n', + }; + assert.deepEqual( + violations(sources).filter( + (violation) => violation.file !== 'scripts/layering/daemon-platform-runtime-inventory.ts', + ), + [], + ); +}); + +test('R76 ignores retired-zone targets, which R65 owns', () => { + const sources = { + 'src/platforms/android.ts': 'export const legacy = 1;\n', + 'src/daemon/fixture.ts': 'import { legacy } from "../platforms/android.ts";\nvoid legacy;\n', + }; + assert.deepEqual(edgeViolations(sources, 'src/daemon/fixture.ts'), []); +}); + +test('the root composition family is src/platform-runtime.ts plus src/platform-runtime-*.ts only', () => { + assert.equal(isRootPlatformRuntimeTarget('src/platform-runtime.ts'), true); + assert.equal(isRootPlatformRuntimeTarget('src/platform-runtime-android.ts'), true); + assert.equal(isRootPlatformRuntimeTarget('src/platform-runtime-gateway.ts'), true); + assert.equal(isRootPlatformRuntimeTarget('src/platform-runtime-android.tsx'), false); + assert.equal(isRootPlatformRuntimeTarget('src/platform-runtime.ts.bak'), false); + assert.equal(isRootPlatformRuntimeTarget('src/platforms/runtime.ts'), false); + assert.equal(isRootPlatformRuntimeTarget('src/daemon/platform-runtime.ts'), false); +}); diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts new file mode 100644 index 000000000..778b438fa --- /dev/null +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -0,0 +1,320 @@ +import { isProductionSourceFile } from './tracked-sources.ts'; +import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; + +// The classified inventory of every production daemon import of a root platform-runtime +// composition module (#2278, ADR 0022). R65 already bans daemon imports of concrete platform +// packages and the retired src/platforms zone; the root src/platform-runtime*.ts family is the +// one composition layer the daemon may still touch, and this table is the classification of +// every edge that does. An edge is unclassifiable until it is recorded here with a rationale, +// and a recorded edge that no longer exists is stale — both fail, so the inventory and the +// tree cannot drift apart in either direction. + +export const DAEMON_PLATFORM_RUNTIME_RULE = 'R76 daemon-platform-runtime-inventory'; + +export type DaemonPlatformRuntimeClassification = + | 'composition-essential' + | 'daemon-policy-essential' + | 'leaked-platform-mechanics'; + +export type DaemonPlatformRuntimeEdge = Readonly<{ + file: string; + target: string; + /** + * Exact named symbols across every edge of the pair; empty for static side-effect imports (a + * destructured dynamic import records its bindings, so widening the destructure is a drift, not + * a silent expansion). Unnameable dynamic-import forms cannot be recorded here, and R76 rejects + * the edge: a rest or computed destructure binding, or a namespace/side-effect import() call — + * both expose exports beyond this list. + */ + symbols: readonly string[]; + classification: DaemonPlatformRuntimeClassification; + rationale: string; + /** The seam or child issue that deepens a leaked-platform-mechanics edge. */ + deepenedBy?: string; +}>; + +/** The root platform-runtime composition family: src/platform-runtime.ts and src/platform-runtime-*.ts. */ +export function isRootPlatformRuntimeTarget(target: string): boolean { + return /^src\/platform-runtime(?:\.ts|-[a-z0-9-]+\.ts)$/.test(target); +} + +/** + * Measured on origin/main 6e22e266d7 for #2278: 14 production edges in 9 daemon files. + * The ios-app-session-hint entry covers two edges (the import and the re-export of the + * same symbol); every other entry is one edge. + */ +export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] = [ + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime.ts', + symbols: [ + 'androidObservation', + 'createPlatformRuntimeGateway', + 'createPlatformDeviceInventoryGateways', + 'createRequestPlatformProviders', + ], + classification: 'composition-essential', + rationale: + 'process-root assembly of the neutral runtime gateway, device-inventory gateways, and ' + + 'request platform providers (ADR 0019 section 1/2 boundary); the daemon holds no ' + + 'platform mechanics at this site, only the composition the process root owns.', + }, + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-host-diagnostics.ts', + symbols: ['createHostDiagnostics'], + classification: 'composition-essential', + rationale: + 'process-root assembly of the neutral HostDiagnostics contract capability; the ' + + 'per-family probes load lazily inside the root module, so the daemon consumes only ' + + 'the contract surface.', + }, + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-apple-runner-owner.ts', + symbols: [ + 'configureAppleRunnerDeviceClaimAuthorityProbe', + 'configureAppleRunnerLeaseOwnerStateDir', + ], + classification: 'leaked-platform-mechanics', + rationale: + 'daemon startup/shutdown names the Apple runner owner directly; the daemon-owned ' + + 'inputs (lease-owner state dir, claim-authority probe) should flow through typed ' + + 'lifecycle participation instead of configure calls into a platform-composed owner.', + deepenedBy: '#2333', + }, + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-resource-cleanup.ts', + symbols: [ + 'cleanupManagedWebRuntimeOrphans', + 'platformResourceCleanup', + 'resetAndroidSnapshotHelperRuntime', + ], + classification: 'leaked-platform-mechanics', + rationale: + 'startup/shutdown cleanup participation names the Android snapshot-helper and Web ' + + 'orphan owners directly; platformResourceCleanup (the neutral PlatformResourceCleanup ' + + 'contract) is the model the other two symbols should follow.', + deepenedBy: '#2333', + }, + { + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-operation-host.ts', + symbols: ['recoverLegacyAppLogMarkersAfterDaemonLock'], + classification: 'leaked-platform-mechanics', + rationale: + 'daemon startup names app-log legacy marker recovery, a platform process mechanic; ' + + 'it should join the same typed lifecycle participation as the other startup cleanups.', + deepenedBy: '#2333', + }, + { + file: 'src/daemon/device-claim-owner-recovery.ts', + target: 'src/platform-runtime.ts', + symbols: ['createPlatformRuntimeGateway'], + classification: 'composition-essential', + rationale: + "per-transaction neutral gateway assembly scoped to the dead owner's state dir " + + '(#2168); the process root cannot carry a per-claim sessionsDir, so the scoped ' + + "composition belongs to the recovery policy's own module.", + }, + { + file: 'src/daemon/device-ready.ts', + target: 'src/platform-runtime-device-ready.ts', + symbols: ['ensureLocalPlatformDeviceReady'], + classification: 'composition-essential', + rationale: + 'neutral local-device-readiness port assembled at the root composition layer (the ' + + 'platform dispatch is internal to the root module); the daemon keeps its TTL cache ' + + 'and provider-device policy locally.', + }, + { + file: 'src/daemon/direct-ios-selector.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['queryAppleRuntimeSelector'], + classification: 'leaked-platform-mechanics', + rationale: + 'the direct-iOS fast path queries the Apple runner selector mechanics directly; the ' + + 'selector-producer seam owned by #2273/#2274 is the accepted deepening, and this ' + + 'audit deliberately adds no second selector producer.', + deepenedBy: '#2273, #2274', + }, + { + file: 'src/daemon/ios-app-session-hint.ts', + target: 'src/platform-runtime-open-target.ts', + symbols: ['resolveSoleForegroundIosApp'], + classification: 'leaked-platform-mechanics', + rationale: + 'the open-hint policy (daemon-owned: when to emit, length bound, never-guess) calls ' + + 'the Apple foreground-app probe and re-exports it; the probe should arrive through ' + + 'a semantic observation port instead of the mixed open-target module.', + deepenedBy: '#2332', + }, + { + file: 'src/daemon/request-recording-health.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['inspectAppleRunnerSession'], + classification: 'leaked-platform-mechanics', + rationale: + 'recording-health refresh reads the Apple runner session mechanics directly; the ' + + 'daemon needs a semantic runner-session observation (alive plus current session id), ' + + 'not the runner probe.', + deepenedBy: '#2332', + }, + { + file: 'src/daemon/session-device-resolution.ts', + target: 'src/platform-runtime-apple-resources.ts', + symbols: ['inspectAppleRunnerSession'], + classification: 'leaked-platform-mechanics', + rationale: + 'device refresh uses a live runner session as simulator-boot evidence; the daemon ' + + 'needs the same semantic runner-session observation as recording health, not the ' + + 'runner probe.', + deepenedBy: '#2332', + }, + { + file: 'src/daemon/handlers/session-selector-dispatch.ts', + target: 'src/platform-runtime-open-target.ts', + symbols: ['resolveAndroidPackageForOpen', 'resolveSessionAppBundleIdForTarget'], + classification: 'leaked-platform-mechanics', + rationale: + 'selector dispatch consumes the mixed open-target module; Android package resolution ' + + 'is platform mechanics that should sit behind the Android owning seam.', + deepenedBy: '#2334', + }, + { + file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', + target: 'src/platform-runtime-open-target.ts', + symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], + classification: 'leaked-platform-mechanics', + rationale: + 'open-prepare policy consumes the mixed open-target module; the neutral open ' + + 'plan/result should be separated from the platform mechanics that share the file.', + deepenedBy: '#2334', + }, +] as const; + +function keyOf(file: string, target: string): string { + return `${file} -> ${target}`; +} + +function sorted(symbols: readonly string[]): string[] { + return [...symbols].sort(); +} + +/** + * Catches: unclassified daemon-to-root platform-runtime coupling regrowing — a new edge (or a + * new symbol on an existing edge) that the #2278 audit never classified, the mirror failure, a + * classified edge that no longer exists and would silently admit its return, and dynamic + * imports whose binding set the inventory cannot name: a rest or computed destructure binding, + * or a namespace/side-effect import() call exposed alongside (or instead of) the named ones. + * Evidence: #2278 measured 14 production edges in 9 daemon files at origin/main 6e22e266d7; + * this table is that measurement, classified per ADR 0022. + * Cost: attributed to the R76 rule registration in check.ts; not a standalone CI job. + * Kill criterion: the daemon reaches the platform only through the gateway and declared + * contract capabilities (the inventory empty), or a maintainer decision retires the + * classification requirement. + */ +export function checkDaemonPlatformRuntimeInventory( + edges: readonly ResolvedImportEdge[], +): LayeringViolation[] { + const actual = new Map< + string, + { line: number; symbols: Set; residue: boolean; openEnded: boolean } + >(); + for (const edge of edges) { + if (!edge.file.startsWith('src/daemon/')) continue; + if (!isProductionSourceFile(edge.file)) continue; + if (!isRootPlatformRuntimeTarget(edge.target)) continue; + const key = keyOf(edge.file, edge.target); + const entry = actual.get(key) ?? { + line: edge.line, + symbols: new Set(), + residue: false, + openEnded: false, + }; + for (const symbol of edge.symbols) entry.symbols.add(symbol); + entry.residue = entry.residue || edge.bindingResidue; + // Validated per edge before the per-pair union: a dynamic import that names no binding + // exposes the whole module namespace, which sibling named edges of the same pair would + // otherwise mask inside the union. + entry.openEnded = + entry.openEnded || (edge.dynamic && !edge.bindingResidue && edge.symbols.length === 0); + actual.set(key, entry); + } + + const violations: LayeringViolation[] = []; + const seen = new Set(); + + for (const [key, entry] of actual) { + const declaration = DAEMON_PLATFORM_RUNTIME_EDGES.find( + (candidate) => keyOf(candidate.file, candidate.target) === key, + ); + if (declaration === undefined) { + violations.push({ + rule: DAEMON_PLATFORM_RUNTIME_RULE, + file: key.split(' -> ')[0]!, + line: entry.line, + message: + `unclassified daemon-to-root platform-runtime coupling: ${key}. Classify it in ` + + `DAEMON_PLATFORM_RUNTIME_EDGES (${DAEMON_PLATFORM_RUNTIME_RULE}) with its rationale, ` + + `or remove the coupling.`, + }); + continue; + } + seen.add(key); + if (entry.residue) { + violations.push({ + rule: DAEMON_PLATFORM_RUNTIME_RULE, + file: key.split(' -> ')[0]!, + line: entry.line, + message: + `unnameable dynamic-import binding for ${key}: a rest or computed destructure exposes ` + + `bindings the inventory cannot name. Destructure every binding explicitly and record it ` + + `in DAEMON_PLATFORM_RUNTIME_EDGES (${DAEMON_PLATFORM_RUNTIME_RULE}), or remove the coupling.`, + }); + continue; + } + if (entry.openEnded) { + violations.push({ + rule: DAEMON_PLATFORM_RUNTIME_RULE, + file: key.split(' -> ')[0]!, + line: entry.line, + message: + `open-ended dynamic import for ${key}: a namespace or side-effect import() exposes the ` + + `whole module, which the inventory cannot name symbol by symbol. Destructure every ` + + `binding explicitly and record it in DAEMON_PLATFORM_RUNTIME_EDGES ` + + `(${DAEMON_PLATFORM_RUNTIME_RULE}), or remove the coupling.`, + }); + continue; + } + const expected = sorted(declaration.symbols); + const measured = sorted([...entry.symbols]); + if (expected.join('\u0000') !== measured.join('\u0000')) { + violations.push({ + rule: DAEMON_PLATFORM_RUNTIME_RULE, + file: key.split(' -> ')[0]!, + line: entry.line, + message: + `classified symbols drifted for ${key}: the tree imports ${measured.join(', ') || '(none)'} ` + + `but the inventory records ${expected.join(', ') || '(none)'}. Update the inventory ` + + `entry in the same change, or remove the added coupling.`, + }); + } + } + + for (const declaration of DAEMON_PLATFORM_RUNTIME_EDGES) { + const key = keyOf(declaration.file, declaration.target); + if (actual.has(key) || seen.has(key)) continue; + violations.push({ + rule: DAEMON_PLATFORM_RUNTIME_RULE, + file: 'scripts/layering/daemon-platform-runtime-inventory.ts', + line: 1, + message: + `stale classified edge: ${key} no longer exists. Remove the entry so the coupling ` + + `cannot return unclassified.`, + }); + } + + return violations; +} diff --git a/scripts/layering/layering-ast.ts b/scripts/layering/layering-ast.ts index 594864a49..2e47bd458 100644 --- a/scripts/layering/layering-ast.ts +++ b/scripts/layering/layering-ast.ts @@ -42,3 +42,91 @@ export function visitAst(node: unknown, visitor: (node: Record) visitor(record); for (const child of Object.values(record)) visitAst(child, visitor); } + +const IMPORT_WRAPPER_TYPES = new Set([ + 'AwaitExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', +]); + +function unwrapImportInit(node: unknown): Record | null { + let current = node; + while (current !== null && typeof current === 'object') { + const record = current as Record; + if (typeof record.type !== 'string' || !IMPORT_WRAPPER_TYPES.has(record.type)) { + return record.type === 'ImportExpression' ? record : null; + } + current = + record.type === 'AwaitExpression' || record.type === 'ParenthesizedExpression' + ? record.argument + : record.expression; + } + return null; +} + +export type DestructuredDynamicImport = Readonly<{ + /** Export names captured from static (non-computed) property keys. */ + symbols: readonly string[]; + /** True when the pattern also holds a rest element or a computed key: the import surface is wider than `symbols`. */ + residue: boolean; +}>; + +/** + * Bindings captured by destructuring a dynamic import + * (`const { a, b: local } = await import('...')` captures `a` and `b`), keyed by the import + * expression's source offset. A rest element or a computed key is a binding the scanner cannot + * name and is reported as residue, never dropped: it exposes exports beyond `symbols`. + * Namespace-form and bare dynamic imports capture nothing. + */ +export function destructuredDynamicImportBindings( + program: unknown, +): ReadonlyMap { + const captures = new Map(); + const visit = (node: unknown): void => { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + const record = node as Record; + if (record.type === 'VariableDeclarator') { + const init = unwrapImportInit(record.init); + const id = record.id; + if ( + init !== null && + typeof init.start === 'number' && + id !== null && + typeof id === 'object' && + (id as Record).type === 'ObjectPattern' + ) { + const symbols: string[] = []; + let residue = false; + const properties = (id as Record).properties; + if (!Array.isArray(properties)) { + residue = true; + } else { + for (const property of properties) { + if ( + property === null || + typeof property !== 'object' || + (property as Record).type !== 'Property' || + (property as Record).computed === true + ) { + residue = true; + continue; + } + const name = propertyName((property as Record).key); + if (name) symbols.push(name); + else residue = true; + } + } + captures.set(init.start, { symbols, residue }); + } + } + for (const child of Object.values(record)) visit(child); + }; + visit(program); + return captures; +} diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 918905852..dee949676 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -56,7 +56,14 @@ test('parseImports detects multiline dynamic imports', () => { const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n')); assert.deepEqual(edges, [ - { spec: '../multiline.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] }, + { + spec: '../multiline.ts', + dynamic: true, + typeOnly: false, + line: 1, + symbols: [], + bindingResidue: false, + }, ]); }); @@ -64,10 +71,40 @@ test('parseImports resolves constant-template dynamic imports', () => { const edges = parseImports('void import(`../template.ts`);'); assert.deepEqual(edges, [ - { spec: '../template.ts', dynamic: true, typeOnly: false, line: 1, symbols: [] }, + { + spec: '../template.ts', + dynamic: true, + typeOnly: false, + line: 1, + symbols: [], + bindingResidue: false, + }, ]); }); +test('parseImports captures destructured named bindings of dynamic imports, keyed by export name', () => { + const edges = parseImports( + [ + "const { a, 'b': c } = await import('./dyn.ts');", + "const mod = await import('./dyn.ts');", + "const wrapped = (await import('./dyn.ts')) as Mod;", + 'const { a, ...rest } = await import("./dyn.ts");', + 'const { [keyExpr]: named } = await import("./dyn.ts");', + ].join('\n'), + ); + + assert.deepEqual( + edges.map(({ spec, symbols, bindingResidue }) => ({ spec, symbols, bindingResidue })), + [ + { spec: './dyn.ts', symbols: ['a', 'b'], bindingResidue: false }, + { spec: './dyn.ts', symbols: [], bindingResidue: false }, + { spec: './dyn.ts', symbols: [], bindingResidue: false }, + { spec: './dyn.ts', symbols: ['a'], bindingResidue: true }, + { spec: './dyn.ts', symbols: [], bindingResidue: true }, + ], + ); +}); + test('parseImports retains named source symbols without changing edge-kind detection', () => { const edges = parseImports( [ diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 841cf9de8..49999813b 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -1,15 +1,23 @@ import path from 'node:path'; import { PLATFORMS } from '@agent-device/kernel/device'; import { parseSync } from 'oxc-parser'; -import { visitAst } from './layering-ast.ts'; +import { destructuredDynamicImportBindings, visitAst } from './layering-ast.ts'; export type ImportEdge = { spec: string; dynamic: boolean; typeOnly: boolean; line: number; - /** Named symbols imported from the target; empty for side-effect, namespace, and dynamic imports. */ + /** + * Named symbols imported from the target; empty for side-effect and namespace imports, and for + * dynamic imports that do not destructure named bindings. + */ symbols: readonly string[]; + /** + * True when a dynamic-import destructure holds a binding the scanner cannot name (a rest element + * or a computed key); `symbols` then does not enumerate the full imported surface. + */ + bindingResidue: boolean; }; export type ResolvedImportEdge = ImportEdge & { @@ -151,16 +159,20 @@ function literalSpecifier(node: unknown): string | undefined { function scanDynamicImports(source: string): ImportEdge[] { const edges: ImportEdge[] = []; const parsed = parseSync('layering-imports.ts', source); + const destructured = destructuredDynamicImportBindings(parsed.program); visitAst(parsed.program, (node) => { if (node.type !== 'ImportExpression') return; const spec = literalSpecifier(node.source); if (spec === undefined) return; + const start = node.start as number | undefined; + const capture = typeof start === 'number' ? destructured.get(start) : undefined; edges.push({ spec, dynamic: true, typeOnly: false, - line: sourceLine(source, node.start as number | undefined), - symbols: [], + line: sourceLine(source, start), + symbols: capture ? [...capture.symbols] : [], + bindingResidue: capture?.residue ?? false, }); }); return edges; @@ -169,7 +181,14 @@ function scanDynamicImports(source: string): ImportEdge[] { function scanSideEffectImport(line: string, lineNo: number): ImportEdge | null { const match = /^\s*import\s+['"]([^'"]+)['"]/.exec(line); return match - ? { spec: match[1]!, dynamic: false, typeOnly: false, line: lineNo, symbols: [] } + ? { + spec: match[1]!, + dynamic: false, + typeOnly: false, + line: lineNo, + symbols: [], + bindingResidue: false, + } : null; } @@ -233,6 +252,7 @@ function scanFromImport(lines: string[], index: number): ImportEdge | null { typeOnly: statementIsTypeOnly(normalizedStatement), line: start + 1, symbols: importedSymbols(normalizedStatement), + bindingResidue: false, }; } diff --git a/scripts/layering/ratchet-reference.test.ts b/scripts/layering/ratchet-reference.test.ts index daa081fb3..e27e79e53 100644 --- a/scripts/layering/ratchet-reference.test.ts +++ b/scripts/layering/ratchet-reference.test.ts @@ -49,13 +49,27 @@ test('sessionStateWritePressure counts written fields and (field, writer) claims }); }); -test('measureRatchets reports all three ratchets from one tree', () => { +test('measureRatchets reports all ratchets from one tree', () => { const sources = tree(); assert.deepEqual(measureRatchets(sources, resolveImportEdges(sources)), { typeInversions: { 'commands -> client': 1 }, largestTypeCycle: ['src/client/client.ts', 'src/commands/loop.ts'], sessionState: { writerOwnedFields: 2, ownerFileClaims: 3 }, + sessionAuthority: { shapeFiles: [], authorityFiles: [] }, }); + const withAuthority = tree({ + 'src/daemon/handlers/fixture.ts': + "import type { SessionState } from '../session-state.ts';\n" + + "import { SessionStore } from '../session-store.ts';", + 'src/daemon/session-store.ts': 'export class SessionStore {}', + }); + assert.deepEqual( + measureRatchets(withAuthority, resolveImportEdges(withAuthority)).sessionAuthority, + { + shapeFiles: ['src/daemon/handlers/fixture.ts'], + authorityFiles: ['src/daemon/handlers/fixture.ts'], + }, + ); }); test('memoizedImportParser parses each distinct source text once', () => { diff --git a/scripts/layering/ratchet-reference.ts b/scripts/layering/ratchet-reference.ts index 45317bb35..2fcc40914 100644 --- a/scripts/layering/ratchet-reference.ts +++ b/scripts/layering/ratchet-reference.ts @@ -1,7 +1,8 @@ -// The three ratcheted measurements (R6 type-spine inversions, R9 largest type cycle, R10's R7 -// ownership pressure) and where their reference numbers come from: the merge-base with -// origin/main, measured by the same functions that measure the working tree. Growth fails, a -// shrink needs no edit, and no recorded number can sit above what main actually holds. +// The ratcheted measurements (R6 type-spine inversions, R9 largest type cycle, R10's R7 +// ownership pressure, R75's session authority overlay) and where their reference numbers +// come from: the merge-base with origin/main, measured by the same functions that measure +// the working tree. Growth fails, a shrink needs no edit, and no recorded number can sit +// above what main actually holds. // // The base tree is read through the shared committed-tree reader (one `git ls-tree`, one // `git cat-file --batch`), never a second checkout and never a read per file. @@ -16,6 +17,10 @@ import { type ResolvedImportEdge, } from './model.ts'; import { workspaceSpecifierTargetsFromManifests } from './package-boundaries.ts'; +import { + measureSessionAuthorityOverlay, + type SessionAuthorityOverlay, +} from './session-authority-overlay.ts'; import { sessionStateWritePressure, type SessionStateWritePressure } from './session-state.ts'; export type LayeringRatchets = Readonly<{ @@ -25,6 +30,8 @@ export type LayeringRatchets = Readonly<{ largestTypeCycle: readonly string[]; /** R10: R7 ownership pressure. */ sessionState: SessionStateWritePressure; + /** R75: SessionState-shape / SessionStore-authority overlay, full production scope. */ + sessionAuthority: SessionAuthorityOverlay; }>; export type MergeBaseRatchets = LayeringRatchets & Readonly<{ ref: string }>; @@ -37,6 +44,7 @@ export function measureRatchets( typeInversions: typeInversionCounts(edges), largestTypeCycle: largestTypeCycleMembers(edges), sessionState: sessionStateWritePressure(sources), + sessionAuthority: measureSessionAuthorityOverlay(sources, edges), }; } diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts new file mode 100644 index 000000000..aca280a92 --- /dev/null +++ b/scripts/layering/session-authority-overlay.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveImportEdges } from './model.ts'; +import { + checkSessionAuthorityOverlay, + handlerOwnedOverlay, + measureSessionAuthorityOverlay, + SESSION_AUTHORITY_OVERLAY_RULE, + type SessionAuthorityOverlay, +} from './session-authority-overlay.ts'; + +const STATE_STUB = + 'export type SessionState = {\n name: string;\n};\n' + + 'export type DaemonRequest = { x: number; };\n'; +const STORE_STUB = + 'export class SessionStore { get(name: string) { return name; } }\n' + + 'export function resolveDaemonStateDir() { return "."; };\n'; + +function overlayOf(sources: Record): SessionAuthorityOverlay { + const tree = new Map(Object.entries(sources)); + return measureSessionAuthorityOverlay(tree, resolveImportEdges(tree)); +} + +test('the overlay counts symbol-level production importers, not file-level importers', () => { + const sources = { + 'src/daemon/session-state.ts': STATE_STUB, + 'src/daemon/session-store.ts': STORE_STUB, + 'src/daemon/handlers/fixture.ts': + "import type { SessionState } from '../session-state.ts';\n" + + "import { SessionStore } from '../session-store.ts';\n", + 'src/daemon/handlers/request-only.ts': + "import type { DaemonRequest } from '../session-state.ts';\n" + + "import { resolveDaemonStateDir } from '../session-store.ts';\n", + 'src/daemon/non-handler.ts': "import type { SessionState } from './session-state.ts';\n", + 'src/daemon/handlers/fixture.test.ts': + "import type { SessionState } from '../session-state.ts';\n", + }; + assert.deepEqual(overlayOf(sources), { + shapeFiles: ['src/daemon/handlers/fixture.ts', 'src/daemon/non-handler.ts'], + authorityFiles: ['src/daemon/handlers/fixture.ts'], + }); +}); + +test('the overlay follows the SessionState declaration to a moved path, not a recorded one', () => { + // Planted-red against a hardcoded src/daemon/types.ts target: #2346 moved the declaration to + // session-state.ts, so importers point there, and the overlay must still count them. + const sources = { + 'src/daemon/types.ts': 'export type DaemonRequest = { x: number; };\n', + 'src/daemon/session-state.ts': STATE_STUB, + 'src/daemon/session-store.ts': STORE_STUB, + 'src/daemon/handlers/fixture.ts': "import type { SessionState } from '../session-state.ts';\n", + 'src/daemon/non-handler.ts': "import type { SessionState } from './session-state.ts';\n", + }; + assert.deepEqual(overlayOf(sources), { + shapeFiles: ['src/daemon/handlers/fixture.ts', 'src/daemon/non-handler.ts'], + authorityFiles: [], + }); +}); + +test('the handler-owned subset is the flat handlers surface only', () => { + const overlay: SessionAuthorityOverlay = { + shapeFiles: [ + 'src/daemon/handlers/fixture.ts', + 'src/daemon/non-handler.ts', + 'src/daemon/interaction/internal/interaction.ts', + ], + authorityFiles: ['src/daemon/handlers/fixture.ts', 'src/daemon/request-router.ts'], + }; + assert.deepEqual(handlerOwnedOverlay(overlay), { + shapeFiles: ['src/daemon/handlers/fixture.ts'], + authorityFiles: ['src/daemon/handlers/fixture.ts'], + }); +}); + +test('R75 fails a handler file that gains a shape or authority edge beyond the merge-base', () => { + const reference: SessionAuthorityOverlay = { + shapeFiles: ['src/daemon/handlers/kept.ts'], + authorityFiles: ['src/daemon/handlers/kept.ts'], + }; + const measured: SessionAuthorityOverlay = { + shapeFiles: ['src/daemon/handlers/kept.ts', 'src/daemon/handlers/new-shape.ts'], + authorityFiles: ['src/daemon/handlers/new-authority.ts'], + }; + const violations = checkSessionAuthorityOverlay(measured, reference); + assert.equal(violations.length, 2); + const shape = violations.find( + (violation) => violation.file === 'src/daemon/handlers/new-shape.ts', + )!; + const authority = violations.find( + (violation) => violation.file === 'src/daemon/handlers/new-authority.ts', + )!; + assert.equal(shape.rule, SESSION_AUTHORITY_OVERLAY_RULE); + assert.equal(authority.rule, SESSION_AUTHORITY_OVERLAY_RULE); + assert.match(shape.message, /new handler-owned SessionState shape edge/); + assert.match(authority.message, /new handler-owned SessionStore authority edge/); +}); + +test('R75 passes when the handler-owned sets hold or shrink', () => { + const reference: SessionAuthorityOverlay = { + shapeFiles: ['src/daemon/handlers/a.ts', 'src/daemon/handlers/b.ts'], + authorityFiles: ['src/daemon/handlers/a.ts'], + }; + const measured: SessionAuthorityOverlay = { + shapeFiles: ['src/daemon/handlers/a.ts'], + authorityFiles: [], + }; + assert.deepEqual(checkSessionAuthorityOverlay(measured, reference), []); +}); + +test('R75 leaves new non-handler importers to the module declarations, not this ratchet', () => { + const measured: SessionAuthorityOverlay = { + shapeFiles: ['src/daemon/new-module.ts'], + authorityFiles: ['src/daemon/server/new-module.ts'], + }; + assert.deepEqual( + checkSessionAuthorityOverlay(measured, { shapeFiles: [], authorityFiles: [] }), + [], + ); +}); + +test('the end-to-end measurement feeds the ratchet: a new handler importer is red, an existing one is not', () => { + const base = { + 'src/daemon/session-state.ts': STATE_STUB, + 'src/daemon/session-store.ts': STORE_STUB, + 'src/daemon/handlers/kept.ts': "import type { SessionState } from '../session-state.ts';\n", + }; + const reference = handlerOwnedOverlay(overlayOf(base)); + const grown = { + ...base, + 'src/daemon/handlers/new.ts': "import type { SessionState } from '../session-state.ts';\n", + }; + const measured = handlerOwnedOverlay(overlayOf(grown)); + assert.deepEqual(checkSessionAuthorityOverlay(reference, reference), []); + const violations = checkSessionAuthorityOverlay(measured, reference); + assert.equal(violations.length, 1); + assert.equal(violations[0]!.file, 'src/daemon/handlers/new.ts'); +}); diff --git a/scripts/layering/session-authority-overlay.ts b/scripts/layering/session-authority-overlay.ts new file mode 100644 index 000000000..3e4d3a78c --- /dev/null +++ b/scripts/layering/session-authority-overlay.ts @@ -0,0 +1,112 @@ +import { isProductionSourceFile } from './tracked-sources.ts'; +import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; +import { sessionStateDeclarationFile } from './session-state.ts'; + +// The SessionState/SessionStore authority overlay (#2278, ADR 0022): which production files +// import the SessionState shape from the module that declares it and the SessionStore authority +// from src/daemon/session-store.ts, at symbol level so that a file importing only helper +// functions from session-store.ts is not an authority edge. R7 already owns the write side +// (field ownership); this overlay owns the read side. The shape target is found by the +// declaration, not a recorded path: a path constant would silently measure zero shape importers +// the moment the declaration moves (#2346 moved it from types.ts to session-state.ts). The +// ratchet applies to the handler-owned subset — the flat src/daemon/handlers/ surface the +// #2132 migration targeted — and is a membership ratchet against the merge-base: the set may +// only shrink, so a handler file gaining state-shape or store authority fails instead of +// silently regrowing the hotspot. + +export const SESSION_AUTHORITY_OVERLAY_RULE = 'R75 session-authority-overlay'; + +const SESSION_STORE_AUTHORITY_TARGET = 'src/daemon/session-store.ts'; +const HANDLER_ROOT = 'src/daemon/handlers/'; + +export type SessionAuthorityOverlay = Readonly<{ + /** Production files importing the SessionState symbol from its declaring module, sorted. */ + shapeFiles: readonly string[]; + /** Production files importing the SessionStore symbol from src/daemon/session-store.ts, sorted. */ + authorityFiles: readonly string[]; +}>; + +export function measureSessionAuthorityOverlay( + sources: ReadonlyMap, + edges: readonly ResolvedImportEdge[], +): SessionAuthorityOverlay { + const shapeTarget = sessionStateDeclarationFile(sources); + const shape = new Set(); + const authority = new Set(); + for (const edge of edges) { + if (!isProductionSourceFile(edge.file)) continue; + if ( + shapeTarget !== undefined && + edge.target === shapeTarget && + edge.symbols.includes('SessionState') + ) { + shape.add(edge.file); + } + if (edge.target === SESSION_STORE_AUTHORITY_TARGET && edge.symbols.includes('SessionStore')) { + authority.add(edge.file); + } + } + return { shapeFiles: [...shape].sort(), authorityFiles: [...authority].sort() }; +} + +/** The ratcheted subset: the flat handler surface, the one #2132's migration drove down. */ +export function handlerOwnedOverlay(overlay: SessionAuthorityOverlay): SessionAuthorityOverlay { + const inHandlers = (file: string) => file.startsWith(HANDLER_ROOT); + return { + shapeFiles: overlay.shapeFiles.filter(inHandlers), + authorityFiles: overlay.authorityFiles.filter(inHandlers), + }; +} + +/** + * Catches: the handler-owned SessionState-shape / SessionStore-authority surface regrowing — + * a file under src/daemon/handlers/ importing either symbol that the merge-base does not + * already import it, which is exactly the deep-import the #2132 migration paid to delete. + * Both overlays are full-scope; the handler-owned subset is filtered here, at the single + * place the ratchet applies it, so the message can never name a file outside its scope. + * Evidence: #2132's completion record (2026-09-02) moved handler-owned shape edges 55 -> 16 + * and authority edges 50 -> 23 and left the residue to a fresh authority audit; #2278 ran + * that audit at origin/main 6e22e266d7 and measured 14/22 handler-owned (112/68 + * repo-wide), classified in ADR 0022. + * Cost: attributed to the R75 rule registration in check.ts; the measurement is folded into + * measureRatchets (ratchet-reference.ts), so the reference tree pays no extra pass. + * Kill criterion: the handlers directory holds no SessionState/SessionStore importers + * (both sets empty), or a maintainer decision re-scopes where handler authority may live. + */ +export function checkSessionAuthorityOverlay( + measured: SessionAuthorityOverlay, + reference: SessionAuthorityOverlay, +): LayeringViolation[] { + const scopedMeasured = handlerOwnedOverlay(measured); + const scopedReference = handlerOwnedOverlay(reference); + const violations: LayeringViolation[] = []; + const known = (files: readonly string[]) => new Set(files); + const referenceShape = known(scopedReference.shapeFiles); + const referenceAuthority = known(scopedReference.authorityFiles); + + for (const file of scopedMeasured.shapeFiles) { + if (referenceShape.has(file)) continue; + violations.push({ + rule: SESSION_AUTHORITY_OVERLAY_RULE, + file, + line: 1, + message: + 'new handler-owned SessionState shape edge: the merge-base holds no such import. ' + + "Route the read through the owning module's facade or a named semantic query, or " + + "accept the growth at ADR 0022's authority overlay explicitly.", + }); + } + for (const file of scopedMeasured.authorityFiles) { + if (referenceAuthority.has(file)) continue; + violations.push({ + rule: SESSION_AUTHORITY_OVERLAY_RULE, + file, + line: 1, + message: + 'new handler-owned SessionStore authority edge: the merge-base holds no such import. ' + + "Route the operation through the owning module's facade or a named store operation, " + + "or accept the growth at ADR 0022's authority overlay explicitly.", + }); + } + return violations; +} diff --git a/scripts/layering/zone-policy.test.ts b/scripts/layering/zone-policy.test.ts index 407bfd09e..3b999bc63 100644 --- a/scripts/layering/zone-policy.test.ts +++ b/scripts/layering/zone-policy.test.ts @@ -19,6 +19,7 @@ function edge(file: string, fromZone: string, toZone: string, kind: Kind = {}) { dynamic: kind.dynamic ?? false, typeOnly: kind.typeOnly ?? false, symbols: [], + bindingResidue: false, }, }; }