Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,12 @@ The perfect-shape refactor is complete and merged. Its end-state:
which R5 ignores by design: a type-only import is free at runtime, but "zone A is declared in
terms of zone B" is still a boundary claim, and ranking type edges surfaced 61 inversions the gate
had never seen. `TYPE_INVERSION_BASELINE` in `check.ts` holds the remaining pairs with their
counts; the numbers may only shrink, and a new pair fails outright.
counts; the numbers may only shrink, and a new pair fails outright. Down to **7**, and each is a
deliberate position rather than a misplaced declaration: 4 are `AgentDeviceClient` used as an
opaque handle (the facade is built from `commands/`'s own projection registry, so moving it down
is a design call about where that registry belongs, not a file move), and 3 are the ADR 0003
daemon descriptor, whose route type is `keyof typeof DAEMON_ROUTE_HANDLERS` — derived from what
the server actually implements. Both are explained at the baseline.
- SessionState ownership (R7). `SessionStore.get()` returns the live record out of a private Map
and `set()` re-puts the same reference, so any `session.<field> = …` in the daemon is a durable
write to store-owned state — persistence depends on aliasing, not on an API call. That is
Expand All @@ -293,6 +298,14 @@ The perfect-shape refactor is complete and merged. Its end-state:
two modules until `activateRefFrame` took the transition, and `snapshotScopeSource` +
`snapshotGeneration` were assigned in `snapshot-runtime.ts` until `setSnapshotLineage` took
theirs.
- Type-cycle growth (R9). R4 keeps the VALUE import graph acyclic, so every remaining cycle is
created by type-only imports — free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a strongly-connected component of 102 files,
no file has a self-contained slice. `TYPE_CYCLE_BASELINE` in `check.ts` ratchets it for **growth
only**, deliberately unlike R6: reducing it is a real refactor rather than a file move, so a hard
equality would turn every unrelated improvement into a baseline edit. A shrunk tree is reported in
the success line instead of failing. Hubs by in-component dependents: `runtime-contract.ts` (25),
`commands/runtime-types.ts` (21), `backend.ts` (15), `commands/runtime-common.ts` (12).
- Zero-dep CI jobs (R8). Some jobs run scripts straight from a checkout with `install-deps: false`,
so they have no `node_modules`. Nothing local can feel that constraint — every dev machine has
`node_modules` sitting right there — so a script grows a package import, passes locally, and fails
Expand Down
210 changes: 192 additions & 18 deletions docs/dependency-graph-findings.md

Large diffs are not rendered by default.

120 changes: 95 additions & 25 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
// - Over the ZERO-DEP CI JOBS only: their scripts may import nothing that needs
// installing (R8), a constraint no local run can feel because `node_modules` is
// always there locally and never there in those jobs.
// - Over the TYPE GRAPH: the largest type-level import cycle may not grow (R9). R4
// keeps the value graph acyclic, so these cycles are free at runtime but bound
// what can be read in isolation. Growth-only, deliberately loose.
// Only `(root)` is unranked (see `UNRANKED_ZONES` in model.ts): it holds the
// entrypoints and the composition roots that wire the command surface into the
// daemon, which R2 forbids the daemon from importing, so they sit outside the
Expand All @@ -38,6 +41,7 @@ import { uninstallableImports, zeroDepJobs } from './zero-dep-jobs.ts';
import {
backEdgePair,
findValueImportCycles,
largestTypeCycleSize,
resolveImportEdges,
topFolder,
typeInversionPair,
Expand Down Expand Up @@ -187,34 +191,46 @@ function checkBackEdges(edges: readonly ResolvedImportEdge[]): Violation[] {
});
}

