Skip to content

Commit b03e8cf

Browse files
committed
fix(layering): re-enforce commands-schema-floor after cli-schema folded into it
Review on #2678 found a real enforcement gap: folding cli-schema into commands/schema/ put both sides of the #2543 "commands never imports cli-schema back" direction into the same commands zone, which checkLayeringRules's ZONE_POLICIES table cannot see (it skips every same-zone edge before the table ever runs). The R2 zone-policy row that used to declare the direction became silently unenforceable, not just harmlessly dead like the daemon-side check. - add scripts/layering/commands-schema-boundary.ts: a folder-scoped check (commands outside schema/ must not import commands/schema/) wired into check.ts's rule registry, replacing the lost R2 row - drop the now-unenforceable R2 row from zone-policy.ts's table and its test, and the header comments that described it - delete the actually-dead checkDaemonCliSchemaBoundary from daemon-modularity.ts (the daemon side stays covered generically by R2's daemon/core -> commands row) and its tests Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmJYA9V4uKcAGBqedwXbwD
1 parent 396c260 commit b03e8cf

7 files changed

Lines changed: 122 additions & 90 deletions

‎scripts/layering/check.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
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, cli-schema }
6+
// { contracts, request, selectors } ◄ core ◄ commands
77
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
88
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts. The former rank-0 kernel
99
// zone lives in packages/kernel since #1490 W0; R11 owns its boundary.)
10-
// `commands` and `cli-schema` share a rank, so the spine cannot order them; R2 declares the
11-
// direction instead — cli-schema renders the command facets and reads them, commands never
12-
// imports cli-schema (#2543).
10+
// `commands/schema/` renders the rest of commands/'s facets and reads them; commands never
11+
// imports it back (#2543). It shares the commands zone (#2679 folded the standalone
12+
// cli-schema zone into it), so the ranked spine and the R2 zone-policy table cannot see the
13+
// edge — `commands-schema-boundary.ts` enforces the direction as a folder-scoped rule instead.
1314
//
1415
// This gate enforces five things, across four scopes:
1516
// - GLOBALLY, across every production source file: the remaining R2 move rule and
@@ -103,6 +104,7 @@ import {
103104
readTrackedPlatformPackageDeclarations,
104105
} from './platform-package-repository.ts';
105106
import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts';
107+
import { checkCommandsSchemaBoundary } from './commands-schema-boundary.ts';
106108
import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts';
107109
import { substrateDomainShapeViolations } from './substrate-domain-shape.ts';
108110
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
@@ -436,6 +438,7 @@ export type LayeringRule = (context: LayeringContext) => LayeringViolation[];
436438
*/
437439
export const LAYERING_RULE_IDS = [
438440
'zone-policies',
441+
'commands-schema-boundary',
439442
'value-import-cycles',
440443
'runtime-execution-integrity',
441444
'source-execution-compatibility',
@@ -468,6 +471,7 @@ export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
468471

469472
export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
470473
'zone-policies': (context) => checkLayeringRules(context.edges),
474+
'commands-schema-boundary': (context) => checkCommandsSchemaBoundary(context.edges),
471475
'value-import-cycles': (context) => checkCycles(context.edges),
472476
'runtime-execution-integrity': (context) => runtimeExecutionIntegrityViolations(context.sources),
473477
'source-execution-compatibility': (context) =>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { checkCommandsSchemaBoundary } from './commands-schema-boundary.ts';
4+
import { targetDagZone, type ResolvedImportEdge } from './model.ts';
5+
6+
function importEdge(
7+
file: string,
8+
target: string,
9+
overrides: Partial<ResolvedImportEdge> = {},
10+
): ResolvedImportEdge {
11+
return {
12+
file,
13+
target,
14+
spec: target,
15+
line: 1,
16+
dynamic: false,
17+
typeOnly: false,
18+
symbols: [],
19+
bindingResidue: false,
20+
fromZone: targetDagZone(file),
21+
toZone: targetDagZone(target),
22+
...overrides,
23+
};
24+
}
25+
26+
test('commands outside schema/ must not import commands/schema/', () => {
27+
const edge = importEdge('src/commands/capture/wait.ts', 'src/commands/schema/command-schema.ts');
28+
const violations = checkCommandsSchemaBoundary([edge]);
29+
assert.equal(violations.length, 1);
30+
assert.equal(violations[0]!.rule, 'R2 commands-floor');
31+
assert.match(violations[0]!.message, /must not import src\/commands\/schema\/command-schema\.ts/);
32+
});
33+
34+
test('holds for value, type-only, and dynamic imports alike', () => {
35+
for (const overrides of [{}, { typeOnly: true }, { dynamic: true }]) {
36+
const edge = importEdge(
37+
'src/commands/capture/wait.ts',
38+
'src/commands/schema/command-schema.ts',
39+
overrides,
40+
);
41+
assert.equal(checkCommandsSchemaBoundary([edge]).length, 1);
42+
}
43+
});
44+
45+
test('the declared direction — schema reading the rest of commands — stays silent', () => {
46+
const edge = importEdge(
47+
'src/commands/schema/command-schema.ts',
48+
'src/commands/command-metadata.ts',
49+
);
50+
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
51+
});
52+
53+
test('schema importing its own sibling files stays silent', () => {
54+
const edge = importEdge(
55+
'src/commands/schema/cli-help.ts',
56+
'src/commands/schema/command-schema.ts',
57+
);
58+
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
59+
});
60+
61+
test('a non-commands zone importing commands/schema/ is not this rule’s concern', () => {
62+
const edge = importEdge('src/mcp/server-guide.ts', 'src/commands/schema/cli-help.ts');
63+
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
64+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Catches: a file under src/commands/ (outside commands/schema/) importing back into
2+
// commands/schema/ — the render-the-facets direction #2543 declared (cli-schema reads
3+
// commands, commands never reads cli-schema back). #2679 folded the standalone cli-schema
4+
// zone into commands/schema/, so the two now share one zone and the ranked-spine / R2
5+
// zone-policy table can no longer see the edge at all: checkLayeringRules in check.ts skips
6+
// every same-zone edge before ZONE_POLICIES ever runs. This is the folder-scoped
7+
// replacement for that lost R2 row.
8+
// Evidence: #2543 declared the direction as the second R2 commands-floor row in
9+
// zone-policy.ts; #2679 moved src/cli-schema/* into src/commands/schema/* and found the
10+
// direction unenforced on the merged tree.
11+
// Cost: ~30 LOC (rule + test).
12+
// Kill criterion: none enforced today; retire only by maintainer decision that commands/
13+
// reading its own schema layer back is fine, or that commands/schema/ should move out of
14+
// the commands zone entirely (at which point the ranked-spine R2 table can express the
15+
// direction again).
16+
17+
import { matchesDeclaredRoot } from './architecture-ownership.ts';
18+
import type { LayeringViolation, ResolvedImportEdge } from './model.ts';
19+
20+
const COMMANDS_SCHEMA_ROOT = 'src/commands/schema/';
21+
const COMMANDS_ROOT = 'src/commands/';
22+
23+
export function checkCommandsSchemaBoundary(
24+
edges: readonly ResolvedImportEdge[],
25+
): LayeringViolation[] {
26+
return edges
27+
.filter(
28+
(edge) =>
29+
matchesDeclaredRoot(edge.file, COMMANDS_ROOT) &&
30+
!matchesDeclaredRoot(edge.file, COMMANDS_SCHEMA_ROOT) &&
31+
matchesDeclaredRoot(edge.target, COMMANDS_SCHEMA_ROOT),
32+
)
33+
.map((edge) => ({
34+
rule: 'R2 commands-floor',
35+
file: edge.file,
36+
line: edge.line,
37+
message:
38+
`${edge.file} must not import ${edge.target}: commands/schema/ is the CLI/MCP schema ` +
39+
'layer that renders the command facets, so it sits above the rest of commands/ and ' +
40+
'reads them; commands must not import it back (#2543). The shared CommandSchema type ' +
41+
'and flag grammar live in @agent-device/command-registry, below both.',
42+
}));
43+
}

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

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -106,38 +106,6 @@ test('R10 banks an R7 shrink with no edit anywhere', () => {
106106
);
107107
});
108108

109-
test('R10 forbids the daemon importing the CLI schema layer, value or type', () => {
110-
const valueEdge: ResolvedImportEdge = {
111-
...importEdge('src/daemon/request-execution-scope.ts', 'src/cli-schema/command-schema.ts'),
112-
typeOnly: false,
113-
};
114-
const typeEdge = importEdge(
115-
'src/daemon/session-action-recorder.ts',
116-
'src/cli-schema/command-schema.ts',
117-
);
118-
const violations = checkDaemonModularityRatchets(
119-
[...baselineEdges(), valueEdge, typeEdge],
120-
REFERENCE,
121-
REFERENCE,
122-
);
123-
assert.deepEqual(
124-
violations.map(({ rule, file }) => ({ rule, file })),
125-
[
126-
{ rule: 'R10 daemon-modularity', file: 'src/daemon/request-execution-scope.ts' },
127-
{ rule: 'R10 daemon-modularity', file: 'src/daemon/session-action-recorder.ts' },
128-
],
129-
);
130-
assert.match(violations[0]!.message, /must not import src\/cli-schema\/command-schema\.ts/);
131-
});
132-
133-
test('R10 permits a non-daemon importer of the CLI schema layer', () => {
134-
const cliEdge = importEdge('src/cli/parser/args.ts', 'src/cli-schema/command-schema.ts');
135-
assert.deepEqual(
136-
checkDaemonModularityRatchets([...baselineEdges(), cliEdge], REFERENCE, REFERENCE),
137-
[],
138-
);
139-
});
140-
141109
test('external daemon request/session-state importer membership changes require the baseline to change', () => {
142110
const edges = resolveImportEdges(
143111
new Map([

‎scripts/layering/daemon-modularity.ts‎

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -65,35 +65,10 @@ export function checkDaemonModularityRatchets(
6565
...checkSessionStateBaseline(measured.sessionState, reference.sessionState),
6666
...checkTypeCycleBaseline(measured.largestTypeCycle, reference.largestTypeCycle),
6767
...checkDaemonTypesImporters(edges),
68-
...checkDaemonCliSchemaBoundary(edges),
6968
...checkLogicalModuleImports(edges),
7069
];
7170
}
7271

73-
// The daemon resolves command routes through the registry and fills request defaults through the
74-
// command registry, so it never needs the CLI schema layer. #2543 cut the last two value edges
75-
// (`request-execution-scope.ts` and `session-action-recorder.ts`); this pins that decoupling so a
76-
// future daemon module cannot reach `src/cli-schema/` again and re-pull the parser closure.
77-
const DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT = 'src/cli-schema/';
78-
79-
function checkDaemonCliSchemaBoundary(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
80-
return edges
81-
.filter(
82-
(edge) =>
83-
edge.file.startsWith('src/daemon/') &&
84-
matchesDeclaredRoot(edge.target, DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT),
85-
)
86-
.map((edge) => ({
87-
rule: 'R10 daemon-modularity',
88-
file: edge.file,
89-
line: edge.line,
90-
message:
91-
`${edge.file} must not import ${edge.target}: the daemon resolves routes through ` +
92-
'@agent-device/command-registry and must not load the CLI schema layer. Declare the shared ' +
93-
'shape in the command registry or contracts, not in src/cli-schema/.',
94-
}));
95-
}
96-
9772
export function checkRetiredSessionLifecyclePaths(
9873
sourceFiles: readonly string[],
9974
): LayeringViolation[] {

‎scripts/layering/zone-policy.test.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,6 @@ test('R2 commands-floor closes the remaining zones below the command surface, wh
4444
assert.deepEqual(firing(edge('src/mcp/tools.ts', 'mcp', 'commands')), []);
4545
});
4646

47-
test('R2 commands-floor places commands below the cli-schema layer, whatever the kind', () => {
48-
// #2543: cli-schema renders the facets commands authors, so it reads commands and commands never
49-
// reads it back. The direction holds for value, type-only, and dynamic forms alike.
50-
for (const kind of [{}, { typeOnly: true }, { dynamic: true }]) {
51-
assert.ok(
52-
firing(edge('src/commands/thing.ts', 'commands', 'cli-schema', kind)).includes(
53-
'R2 commands-floor',
54-
),
55-
`commands -> cli-schema ${JSON.stringify(kind)} must violate R2`,
56-
);
57-
}
58-
59-
// The declared direction — cli-schema reading the facets — must stay silent.
60-
assert.deepEqual(firing(edge('src/cli-schema/command-schema.ts', 'cli-schema', 'commands')), []);
61-
});
62-
6347
test('the retired R3 platforms seam has no zone-policy declaration', () => {
6448
assert.equal(
6549
ZONE_POLICIES.some(({ rule }) => rule === 'R3 platforms-seam'),

‎scripts/layering/zone-policy.ts‎

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ export type ZonePolicy = {
4444
* packages/kernel, where package resolution and R11 package-boundaries enforce
4545
* the sink property physically — a package cannot import root src at all.
4646
*
47-
* The two rows share `R2 commands-floor`: both state that commands sits at a floor and is read
48-
* from above. The first keeps `core`/`daemon` below it; the second places `cli-schema` above it,
49-
* so commands may not import the schema layer that renders its facets (#2543 declared the
50-
* direction after the shared schema grammar moved down to the command registry).
47+
* `R2 commands-floor` states that commands sits at a floor and is read from above, keeping
48+
* `core`/`daemon` below it. It used to carry a second row placing `cli-schema` above commands
49+
* (#2543: commands may not import the schema layer that renders its facets), but #2679 folded
50+
* `cli-schema` into `commands/schema/`, putting both sides of that direction in the same zone —
51+
* a same-zone edge this table can never see, since `checkLayeringRules` only evaluates edges
52+
* that cross zones. `commands-schema-boundary.ts` enforces that direction now, as a
53+
* folder-scoped rule instead of a zone-policy row.
5154
*/
5255
export const ZONE_POLICIES: readonly ZonePolicy[] = [
5356
{
@@ -58,15 +61,6 @@ export const ZONE_POLICIES: readonly ZonePolicy[] = [
5861
'commands/ is the command surface, above these zones. Depend on shared kernel/contracts ' +
5962
'instead; if two zones need the same rule, put the rule below both of them.',
6063
},
61-
{
62-
rule: 'R2 commands-floor',
63-
from: ['commands'],
64-
to: ['cli-schema'],
65-
hint:
66-
'cli-schema is the CLI/MCP schema layer that renders the command facets, so it sits above ' +
67-
'commands and reads them; commands must not import it back. The shared CommandSchema type and ' +
68-
'flag grammar live in @agent-device/command-registry, below both zones.',
69-
},
7064
];
7165

7266
function kindOf(imp: ImportEdge): 'type-only' | 'dynamic' | 'value' {

0 commit comments

Comments
 (0)