NOT a finished change — pushed so the comparison behind a pending decision is reproducible rather than a claim in a chat log. PR #1409 still carries the viewer. Question: the viewer's productive output turned out to be the JSON, not the render. Every finding this session came from numeric queries; the render was never opened to make a decision. So: does an analysis-only version meet the repo's bar WITHOUT the Fallow exemption that PR #1409 needs? Method: delete viewer.{js,css,html} and the geometry (clusterLayout, layeredLayout), keep computeLevels (it is analysis, not layout), emit JSON plus the text summary, and REMOVE scripts/depgraph/** from ignorePatterns. Result, with zero exemptions: with viewer analysis only complexity findings 23 (1 CRIT) 2 unused files 2 0 unused exports 3 0 clone groups 1 0 lines 2811 ~590 Identical output: 898 files, 4627 edges, 1338 redundant value edges, 8 non-gated cycles, R6 42 (matching the gate's baseline). So the numeric part can meet the repo's bar unexempted; viewer.js — 920 lines with a CRITICAL-complexity `draw` — never could. Fixed along the way rather than suppressed: extracted `valueSuccessors` (the value-edge adjacency was built identically in markRedundantEdges and computeLevels — a real clone), extracted `edgeKindCode`/`edgeFlags` from a nested ternary with CRAP 42, un-exported buildPayload/main, and deleted `fileGroup` and the `group` node field, both dead once the cluster layout went. Still open if this direction is chosen: split `buildGraph` (81 lines, 20 cyclomatic) and `markRedundantEdges` — the last 2 complexity findings, ordinary functions rather than a canvas renderer. README is rewritten to match the report-only shape; the when-to-use guidance carries over unchanged, since it was already about numeric queries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
Dependency graph report
pnpm depgraph # -> .tmp/depgraph/graph.json + a text summary
pnpm depgraph --out /tmp/graph.json
pnpm depgraph:test
Emits the dependency graph of every production file under src/ (tests excluded) as JSON,
plus a short summary of what the layering gate does not enforce. There is no renderer: the
productive artifact is the JSON, queried directly.
When to reach for this
It pays for itself on three questions, and misleads on a fourth.
"What am I about to break?" nodes[].in is the dependent count — blast radius. Size the
nodes by dependents and the files you should touch carefully are the big ones. Faster than
grepping, and it counts type-only and dynamic edges that a grep for from '...' misses.
"Where is the debt actually concentrated?" Zone-level counts (zoneEdges) answer "which
boundary carries the most traffic" in one query. The pass that produced ADR-adjacent findings
started here.
"What is wrong that the gate does not enforce?" This is the part CI cannot give you. The gate rejects value-import cycles (R4) and spine back-edges (R5); the graph additionally reports:
- transitively redundant value edges — the target is still reachable from the source at distance >= 2, so the direct import changes nothing about what the module can see. A candidate, not a defect: a direct import is often clearer than a re-export chain. There are ~1300 of these, so treat it as a place to look, never a work list.
- type-only and dynamic cycles — 8 of them, all outside R4 by design (a type-only import is free at runtime, a dynamic one is a deliberate cold-start seam). Worth reading when a module feels hard to reason about.
Where it misleads: a cluster's size is not its difficulty. This is worth stating plainly
because it already cost a day. The commands -> client cluster looked like the obvious win — 28
type-only inversions, all pointing at 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/;
declaring it in contracts/ made the foundation depend on the layers above it. The picture shows
you an edge's weight, not whether it can be reversed.
So: use the render to find a candidate, then answer "can this move?" numerically before planning anything. The question is always what does the target itself import, and what rank is that?
pnpm depgraph
# Zone pairs that invert the ranked spine, by type-only edge count. Reproduces the gate's R6
# breakdown from the JSON alone — if these disagree with TYPE_INVERSION_BASELINE, regenerate.
node -e "const j=require('./.tmp/depgraph/index.json');
const rank=Object.fromEntries(j.zones.map(z=>[z.id,z.rank]));
j.zoneEdges
.filter(e=>rank[e.from]!=null && rank[e.to]!=null && rank[e.from]<rank[e.to])
.map(e=>({pair:e.from+' -> '+e.to, typeOnly:e.count-e.valueCount}))
.filter(e=>e.typeOnly>0).sort((a,b)=>b.typeOnly-a.typeOnly)
.forEach(e=>console.log(String(e.typeOnly).padStart(4), e.pair));"
Note zoneEdges[].backEdge flags R5 value back-edges only, and there are none — filtering on
it returns an empty list, which is the gate passing, not a broken query.
What is authoritative
pnpm check:layering is. This reads the same model, so the numbers should agree — its R6
count matching TYPE_INVERSION_BASELINE is a useful self-check — but if they ever diverge, the
gate is right and the graph is stale. Nothing here runs in CI, and nothing here should gate a
merge: it is an instrument, not a rule.
Why it reuses the layering gate
The graph is extracted with scripts/layering/model.ts, the same module
scripts/layering/check.ts uses in CI. File set, zone partition, edge kinds
(value / type-only / dynamic), and cycle definition are therefore identical to the rules
the gate enforces — a separate extractor with its own resolution behaviour would draw a
graph nobody is enforcing. Cross-checked once against dependency-cruiser 3.1.1 (at the commit it was written): same
modules and edges, plus 88 dynamic/type-only edges dependency-cruiser fails to resolve.
What the JSON carries
zones[]— id, spinerank(nullwhen intentionally unranked),classification, file count, LOC.zoneEdges[]— per zone pair: totalcount,valueCount, andbackEdge(R5 value back-edges only — see the note above).nodes[]— per file: zone index, LOC,in/outdegree,lvl(longest path to a sink over value edges; R4 guarantees that subgraph is a DAG), andcyc(index intocycles, or-1).edges[]— index-addressed[from, to, kind, flags]. Kind:0value,1type-only,2dynamic. Flags bitfield:1spine back-edge,2transitively redundant,4type-only inversion.cycles[]— each withkind(value/type/dynamic) and its node path.
A "redundant" edge means the target is still reachable from the source at distance >= 2 over value edges, so removing the direct import would not change what the module can see. That makes it a candidate, not a defect: plenty of direct imports are clearer than relying on a re-export chain.