// R6 ratchet: type-only spine inversions, per zone pair. R5 cannot see these (a
// type-only import is free at runtime), but "zone A is declared in terms of zone B"
// is still a boundary claim, and ranking type edges surfaced 61 of them. Two clusters
// remain, each needing its own change rather than a file move:
// R6 ratchet: type-only spine inversions, per zone pair. R5 cannot see these (a type-only import
// is free at runtime), but "zone A is declared in terms of zone B" is still a boundary claim, and
// ranking type edges surfaced 61 of them. Down to 7, and every one of the 7 is now a deliberate
// architectural position rather than a misplaced declaration:
//
// commands/contracts/mcp the per-command Options/Result vocabulary, plus the client
// -> client facade itself, are declared inside the 1.2k-line public
// Node-client surface (client/client-types.ts) instead of in
// contracts/, where the 24 types it already re-exports live.
// core/commands -> daemon-server the ADR 0003 daemon facet: core's descriptor registry
// composes a shape the daemon owns. The daemon keeps the
// VALUES; only the shape needs to move below core.
// replay -> daemon-server `SessionAction`, the recorded-action shape replay reads and
// writes. It belongs in contracts/, but it references
// `CommandFlags` (core) which references `DaemonBatchStep`
// (core), so it moves as a chain of three, not one file.
// commands/mcp -> client (4) `AgentDeviceClient`, used as an opaque handle ("the client this
// command runs against"). It cannot move below `commands/` because
// the facade is BUILT from the command surface's own projection
// registry: AgentDeviceClient -> AgentDeviceCommandClient ->
// ProjectedNavigationCommandClient -> NAVIGATION_COMMAND_PROJECTIONS
// in commands/system/. That is a genuine zone-level cycle, and
// breaking it means deciding where the projection registry belongs —
// a design call, not a file move. R5 is zero here: nothing imports
// the client at runtime, only its type.
//
// core -> daemon-server (2) `DaemonCommandDescriptor`, which is STATED IN TERMS OF the daemon's
// own server-private `DaemonRequest` (`refFrameEffect`,
// `allowSessionlessDefaultDevice`, `skipSessionlessProviderDevice`
// are all `(req: DaemonRequest) => …`). It therefore cannot be
// declared below the daemon, and having core/ re-declare a parallel
// 13-field shape would trade one erased edge for a second source of
// truth. Zones that only need to CLASSIFY a command take
// `contracts/dispatched-command.ts` instead. ADR 0003/0008.
//
// commands -> daemon-server (1) `DaemonCommandRoute` = `keyof typeof DAEMON_ROUTE_HANDLERS`, so
// it is COMPUTED FROM the daemon's handler table and cannot exist
// below it. `commands/command-explain.ts` uses it to key an
// exhaustive `Record<DaemonCommandRoute, string>` of owner files; a
// hand-written union in contracts/ would drop that exhaustiveness.
//
// See docs/dependency-graph-findings.md §0 for the long form. The counts may only go DOWN. Fixing edges without lowering the number fails too, so the baseline
// cannot quietly stop describing the tree.
//
// The counts may only go DOWN. Fixing edges without lowering the number fails too, so the
// baseline cannot quietly stop describing the tree.
// Exported so scripts/depgraph can assert its own graph build reproduces it — see the
// baseline-parity test there. The gate remains the authority; the report follows.
export const TYPE_INVERSION_BASELINE: Readonly<Record<string, number>> = {
'commands -> client': 28,
'commands -> client': 3,
'commands -> daemon-server': 1,
'contracts -> client': 1,
'core -> daemon-server': 5,
'core -> daemon-server': 2,
'mcp -> client': 1,
'replay -> daemon-server': 6,
};

function checkTypeInversions(edges: readonly ResolvedImportEdge[]): Violation[] {
Expand Down Expand Up @@ -277,6 +293,44 @@ function checkTypeInversions(edges: readonly ResolvedImportEdge[]): Violation[]
return violations;
}

// R9: the largest type-level import cycle, ratcheted for GROWTH ONLY.
//
// R4 keeps the value graph acyclic, so every cycle counted here is created by type-only imports.
// That is free at runtime and invisible to R5/R6, but it bounds what can be read in isolation:
// inside a strongly-connected component of 102 files, no file has a self-contained slice — reading
// any one of them means reaching every other one's declarations. It is the largest single obstacle
// to understanding a subsystem on its own, and nothing else in this gate measures it.
//
// Deliberately loose. Unlike R6 this does NOT fail when the number drops, because the number is
// large and reducing it is a real refactor rather than a file move — a hard equality would turn
// every unrelated improvement into a baseline edit. It fails only on growth, and reports the slack
// when the tree has improved so the baseline can be lowered when someone is in here anyway.
//
// Hubs at the time of writing, by in-component dependents: runtime-contract.ts (25),
// commands/runtime-types.ts (21), backend.ts (15), commands/runtime-common.ts (12). A pass at this
// starts there. See docs/dependency-graph-findings.md.
// Set to what THIS branch achieves, not to what main carries: main is at 107, and the boundary
// moves here bring it to 102. Measured on the rebased tree — an earlier 87 was taken against an
// older main and was stale rather than violated, which is exactly the kind of drift a ratchet
// measured at merge time prevents.
const TYPE_CYCLE_BASELINE = 102;

