Files
Claude cd19a73c55 feat(scripts): interactive dependency-graph viewer, with when-to-use guidance
Renders every production file under src/ as a pannable graph in one self-contained
HTML file — no external requests, no runtime dependency, layouts precomputed at
build time so the viewer never runs a physics simulation on a phone.

  pnpm depgraph          # -> .tmp/depgraph/index.html (+ index.json)
  pnpm depgraph:test

It reuses the layering gate's model (`listSourceFiles`, `resolveImportEdges`,
`zoneRank`) rather than extracting its own graph. That matters more than it
sounds: a separate extractor with its own resolution behaviour would draw a graph
nobody enforces. Because the model is shared, its R6 count reproduces
TYPE_INVERSION_BASELINE exactly, which doubles as a self-check.

The README now documents WHEN it is productive, because the honest answer is
"for three questions, and it misleads on a fourth":

- what am I about to break (dependent counts, including the type-only and dynamic
  edges a grep for `from '...'` misses);
- where is the debt concentrated (zone-level counts);
- what is wrong that CI does not enforce — ~1300 transitively redundant value
  edges and 8 type-only/dynamic cycles, both outside the gate by design.

The fourth: a cluster's SIZE IS NOT ITS DIFFICULTY. `commands -> client` looked
like the obvious win at 28 edges into one file; moving that file down took the
gate from 42 to 48, because the vocabulary it holds depends on commands/, metro/,
core/ and remote/. The render shows an edge's weight, not whether it can be
reversed — so the README pairs every visual question with the numeric query that
answers "can this actually move?", verified against the real output rather than
written from memory.

Also states plainly that `pnpm check:layering` is authoritative and nothing here
gates a merge: it is an instrument, not a rule.

scripts/depgraph/** joins scripts/layering/**, scripts/perf/** and
scripts/maestro-conformance/** in Fallow's ignorePatterns, which is how this repo
already treats tooling trees. Worth knowing rather than discovering: that exempts
viewer.js from the complexity gate, and its `draw` function would fail it.

Two exports added to scripts/layering/model.ts: `zoneRank` (the viewer colours
nodes by rank, so an inversion reads as an edge pointing the wrong way down the
ramp) and `targetDagZone`, previously module-private.

`pnpm check` green, 4488 unit tests. Verified against current main: 898 files,
4627 edges, 25 zones, R6 count matching the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
2026-07-27 06:54:46 +00:00

148 lines
4.6 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveImportEdges } from '../layering/model.ts';
import { buildGraph, collapseEdges, collectCycles, markRedundantEdges } from './model.ts';
function sources(entries: Record<string, string>): Map<string, string> {
return new Map(Object.entries(entries));
}
test('collapseEdges keeps one edge per pair at the strongest kind', () => {
const edges = resolveImportEdges(
sources({
'src/core/a.ts': [
"import type { Shape } from './b.ts';",
"import { run } from './b.ts';",
"import type { Other } from './c.ts';",
"void import('./d.ts');",
].join('\n'),
'src/core/b.ts': 'export const run = 1;',
'src/core/c.ts': 'export type Other = string;',
'src/core/d.ts': 'export const lazy = 1;',
}),
);
assert.deepEqual(
collapseEdges(edges).map((edge) => ({ to: edge.to, kind: edge.kind })),
[
{ to: 'src/core/b.ts', kind: 'value' },
{ to: 'src/core/c.ts', kind: 'type' },
{ to: 'src/core/d.ts', kind: 'dynamic' },
],
);
});
test('redundant marks only value edges whose target is already reachable at distance >= 2', () => {
const edges = collapseEdges(
resolveImportEdges(
sources({
// a -> b -> c makes the direct a -> c edge removable; a -> d is the only route to d.
'src/core/a.ts': [
"import { b } from './b.ts';",
"import { c } from './c.ts';",
"import { d } from './d.ts';",
].join('\n'),
'src/core/b.ts': "export { c as b } from './c.ts';",
'src/core/c.ts': 'export const c = 1;',
'src/core/d.ts': 'export const d = 1;',
}),
),
);
markRedundantEdges(edges);
const flagged = edges
.filter((edge) => edge.redundant)
.map((edge) => `${edge.from} -> ${edge.to}`);
assert.deepEqual(flagged, ['src/core/a.ts -> src/core/c.ts']);
});
test('a type-only shortcut is never treated as redundant against a value path', () => {
const edges = collapseEdges(
resolveImportEdges(
sources({
'src/core/a.ts': ["import { b } from './b.ts';", "import type { C } from './c.ts';"].join(
'\n',
),
'src/core/b.ts': "export { c as b } from './c.ts';",
'src/core/c.ts': 'export type C = string;\nexport const c = 1;',
}),
),
);
markRedundantEdges(edges);
assert.deepEqual(
edges.filter((edge) => edge.redundant),
[],
);
});
test('collectCycles separates gate-rejected value cycles from type-only and dynamic loops', () => {
const valueCycle = collectCycles(
resolveImportEdges(
sources({
'src/core/a.ts': "import { b } from './b.ts';\nexport const a = 1;",
'src/core/b.ts': "import { a } from './a.ts';\nexport const b = 1;",
}),
),
);
assert.deepEqual(
valueCycle.map((cycle) => cycle.kind),
['value'],
);
const typeCycle = collectCycles(
resolveImportEdges(
sources({
'src/core/a.ts': "import type { B } from './b.ts';\nexport type A = B;",
'src/core/b.ts': "import type { A } from './a.ts';\nexport type B = A | null;",
}),
),
);
assert.deepEqual(
typeCycle.map((cycle) => cycle.kind),
['type'],
);
const dynamicCycle = collectCycles(
resolveImportEdges(
sources({
'src/core/a.ts': "export const a = () => import('./b.ts');",
'src/core/b.ts': "export const b = () => import('./a.ts');",
}),
),
);
assert.deepEqual(
dynamicCycle.map((cycle) => cycle.kind),
['dynamic'],
);
});
test('buildGraph reports zone membership, degrees, and cross-zone edge counts', () => {
const files = sources({
'src/kernel/errors.ts': 'export const fail = 1;\n',
'src/core/interactors/tap.ts': "import { fail } from '../../kernel/errors.ts';\n",
'src/commands/tap.ts': [
"import { fail } from '../kernel/errors.ts';",
"import '../core/interactors/tap.ts';",
].join('\n'),
});
const graph = buildGraph(files, resolveImportEdges(files));
const kernel = graph.nodes.find((node) => node.id === 'src/kernel/errors.ts')!;
assert.equal(kernel.zone, 'kernel');
assert.equal(kernel.fanIn, 2);
assert.equal(kernel.fanOut, 0);
const interactor = graph.nodes.find((node) => node.id === 'src/core/interactors/tap.ts')!;
assert.equal(interactor.group, 'core/interactors');
assert.deepEqual(
graph.zoneEdges.map((edge) => `${edge.from} -> ${edge.to} (${edge.count})`),
['commands -> core (1)', 'commands -> kernel (1)', 'core -> kernel (1)'],
);
assert.deepEqual(
graph.zones.map((zone) => zone.classification),
['ranked', 'ranked', 'ranked'],
);
});