* feat(scripts): dependency-graph report over the layering gate's model
Reports what the layering gate deliberately does not enforce, as JSON plus a short
summary. No renderer: the productive artifact is the JSON.
pnpm depgraph # -> .tmp/depgraph/graph.json + summary
pnpm depgraph:test
Dependency graph: 898 files, 4627 edges, 25 zones
value-import cycles (R4): 0
type-only/dynamic cycles (not gate-rejected): 8
spine back-edges (R5): 0
type-only spine inversions (R6): 42
transitively redundant value edges: 1338
The two numbers worth having are the ones CI cannot give you. Transitively
redundant value edges — where the target is still reachable at distance >= 2, so
the direct import changes nothing about what the module can see — need a real
reachability pass, not a grep. And cycle detection over type-only and dynamic
edges covers the loops R4 excludes by design. Both are candidate lists, never work
lists; at ~1300 the redundancy set is a place to look.
It reuses scripts/layering/model.ts, the same module check.ts uses in CI, so the
file set, zone partition, edge kinds and cycle definition are the enforced ones. A
second extractor would describe a graph nobody gates. Consequence worth having:
its R6 count reproduces TYPE_INVERSION_BASELINE, so a mismatch means one of the two
is stale.
This is the analysis half of a viewer that was built and dropped. The render cost
~2200 lines and needed a Fallow exemption for a 920-line canvas file, and nobody
read it. Everything here clears the repo's bar with NO exemption — scripts/depgraph
is deliberately absent from ignorePatterns, unlike scripts/layering, scripts/perf
and scripts/maestro-conformance.
Getting there meant fixing rather than suppressing: extracted `valueSuccessors`
(the value-edge adjacency was built identically in two places — a real clone),
split `buildGraph` into four named aggregation steps, split
`reachableBeyondDirectEdge` out of `markRedundantEdges`, extracted
`compareZoneEdges`/`crossedZonePair`, extracted `edgeKindCode`/`edgeFlags` from a
nested ternary scoring CRAP 42, and deleted `fileGroup` plus the `group` node field
once the cluster layout went.
Two additive exports on scripts/layering/model.ts: `zoneRank` and `targetDagZone`
(previously module-private). The gate's behaviour is unchanged.
`pnpm check` green, 4488 unit tests, 5 model tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* ci(layering): assert the depgraph report reproduces the gate's baseline
The report reads the same model as the gate, so its inversion count must equal
TYPE_INVERSION_BASELINE. That agreement was previously a nice property nobody
checked; the Layering Guard job now runs scripts/depgraph/model.test.ts, so the
two cannot be green independently. Verified by bumping a baseline entry by one and
confirming the job fails with a message naming the fix.
The count feeding the check is computed by `typeInversionsByPair`, which applies
the gate's rule — once per FILE pair, over the raw resolved edges — rather than
reading the collapsed edge list. That matters: `collapseEdges` keeps one edge per
pair with the strongest kind winning, and `dynamic` outranks `type`, so a module
imported both lazily and for its types would collapse to `dynamic` and drop out of
the count. No such pair exists today (measured: 0 of 42 inverting pairs), but a
number wired into a CI equality check must not be able to drift for a reason
unrelated to layering.
Stated honestly in the README and the test: this is a cross-check of the report's
extraction and the baseline against the real tree, not two independent algorithms.
The gate remains the authority — if they disagree, the baseline or the tree is
wrong, never the test.
TYPE_INVERSION_BASELINE is now exported for this purpose.
Not done here, deliberately: the ~1338 transitively redundant value edges are a
candidate for a loose growth-only ratchet later. They are a candidate list, not a
work list, and a hard count would be noise.
`pnpm check` green, 4488 unit tests, 6 depgraph model tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* fix(depgraph): make the source reviewable, and stop overclaiming removability
Four review findings, all of them real.
P1 - the implementation was binary. scripts/depgraph/model.ts contained two raw NUL
bytes used as map-key delimiters, so Git classified a ~346-line file as binary and
hid its entire diff behind `- -`. Replaced with a unicode escape: identical at
runtime, textual on disk. I had seen the symptom repeatedly - every grep on that
file printed "binary file matches" - and worked around it with python instead of
asking why, which is how it survived to review.
Guarded repo-wide rather than for this one file: a new test asserts no tracked .ts
under src/ or scripts/ contains a raw NUL, verified by reintroducing one and
watching it fail. Nothing else would catch a recurrence, and the failure mode is
silent - the code works, the review does not.
P1 - "transitively redundant" claimed removability it cannot support. Module
reachability does not carry bindings: if `a` imports `{ c }` while `b` only
re-exports it as `{ c as b }`, the path a -> b -> c exists and deleting a -> c still
breaks `a`. The fixture in model.test.ts is exactly that shape and its comment said
"removable". Reachability also says nothing about when a module's side effects run.
Renamed throughout to what it measures - `transitivelyReachable`,
`markTransitivelyReachableEdges`, and a summary line reading "value edges whose
target is also reachable at distance >= 2 (reachability only - not a removability
claim)". The caveats and the counterexample are now stated in the marker function,
the fixture comment and the README, and symbol-level analysis is named as what
deciding any individual edge would actually require.
P2 - build.ts had no coverage. Every test exercised model.ts, so the CLI could break
its output path, wire shape or summary silently. Added three subprocess tests:
default path plus summary-agrees-with-payload, `--out` honoured and valid JSON
written, and a trailing `--out` falling back rather than crashing (pinned so it is a
decision, not an accident). `pnpm depgraph:test` now runs inside `check:tooling`, so
`pnpm check` covers it.
P2 - README was wrong three ways: it queried `.tmp/depgraph/index.json` after the
output moved to `graph.json` (the documented command failed as written), it derived
inversions from collapsed `zoneEdges`, which can undercount, and it claimed both
that the report runs in CI and that nothing here runs in CI. The query now reads
`typeInversions` and was run verbatim; the CI sentence names exactly which single
test runs and states that nothing else gates a merge.
pnpm check green, 4488 unit tests, 10 depgraph tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
---------
Co-authored-by: Claude <noreply@anthropic.com>
6.7 KiB
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:
- value edges whose target is also reachable at distance >= 2 — static module reachability,
and only that. It is not a removability claim, and the obvious reading is wrong:
reachability does not carry bindings (if
aimports{ c }whilebonly re-exports it as{ c as b }, the path exists and deletinga -> cstill breaksa), it does not preserve when a module's side effects run, and a direct import is often deliberately clearer than reaching through a barrel. Deciding whether any given edge can go needs symbol-level analysis this does not attempt. ~1300 of them: 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. Read `typeInversions` rather than deriving it from
# `zoneEdges`: those counts come from the COLLAPSED edge list, where one edge per file pair
# survives and `dynamic` outranks `type`, so a module imported both lazily and for its types
# would drop out. `typeInversions` is counted by the gate's own rule and is what CI compares
# against TYPE_INVERSION_BASELINE.
node -e "const j=require('./.tmp/depgraph/graph.json');
Object.entries(j.typeInversions)
.sort((a, b) => b[1] - a[1])
.forEach(([pair, n]) => console.log(String(n).padStart(4), 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. The viewer reads the same model, so the numbers should agree — and that
agreement is now enforced rather than hoped for: the Layering Guard job runs
scripts/depgraph/model.test.ts, whose last test asserts this report's inversion count reproduces
TYPE_INVERSION_BASELINE. If the tree changes and only one side is updated, CI fails and names the
difference. The two cannot be green independently.
What that check proves precisely: the report's graph build, over the real tree, agrees with the
gate's baseline. It is a cross-check of the extraction and the baseline against reality, not two
independent algorithms — typeInversionsByPair deliberately applies the gate's counting rule (once
per file pair, over raw edges) so the numbers cannot diverge for a reason unrelated to layering. In
particular it does NOT count from the collapsed edge list, where dynamic outranks type and a
module imported both lazily and for its types would drop out.
If they ever disagree, the gate is right and the baseline or the tree is wrong.
Notes
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.
One thing here DOES run in CI, and only one: the Layering Guard job runs
scripts/depgraph/model.test.ts, whose parity test asserts this report's inversion count equals
TYPE_INVERSION_BASELINE. Nothing else here gates a merge — the report itself is an instrument, not
a rule, and no finding it produces is enforced.
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,2target also reachable at distance >= 2,4type-only inversion.cycles[]— each withkind(value/type/dynamic) and its node path.
Bit 2 means the target is reachable from the source at distance >= 2 over value edges. That is
module reachability, not removability — see the caveats above. Treat it as a question ("why is
this imported directly as well?"), never as an instruction.