function checkTypeCycleGrowth(actual: number): Violation[] {
if (actual <= TYPE_CYCLE_BASELINE) return [];
return [
{
rule: 'R9 type-cycle-growth',
file: 'scripts/layering/check.ts',
line: 1,
message:
`the largest type-level import cycle grew to ${actual} files (baseline ` +
`${TYPE_CYCLE_BASELINE}). A type-only import that closes a loop makes every file in the ` +
`loop unreadable in isolation. Declare the shared type below both modules, or if the growth ` +
`is genuinely warranted, raise TYPE_CYCLE_BASELINE in the same commit and say why.`,
},
];
}

function checkSessionStateOwnership(sources: ReadonlyMap<string, string>): Violation[] {
const types = sources.get('src/daemon/types.ts');
if (!types) {
Expand Down Expand Up @@ -431,16 +485,29 @@ function sessionStateFieldCount(): number {
);
}

function report(files: readonly string[], violations: readonly Violation[]): number {
/** R9 is growth-only, so a shrunk tree is reported rather than failed — see checkTypeCycleGrowth. */
function typeCycleNote(actual: number): string {
if (actual >= TYPE_CYCLE_BASELINE) return `the largest type-level cycle is ${actual} files (R9)`;
return (
`the largest type-level cycle is down to ${actual} files, under the R9 baseline of ` +
`${TYPE_CYCLE_BASELINE} — lower TYPE_CYCLE_BASELINE when convenient`
);
}

function report(
files: readonly string[],
violations: readonly Violation[],
typeCycle: number,
): number {
if (violations.length === 0) {
process.stdout.write(
`Layering guard: OK — ${files.length} source files satisfy R1-R3 and contain no ` +
`value-import cycles (both checked globally); the ranked target spine contains no ` +
`back-edges (only the composition root is unranked), and its type-only ` +
`inversions match the R6 ratchet (${Object.values(TYPE_INVERSION_BASELINE).reduce((sum, count) => sum + count, 0)} remaining); ` +
`all ${sessionStateFieldCount()} SessionState fields are classified and every write is ` +
`inside its declared owner (R7); and every zero-dep CI job resolves without ` +
`node_modules (R8).\n`,
`inside its declared owner (R7); every zero-dep CI job resolves without ` +
`node_modules (R8); and ${typeCycleNote(typeCycle)}.\n`,
);
return 0;
}
Expand Down Expand Up @@ -470,15 +537,18 @@ export function main(): number {
const sourceFiles = listSourceFiles();
const sources = readSources(sourceFiles);
const edges = resolveImportEdges(sources);
// Computed once and threaded: the rule and the success line must report the same number.
const typeCycle = largestTypeCycleSize(edges);
const violations = [
...checkLayeringRules(edges),
...checkCycles(edges),
...checkBackEdges(edges),
...checkTypeInversions(edges),
...checkSessionStateOwnership(sources),
...checkTypeCycleGrowth(typeCycle),
...checkZeroDepJobs(),
];
return report(sourceFiles, violations);
return report(sourceFiles, violations, typeCycle);
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
Expand Down
46 changes: 46 additions & 0 deletions scripts/layering/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from './session-state.ts';
import { uninstallableImports, zeroDepJobs } from './zero-dep-jobs.ts';
import {
largestTypeCycleSize,
RANKED_ZONES,
typeInversionPair,
UNRANKED_ZONES,
Expand Down Expand Up @@ -467,3 +468,48 @@ test('classification drift is reported in all three directions', () => {
);
assert.deepEqual(inBoth, [], 'the real tables must not overlap');
});

// R9 shipped its first revision with no test — every other rule here has one, and the only
// verification was a manual injection CI cannot repeat. These pin the three distinctions the rule
// depends on: which edge kinds count, and that an acyclic graph reports 1 rather than 0.
test('largestTypeCycleSize counts type-only cycles and ignores dynamic ones', () => {
// Acyclic: every component is a single file, so the largest is 1 (not 0).
const acyclic = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "import type { B } from '../contracts/b.ts';",
'src/contracts/b.ts': 'export type B = 1;',
})),
);
assert.equal(largestTypeCycleSize(acyclic), 1);

// A three-file loop closed by type-only imports is exactly what R4 permits and R9 measures.
const typeCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "import type { B } from './b.ts';",
'src/core/b.ts': "import type { C } from './c.ts';\nexport type B = 1;",
'src/core/c.ts': "import type { A } from './a.ts';\nexport type C = 1;",
})),
);
assert.equal(largestTypeCycleSize(typeCycle), 3);

