Skip to content

feat(scripts): dependency-graph report over the layering gate's model - #1410

Merged
thymikee merged 3 commits into
mainfrom
claude/depgraph-report
Jul 27, 2026
Merged

feat(scripts): dependency-graph report over the layering gate's model#1410
thymikee merged 3 commits into
mainfrom
claude/depgraph-report

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

The analysis half of the graph tooling, without the renderer. Emits the production import graph as
JSON plus a short summary of what the layering gate deliberately does not enforce.

$ pnpm depgraph
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
  wrote .tmp/depgraph/graph.json

392 lines, pnpm check green, and — the part that matters — it needs no Fallow exemption.

Why keep this when the viewer was dropped

#1409 rendered the same data and was closed: ~2200 lines plus an exemption for a 920-line canvas
file, and nobody drew a conclusion from the picture. This is the half that computes things you
cannot get another way:

  • 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. That needs a real
    reachability pass, not a grep. A candidate list, not a defect list: a direct import is often
    clearer than a re-export chain, and at ~1300 it is a place to look, never a work list.
  • Cycles over type-only and dynamic edges — the loops R4 excludes by design (a type-only import
    is free at runtime; a dynamic one is a deliberate cold-start seam). 8 of them.
  • Per-file fan-in/fan-out and lvl (longest path to a sink), per-zone size — blast radius and
    "which boundary is big", in one query.

It reuses the gate's model

Extraction is 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 ones actually enforced. A separate extractor
would describe a graph nobody gates.

That gives a free consistency check: the R6 count reproduces TYPE_INVERSION_BASELINE. If they
disagree, one of the two is stale — and the gate is the one that wins.

No exemption, and what that took

