Skip to content

Commit 4e00f4a

Browse files
thymikeeclaude
andcommitted
chore(gates): rank every root module and reject zone-level value cycles (#3280)
R5 could not see through the unranked (root) zone: nine zones formed one value cycle through it, and src/sdk/index.ts and src/ai-sdk/index.ts (rank 4) reached daemon-client (rank 5) through src/agent-device-client.ts. - scripts/layering/root-module-zones.ts declares the zone of every src/*.ts module, and targetDagZone reads it. (root) ranks above the spine (8) and holds only bin.ts, cli.ts and daemon.ts, so a ranked import of any undeclared root module is an R5 back-edge. New zones: daemon-contracts (2), command-runtime (3), platform-runtime (4, now including its private src/platform-runtime/ submodule) and platform-runtime-host (5: its facets read daemon session artifacts, and daemon-server and platform-runtime reach it only through loadHost's import()). core takes the two root modules it imports; daemon-client takes agent-device-client.ts. - With that declaration and the old ranks, the gate reports exactly the two known inversions (sdk -> daemon-client, ai-sdk -> daemon-client). The published SDK entries must export a client that reaches the daemon with no injected transport, so no code change removes that dependency: sdk and ai-sdk move to rank 6 above daemon-client, cli to 7. - R80 zone-value-dag projects static value imports onto zones and rejects any cycle, which catches same-rank pairs R5 cannot order. - Dynamic imports stay out of R5 and R80; the reason is recorded at R5 in check.ts. R6 now measures seven type-only inversions the unranked root hid (commands -> daemon-client, core -> platform-runtime, core -> platform-runtime-host, plugins -> sdk, and three platform-runtime -> platform-runtime-host types of lazily loaded host modules), ratcheted equally at the merge-base. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 24aaab2 commit 4e00f4a

8 files changed

Lines changed: 393 additions & 32 deletions

File tree

‎docs/dependency-graph-findings.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ type-only inversions, R7 pins SessionState field ownership, and the shared selec
7474
(`daemon/interaction/internal/interaction-common.ts`, step 3) and the recorder boundary
7575
(`session-action-recorder.ts`, step 4). The two-pass structure is two call sites, not a scattered
7676
concern.
77-
- Still outside every rule: **dynamic** import direction (0 inversions today, nothing watching),
78-
and anything inside a zone.
77+
- Still outside every rule: **dynamic** import direction, out of scope by decision with the reason
78+
recorded at R5 in `scripts/layering/check.ts` (#3280), and anything inside a zone.
7979

8080
## 0. Where the inversions ended up (and why 5 is the floor for now)
8181

@@ -263,6 +263,14 @@ moving `DaemonRequest` is most of what §1's second cluster needs.
263263

264264
## 3. `(root)`: what is left is there for a reason
265265

266+
> **Status after #3280:** the section below is historical. Every root module now declares its zone
267+
> in `scripts/layering/root-module-zones.ts`, `(root)` ranks above the spine and holds only
268+
> `bin.ts`, `cli.ts` and `daemon.ts`, and R80 asserts that the zone graph of static value imports is
269+
> acyclic. Ranking the composition roots did not weaken R2: it still rejects a direct `daemon/` ->
270+
> `commands/` import, and the daemon reaches the command surface through the ranked
271+
> `command-runtime` zone, which the zone graph now shows instead of hiding. The SDK entries rank
272+
> above `daemon-client`, because the typed client they publish reaches the daemon through it.
273+
266274
13 files: 11 published `package.json` entrypoints, the three executables (`bin.ts`, `cli.ts`,
267275
`daemon.ts`), and the composition roots — `runtime.ts`, `agent-device-client.ts`,
268276
`provider-device-runtime.ts`, `command-catalog.ts`.

‎scripts/layering/check.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
//
44
// Ranked target spine, as rank groups lowest to highest. `A ◄ B` means B may not
55
// be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:
6-
// { contracts, request, selectors } ◄ core ◄ commands
7-
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
8-
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts. The former rank-0 kernel
9-
// zone lives in packages/kernel since #1490 W0; R11 owns its boundary.)
6+
// { contracts, selectors, ... } ◄ { core, daemon-contracts } ◄ { commands, command-runtime }
7+
// ◄ { client, daemon-server, platform-runtime, ... }
8+
// ◄ { daemon-client, platform-runtime-host } ◄ { sdk, ai-sdk } ◄ cli ◄ (root)
9+
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts; root modules declare their zone in
10+
// root-module-zones.ts. The former rank-0 kernel zone lives in packages/kernel since #1490 W0;
11+
// R11 owns its boundary.)
1012
// `commands/schema/` renders the rest of commands/'s facets and reads them; commands never
1113
// imports it back (#2543). It shares the commands zone (#2679 folded the standalone
1214
// cli-schema zone into it), so the ranked spine and the R2 zone-policy table cannot see the
@@ -22,6 +24,8 @@
2224
// - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e.
2325
// an import whose source zone outranks its target zone, plus a ratchet on the
2426
// same inversion measured over TYPE-ONLY edges (R6).
27+
// - Over the ZONE GRAPH: static value imports between zones form no cycle (R80), which R5
28+
// cannot see between two zones of the same rank.
2529
// - Over the DAEMON and its capture-admission adapters: SessionState field ownership (R7), because the session
2630
// record is store-owned mutable state that any daemon module can write; and the terminal
2731
// concrete-platform boundary (R65), which rejects every import form into the retired
@@ -52,9 +56,9 @@
5256
// R6, R9, and the R10 R7 counts are ratchets with no written-down reference: each is the same
5357
// measurement taken over the merge-base with origin/main (`ratchet-reference.ts`), so growth
5458
// fails, a shrink needs no edit, and no change can bank headroom.
55-
// `(root)` holds entrypoints and composition roots. The retired `src/utils` zone is deliberately
56-
// outside the spine and is rejected separately by R14; extracted workspace package zones are
57-
// classified separately and held behind R11 instead of the src folder spine.
59+
// `(root)` holds the executables and ranks above the spine. The retired `src/utils` zone is
60+
// deliberately outside the spine and is rejected separately by R14; extracted workspace package
61+
// zones are classified separately and held behind R11 instead of the src folder spine.
5862

5963
import { execFileSync } from 'node:child_process';
6064
import fs from 'node:fs';
@@ -79,6 +83,7 @@ import {
7983
type ResolvedImportEdge,
8084
} from './model.ts';
8185
import { checkTypeInversions } from './type-inversion-ratchet.ts';
86+
import { checkZoneValueDag } from './zone-value-dag.ts';
8287
import {
8388
measureRatchets,
8489
mergeBaseRatchets,
@@ -237,6 +242,15 @@ function checkRecordRuntimeOwnership(sources: ReadonlyMap<string, string>): Laye
237242
* import direction no longer matters. Splitting zones into packages would not replace it: the
238243
* A4 spike found an undeclared workspace package still resolves through root node_modules and
239244
* a relative tunnel into another package's src still compiles.
245+
* Scope: static value imports. Dynamic imports are out of scope for R5 and R80 by decision
246+
* (#3280). An `import()` target never joins the importer's eager closure, so a lazy edge cannot
247+
* cost what a value back-edge costs, and the code places lazy seams exactly where a module
248+
* defers one ranked above it or one that depends back on it: `cli/process-entry.ts` loads
249+
* `cli.ts`, the MCP tools load the typed client, the daemon's provider registry loads the
250+
* Limrun runtime from `src/sdk/`, and the platform runtime loads its operation host
251+
* (`loadHost`), which reads daemon session artifacts. Ranking them would reject that pattern
252+
* rather than a mistake; what a lazy edge may load is owned by R13's laziness policy and the
253+
* eager-closure budgets.
240254
*/
241255
function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
242256
const seen = new Set<string>();
@@ -398,7 +412,8 @@ function report(
398412
process.stdout.write(
399413
`Layering guard: OK — ${files.length} source files satisfy R2 and contain no ` +
400414
`value-import cycles (both checked globally); the ranked target spine contains no ` +
401-
`back-edges; the ranked spine's type-only inversions hold at or under the merge-base ` +
415+
`back-edges and the zone graph no value-import cycle (R80); the ranked spine's ` +
416+
`type-only inversions hold at or under the merge-base ` +
402417
`${reference.ref.slice(0, 10)} per zone pair (R6, ${inversions} remaining); ` +
403418
`${RETIRED_PATH_RULES.R14.rule} permits no tracked paths under retired src/utils; ` +
404419
`all ${sessionStateFieldCount()} SessionState fields are classified and every write is ` +
@@ -473,6 +488,7 @@ export const LAYERING_RULE_IDS = [
473488
'substrate-domain-shape',
474489
'selector-pipeline-ownership',
475490
'back-edges',
491+
'zone-value-dag',
476492
'type-spine-inversions',
477493
'session-state-ownership',
478494
'daemon-modularity-ratchets',
@@ -514,6 +530,7 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
514530
'selector-pipeline-ownership': (context) =>
515531
selectorPipelineOwnershipViolations(context.edges, workspaceSpecifierTargets(repoRoot)),
516532
'back-edges': (context) => checkBackEdges(context.edges),
533+
'zone-value-dag': (context) => checkZoneValueDag(context.edges),
517534
'type-spine-inversions': (context) =>
518535
checkTypeInversions(context.edges, context.reference.typeInversions),
519536
'session-state-ownership': (context) => checkSessionStateOwnership(context.sources),

‎scripts/layering/model.test.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,11 @@ test('ranked and unranked zones are disjoint and both non-empty', () => {
285285
test('classifyZone separates the ranked spine from intentionally-unranked zones', () => {
286286
assert.equal(classifyZone('contracts'), 'ranked');
287287
assert.equal(classifyZone('daemon-server'), 'ranked');
288-
assert.equal(classifyZone('(root)'), 'unranked');
289-
assert.equal(classifyZone('platform-runtime'), 'unranked');
290288
assert.equal(classifyZone('platforms'), 'unclassified');
291289
assert.equal(classifyZone('utils'), 'unclassified');
292-
// Every satellite zone joined the spine; only the composition root stays out, because R2
293-
// forbids daemon/ from importing commands/ so the files that wire them cannot be ranked.
290+
// Every src/ zone is on the spine, `(root)` and the zones root modules declare included.
291+
assert.equal(classifyZone('(root)'), 'ranked');
292+
assert.equal(classifyZone('platform-runtime'), 'ranked');
294293
assert.equal(classifyZone('mcp'), 'ranked');
295294
assert.equal(classifyZone('screenshot-diff'), 'ranked');
296295
// A zone that is neither ranked nor listed peripheral must be flagged, never
@@ -302,7 +301,7 @@ test('every production zone is deliberately classified as ranked or unranked', (
302301
// Drift guard: a new src/<folder>/ (or a daemon-client/server split) forces a
303302
// deliberate ranked-vs-peripheral decision here instead of silently escaping
304303
// spine back-edge detection. If this fails, add the new zone to TARGET_DAG_RANK
305-
// (ranked spine) or UNRANKED_ZONES (root/peripheral) in model.ts.
304+
// (ranked spine) or UNRANKED_ZONES (peripheral) in model.ts.
306305
const productionFiles = listSourceFiles();
307306
assert.deepEqual(unclassifiedZones(productionFiles), []);
308307

‎scripts/layering/model.ts‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'node:path';
22
import { PLATFORMS } from '@agent-device/kernel/device';
33
import { parseSync } from 'oxc-parser';
44
import { destructuredDynamicImportBindings, visitAst } from './layering-ast.ts';
5+
import { declaredRootModuleZone } from './root-module-zones.ts';
56

67
export type ImportEdge = {
78
spec: string;
@@ -56,17 +57,23 @@ const TARGET_DAG_RANK = new Map([
5657
['selectors', 1],
5758
['session-journal', 1],
5859
['core', 2],
60+
['daemon-contracts', 2],
61+
['command-runtime', 3],
5962
['commands', 3],
6063
['mcp', 3],
61-
['ai-sdk', 4],
6264
['client', 4],
6365
['daemon-server', 4],
6466
['metro', 4],
67+
['platform-runtime', 4],
6568
['remote', 4],
66-
['sdk', 4],
6769
['plugins', 4],
6870
['daemon-client', 5],
69-
['cli', 6],
71+
['platform-runtime-host', 5],
72+
// The SDK entries publish the typed client, which reaches the daemon through daemon-client.
73+
['ai-sdk', 6],
74+
['sdk', 6],
75+
['cli', 7],
76+
['(root)', 8],
7077
]);
7178

7279
export const RANKED_ZONES: ReadonlySet<string> = new Set(TARGET_DAG_RANK.keys());
@@ -81,25 +88,19 @@ export function zoneRank(zone: string): number | null {
8188
}
8289

8390
// Zones deliberately left OUT of the src folder spine. They are NOT unenforced:
84-
// every file remains under the global value-cycle rule (R4). `(root)` composes
85-
// the spine from above; extracted package zones are held by R11 package exports
86-
// and the no-root-back-import rule instead of their former src folder rank.
91+
// every file remains under the global value-cycle rule (R4) and the zone-level value DAG
92+
// (R80). Extracted package zones are held by R11 package exports and the no-root-back-import
93+
// rule instead of a src folder rank.
8794
//
88-
// The satellite zones used to be listed here too, on the grounds that ranking them would
89-
// invent an order the architecture had not committed to. Once `(root)` was emptied of shared
90-
// contracts, every one of them turned out to have a consistent rank already — so the order was
91-
// there, just unasserted. The former `utils` zone was retired into owning modules and packages.
92-
// Extracted workspace packages are not src/ zones: R11 owns their physical seams, and their zone
93-
// names only appear in workspace-aware graphs. The platform packages additionally carry R13's
95+
// Every other src/ zone is ranked, `(root)` included: root modules declare their zone in
96+
// `root-module-zones.ts`, and `(root)` ranks above the spine. Extracted workspace packages are
97+
// not src/ zones: R11 owns their physical seams, and their zone names only appear in
98+
// workspace-aware graphs. The platform packages additionally carry R13's
9499
// exact-family/composition/laziness policy.
95100
export const UNRANKED_ZONES: ReadonlySet<string> = new Set([
96-
'(root)',
97101
// Stand-ins the bundler resolves in place of a dependency it deliberately omits. Nothing in
98102
// the production graph imports them, so ranking them would claim an edge the alias replaces.
99103
'vendor',
100-
// Private implementation submodules of the canonical root composition. R13 owns their exact
101-
// importer and concrete-platform authority; giving them a spine rank would duplicate that seam.
102-
'platform-runtime',
103104
'kernel',
104105
'host-kit',
105106
'capture-kit',
@@ -293,7 +294,7 @@ export function targetDagZone(file: string): string {
293294
// #2342 relocated the daemon client to its own `src/daemon-client/` folder, so the
294295
// client zone now falls out of the folder itself; `src/daemon/` is server-only.
295296
if (file.startsWith('src/daemon/')) return 'daemon-server';
296-
return topFolder(file);
297+
return declaredRootModuleZone(file) ?? topFolder(file);
297298
}
298299

299300
// The set of zones every production file resolves into. A zone that is neither ranked
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { listSourceFiles } from './check.ts';
4+
import { collectBackEdges, RANKED_ZONES, resolveImportEdges, zoneRank } from './model.ts';
5+
import { rootModuleZoneDrift } from './root-module-zones.ts';
6+
7+
test('every root module is declared in exactly one zone, and every declaration names a module', () => {
8+
assert.deepEqual(
9+
rootModuleZoneDrift(listSourceFiles()),
10+
{ undeclared: [], stale: [], duplicated: [] },
11+
'declare each src/*.ts module in exactly one zone of ROOT_MODULE_ZONES (root-module-zones.ts)',
12+
);
13+
});
14+
15+
test('drift names a root module with no declared zone and a declaration with no module', () => {
16+
const drift = rootModuleZoneDrift(['src/bin.ts', 'src/new-helper.ts', 'src/cli/new-helper.ts']);
17+
assert.deepEqual(drift.undeclared, ['src/new-helper.ts']);
18+
assert.ok(drift.stale.includes('src/daemon.ts'));
19+
assert.ok(!drift.stale.includes('src/bin.ts'));
20+
});
21+
22+
test('(root) ranks above every other zone', () => {
23+
const root = zoneRank('(root)')!;
24+
for (const zone of RANKED_ZONES) {
25+
if (zone !== '(root)') assert.ok(zoneRank(zone)! < root, `${zone} must rank below (root)`);
26+
}
27+
});
28+
29+
test('R5 ranks a root module by its declared zone, so no lower zone reaches the daemon client through it', () => {
30+
const edges = resolveImportEdges(
31+
new Map([
32+
['src/sdk/index.ts', "export { createAgentDeviceClient } from '../agent-device-client.ts';"],
33+
[
34+
'src/ai-sdk/index.ts',
35+
"import { createAgentDeviceClient } from '../agent-device-client.ts';",
36+
],
37+
[
38+
'src/client/lease.ts',
39+
"import { createAgentDeviceClient } from '../agent-device-client.ts';",
40+
],
41+
[
42+
'src/agent-device-client.ts',
43+
"import { createRequestGuard } from './daemon-client/daemon-client-transport.ts';",
44+
],
45+
['src/daemon-client/daemon-client-transport.ts', 'export const createRequestGuard = 1;'],
46+
]),
47+
);
48+
49+
// The published SDK entries rank above the typed client they publish; the client zone does not.
50+
assert.deepEqual(collectBackEdges(edges), {
51+
'client -> daemon-client': ['src/client/lease.ts -> src/agent-device-client.ts'],
52+
});
53+
});
54+
55+
test('the platform runtime reaches its operation host only through import()', () => {
56+
const edges = resolveImportEdges(
57+
new Map([
58+
['src/platform-runtime.ts', "void import('./platform-runtime-operation-host.ts');"],
59+
[
60+
'src/platform-runtime-daemon-lifecycle.ts',
61+
"import './platform-runtime-operation-host.ts';",
62+
],
63+
['src/platform-runtime-operation-host.ts', "import './daemon/app-log-files.ts';"],
64+
['src/daemon/app-log-files.ts', 'export const appLog = 1;'],
65+
]),
66+
);
67+
68+
assert.deepEqual(collectBackEdges(edges), {
69+
'platform-runtime -> platform-runtime-host': [
70+
'src/platform-runtime-daemon-lifecycle.ts -> src/platform-runtime-operation-host.ts',
71+
],
72+
});
73+
});
74+
75+
test('an undeclared root module is (root), so a ranked import of it is a back-edge', () => {
76+
const edges = resolveImportEdges(
77+
new Map([
78+
['src/commands/surface.ts', "import { shared } from '../new-helper.ts';"],
79+
['src/new-helper.ts', 'export const shared = 1;'],
80+
]),
81+
);
82+
83+
assert.deepEqual(collectBackEdges(edges), {
84+
'commands -> (root)': ['src/commands/surface.ts -> src/new-helper.ts'],
85+
});
86+
});

0 commit comments

Comments
 (0)