// A loop closed through a DYNAMIC import is excluded on purpose: a lazy seam is not a
// comprehension barrier, and R3 relies on dynamic imports existing. With no non-dynamic edge at
// all no file enters the walk, so the floor here is 0 rather than 1 — specified, not incidental.
const dynamicCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "void import('./b.ts');",
'src/core/b.ts': "void import('./a.ts');",
})),
);
assert.equal(largestTypeCycleSize(dynamicCycle), 0);

// A value cycle counts too — R4 rejects it separately, so R9 must not be the thing that
// notices, but it must not under-report either.
const valueCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "export { b } from './b.ts';",
'src/core/b.ts': "export { a } from './a.ts';",
})),
);
assert.equal(largestTypeCycleSize(valueCycle), 2);
});
69 changes: 69 additions & 0 deletions scripts/layering/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,72 @@ export function collectBackEdges(edges: readonly ResolvedImportEdge[]): BackEdge
.map(([pair, identities]) => [pair, [...identities].sort()]),
);
}

/**
* Size of the largest strongly-connected component over value AND type-only edges.
*
* R4 keeps the VALUE graph acyclic, so any cycle here is created by type-only imports. That costs
* nothing at runtime — types are erased — but it bounds what can be read in isolation: every file
* in the component transitively references every other one's declarations, so none of them has a
* self-contained slice. Dynamic edges are excluded deliberately: a dynamic import is a lazy seam,
* and a loop through one is not a comprehension barrier in the same way.
*
* Returned as a single number because that is all R9 ratchets.
*
* Floor semantics, which are specified rather than incidental: only files that participate in at
* least one non-dynamic edge are considered, so an acyclic graph reports 1 (every such file is its
* own trivial component) and a graph whose only edges are dynamic reports 0 (no file enters the
* walk). Both are immaterial to a growth ratchet, but they are pinned in model.test.ts so nobody
* later reads 0 and 1 as a meaningful difference.
*/
export function largestTypeCycleSize(edges: readonly ResolvedImportEdge[]): number {
return largestTypeCycleMembers(edges).length;
}

/** Members of the largest value+type strongly-connected component, sorted. */
function largestTypeCycleMembers(edges: readonly ResolvedImportEdge[]): string[] {
const successors = new Map<string, string[]>();
for (const edge of edges) {
if (edge.dynamic) continue;
const list = successors.get(edge.file) ?? [];
list.push(edge.target);
successors.set(edge.file, list);
}

const index = new Map<string, number>();
const lowLink = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
let next = 0;
let biggest: string[] = [];

function visit(file: string): void {
index.set(file, next);
lowLink.set(file, next);
next++;
stack.push(file);
onStack.add(file);

for (const target of successors.get(file) ?? []) {
if (!index.has(target)) {
visit(target);
lowLink.set(file, Math.min(lowLink.get(file)!, lowLink.get(target)!));
} else if (onStack.has(target)) {
lowLink.set(file, Math.min(lowLink.get(file)!, index.get(target)!));
}
}

if (lowLink.get(file) !== index.get(file)) return;
const component: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
component.push(member);
} while (member !== file);
if (component.length > biggest.length) biggest = component;
}

for (const file of successors.keys()) if (!index.has(file)) visit(file);
return biggest.sort();
}
2 changes: 1 addition & 1 deletion src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
MetroPrepareOptions,
MetroReloadOptions,
} from '../agent-device-client.ts';
import type { SettingsUpdateOptions } from '../client/client-types.ts';
import type { SettingsUpdateOptions } from '../contracts/client-settings.ts';
import { AppError } from '../kernel/errors.ts';
import { resolveCliOptions } from '../cli/resolve-cli-options.ts';

Expand Down
Loading
Loading