Skip to content

Commit bcdaa44

Browse files
thymikeeclaude
andcommitted
chore(gates): ban daemon imports from the client and external daemon-request importers
R78 and R10 inventories are empty, so both become bans. The wire-compat ledger acks the type-name-only digest changes and waives the DaemonWireRequest narrowing in the closure policy. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent fc8b9b0 commit bcdaa44

6 files changed

Lines changed: 109 additions & 336 deletions

File tree

Lines changed: 24 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'node:test';
3-
import {
4-
checkDaemonClientEntry,
5-
DAEMON_CLIENT_ENTRY_EDGES,
6-
DAEMON_CLIENT_ENTRY_RULE,
7-
} from './daemon-client-entry.ts';
3+
import { checkDaemonClientEntry, DAEMON_CLIENT_ENTRY_RULE } from './daemon-client-entry.ts';
84
import { targetDagZone, type ResolvedImportEdge } from './model.ts';
95

106
function clientEdge(
@@ -25,17 +21,8 @@ function clientEdge(
2521
};
2622
}
2723

28-
function recordedWireEdges(): ResolvedImportEdge[] {
29-
return DAEMON_CLIENT_ENTRY_EDGES.map((edge) => clientEdge(edge.file, edge.target, 'type'));
30-
}
31-
32-
test('the recorded wire-only edges are the accepted residue', () => {
33-
assert.deepEqual(checkDaemonClientEntry(recordedWireEdges()), []);
34-
});
35-
3624
test('a client import of a non-daemon module is not daemon-client entry surface', () => {
3725
const edges = [
38-
...recordedWireEdges(),
3926
clientEdge(
4027
'src/daemon-client/daemon-client-metadata.ts',
4128
'packages/daemon-contracts/src/daemon-process.ts',
@@ -46,83 +33,29 @@ test('a client import of a non-daemon module is not daemon-client entry surface'
4633
assert.deepEqual(checkDaemonClientEntry(edges), []);
4734
});
4835

49-
test('a planted runtime import of daemon internals by the client is red', () => {
50-
const violations = checkDaemonClientEntry([
51-
...recordedWireEdges(),
52-
clientEdge(
53-
'src/daemon-client/daemon-client-metadata.ts',
54-
'packages/daemon/src/core/config.ts',
55-
'value',
56-
),
57-
]);
58-
assert.equal(violations.length, 1);
59-
assert.equal(violations[0]!.rule, DAEMON_CLIENT_ENTRY_RULE);
60-
assert.match(violations[0]!.message, /value-imports daemon internals/);
61-
assert.match(
62-
violations[0]!.message,
63-
/daemon-client-metadata\.ts -> packages\/daemon\/src\/core\/config\.ts/,
36+
test('a daemon import outside the client is not daemon-client entry surface', () => {
37+
assert.deepEqual(
38+
checkDaemonClientEntry([
39+
clientEdge('src/cli/commands/daemon.ts', 'packages/daemon/src/server/daemon-stop.ts'),
40+
]),
41+
[],
6442
);
6543
});
6644

67-
test('a planted dynamic import of daemon internals by the client is red', () => {
68-
const violations = checkDaemonClientEntry([
69-
...recordedWireEdges(),
70-
clientEdge(
71-
'src/daemon-client/daemon-client-progress.ts',
72-
'packages/daemon/src/core/config.ts',
73-
'dynamic',
74-
),
75-
]);
76-
assert.equal(violations.length, 1);
77-
assert.match(violations[0]!.message, /dynamic-imports daemon internals/);
78-
});
79-
80-
test('a runtime import is red even when the same pair is a recorded type edge', () => {
81-
const violations = checkDaemonClientEntry([
82-
...recordedWireEdges(),
83-
clientEdge(
84-
'src/daemon-client/daemon-client-rpc.ts',
85-
'packages/daemon/src/core/daemon-request.ts',
86-
'value',
87-
),
88-
]);
89-
assert.equal(violations.length, 1);
90-
assert.match(violations[0]!.message, /value-imports daemon internals/);
91-
});
92-
93-
test('an unrecorded type import of daemon internals is red', () => {
94-
const violations = checkDaemonClientEntry([
95-
...recordedWireEdges(),
96-
clientEdge(
97-
'src/daemon-client/daemon-launch-spec.ts',
98-
'packages/daemon/src/core/daemon-request.ts',
99-
),
100-
]);
101-
assert.equal(violations.length, 1);
102-
assert.match(violations[0]!.message, /unclassified daemon-client import of daemon internals/);
103-
assert.match(
104-
violations[0]!.message,
105-
/daemon-launch-spec\.ts -> packages\/daemon\/src\/core\/daemon-request\.ts/,
106-
);
107-
});
108-
109-
test('a type import of a daemon module other than the recorded type is red', () => {
110-
const recorded = DAEMON_CLIENT_ENTRY_EDGES.map((edge) =>
111-
edge.file === 'src/daemon-client/daemon-client.ts'
112-
? clientEdge(edge.file, 'packages/daemon/src/core/session-state.ts', 'type')
113-
: clientEdge(edge.file, edge.target, 'type'),
114-
);
115-
const violations = checkDaemonClientEntry(recorded);
116-
// The moved edge is undeclared (unclassified) and its recorded edge is now gone (stale).
117-
assert.equal(violations.length, 2);
118-
assert.ok(violations.some((v) => /unclassified daemon-client import/.test(v.message)));
119-
assert.ok(violations.some((v) => /stale declared edge/.test(v.message)));
120-
});
121-
122-
test('a recorded edge the tree no longer imports is stale', () => {
123-
const dropped = recordedWireEdges().slice(1);
124-
const violations = checkDaemonClientEntry(dropped);
125-
assert.equal(violations.length, 1);
126-
assert.match(violations[0]!.message, /stale declared edge/);
127-
assert.match(violations[0]!.message, new RegExp(DAEMON_CLIENT_ENTRY_EDGES[0]!.file));
128-
});
45+
for (const kind of ['value', 'dynamic', 'type'] as const) {
46+
test(`a planted ${kind} import of daemon internals by the client is red`, () => {
47+
const violations = checkDaemonClientEntry([
48+
clientEdge(
49+
'src/daemon-client/daemon-client-metadata.ts',
50+
'packages/daemon/src/core/daemon-request.ts',
51+
kind,
52+
),
53+
]);
54+
assert.equal(violations.length, 1);
55+
assert.equal(violations[0]!.rule, DAEMON_CLIENT_ENTRY_RULE);
56+
assert.match(
57+
violations[0]!.message,
58+
/daemon-client-metadata\.ts -> packages\/daemon\/src\/core\/daemon-request\.ts/,
59+
);
60+
});
61+
}
Lines changed: 20 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,159 +1,39 @@
11
import type { LayeringViolation, ResolvedImportEdge } from './model.ts';
22
import { DAEMON_SOURCE_ROOT } from './tracked-sources.ts';
33

4-
// The declared daemon-entry surface for the client (#2559). `src/daemon-client/**` composes the
5-
// daemon over the network, not over its module graph, so it is NOT an entry point into daemon
6-
// internals: the process root composition is. Every on-disk or protocol contract the client shares
7-
// with the daemon (state-dir/transport resolution, pidfile liveness, the repair tombstone, the
8-
// progress framing) therefore lives below both sides — since the daemon-contracts extraction, in
9-
// `packages/daemon-contracts` — not under `packages/daemon/src/`, and the client
10-
// reaches it there. What remains is the client reading the daemon's request/response vocabulary —
11-
// the `DaemonRequest`/`DaemonResponse` types that anchor the ADR 0006 wire surface — recorded here,
12-
// per measured `file -> target` edge, and only as a type-only import. A runtime (value or dynamic)
13-
// import of a
14-
// daemon module by the client is always a violation, an undeclared type import is a violation, and
15-
// a recorded edge that no longer exists is stale; all three fail, so the set cannot grow, and a
16-
// repair cannot leave a stale entry that would let the edge back in unnoticed.
17-
184
export const DAEMON_CLIENT_ENTRY_RULE = 'R78 daemon-client-entry';
195

20-
const REQUEST_RATIONALE =
21-
'the client names the request and response it sends and reads across the socket — the same ' +
22-
'DaemonRequest/DaemonResponse vocabulary that anchors the ADR 0006 wire surface and is gated by ' +
23-
'test/wire-compat. Reading it as a type is unavoidable and closes the wire surface; a value import ' +
24-
'would drag the daemon-private request half (#2318/#2322) into the client.';
25-
26-
export type DaemonClientEntryEdge = Readonly<{
27-
file: string;
28-
target: string;
29-
rationale: string;
30-
}>;
31-
326
/**
33-
* The residual client→daemon imports, every one of them a type-only read of the canonical
34-
* DaemonRequest/DaemonResponse vocabulary. It names measured edges, never a directory, so a new
35-
* client module reaching the daemon — even for the same type — fails until it is recorded here.
36-
*/
37-
export const DAEMON_CLIENT_ENTRY_EDGES: readonly DaemonClientEntryEdge[] = [
38-
{
39-
file: 'src/daemon-client/daemon-client.ts',
40-
target: 'packages/daemon/src/core/daemon-request.ts',
41-
rationale: REQUEST_RATIONALE,
42-
},
43-
{
44-
file: 'src/daemon-client/daemon-client-lifecycle.ts',
45-
target: 'packages/daemon/src/core/daemon-request.ts',
46-
rationale: REQUEST_RATIONALE,
47-
},
48-
{
49-
file: 'src/daemon-client/daemon-client-lease-beat.ts',
50-
target: 'packages/daemon/src/core/daemon-request.ts',
51-
rationale: REQUEST_RATIONALE,
52-
},
53-
{
54-
file: 'src/daemon-client/daemon-client-liveness-probe.ts',
55-
target: 'packages/daemon/src/core/daemon-request.ts',
56-
rationale: REQUEST_RATIONALE,
57-
},
58-
{
59-
file: 'src/daemon-client/daemon-client-progress.ts',
60-
target: 'packages/daemon/src/core/daemon-request.ts',
61-
rationale: REQUEST_RATIONALE,
62-
},
63-
{
64-
file: 'src/daemon-client/daemon-client-rpc.ts',
65-
target: 'packages/daemon/src/core/daemon-request.ts',
66-
rationale: REQUEST_RATIONALE,
67-
},
68-
{
69-
file: 'src/daemon-client/daemon-client-transport.ts',
70-
target: 'packages/daemon/src/core/daemon-request.ts',
71-
rationale: REQUEST_RATIONALE,
72-
},
73-
] as const;
74-
75-
function keyOf(file: string, target: string): string {
76-
return `${file} -> ${target}`;
77-
}
78-
79-
type MeasuredEdge = { file: string; target: string; line: number; runtimeKind: string | null };
80-
81-
/**
82-
* Catches: the client reaching daemon internals again — a runtime import of any daemon module, or a
83-
* new type-only import that was not measured and recorded. The runtime half is the regression this
84-
* gate exists for: #2559 relocated the shared contracts to the process root specifically so the
85-
* client stops value-importing `config.ts` or the shared contracts now owned by
86-
* `packages/daemon-contracts`. The mirror failure — a recorded edge whose import is gone —
87-
* is caught too, so a repair cannot leave the door open.
88-
* Evidence: #2559 measured 13 client→daemon pairs (8 runtime) at the #2557 tip; the runtime half is
89-
* now 0 and the 5 wire-only type edges are the residue recorded above.
7+
* Catches: the client reaching daemon internals — any import, type-only or runtime, of a module
8+
* under the daemon package. `src/daemon-client/**` composes the daemon over the network, not over
9+
* its module graph. What the client shares with the daemon lives below both sides: the on-disk and
10+
* protocol contracts in `packages/daemon-contracts`, the request shape in
11+
* `@agent-device/contracts/command` (`DaemonWireRequest`) and the response vocabulary in
12+
* `@agent-device/kernel/contracts`.
13+
* Evidence: #2559 measured 13 client→daemon pairs (8 runtime) at the #2557 tip; the runtime half was
14+
* driven to 0 then, and the wire-type half to 0 when the client moved to `DaemonWireRequest`.
9015
* Cost: one rule registered in check.ts's whole-graph pass; not a standalone CI job.
91-
* Kill criterion: the client reaches the daemon only through its network protocol (the inventory
92-
* empty), or a maintainer decision that the client may compose daemon internals directly.
16+
* Kill criterion: a maintainer decision that the client may compose daemon internals directly.
9317
*/
9418
export function checkDaemonClientEntry(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
95-
const measured = new Map<string, MeasuredEdge>();
19+
const violations: LayeringViolation[] = [];
20+
const reported = new Set<string>();
9621
for (const edge of edges) {
9722
if (!edge.file.startsWith('src/daemon-client/')) continue;
9823
if (!edge.target.startsWith(DAEMON_SOURCE_ROOT)) continue;
99-
const key = keyOf(edge.file, edge.target);
100-
const entry = measured.get(key) ?? {
101-
file: edge.file,
102-
target: edge.target,
103-
line: edge.line,
104-
runtimeKind: null,
105-
};
106-
if (entry.runtimeKind === null && !edge.typeOnly) {
107-
entry.runtimeKind = edge.dynamic ? 'dynamic-imports' : 'value-imports';
108-
}
109-
measured.set(key, entry);
110-
}
111-
112-
const declared = new Set(DAEMON_CLIENT_ENTRY_EDGES.map((edge) => keyOf(edge.file, edge.target)));
113-
const violations: LayeringViolation[] = [];
114-
const seen = new Set<string>();
115-
116-
for (const [key, entry] of measured) {
117-
if (entry.runtimeKind !== null) {
118-
violations.push({
119-
rule: DAEMON_CLIENT_ENTRY_RULE,
120-
file: entry.file,
121-
line: entry.line,
122-
message:
123-
`the daemon client ${entry.runtimeKind} daemon internals: ${key}. The client composes the ` +
124-
`daemon over the network, not its module graph. Move the shared contract to ` +
125-
`packages/daemon-contracts (or read the DaemonRequest/DaemonResponse types in ` +
126-
`packages/daemon/src/core/daemon-request.ts as a type).`,
127-
});
128-
seen.add(key);
129-
continue;
130-
}
131-
if (!declared.has(key)) {
132-
violations.push({
133-
rule: DAEMON_CLIENT_ENTRY_RULE,
134-
file: entry.file,
135-
line: entry.line,
136-
message:
137-
`unclassified daemon-client import of daemon internals: ${key}. If the client legitimately ` +
138-
`reads a request/response type, record the measured edge in DAEMON_CLIENT_ENTRY_EDGES ` +
139-
`(${DAEMON_CLIENT_ENTRY_RULE}) with its rationale; otherwise reach the shared root contract.`,
140-
});
141-
}
142-
seen.add(key);
143-
}
144-
145-
for (const edge of DAEMON_CLIENT_ENTRY_EDGES) {
146-
const key = keyOf(edge.file, edge.target);
147-
if (seen.has(key)) continue;
24+
const key = `${edge.file} -> ${edge.target}`;
25+
if (reported.has(key)) continue;
26+
reported.add(key);
14827
violations.push({
14928
rule: DAEMON_CLIENT_ENTRY_RULE,
150-
file: 'scripts/layering/daemon-client-entry.ts',
151-
line: 1,
29+
file: edge.file,
30+
line: edge.line,
15231
message:
153-
`stale declared edge: ${key} no longer exists. Remove it from DAEMON_CLIENT_ENTRY_EDGES so ` +
154-
`the client dependency cannot return unclassified.`,
32+
`the daemon client imports daemon internals: ${key}. The client composes the daemon over ` +
33+
`the network, not its module graph. Move the shared contract to packages/daemon-contracts, ` +
34+
`or read DaemonWireRequest from @agent-device/contracts/command and DaemonResponse from ` +
35+
`@agent-device/kernel/contracts.`,
15536
});
15637
}
157-
15838
return violations;
15939
}

‎scripts/layering/daemon-modularity.test.ts‎

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,13 @@ import {
66
checkRetiredSessionLifecyclePaths,
77
checkRetiredSessionObservabilityPaths,
88
checkRetiredSnapshotExecutionPaths,
9-
DAEMON_MODULARITY_BASELINE,
109
} from './daemon-modularity.ts';
11-
import { resolveImportEdges, targetDagZone, type ResolvedImportEdge } from './model.ts';
10+
import { resolveImportEdges, type ResolvedImportEdge } from './model.ts';
1211
import type { LayeringRatchets } from './ratchet-reference.ts';
1312

14-
function importEdge(file: string, target: string): ResolvedImportEdge {
15-
return {
16-
file,
17-
target,
18-
spec: target,
19-
line: 1,
20-
dynamic: false,
21-
typeOnly: true,
22-
symbols: [],
23-
fromZone: targetDagZone(file),
24-
toZone: targetDagZone(target),
25-
};
26-
}
27-
28-
function baselineDaemonTypesEdges(): ResolvedImportEdge[] {
29-
return DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters.map((file) =>
30-
importEdge(file, 'packages/daemon/src/core/daemon-request.ts'),
31-
);
32-
}
33-
34-
/** Every baseline edge is present and nothing else is forbidden: the quiet state of the ratchets. */
13+
/** The quiet state of the ratchets: no forbidden edge present. */
3514
function baselineEdges(): ResolvedImportEdge[] {
36-
return baselineDaemonTypesEdges();
15+
return [];
3716
}
3817

3918
/** Where a member of `zone` lives, so a zone count can be turned back into file paths. */
@@ -105,7 +84,7 @@ test('R10 banks an R7 shrink with no edit anywhere', () => {
10584
);
10685
});
10786

108-
test('external daemon request/session-state importer membership changes require the baseline to change', () => {
87+
test('a production import of the daemon request/session-state modules outside the daemon is red', () => {
10988
const edges = resolveImportEdges(
11089
new Map([
11190
[
@@ -116,28 +95,9 @@ test('external daemon request/session-state importer membership changes require
11695
]),
11796
);
11897

119-
const violations = checkDaemonModularityRatchets(
120-
[...baselineEdges(), ...edges],
121-
REFERENCE,
122-
REFERENCE,
123-
);
98+
const violations = checkDaemonModularityRatchets(edges, REFERENCE, REFERENCE);
12499
assert.equal(violations.length, 1);
125-
// The recorded list owns its own size (#2342 relocated five client files into it), so the
126-
// message assertion reads it rather than pinning a literal that the baseline would outgrow.
127-
assert.match(
128-
violations[0]!.message,
129-
new RegExp(
130-
`may only shrink from the recorded ${DAEMON_MODULARITY_BASELINE.externalDaemonTypesImporters.length}`,
131-
),
132-
);
133-
134-
const removed = checkDaemonModularityRatchets(
135-
baselineDaemonTypesEdges().slice(1),
136-
REFERENCE,
137-
REFERENCE,
138-
);
139-
assert.equal(removed.length, 1);
140-
assert.match(removed[0]!.message, /delete it from externalDaemonTypesImporters/);
100+
assert.match(violations[0]!.message, /src\/client\/new-importer\.ts imports a daemon request/);
141101
});
142102

143103
test('logical modules reject forbidden imports', () => {

0 commit comments

Comments
 (0)