diff --git a/docs/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 8b49ea36ab..3454e721c5 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -324,6 +324,13 @@ lines in one flat directory whose _names_ carry a hierarchy the filesystem does (14 723 lines), 16 `interaction-*`, 13 `record-*`, 5 `snapshot-*`. `daemon/` itself is another 103 flat files. +Since #3391 the zone has an enforced interior: R81 (`scripts/layering/daemon-layers.ts`) owns the +manifest partitioning every production daemon file into five layers — `daemon-core` < +`daemon-resources` < `daemon-execution` < `daemon-sessions` < `daemon-server` — and rejects any +upward static value or type edge between them (dynamic cross-layer edges are reported, not +rejected). The global spine rank stays 4 for all five, so this is the only direction claim inside +the zone; the manifest is the living claim, restated here at risk of drift. + Things that are **not** wrong, checked and ruled out: - **No copy-paste.** Zero 7-line windows repeat across three or more daemon files. diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index eefdba16a3..06a06ae59b 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -54,6 +54,10 @@ // brand casts inside those two modules, so a udid never runs outside the set that holds it. // - Over REQUEST-BOUND RUNTIME EXECUTION: facts remain the only admission authority and daemon // code cannot manufacture or repair a narrowed runtime proof (R66). +// - Over THE DAEMON'S INTERIOR: every production file under src/daemon/ is owned by exactly one +// layer of the manifest in scripts/layering/daemon-layers.ts, and no static edge reads up the +// daemon-local order core < resources < execution < sessions < server (R81). The global spine +// ranks all five layers at daemon-server's rank, so R5/R6/R80 are blind inside the zone. // - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer // mechanics belong in capture-kit or an adapter (R18). // R6, R9, and the R10 R7 counts are ratchets with no written-down reference: each is the same @@ -130,6 +134,7 @@ import { DAEMON_PLATFORM_RUNTIME_EDGES, } from './daemon-platform-runtime-inventory.ts'; import { checkDaemonClientEntry } from './daemon-client-entry.ts'; +import { checkDaemonLayers, readDaemonLayerManifest } from './daemon-layers.ts'; import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts'; import { listTrackedPlatformZoneFiles, @@ -429,7 +434,10 @@ function report( `${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`, + `${handlerOwnedShapeFiles} shape / ${handlerOwnedAuthorityFiles} authority files); and ` + + `every production daemon file is owned by one layer with no upward static edges across ` + + `core < resources < execution < sessions < server (R81; dynamic cross-layer edges are ` + + `reported above when present).\n`, ); return 0; } @@ -509,6 +517,7 @@ export const LAYERING_RULE_IDS = [ 'daemon-platform-runtime-inventory', 'daemon-client-entry', 'session-authority-overlay', + 'daemon-layers', ] as const; export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number]; @@ -576,6 +585,12 @@ export const LAYERING_RULES: Readonly> = { context.ratchets.sessionAuthority, context.reference.sessionAuthority, ), + 'daemon-layers': (context) => + checkDaemonLayers( + context.sourceFiles.filter((file) => file.startsWith('src/daemon/')), + context.edges, + readDaemonLayerManifest(repoRoot), + ), }; export function main(): number { diff --git a/scripts/layering/daemon-layer-manifest.json b/scripts/layering/daemon-layer-manifest.json new file mode 100644 index 0000000000..53a53c7dac --- /dev/null +++ b/scripts/layering/daemon-layer-manifest.json @@ -0,0 +1,298 @@ +{ + "daemon-core": [ + "src/daemon/apple-runner-options.ts", + "src/daemon/capture-disclosure.ts", + "src/daemon/config.ts", + "src/daemon/context.ts", + "src/daemon/daemon-command-registry.ts", + "src/daemon/daemon-policy.ts", + "src/daemon/daemon-request.ts", + "src/daemon/deferred-interaction-outcome.ts", + "src/daemon/device/device-ready.ts", + "src/daemon/device/device-selector-intent.ts", + "src/daemon/direct-ios-selector.ts", + "src/daemon/dispatch-context.ts", + "src/daemon/execution-plan.ts", + "src/daemon/fill-backend-result.ts", + "src/daemon/generic-leaf-execution.ts", + "src/daemon/gesture-runtime.ts", + "src/daemon/interaction-surface-signature.ts", + "src/daemon/interaction/internal/interaction-touch-direct-ios-eligibility.ts", + "src/daemon/interaction/internal/types.ts", + "src/daemon/interactor-resolution.ts", + "src/daemon/ios-app-session-hint.ts", + "src/daemon/lease-context.ts", + "src/daemon/managed-session-artifact-path.ts", + "src/daemon/platform-owner-lifecycle.ts", + "src/daemon/platform-resource-cleanup.ts", + "src/daemon/provider-device-admission.ts", + "src/daemon/recording-gesture-events.ts", + "src/daemon/recording-gestures.ts", + "src/daemon/recording-timing.ts", + "src/daemon/recording-values.ts", + "src/daemon/ref-frame-slot.ts", + "src/daemon/ref-frame.ts", + "src/daemon/request-dispatch-ledger.ts", + "src/daemon/request-runtime-binding.ts", + "src/daemon/runtime-admission.ts", + "src/daemon/runtime-operation-effects.ts", + "src/daemon/runtime-session.ts", + "src/daemon/selector-operation-binding.ts", + "src/daemon/selector-recording.ts", + "src/daemon/session-action-recorder.ts", + "src/daemon/session-artifact-paths.ts", + "src/daemon/session-capture-binding.ts", + "src/daemon/session-idle-tombstone.ts", + "src/daemon/session-recovery-hints.ts", + "src/daemon/session-replay-transaction.ts", + "src/daemon/session-runtime-admission.ts", + "src/daemon/session-script-active-publication.ts", + "src/daemon/session-script-publication-capability.ts", + "src/daemon/session-script-publication-state.ts", + "src/daemon/session-script-writer.ts", + "src/daemon/session-selector.ts", + "src/daemon/session-snapshot-freshness.ts", + "src/daemon/session-state.ts", + "src/daemon/session-store.ts", + "src/daemon/session-tenant-scope.ts", + "src/daemon/snapshot-capture.ts", + "src/daemon/snapshot-interactor-capture.ts", + "src/daemon/snapshot-runtime-binding.ts", + "src/daemon/snapshot-runtime-capture-input.ts", + "src/daemon/snapshot-session.ts", + "src/daemon/tenant-owned-entry.ts", + "src/daemon/touch-runtime.ts" + ], + "daemon-resources": [ + "src/daemon/app-log-admission-ledger.ts", + "src/daemon/app-log-files.ts", + "src/daemon/app-log-resource-recovery.ts", + "src/daemon/app-log-resource-store.ts", + "src/daemon/app-log-session-resource.ts", + "src/daemon/app-log-start-preflight.ts", + "src/daemon/app-log.ts", + "src/daemon/artifact-tracking.ts", + "src/daemon/device/device-claim-admission.ts", + "src/daemon/device/device-claim-conflict.ts", + "src/daemon/device/device-claim-inspection.ts", + "src/daemon/device/device-claim-paths.ts", + "src/daemon/device/device-claim-reboot.ts", + "src/daemon/device/device-claim-record.ts", + "src/daemon/device/device-claim-rule.ts", + "src/daemon/device/device-claim-settlement.ts", + "src/daemon/device/device-claim-store.ts", + "src/daemon/device/device-claims.ts", + "src/daemon/device/device-mutation-drain.ts", + "src/daemon/human-control-contract.ts", + "src/daemon/lease-in-flight-work.ts", + "src/daemon/lease-registry-scope.ts", + "src/daemon/lease-registry.ts", + "src/daemon/macos-app-lease.ts", + "src/daemon/provider-session-ownership.ts", + "src/daemon/session-device-resolution.ts" + ], + "daemon-execution": [ + "src/daemon/android-foreground-surface.ts", + "src/daemon/android-snapshot-timeout-evidence.ts", + "src/daemon/android-system-dialog.ts", + "src/daemon/app-event-runtime.ts", + "src/daemon/app-events.ts", + "src/daemon/apps-runtime.ts", + "src/daemon/back-runtime.ts", + "src/daemon/focus-runtime.ts", + "src/daemon/fold-runtime.ts", + "src/daemon/generic-runtime-execution.ts", + "src/daemon/generic-settle.ts", + "src/daemon/handlers/react-native.ts", + "src/daemon/handlers/session-clipboard.ts", + "src/daemon/handlers/session-selector-dispatch.ts", + "src/daemon/handlers/snapshot-alert.ts", + "src/daemon/handlers/snapshot-settings.ts", + "src/daemon/interaction/index.ts", + "src/daemon/interaction/internal/find-match-ranking.ts", + "src/daemon/interaction/internal/find-match-resolution.ts", + "src/daemon/interaction/internal/find-target-capture.ts", + "src/daemon/interaction/internal/find.ts", + "src/daemon/interaction/internal/interaction-android-escape.ts", + "src/daemon/interaction/internal/interaction-common.ts", + "src/daemon/interaction/internal/interaction-flags.ts", + "src/daemon/interaction/internal/interaction-gesture-response.ts", + "src/daemon/interaction/internal/interaction-gesture.ts", + "src/daemon/interaction/internal/interaction-ios-tap-outcome.ts", + "src/daemon/interaction/internal/interaction-recorded-input.ts", + "src/daemon/interaction/internal/interaction-ref-policy.ts", + "src/daemon/interaction/internal/interaction-response-data-transform.ts", + "src/daemon/interaction/internal/interaction-response.ts", + "src/daemon/interaction/internal/interaction-runtime.ts", + "src/daemon/interaction/internal/interaction-session.ts", + "src/daemon/interaction/internal/interaction-snapshot.ts", + "src/daemon/interaction/internal/interaction-targeting.ts", + "src/daemon/interaction/internal/interaction-touch-android-freshness.ts", + "src/daemon/interaction/internal/interaction-touch-android-readiness.ts", + "src/daemon/interaction/internal/interaction-touch-direct-ios.ts", + "src/daemon/interaction/internal/interaction-touch-fill.ts", + "src/daemon/interaction/internal/interaction-touch-payload.ts", + "src/daemon/interaction/internal/interaction-touch-policy.ts", + "src/daemon/interaction/internal/interaction-touch-prepare.ts", + "src/daemon/interaction/internal/interaction-touch-press-admission.ts", + "src/daemon/interaction/internal/interaction-touch-press.ts", + "src/daemon/interaction/internal/interaction-touch-reference-frame.ts", + "src/daemon/interaction/internal/interaction-touch-response.ts", + "src/daemon/interaction/internal/interaction-touch-runtime.ts", + "src/daemon/interaction/internal/interaction-touch-targets.ts", + "src/daemon/interaction/internal/interaction.ts", + "src/daemon/internal-observation.ts", + "src/daemon/keyboard-runtime.ts", + "src/daemon/offscreen-target-probe.ts", + "src/daemon/orientation-runtime.ts", + "src/daemon/ref-positionals.ts", + "src/daemon/ref-snapshot-flag-policy.ts", + "src/daemon/request-dispatch-disclosure.ts", + "src/daemon/request-generic-dispatch.ts", + "src/daemon/runtime-artifacts.ts", + "src/daemon/runtime-policy.ts", + "src/daemon/screenshot-crop-target.ts", + "src/daemon/screenshot-crop.ts", + "src/daemon/screenshot-runtime-binding.ts", + "src/daemon/screenshot-runtime.ts", + "src/daemon/scroll-movement.ts", + "src/daemon/scroll-runtime.ts", + "src/daemon/scroll-until.ts", + "src/daemon/selector-capture-binding.ts", + "src/daemon/selector-capture-runtime.ts", + "src/daemon/selector-match-errors.ts", + "src/daemon/selector-runtime-backend.ts", + "src/daemon/selector-runtime.ts", + "src/daemon/selector-text-runtime.ts", + "src/daemon/session-snapshot.ts", + "src/daemon/snapshot-timeout-evidence.ts", + "src/daemon/sparse-fallback-screenshot.ts", + "src/daemon/system-button-runtime.ts", + "src/daemon/tv-remote-runtime.ts", + "src/daemon/type-target-warning.ts", + "src/daemon/type-text-runtime.ts", + "src/daemon/viewport-runtime.ts", + "src/daemon/wait-current-surface.ts", + "src/daemon/wait-runtime.ts" + ], + "daemon-sessions": [ + "src/daemon/application-lifecycle-execution.ts", + "src/daemon/device/device-claim-reconciliation.ts", + "src/daemon/device/device-targets.ts", + "src/daemon/dispatch-payload.ts", + "src/daemon/handlers/human-control.ts", + "src/daemon/handlers/lease.ts", + "src/daemon/handlers/record-runtime-request.ts", + "src/daemon/handlers/record-runtime-response.ts", + "src/daemon/handlers/record-runtime.ts", + "src/daemon/handlers/record-trace.ts", + "src/daemon/handlers/session-app-deployment-route.ts", + "src/daemon/handlers/session-app-deployment.ts", + "src/daemon/handlers/session-app-source-deployment.ts", + "src/daemon/handlers/session-batch.ts", + "src/daemon/handlers/session-command-input.ts", + "src/daemon/handlers/session-doctor-app.ts", + "src/daemon/handlers/session-doctor-device.ts", + "src/daemon/handlers/session-doctor-metro.ts", + "src/daemon/handlers/session-doctor-options.ts", + "src/daemon/handlers/session-doctor-output.ts", + "src/daemon/handlers/session-doctor-types.ts", + "src/daemon/handlers/session-doctor.ts", + "src/daemon/handlers/session-prepare.ts", + "src/daemon/handlers/session-replay-command.ts", + "src/daemon/handlers/session-replay-video-owner.ts", + "src/daemon/handlers/session-runtime-command.ts", + "src/daemon/handlers/session-runtime-port-reverse.ts", + "src/daemon/handlers/session-script-publication.ts", + "src/daemon/handlers/session-state.ts", + "src/daemon/handlers/session.ts", + "src/daemon/handlers/snapshot.ts", + "src/daemon/handlers/trace-runtime.ts", + "src/daemon/install-source-resolution.ts", + "src/daemon/lease-lifecycle.ts", + "src/daemon/materialized-path-registry.ts", + "src/daemon/open-device-contention-wait.ts", + "src/daemon/payload-input.ts", + "src/daemon/request-admission.ts", + "src/daemon/request-handler-chain.ts", + "src/daemon/request-recording-health.ts", + "src/daemon/route-owner-files.ts", + "src/daemon/server/daemon-idle-reap.ts", + "src/daemon/session-lifecycle/index.ts", + "src/daemon/session-lifecycle/internal/inventory.ts", + "src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts", + "src/daemon/session-lifecycle/internal/session-close-runtime-admission.ts", + "src/daemon/session-lifecycle/internal/session-close-script.ts", + "src/daemon/session-lifecycle/internal/session-close.ts", + "src/daemon/session-lifecycle/internal/session-open-execution.ts", + "src/daemon/session-lifecycle/internal/session-open-foreground.ts", + "src/daemon/session-lifecycle/internal/session-open-prepare.ts", + "src/daemon/session-lifecycle/internal/session-open-state.ts", + "src/daemon/session-lifecycle/internal/session-open-surface.ts", + "src/daemon/session-lifecycle/internal/session-open-warnings.ts", + "src/daemon/session-lifecycle/internal/session-open.ts", + "src/daemon/session-lifecycle/internal/session-startup-metrics.ts", + "src/daemon/session-observability/index.ts", + "src/daemon/session-observability/internal/session-audio.ts", + "src/daemon/session-observability/internal/session-network.ts", + "src/daemon/session-observability/internal/session-observability.ts", + "src/daemon/session-observability/internal/session-perf-runtime.ts", + "src/daemon/session-replay-coordinator.ts", + "src/daemon/session-routing.ts", + "src/daemon/session-runtime.ts", + "src/daemon/session-teardown-budget.ts", + "src/daemon/session-teardown.ts", + "src/daemon/snapshot-command-runtime.ts", + "src/daemon/snapshot-diff-runtime.ts", + "src/daemon/snapshot-quality-latch.ts", + "src/daemon/snapshot-runtime.ts", + "src/daemon/startup-deadline.ts", + "src/daemon/web-session-names.ts" + ], + "daemon-server": [ + "src/daemon/application-lifecycle-recovery.ts", + "src/daemon/artifact-archive.ts", + "src/daemon/artifact-download.ts", + "src/daemon/daemon-stop.ts", + "src/daemon/device/device-claim-owner-recovery.ts", + "src/daemon/downloadable-artifact-http.ts", + "src/daemon/host-lease-http.ts", + "src/daemon/http-errors.ts", + "src/daemon/http-request-target.ts", + "src/daemon/human-control-http.ts", + "src/daemon/platform-request-scope.ts", + "src/daemon/provider-lease-expiry.ts", + "src/daemon/replay-device-selection.ts", + "src/daemon/request-binding.ts", + "src/daemon/request-diagnostics-http.ts", + "src/daemon/request-execution-locks.ts", + "src/daemon/request-execution-scope.ts", + "src/daemon/request-finalization.ts", + "src/daemon/request-lease-work.ts", + "src/daemon/request-lock-policy.ts", + "src/daemon/request-platform-provider-context.ts", + "src/daemon/request-router.ts", + "src/daemon/request-save-script-policy.ts", + "src/daemon/response-views.ts", + "src/daemon/resumable-upload-range.ts", + "src/daemon/resumable-upload-transfer.ts", + "src/daemon/resumable-upload.ts", + "src/daemon/server/daemon-lease-finalizer.ts", + "src/daemon/server/daemon-metadata-loss.ts", + "src/daemon/server/daemon-runtime.ts", + "src/daemon/server/daemon-session-idle-expiry.ts", + "src/daemon/server/daemon-shutdown-claims.ts", + "src/daemon/server/http-authorization.ts", + "src/daemon/server/http-instance-precondition.ts", + "src/daemon/server/http-rpc-envelope.ts", + "src/daemon/server/http-rpc-methods.ts", + "src/daemon/server/http-server.ts", + "src/daemon/server/server-lifecycle.ts", + "src/daemon/server/server-shutdown.ts", + "src/daemon/server/tenant-trust.ts", + "src/daemon/server/transport.ts", + "src/daemon/session-idle-expiry.ts", + "src/daemon/upload-http.ts", + "src/daemon/upload.ts" + ] +} diff --git a/scripts/layering/daemon-layers.test.ts b/scripts/layering/daemon-layers.test.ts new file mode 100644 index 0000000000..2e27eca044 --- /dev/null +++ b/scripts/layering/daemon-layers.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { test } from 'node:test'; +import { listSourceFiles } from './check.ts'; +import { resolveImportEdges, type ResolvedImportEdge } from './model.ts'; +import { + checkDaemonLayers, + daemonLayerManifestDrift, + DAEMON_LAYER_ORDER, + readDaemonLayerManifest, +} from './daemon-layers.ts'; + +const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); + +// Fixtures sit at the real daemon paths so the layer lookup treats them like production files; +// `listSourceFiles()` keeps the manifest half honest against the tracked tree. +const FIXTURE_MANIFEST = { + 'daemon-core': ['src/daemon/core.ts'], + 'daemon-resources': ['src/daemon/resources.ts'], + 'daemon-execution': ['src/daemon/execution.ts'], + 'daemon-sessions': ['src/daemon/sessions.ts'], + 'daemon-server': ['src/daemon/server.ts'], +}; +const FIXTURE_FILES = Object.values(FIXTURE_MANIFEST).flat(); + +function fixtureEdges(sources: ReadonlyMap): ResolvedImportEdge[] { + const empty = new Map(FIXTURE_FILES.map((file) => [file, ''] as const)); + return resolveImportEdges(new Map([...empty, ...sources])); +} + +function driftFor( + manifest: Record, + files: readonly string[] = FIXTURE_FILES, +) { + return daemonLayerManifestDrift(files, manifest); +} + +test('the manifest owns every tracked daemon file exactly once, and no static edge reads upward', () => { + const daemonFiles = listSourceFiles().filter((file) => file.startsWith('src/daemon/')); + assert.ok(daemonFiles.length > 0, 'expected daemon production files to scan'); + const manifest = readDaemonLayerManifest(repoRoot); + assert.deepEqual(Object.keys(manifest).sort(), [...DAEMON_LAYER_ORDER].sort()); + assert.deepEqual(daemonLayerManifestDrift(daemonFiles, manifest), []); + + // End-to-end over the real daemon subgraph: every edge whose two ends are manifest files. + const sources = new Map( + daemonFiles.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]), + ); + const reported: string[] = []; + const violations = checkDaemonLayers(daemonFiles, resolveImportEdges(sources), manifest, (line) => + reported.push(line), + ); + assert.deepEqual(violations, []); + // The dynamic report names the lazy handler-chain seams, all of them reading downward. + assert.match(reported.join(''), /dynamic cross-layer daemon edge/); + assert.ok(!reported.join('').includes('[upward'), 'dynamic cross-layer edges stay downward'); +}); + +test('an upward value import fails the layer order', () => { + const violations = checkDaemonLayers( + FIXTURE_FILES, + fixtureEdges(new Map([['src/daemon/core.ts', "import { thing } from './resources.ts';"]])), + FIXTURE_MANIFEST, + () => {}, + ); + assert.equal(violations.length, 1); + assert.equal(violations[0]!.rule, 'R81 daemon-layers'); + assert.equal(violations[0]!.file, 'src/daemon/core.ts'); + assert.match(violations[0]!.message, /value import reads up the daemon layer order/); +}); + +test('an upward type-only import fails the layer order too', () => { + const violations = checkDaemonLayers( + FIXTURE_FILES, + fixtureEdges( + new Map([['src/daemon/resources.ts', "import type { Thing } from './sessions.ts';"]]), + ), + FIXTURE_MANIFEST, + () => {}, + ); + assert.equal(violations.length, 1); + assert.match(violations[0]!.message, /type-only import reads up the daemon layer order/); +}); + +test('downward and same-layer static edges stay silent', () => { + const violations = checkDaemonLayers( + FIXTURE_FILES, + fixtureEdges( + new Map([ + ['src/daemon/server.ts', "import { thing } from './core.ts';"], + ['src/daemon/sessions.ts', "import type { CoreThing } from './core.ts';"], + ]), + ), + FIXTURE_MANIFEST, + () => {}, + ); + assert.deepEqual(violations, []); +}); + +test('a dynamic cross-layer edge is reported, not rejected', () => { + const reported: string[] = []; + const violations = checkDaemonLayers( + FIXTURE_FILES, + fixtureEdges(new Map([['src/daemon/sessions.ts', "void import('./server.ts');"]])), + FIXTURE_MANIFEST, + (line) => reported.push(line), + ); + assert.deepEqual(violations, []); + assert.equal(reported.length, 2); + assert.match(reported.join(''), /1 dynamic cross-layer daemon edge/); + assert.match( + reported.join(''), + /src\/daemon\/sessions\.ts \(daemon-sessions\) -> src\/daemon\/server\.ts \(daemon-server\) \[upward, reported only\]/, + ); +}); + +test('a production daemon file missing from the manifest fails', () => { + const violations = driftFor(FIXTURE_MANIFEST, [...FIXTURE_FILES, 'src/daemon/server2.ts']); + assert.equal(violations.length, 1); + assert.equal(violations[0]!.file, 'src/daemon/server2.ts'); + assert.match(violations[0]!.message, /production daemon file is in no layer/); +}); + +test('a file claimed by two layers fails', () => { + const manifest = { + ...FIXTURE_MANIFEST, + 'daemon-resources': ['src/daemon/resources.ts', 'src/daemon/core.ts'], + }; + const violations = driftFor(manifest); + assert.equal(violations.length, 1); + assert.match(violations[0]!.message, /"src\/daemon\/core\.ts" is claimed by more than one layer/); +}); + +test('a manifest entry that is not a tracked production file fails', () => { + const manifest = { + ...FIXTURE_MANIFEST, + 'daemon-core': ['src/daemon/core.ts', 'src/daemon/gone.ts'], + }; + const violations = driftFor(manifest); + assert.equal(violations.length, 1); + assert.match( + violations[0]!.message, + /"src\/daemon\/gone\.ts" is owned by "daemon-core" but is not a tracked production file/, + ); +}); + +test('a test, fixture, or foreign path in the manifest fails as a non-production entry', () => { + const manifest = { + ...FIXTURE_MANIFEST, + 'daemon-core': [ + 'src/daemon/core.ts', + 'src/daemon/__tests__/core.test.ts', + 'src/core/helper.ts', + ], + }; + const violations = driftFor(manifest); + assert.equal(violations.length, 2); + for (const violation of violations) { + assert.match(violation.message, /is not a production path under src\/daemon\/\./); + } +}); + +test('an unknown layer key fails, naming the declared order', () => { + const violations = driftFor({ + ...FIXTURE_MANIFEST, + 'daemon-cache': [], + }); + assert.equal(violations.length, 1); + assert.match(violations[0]!.message, /unknown daemon layer "daemon-cache"/); +}); + +test('a layer key missing from the manifest fails even when no file is orphaned', () => { + const manifest = { ...FIXTURE_MANIFEST } as Record; + delete manifest['daemon-resources']; + const violations = driftFor( + manifest, + FIXTURE_FILES.filter((file) => file !== 'src/daemon/resources.ts'), + ); + assert.equal(violations.length, 1); + assert.match(violations[0]!.message, /layer "daemon-resources" is missing from the manifest/); +}); + +test('a declared layer whose array is empty fails, so it cannot linger after its last file moves', () => { + const violations = driftFor( + { ...FIXTURE_MANIFEST, 'daemon-resources': [] }, + FIXTURE_FILES.filter((file) => file !== 'src/daemon/resources.ts'), + ); + assert.equal(violations.length, 1); + assert.match(violations[0]!.message, /layer "daemon-resources" owns no files/); +}); diff --git a/scripts/layering/daemon-layers.ts b/scripts/layering/daemon-layers.ts new file mode 100644 index 0000000000..cbd440c715 --- /dev/null +++ b/scripts/layering/daemon-layers.ts @@ -0,0 +1,243 @@ +// Catches: the daemon's internal layering drifting silently — a production file under `src/daemon/` +// that no layer owns, an owned file that moved or disappeared, a file claimed by two layers, or a +// static import that reads UP the daemon-local order (core < resources < execution < sessions < +// server). The global spine ranks all five at daemon-server's rank 4, so R5/R6 cannot see any +// edge inside the daemon, and R80 cannot see a cycle inside the zone either; this rule is the +// only direction claim over the daemon's interior. Dynamic imports crossing layers are reported +// explicitly, never rejected: the route loaders in `request-handler-chain.ts` defer exactly the +// handlers the eager closure must not carry, and R5 made the same dynamic-vs-static decision +// (#3280). Omitted, duplicated, and unknown manifest entries fail so the manifest cannot decay +// into fiction while the edge rule keeps passing. +// Evidence: #3391 landed this partition; measured against the c2c09d476 graph it holds zero +// upward static value or type edges, which the manifest reproduces and this verifier re-checks. +// Cost: attributed to the R81 rule registration in check.ts; not a standalone CI job. +// Kill criterion: once each layer lives in its own folder under the daemon and the folder derives +// the layer the way `topFolder` derives zones, the file list retires and the edge rule stays. + +import fs from 'node:fs'; +import path from 'node:path'; +import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; +import { isProductionSourceFile } from './tracked-sources.ts'; + +export const DAEMON_LAYER_RULE = 'R81 daemon-layers'; + +/** The daemon-local order, lowest first. The global spine rank stays 4 for all five layers. */ +export const DAEMON_LAYER_ORDER = [ + 'daemon-core', + 'daemon-resources', + 'daemon-execution', + 'daemon-sessions', + 'daemon-server', +] as const; + +export type DaemonLayer = (typeof DAEMON_LAYER_ORDER)[number]; + +/** The manifest shape: each declared layer claims the production files it owns. */ +export type DaemonLayerManifest = Readonly>; + +const MANIFEST_RELPATH = 'scripts/layering/daemon-layer-manifest.json'; + +export function isDaemonLayer(layer: string): layer is DaemonLayer { + return (DAEMON_LAYER_ORDER as readonly string[]).includes(layer); +} + +export function daemonLayerRank(layer: DaemonLayer): number { + return DAEMON_LAYER_ORDER.indexOf(layer); +} + +/** + * The exhaustive `{layer: sourcePaths}` claim over every production file under `src/daemon/`, + * committed next to this verifier so moving a file into its layer's folder carries its ownership + * with it. + */ +export function readDaemonLayerManifest(repoRoot: string): DaemonLayerManifest { + return JSON.parse( + fs.readFileSync(path.join(repoRoot, MANIFEST_RELPATH), 'utf8'), + ) as DaemonLayerManifest; +} + +/** `src/daemon/<…>.ts` and not a test or fixture path — the only entries the manifest may name. */ +function isDaemonProductionPath(file: string): boolean { + return file.startsWith('src/daemon/') && isProductionSourceFile(file); +} + +/** The partition flattened to one lookup; a path in two layers keeps its first claim. */ +export function daemonLayerByFile( + manifest: DaemonLayerManifest, +): ReadonlyMap { + const byFile = new Map(); + for (const [layer, files] of Object.entries(manifest)) { + for (const file of files) { + if (!byFile.has(file)) byFile.set(file, layer); + } + } + return byFile; +} + +function manifestFileViolation(message: string): LayeringViolation { + return { rule: DAEMON_LAYER_RULE, file: MANIFEST_RELPATH, line: 1, message }; +} + +/** + * Manifest-vs-tree drift: production daemon files the manifest omits, entries that name a path + * which is not a tracked production daemon file, a path claimed by two layers, and layer keys + * outside the declared order. + */ +export function daemonLayerManifestDrift( + trackedDaemonProductionFiles: readonly string[], + manifest: DaemonLayerManifest, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + + for (const layer of Object.keys(manifest)) { + if (!isDaemonLayer(layer)) { + violations.push( + manifestFileViolation( + `unknown daemon layer "${layer}". Declared layers: ${DAEMON_LAYER_ORDER.join(', ')}.`, + ), + ); + } + } + for (const layer of DAEMON_LAYER_ORDER) { + const owned = manifest[layer]; + if (owned === undefined) { + violations.push( + manifestFileViolation( + `layer "${layer}" is missing from the manifest. Every declared layer must own files or leave the order.`, + ), + ); + } else if (owned.length === 0) { + violations.push( + manifestFileViolation( + `layer "${layer}" owns no files. A layer that lost its last file leaves the order; an empty entry keeps a phantom position in it.`, + ), + ); + } + } + + const tracked = new Set(trackedDaemonProductionFiles); + const claimed = new Set(); + for (const [layer, files] of Object.entries(manifest)) { + for (const file of files) { + if (!isDaemonProductionPath(file)) { + violations.push( + manifestFileViolation( + `entry "${file}" is not a production path under src/daemon/. Test files, fixtures, and paths outside the daemon carry no layer.`, + ), + ); + continue; + } + if (claimed.has(file)) { + violations.push( + manifestFileViolation( + `"${file}" is claimed by more than one layer, again under "${layer}". A file belongs to exactly one layer.`, + ), + ); + continue; + } + claimed.add(file); + if (!tracked.has(file)) { + violations.push( + manifestFileViolation( + `"${file}" is owned by "${layer}" but is not a tracked production file. Move the entry with the file, in the same change.`, + ), + ); + } + } + } + + for (const file of [...tracked].sort()) { + if (!claimed.has(file)) { + violations.push({ + rule: DAEMON_LAYER_RULE, + file, + line: 1, + message: + `production daemon file is in no layer. Assign it in ${MANIFEST_RELPATH}; ` + + 'defaulting it silently hides its position in the order.', + }); + } + } + return violations; +} + +function rankedLayerPair( + edge: ResolvedImportEdge, + layerByFile: ReadonlyMap, +): Readonly<{ from: DaemonLayer; to: DaemonLayer }> | null { + const from = layerByFile.get(edge.file); + const to = layerByFile.get(edge.target); + if (from === undefined || to === undefined || !isDaemonLayer(from) || !isDaemonLayer(to)) { + return null; + } + return { from, to }; +} + +/** Static value or type-only edges that read up the daemon-local order. */ +export function upwardDaemonLayerEdges( + edges: readonly ResolvedImportEdge[], + layerByFile: ReadonlyMap, +): ResolvedImportEdge[] { + return edges.filter((edge) => { + if (edge.dynamic) return false; + const pair = rankedLayerPair(edge, layerByFile); + return pair !== null && daemonLayerRank(pair.from) < daemonLayerRank(pair.to); + }); +} + +/** + * Dynamic imports whose target sits in a different daemon layer. Reported on the check's stdout, + * never failed — the report keeps the lazy seams visible while the layer folders land. + */ +export function dynamicCrossLayerDaemonEdges( + edges: readonly ResolvedImportEdge[], + layerByFile: ReadonlyMap, +): ResolvedImportEdge[] { + return edges.filter((edge) => { + if (!edge.dynamic) return false; + const pair = rankedLayerPair(edge, layerByFile); + return pair !== null && pair.from !== pair.to; + }); +} + +/** + * The registered R81 check. `trackedDaemonProductionFiles` is the daemon half of the context's + * production scan, so tests and fixtures carry no layer, matching how the other daemon rules + * treat them. + */ +export function checkDaemonLayers( + trackedDaemonProductionFiles: readonly string[], + edges: readonly ResolvedImportEdge[], + manifest: DaemonLayerManifest, + report: (line: string) => void = (line) => process.stdout.write(line), +): LayeringViolation[] { + const violations = daemonLayerManifestDrift(trackedDaemonProductionFiles, manifest); + const layerByFile = daemonLayerByFile(manifest); + for (const edge of upwardDaemonLayerEdges(edges, layerByFile)) { + const pair = rankedLayerPair(edge, layerByFile)!; + violations.push({ + rule: DAEMON_LAYER_RULE, + file: edge.file, + line: edge.line, + message: + `${edge.typeOnly ? 'type-only' : 'value'} import reads up the daemon layer order: ` + + `${edge.file} (${pair.from}) ${edge.typeOnly ? 'types' : 'imports'} ${edge.target} ` + + `(${pair.to}). Move the shared contract below both layers, or call down to the ` + + `lower layer's owner instead of reading upward.`, + }); + } + const dynamic = dynamicCrossLayerDaemonEdges(edges, layerByFile); + if (dynamic.length > 0) { + report( + `Layering guard: R81 reports ${dynamic.length} dynamic cross-layer daemon edge(s) ` + + `(reported, not rejected; see scripts/layering/daemon-layers.ts):\n`, + ); + for (const edge of dynamic) { + const pair = rankedLayerPair(edge, layerByFile)!; + const upward = + daemonLayerRank(pair.from) < daemonLayerRank(pair.to) ? ' [upward, reported only]' : ''; + report(` ${edge.file} (${pair.from}) -> ${edge.target} (${pair.to})${upward}\n`); + } + } + return violations; +}