scripts/depgraph/** is deliberately absent from Fallow's ignorePatterns, unlike
scripts/layering/**, scripts/perf/** and scripts/maestro-conformance/**. Getting there meant
fixing findings rather than suppressing them:

Finding Fix
clone group extracted valueSuccessors — the value-edge adjacency was built identically in markRedundantEdges and computeLevels
buildGraph 81 lines / 20 cyclomatic split into indexCyclesByFile, buildNodes, aggregateZones, aggregateZoneEdges
markRedundantEdges 20 cognitive split out reachableBeyondDirectEdge
aggregateZoneEdges CRAP 31.6 extracted compareZoneEdges and crossedZonePair
CRAP 42 nested ternary extracted edgeKindCode / edgeFlags
3 unused exports, 2 unused files un-exported buildPayload/main; deleted fileGroup and the group node field, both dead once the layout went

Two additive exports

scripts/layering/model.ts gains zoneRank (so a consumer can tell an inversion from an ordinary
edge without re-deriving the spine) and exports targetDagZone, previously module-private. The
gate's own behaviour is unchanged.

Validation

pnpm check green — lint, typecheck, R1–R8, production exports, MCP metadata, build, bundle owner
files, Fallow with no exemption, 4488 unit tests, plus 5 tests on the graph model.

Note docs/dependency-graph-findings.md on main documents an inline throwaway probe for the same
queries, added when the viewer was dropped. That stays useful for one-off questions; this is for the
two computations a probe cannot cheaply reproduce. If you would rather keep only the probe, this PR
is the thing to drop — the doc does not depend on it.


Generated by Claude Code

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
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.85 MB 1.85 MB -806 B
JS gzip 592.7 kB 592.4 kB -333 B
npm tarball 706.9 kB 706.5 kB -409 B
npm unpacked 2.47 MB 2.47 MB -1.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.8 ms 26.6 ms -0.2 ms
CLI --help 56.9 ms 57.7 ms +0.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/cli-help.js -806 B -333 B

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-27 12:00 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Not ready to merge. The renderer/Fallow-exemption removal is a good scope reduction, and #1409's HTML/JSON overwrite bug is structurally gone, but two P1s remain:

  • P1 — primary implementation is still binary/unreviewable. scripts/depgraph/model.ts contains literal U+0000 delimiters, so Git/GitHub reports the new ~346-line file as binary (- -) and hides its diff. Replace them with textual escapes or structured keys and add a raw-NUL guard.
  • P1 — “transitively redundant” still overclaims removability. Module reachability A → B → C does not make C's exports available in A or preserve side-effect/evaluation semantics. The test itself proves the gap: A directly imports c, while B re-exports it as b; deleting A → C removes A's c binding. Reframe this strictly as static module-reachability with caveats, or implement symbol/export-aware analysis before calling ~1,300 edges simplification candidates.

Two P2 validation/docs issues also need correction:

  • The five tests cover only model.ts; no test invokes build.ts, and depgraph:test is not part of pnpm check. Add subprocess coverage for default and explicit --out, JSON/output shape, invalid/missing arguments, and summary/count consistency, then run it plus pnpm check on the exact head.
  • README's worked query still loads .tmp/depgraph/index.json, but pnpm depgraph now writes .tmp/depgraph/graph.json; the documented command fails as written.

Keep ready-for-human withheld until these are resolved and required CI is green.

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
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head e1271cac. The new Layering Guard count check is directionally useful, but prior blockers remain:

  • P1: scripts/depgraph/model.ts still contains literal NUL bytes, so Git treats the primary implementation as binary and hides its diff. Replace them with escaped delimiters and add a guard.
  • P1: “transitively redundant” still overclaims safe removability. Module reachability does not preserve lexical bindings, re-export names, evaluation timing, or side effects; the existing A/B/C test itself loses the direct c binding if A→C is removed. Reframe as reachability-only or implement symbol/export-aware analysis.
  • P2: neither depgraph:test nor CI invokes build.ts; there is still no subprocess coverage for output paths, JSON/wire shape (including typeInversions), summary consistency, or bad arguments. The new test checks only model-derived inversion counts against the baseline.
  • P2: README still queries index.json although the tool writes graph.json, overstates what collapsed zoneEdges can reproduce, and now says both that the report runs in CI and that nothing here runs in CI.

Keep ready-for-human withheld.

thymikee added a commit that referenced this pull request Jul 27, 2026
…s proposed

P1: the section referenced scripts/depgraph/ as present and #1410 as kept —
that PR is open and this tree has no depgraph directory. Both the acyclicity
and component-metrics bullets now name it as proposed (#1410), not landed.

P2: enforcement claims scoped to the concrete guards: publicPlatformString
gates one serialized-output DTO class, runnerProvider is one adopted seam,
R7 covers SessionState only, and boundaries-are-earned plus stable-interface
testing are labeled norms (gated only at the test-only-DI-seam slice). The
intro now says explicitly that bullets distinguish gate, ledger, and norm.
…vability

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

Copy link
Copy Markdown
Member Author

All four addressed in aeecf65. pnpm check green, 4488 unit tests, 10 depgraph tests.

P1 — binary source. Two raw NUL bytes used as map-key delimiters, now a unicode escape: identical at runtime, textual on disk. git diff origin/main...HEAD -- scripts/depgraph/model.ts reports 386 0 instead of - -, so the whole implementation is reviewable.

Worth admitting how this survived: I saw the symptom repeatedly — every grep on that file printed binary file matches — and routed around it with python each time instead of asking why. Guarded repo-wide rather than for this one file, since nothing else would catch a recurrence and the failure mode is silent (the code works, the review doesn't). Verified by reintroducing a NUL and watching the test fail.

P1 — removability overclaim. You're right, and my own fixture proved it: a imports { c } while b re-exports it as { c as b }, so the path exists and deleting a -> c still breaks a. The comment on that fixture said "removable".

Renamed to what it measures — transitivelyReachable, markTransitivelyReachableEdges — and the summary now reads:

value edges whose target is also reachable at distance >= 2: 1338
  (reachability only — not a removability claim, see scripts/depgraph/README.md)

The three caveats (bindings aren't carried, side-effect timing isn't preserved, a direct import is often deliberate) are stated at the marker function, on the fixture, and in the README, with your c/c as b counterexample written out. Symbol-level analysis is named as what deciding any individual edge would actually take. No count is presented as a work list.

P2 — no build.ts coverage. Correct, all five tests hit model.ts only. Added three subprocess tests: default path with the summary asserted against the payload it was derived from, --out honoured with valid JSON at the target, and a trailing --out falling back rather than crashing — pinned so that's a decision rather than an accident. pnpm depgraph:test now runs inside check:tooling, so pnpm check covers it.

P2 — README. Three errors, all real: it queried index.json after the output moved to graph.json (so the documented command failed as written), it derived inversions from collapsed zoneEdges which can undercount, and it asserted both that the report runs in CI and that nothing here does. The query now reads typeInversions and I ran it verbatim against real output. The CI paragraph names the single test that runs (model.test.ts, in the Layering Guard job) and states plainly that nothing else here gates a merge.

One thing I did not change, flagged in case you disagree: zoneEdges still carries count/valueCount from the collapsed list. I documented the undercount hazard next to the query rather than removing the field, because the aggregate is still the right answer for "which boundary carries the most traffic" — it's only unsafe as a route to the inversion count. Happy to drop valueCount if you'd rather it not be derivable at all.


Generated by Claude Code

@thymikee
thymikee merged commit f19864e into main Jul 27, 2026
25 checks passed
@thymikee
thymikee deleted the claude/depgraph-report branch July 27, 2026 11:59
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head aeecf656. The four prior blockers are fixed and the implementation/tests/checks are otherwise clean. P2 docs: scripts/depgraph/README.md says there is no renderer and JSON is the productive artifact, but later tells users “use the render,” refers to “the picture,” and calls it “the viewer.” Replace those stale renderer/viewer references with the JSON/report workflow so the documented instructions are internally consistent and executable. Keep ready-for-human withheld until that is corrected.

thymikee added a commit that referenced this pull request Jul 27, 2026
…ers (#1428)

* docs: map architecture principles to their enforcing gates and ledgers in CONTEXT.md

Writes down the rule → gate → ledger mapping (dependency rule → layering
R1-R3/spine + R6 ratchet; acyclicity → R4 + depgraph tolerated-cycle report;
policy×detail → the two registries + leak guard + transport seam; information
hiding → R7; boundaries-are-earned with the #1409/platform-descriptor
evidence; component metrics as observatory data per #1423) so contributors
and agents get the why behind the guards they hit, in one place.

* review: scope principle claims to actual enforcement; mark depgraph as proposed

P1: the section referenced scripts/depgraph/ as present and #1410 as kept —
that PR is open and this tree has no depgraph directory. Both the acyclicity
and component-metrics bullets now name it as proposed (#1410), not landed.

P2: enforcement claims scoped to the concrete guards: publicPlatformString
gates one serialized-output DTO class, runnerProvider is one adopted seam,
R7 covers SessionState only, and boundaries-are-earned plus stable-interface
testing are labeled norms (gated only at the test-only-DI-seam slice). The
intro now says explicitly that bullets distinguish gate, ledger, and norm.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants