|
1 | 1 | import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; |
2 | 2 | import { DAEMON_SOURCE_ROOT } from './tracked-sources.ts'; |
3 | 3 |
|
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 | | - |
18 | 4 | export const DAEMON_CLIENT_ENTRY_RULE = 'R78 daemon-client-entry'; |
19 | 5 |
|
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 | | - |
32 | 6 | /** |
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`. |
90 | 15 | * 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. |
93 | 17 | */ |
94 | 18 | export function checkDaemonClientEntry(edges: readonly ResolvedImportEdge[]): LayeringViolation[] { |
95 | | - const measured = new Map<string, MeasuredEdge>(); |
| 19 | + const violations: LayeringViolation[] = []; |
| 20 | + const reported = new Set<string>(); |
96 | 21 | for (const edge of edges) { |
97 | 22 | if (!edge.file.startsWith('src/daemon-client/')) continue; |
98 | 23 | 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); |
148 | 27 | violations.push({ |
149 | 28 | rule: DAEMON_CLIENT_ENTRY_RULE, |
150 | | - file: 'scripts/layering/daemon-client-entry.ts', |
151 | | - line: 1, |
| 29 | + file: edge.file, |
| 30 | + line: edge.line, |
152 | 31 | 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.`, |
155 | 36 | }); |
156 | 37 | } |
157 | | - |
158 | 38 | return violations; |
159 | 39 | } |
0 commit comments