Skip to content

Commit 7ed93ce

Browse files
thymikeeclaude
andcommitted
refactor(gates): own the client-only replay rule as daemon-process reachability (R82)
The R11 path gate (daemon root plus two named root files) missed root platform-runtime modules and reached packages. R82 walks every module the daemon process can load from src/daemon.ts, over static and dynamic edges, and denies replay-port's caller-side collector. The eager closure would not do: the composition root and platform-runtime modules load behind a function-scoped await import. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent b1a4cc8 commit 7ed93ce

5 files changed

Lines changed: 126 additions & 108 deletions

File tree

‎scripts/layering/check.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-p
132132
import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts';
133133
import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts';
134134
import { checkDaemonPlatformRuntimeInventory } from './daemon-platform-runtime-inventory.ts';
135+
import { checkDaemonProcessReach } from './daemon-process-reach.ts';
135136
import { checkDaemonClientEntry } from './daemon-client-entry.ts';
136137
import { checkDaemonLayers, DAEMON_ROOT } from './daemon-layers.ts';
137138
import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts';
@@ -433,7 +434,8 @@ function report(
433434
`free of ` +
434435
`concrete platform imports in every executable and type-only form; ` +
435436
`no production daemon file reaches the root platform-runtime family or a hub that ` +
436-
`does (R76); and the handler-owned SessionState/SessionStore ` +
437+
`does (R76); the daemon process reaches no caller-side replay source module over ` +
438+
`static or dynamic edges (R82); and the handler-owned SessionState/SessionStore ` +
437439
`authority overlay holds at or under the merge-base (R75, ` +
438440
`${handlerOwnedShapeFiles} shape / ${handlerOwnedAuthorityFiles} authority files); and ` +
439441
`every production daemon file lives under a layer folder with no upward static edges ` +
@@ -516,6 +518,7 @@ export const LAYERING_RULE_IDS = [
516518
'provider-snapshot-presentation-ownership',
517519
'snapshot-assembly-presentation-neutrality',
518520
'daemon-platform-runtime-inventory',
521+
'daemon-process-reach',
519522
'daemon-client-entry',
520523
'session-authority-overlay',
521524
'daemon-layers',
@@ -580,6 +583,7 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
580583
snapshotAssemblyPresentationViolations(context.sources, context.edges),
581584
'daemon-platform-runtime-inventory': (context) =>
582585
checkDaemonPlatformRuntimeInventory(context.edges),
586+
'daemon-process-reach': (context) => checkDaemonProcessReach(context.edges),
583587
'daemon-client-entry': (context) => checkDaemonClientEntry(context.edges),
584588
'session-authority-overlay': (context) =>
585589
checkSessionAuthorityOverlay(

‎scripts/layering/client-only-subpaths.test.ts‎

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { resolveImportEdges } from './model.ts';
4+
import {
5+
checkDaemonProcessReach,
6+
DAEMON_PROCESS_DENIED_MODULES,
7+
DAEMON_PROCESS_REACH_RULE,
8+
} from './daemon-process-reach.ts';
9+
10+
const COLLECTOR = 'packages/replay-port/src/source-discovery.ts';
11+
12+
function reach(sources: Record<string, string>) {
13+
return checkDaemonProcessReach(resolveImportEdges(new Map(Object.entries(sources))));
14+
}
15+
16+
test('the denied set names replay-port caller-side collector modules', () => {
17+
// Pinned independently so deleting an entry fails here instead of silently shrinking the rule.
18+
assert.deepEqual([...DAEMON_PROCESS_DENIED_MODULES].sort(), [
19+
'packages/replay-port/src/script-source-bundle.ts',
20+
'packages/replay-port/src/source-discovery.ts',
21+
]);
22+
});
23+
24+
test('a root platform-runtime module behind a dynamic import cannot reach the collector', () => {
25+
const found = reach({
26+
'src/daemon.ts': "export const load = () => import('./daemon-composition.ts');\n",
27+
'src/daemon-composition.ts': "import './platform-runtime-daemon-services.ts';\n",
28+
'src/platform-runtime-daemon-services.ts':
29+
"import '../packages/replay-port/src/source-discovery.ts';\n",
30+
[COLLECTOR]: 'export const collect = () => [];\n',
31+
});
32+
assert.equal(found.length, 1);
33+
assert.equal(found[0]!.rule, DAEMON_PROCESS_REACH_RULE);
34+
assert.equal(found[0]!.file, 'src/platform-runtime-daemon-services.ts');
35+
assert.match(
36+
found[0]!.message,
37+
/src\/daemon\.ts -> src\/daemon-composition\.ts -> src\/platform-runtime-daemon-services\.ts -> packages\/replay-port\/src\/source-discovery\.ts/,
38+
);
39+
});
40+
41+
test('a type-only edge loads nothing, and the client may import the collector', () => {
42+
assert.deepEqual(
43+
reach({
44+
'src/daemon.ts':
45+
"import type { Collect } from '../packages/replay-port/src/source-discovery.ts';\n",
46+
'src/commands/replay/index.ts':
47+
"import '../../../packages/replay-port/src/source-discovery.ts';\n",
48+
[COLLECTOR]: 'export type Collect = () => [];\n',
49+
}),
50+
[],
51+
);
52+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { LayeringViolation, ResolvedImportEdge } from './model.ts';
2+
3+
export const DAEMON_PROCESS_REACH_RULE = 'R82 daemon-process-reach';
4+
5+
/** The module a daemon process starts from. */
6+
export const DAEMON_PROCESS_ENTRY = 'src/daemon.ts';
7+
8+
/**
9+
* Modules the daemon process must never load. The client reads the caller's replay scripts and
10+
* ships their contents in the request; the daemon only ever sees that bundle, never caller paths.
11+
*/
12+
export const DAEMON_PROCESS_DENIED_MODULES: readonly string[] = [
13+
'packages/replay-port/src/script-source-bundle.ts',
14+
'packages/replay-port/src/source-discovery.ts',
15+
];
16+
17+
/**
18+
* Catches: any module the daemon process can load — over static and dynamic edges, so the
19+
* composition root and every platform-runtime module behind a function-scoped `await import`
20+
* count — reaching a denied module. The violation names the edge that brings the denied module
21+
* in and the shortest chain from the entry to it.
22+
* Evidence: the client-only placement of replay-port's caller-side collector was a convention
23+
* until #3517; a path-scoped R11 gate covered only files under the daemon root plus two named
24+
* root files, so a root platform-runtime module or a reached package could still import it.
25+
* Cost: one breadth-first walk over the already-resolved edges, inside `pnpm check:layering`.
26+
* Kill criterion: the collector moves into a package the daemon package cannot depend on.
27+
*/
28+
export function checkDaemonProcessReach(
29+
edges: readonly ResolvedImportEdge[],
30+
entry: string = DAEMON_PROCESS_ENTRY,
31+
denied: readonly string[] = DAEMON_PROCESS_DENIED_MODULES,
32+
): LayeringViolation[] {
33+
const outgoing = new Map<string, ResolvedImportEdge[]>();
34+
for (const edge of edges) {
35+
if (edge.typeOnly && !edge.dynamic) continue;
36+
const list = outgoing.get(edge.file) ?? [];
37+
list.push(edge);
38+
outgoing.set(edge.file, list);
39+
}
40+
const cameFrom = new Map<string, ResolvedImportEdge | null>([[entry, null]]);
41+
const queue = [entry];
42+
for (let head = 0; head < queue.length; head += 1) {
43+
const current = queue[head]!;
44+
for (const edge of outgoing.get(current) ?? []) {
45+
if (cameFrom.has(edge.target)) continue;
46+
cameFrom.set(edge.target, edge);
47+
queue.push(edge.target);
48+
}
49+
}
50+
const violations: LayeringViolation[] = [];
51+
for (const module of denied) {
52+
const via = cameFrom.get(module);
53+
if (!via) continue;
54+
const chain: string[] = [module];
55+
for (let at = via; at; at = cameFrom.get(at.file) ?? null) chain.unshift(at.file);
56+
violations.push({
57+
rule: DAEMON_PROCESS_REACH_RULE,
58+
file: via.file,
59+
line: via.line,
60+
message:
61+
`${entry} reaches ${module}, which the daemon process must never load: the daemon ` +
62+
`receives the client's replay source bundle and does not read caller paths. ` +
63+
`Chain: ${chain.join(' -> ')}.`,
64+
});
65+
}
66+
return violations;
67+
}

‎scripts/layering/package-boundaries.ts‎

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,12 @@
2626
// instanceof), and, for `scripts/` files which sit outside the type graph by design, the
2727
// root-side unknown-package and exports-map checks that compiled files get from TS2307.
2828
// On top, `TEST_ONLY_FIXTURE_SUBPATHS` mirrors R13's Android fixture gate for the non-platform
29-
// packages: a production file importing one of those fixture subpaths is an R11 violation, and
30-
// `CLIENT_ONLY_SUBPATHS` keeps the caller-side replay source collector out of daemon code.
29+
// packages: a production file importing one of those fixture subpaths is an R11 violation.
3130

3231
import fs from 'node:fs';
3332
import path from 'node:path';
3433
import { parseImports, resolveWorkspaceSpecifier } from './model.ts';
35-
import {
36-
DAEMON_SOURCE_ROOT,
37-
listTrackedPackageManifests,
38-
listTrackedProductionSources,
39-
} from './tracked-sources.ts';
34+
import { listTrackedPackageManifests, listTrackedProductionSources } from './tracked-sources.ts';
4035

4136
export type PackageBoundaryViolation = {
4237
rule: string;
@@ -263,30 +258,6 @@ function importsTestOnlyFixtureFromProduction(file: string, specifier: string):
263258
return TEST_ONLY_FIXTURE_SUBPATHS.has(specifier) && !isTestConsumerFile(file);
264259
}
265260

266-
/**
267-
* Caller-side replay source acquisition: the client reads script files from the caller's
268-
* filesystem and ships their contents in the request. Daemon production code receives that
269-
* bundle and must never read caller paths itself, so it may not import these subpaths.
270-
*/
271-
export const CLIENT_ONLY_SUBPATHS: ReadonlySet<string> = new Set([
272-
'@agent-device/replay-port/script-source-bundle',
273-
'@agent-device/replay-port/source-discovery',
274-
]);
275-
276-
// The daemon process also runs its root entry and composition root, outside the daemon package.
277-
const DAEMON_PROCESS_ROOT_FILES: ReadonlySet<string> = new Set([
278-
'src/daemon.ts',
279-
'src/daemon-composition.ts',
280-
]);
281-
282-
function importsClientOnlyFromDaemon(file: string, specifier: string): boolean {
283-
return (
284-
CLIENT_ONLY_SUBPATHS.has(specifier) &&
285-
(file.startsWith(DAEMON_SOURCE_ROOT) || DAEMON_PROCESS_ROOT_FILES.has(file)) &&
286-
!isTestConsumerFile(file)
287-
);
288-
}
289-
290261
/**
291262
* Rules for files INSIDE a package. An undeclared sibling import is R11's alone: pnpm links
292263
* every workspace package under the root `node_modules`, so the import resolves, compiles, and
@@ -345,16 +316,6 @@ export function checkPackageInternalSites(
345316
`import it; use the package's production subpath instead.`,
346317
});
347318
}
348-
if (importsClientOnlyFromDaemon(site.file, site.specifier)) {
349-
violations.push({
350-
rule: 'R11 package-boundaries',
351-
file: site.file,
352-
line: site.line,
353-
message:
354-
`'${site.specifier}' reads caller-side replay sources — daemon code receives the ` +
355-
`client's source bundle and may not read caller paths; take it from the request.`,
356-
});
357-
}
358319
const target = packageByName(allPackages, name);
359320
if (!target) {
360321
if (!compiled) {
@@ -441,16 +402,6 @@ export function checkRootSites(
441402
`import it; use the package's production subpath instead.`,
442403
});
443404
}
444-
if (importsClientOnlyFromDaemon(site.file, site.specifier)) {
445-
violations.push({
446-
rule: 'R11 package-boundaries',
447-
file: site.file,
448-
line: site.line,
449-
message:
450-
`'${site.specifier}' reads caller-side replay sources — daemon code receives the ` +
451-
`client's source bundle and may not read caller paths; take it from the request.`,
452-
});
453-
}
454405
const target = packageByName(packages, name);
455406
if (!target) {
456407
if (!compiled) {

0 commit comments

Comments
 (0)