Skip to content

feat(scripts): interactive dependency-graph viewer, with when-to-use guidance - #1409

Closed
thymikee wants to merge 1 commit into
mainfrom
claude/depgraph-viewer
Closed

feat(scripts): interactive dependency-graph viewer, with when-to-use guidance#1409
thymikee wants to merge 1 commit into
mainfrom
claude/depgraph-viewer

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

An interactive, pannable graph of every production file under src/, rendered into one
self-contained HTML file
— no external requests, no build step, no runtime dependency. Layouts are
precomputed at build time and shipped as coordinates, so the viewer never runs a physics simulation
on the reader's device and a given commit always renders identically.

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

Tooling only. Nothing here runs in CI and nothing here gates a merge.

It reuses the layering gate's model

The graph is extracted with scripts/layering/model.ts — the same module check.ts uses in CI — so
the file set, zone partition, edge kinds (value / type-only / dynamic) and cycle definition are
identical to the rules actually enforced. A separate extractor with its own resolution behaviour
would draw a graph nobody is enforcing.

That shared model gives a free self-check: the viewer's R6 count reproduces
TYPE_INVERSION_BASELINE exactly. Verified against current main — 898 files, 4627 edges, 25 zones,
42 type-only inversions.

When it is productive — and when it misleads

This is the part the README now spells out, because the honest answer is "three questions, and it
misleads on a fourth."

Worth reaching for:

Question What answers it
What am I about to break? nodes[].in — dependent count. Counts type-only and dynamic edges a grep for from '...' misses.
Where is the debt concentrated? zoneEdges — which boundary carries the most traffic, in one query.
What's wrong that CI doesn't enforce? ~1300 transitively redundant value edges, 8 type-only/dynamic cycles — both outside the gate by design.

A "redundant" edge means the target is still reachable at distance ≥ 2 over value edges, so the
direct import changes nothing about what the module can see. That makes it a candidate, not a
defect — a direct import is often clearer than a re-export chain. At ~1300 it is a place to look,
never a work list.

Where it misleads — a cluster's size is not its difficulty. Stated plainly in the README because
it already cost real time: commands -> client looked like the obvious win at 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 render shows you an edge's
weight, not whether it can be reversed.

So the README pairs the visual question with the numeric one that answers "can this actually move?",
including a worked query over index.json that reproduces the gate's R6 breakdown. Both examples
were run against real output rather than written from memory — the first draft's example filtered on
zoneEdges[].backEdge and returned an empty list, which is the R5 gate passing rather than a useful
result. That footgun is now documented next to the query.

What is authoritative

pnpm check:layering. The viewer reads the same model so the numbers should agree, but if they ever
diverge the gate is right and the graph is stale.

Two things to know before merging

scripts/depgraph/** joins Fallow's ignorePatterns, alongside scripts/layering/**,
scripts/perf/** and scripts/maestro-conformance/** — how this repo already treats tooling trees
(not baselined, not inline-suppressed). Worth knowing rather than discovering: that exempts
viewer.js from the complexity gate, and its draw function would fail it. If you'd rather this
code met the same bar as src/, say so and I'll split it instead of exempting 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. Both are additive; the gate's behaviour is unchanged.

Validation

pnpm check green — lint, typecheck, R1–R8, production exports, MCP metadata, build, bundle owner
files, Fallow, 4488 unit tests. pnpm depgraph:test covers the graph model (5 tests).

This branch was rebuilt from current main rather than rebased: the earlier version was cut from a
pre-#1405 base and carried stray oxfmt output across 69 unrelated files. Only the viewer's own
files are in this diff.


Generated by Claude Code

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

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.85 MB 1.85 MB 0 B
JS gzip 592.4 kB 592.4 kB 0 B
npm tarball 706.5 kB 706.5 kB +21 B
npm unpacked 2.47 MB 2.47 MB +171 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 27.2 ms -1.8 ms
CLI --help 59.9 ms 57.0 ms -2.9 ms

Top changed chunks: no changes in the largest emitted chunks.

@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 08:40 UTC

@thymikee

thymikee commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Not ready to merge; four actionable findings:

  • P1 — primary implementation is hidden as binary. scripts/depgraph/model.ts and layout.ts contain literal U+0000 delimiters, so Git/GitHub render both as Binary files differ. Replace them with textual escapes (for example \0) or structured keys so the implementation is reviewable.
  • P1 — --out can destroy its own HTML output. build.ts writes HTML to outPath, then derives the JSON path by replacing only a terminal .html suffix with .json. For pnpm depgraph --out /tmp/graph, both paths are identical and JSON overwrites the HTML. Require/normalize .html or always derive a distinct sibling, and add a CLI/build regression test.
  • P1 — the redundant-edge claim is semantically false. A module path A → B → C does not make C's exports lexically available in A, so removing A's direct import of C can break the program. The added test demonstrates this: B re-exports c as b, while A directly imports c. Reframe the metric as runtime module-reachability only (including side-effect/order caveats), or make the analysis symbol/export-aware before advertising ~1,300 simplification candidates.
  • P2 — the viewer bypasses repository maintainability policy. The new viewer.js is 920 lines (and layout.ts is 415), while AGENTS.md requires extraction past 500 LOC. Adding scripts/depgraph/** to Fallow's ignore list explicitly removes complexity enforcement. Split the viewer into focused modules and keep the new tooling under the normal quality gate rather than exempting it.

All ordinary checks are green, but depgraph:test only covers five model tests and does not exercise build/render/layout/CLI behavior; a green exact-head regression for the output-path case is required.

Copy link
Copy Markdown
Member Author

Closing — not merging this. The visual was a one-time aid to get an overview, and it did not earn ongoing maintenance: neither @thymikee nor I drew a conclusion from the render itself. Every finding in the boundary work came from numeric queries against scripts/layering/model.ts, which is already on main.

The honest cost/benefit, measured rather than asserted:

this PR analysis-only, no exemption
Fallow complexity findings 23 (one CRITICAL) 2
Unused files / exports / clones 2 / 3 / 1 0 / 0 / 0
Lines 2811 ~590

So 2200 lines and a Fallow exemption bought a rendering nobody read. Even the analysis-only variant was not load-bearing — the ad-hoc one-liners that produced every actual result imported the gate's model directly and needed none of this package.

Nothing is lost from main: the layering gate (R1–R8) is the enforcement, and docs/dependency-graph-findings.md keeps the analysis record. That doc pointed at this branch for the tooling, so it is being made self-contained with the re-derivation recipe inline — a few lines of query instead of a package.

The experiment that produced the table above is on scratch/depgraph-report if anyone wants to reproduce it; both it and claude/depgraph-viewer are being deleted.


Generated by Claude Code

@thymikee thymikee closed this Jul 27, 2026
thymikee pushed a commit that referenced this pull request Jul 27, 2026
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
thymikee pushed a commit that referenced this pull request Jul 27, 2026
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
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.
thymikee pushed a commit that referenced this pull request Jul 27, 2026
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
thymikee pushed a commit that referenced this pull request Jul 27, 2026
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
thymikee added a commit that referenced this pull request Jul 27, 2026
…atchet what remains (#1435)

* refactor(contracts): declare the public API vocabulary below its consumers

The layering gate's largest remaining cluster was 28 type-only inversions from a
single edge: `commands/` declaring itself in terms of `client/client-types.ts`.
R2 forbids the reverse import, so a shape both surfaces need has to sit below
both. The command/device vocabulary — connection config, the device and session
views, and every per-command Options/Result — now lives in
`contracts/client-api.ts`; `client/client-types.ts` keeps the `AgentDeviceClient`
facade and re-exports the rest through one wildcard.

R6 total: 42 -> 18. No new inversion in any pair.

The published surface is unchanged, and that is verified rather than asserted:
the built `index.d.ts` exports the same 216 type names as main, byte-identical.

Eight shapes deliberately did NOT move, because each is stated in terms of a
HIGHER-ranked zone: `ScrollOptions` (ScrollInputDirection, commands/), the four
navigation Options plus `AgentDeviceCommandClient` (navigation-projection,
commands/), and the two Metro result aliases (metro/). Declaring those in
contracts/ would trade 28 commands->client edges for contracts->commands and
contracts->metro ones — the foundation depending on the layers above it, worse in
kind even though fewer in number. This is measured, not assumed: moving the whole
file to contracts/ first took the gate from 42 to 48, which is how the floor was
found.

Two keystone moves made the other 84 movable:

- `RemoteConnectionProfileFields` joined its sibling `CloudProviderProfileFields`
  in contracts/remote-config-fields.ts. It was the root of the base chain
  (AgentDeviceClientConfig -> AgentDeviceRequestOverrides ->
  DeviceCommandBaseOptions -> every per-command Options), so one rank-4
  declaration was pinning ~80 shapes up with it.
- `DaemonBatchStep` moved to contracts/batch-step.ts. Its `runtime` field was
  written `DaemonRequest['runtime']`, dragging the whole daemon request type in to
  say `SessionRuntimeHints` — the same type, three zones lower.

`CompanionTunnelScope`/`MetroBridgeScope` also moved to contracts/, since the
vocabulary needs the scope shape and it sat next to client-local env-var names.

Six pass-through re-exports in client-types.ts are suppressed per-name with the
reason inline: they exist only to publish contracts/kernel types through the
package entrypoint wildcard, every internal consumer imports them from the
declaring module, so "no consumer" is correct and not actionable — deleting them
would remove names from the public types.

`pnpm check` green, 4488 unit tests. Findings doc records the sequencing for the
last 5: the upstream declarations have to come down before the shapes that need
them can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: drop the graph viewer, keep the query that replaces it

The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.

This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.

Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.

Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(contracts): move the four keystones that pinned the rest of the inversions

R6 type-only spine inversions: 18 -> 7, and every one of the 7 that remains is a
deliberate architectural position rather than a misplaced declaration.

Four keystones moved to contracts/, each of which was pinning a much larger set:

- `CommandFlags` (was core/dispatch-context.ts). One rank-2 declaration holding the
  daemon's request type and every recorded action above it. Its last non-contracts
  dependency was `DaemonBatchStep`, already moved in 3fdbfe0.
- `SessionAction` (was daemon/types.ts). replay/ (6 modules) and compat/maestro/
  read and write session scripts; declaring the shape inside the daemon made both
  depend on the server to describe a file format neither asks it to produce. The
  daemon still owns the recording — only the shape moved.
- `TargetAnnotationV1` shape (was replay/target-identity.ts). ADR 0012 target
  evidence, written by 8 daemon modules and read by commands/; the parsing and
  classification logic stays in replay/.
- `ScrollInputDirection` and the Metro prepare/reload result payloads, which
  unblocked `ScrollOptions` and `MetroPrepareResult`/`MetroReloadResult`.

`DaemonRequest` also split into the three shapes it had been conflating: the
kernel WIRE shape (`flags?: Record<string, unknown>`, because a process boundary
cannot enforce a vocabulary), the new `contracts/command-request.ts`
`CommandRequest` (wire shape with flags typed — what a command surface needs), and
the daemon's own refinement (+ `internal?: DaemonRequestInternal`, carrying
SessionState callbacks and the admitted lease). core/command-descriptor/ had been
importing the third to read `command`, `positionals` and `flags`.

Two things deliberately NOT moved, because moving them would add coupling rather
than remove it, and the baseline now argues both:

- `DaemonCommandDescriptor`/`DaemonCommandRoute` — the route type is
  `keyof typeof DAEMON_ROUTE_HANDLERS`, derived from what the server implements.
  Moving it down means re-declaring route names in contracts plus a gate to prove
  the handler map still covers them. ADR 0003/0008 own that boundary.
- `AgentDeviceClient` — used as an opaque handle by 4 files. The facade is built
  from commands/'s own NAVIGATION_COMMAND_PROJECTIONS, so this is a genuine
  zone-level cycle; breaking it is a design call about where that registry belongs.
  R5 is zero here: nothing imports the client at runtime, only its type.

Also records the largest structural finding, which R6 does not measure: cycles by
edge kind are 1 (value only), 87 (value + type-only), 1 (value + dynamic), 213
(all). At runtime the graph is a clean DAG; the 87-file type-level cluster means
no one of those files' types can be read in isolation. Hubs are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts. Not attempted here — it is a different and much larger
change.

`pnpm check` green, 4488 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(layering): ratchet type-cycle growth (R9), and rule out a narrower client port

R9: the largest strongly-connected component over value + type-only edges may not
grow. R4 keeps the VALUE graph acyclic, so every cycle counted here 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 component of 102 files, no
file has a self-contained slice.

Baseline set to 102, which is what THIS branch achieves - main carries 107 and the
boundary moves here bring it to 102. An earlier revision baselined 87, measured
against an older main; after rebasing onto f19864e the real figure was 102 and the
new rule fired on its own stale baseline. Worth stating because the failure looked
like a regression and was not: attribution showed main at 107 and this branch
reducing it, which is the check working rather than complaining.

Growth-only, deliberately unlike R6. Reducing 102 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. Verified at
the new baseline by adding one type-only import that closes a loop and watching 102
become 108 and the gate reject it.

The refactor itself is still not attempted. Hubs by in-component dependents are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts; a pass starts there.

Separately, investigated the narrower-port idea for the 4 remaining -> client
inversions and it does not work. Measured first:

  files NAMING AgentDeviceClient (the inversions)   4
  files CALLING client methods                     26
  distinct facade namespaces reached               13

The narrowness is an artifact of where the type is named, not of what is used.
Making those four generic over the client type pushes the concrete type into the 26
implementations, turning 4 inversions into up to 26. A port spanning 13 namespaces
is the whole facade, so it would either duplicate the public API shape - a second
source of truth for it - or derive from the facade and carry the same dependency.

So the four are the minimum number of naming sites rather than an accident: they are
the choke point. Recorded as a position with the numbers behind it. The remaining
option is the question underneath it - whether NAVIGATION_COMMAND_PROJECTIONS
belongs in commands/ - and that is a design decision about the command surface, not
a dependency cleanup.

pnpm check green, 4535 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(layering): test R9, specify its floor, and drop two duplications

Adversarial self-review of #1435 found four things worth fixing.

R9 shipped with no unit test. Every other rule in this gate has one (R5 back-edges,
R6 inversions, R7 session state, R8 zero-dep closures); R9's only verification was a
manual injection CI cannot repeat. Added tests for the three distinctions it depends
on: a type-only loop counts, a dynamic-only loop does not, a value loop still does.

Writing that test immediately found an undocumented edge case, which is the argument
for it. largestTypeCycleSize returns 1 for an acyclic graph that has non-dynamic
edges but 0 when every edge is dynamic, because only edge-participating files enter
the walk. Immaterial to a growth ratchet, but an inconsistent floor nobody had
written down. Now specified in the doc comment and pinned by the test, so 0 and 1
cannot later be read as a meaningful difference.

largestTypeCycleMembers was exported with no consumer - speculative API, and
scripts/layering is in Fallow's ignorePatterns so nothing would have flagged it.
Same pattern review caught on the previous head with MaestroRuntimeFlags and
TargetRect. Made module-private.

ResolvedMetroKind was declared twice after the Metro payload move: exported from
contracts/metro.ts and still private in metro/client-metro.ts. client-metro.ts now
imports it.

The gate computed the SCC twice per run, once in the rule and once for the success
line. Computed once and threaded, so the two can no longer disagree.

Also re-verified the claim this PR rests on, with a stronger check than the one in
the body: comparing DECLARATION names in index.d.ts counts inlined internals, and by
that measure this branch appears to lose five names (PrepareMetroRuntimeResult,
ReloadMetroResult, ResolvedMetroKind, SCROLL_INPUT_DIRECTIONS, ScrollInputDirection).
All five are declared-but-not-exported helpers. The real surface - exported names
across all eleven published entrypoints - is 69 on both sides, identical. Also proved
DaemonRequest structurally equal to its pre-split shape with a type-level assertion
rather than by reasoning, and confirmed SessionAction, CommandFlags and
TargetAnnotationV1 moved byte-identically.

pnpm check green, 4535 unit tests, 24 layering tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(contracts): one file per command family, one name per request

Addresses review on #1435.

contracts/client-api.ts was 1,064 LOC and grouped session, app, interaction,
replay, observability and recording contracts together, so it answered no one
question and crossed the >1,000-LOC architecture-debt tripwire in AGENTS.md:124.
Split it into 14 domain-family files by the command families that already exist
(client-connection, client-device-view, client-session, client-lease, client-app,
client-capture, client-target, client-gesture, client-selector-read,
client-replay, client-observability, client-settings, client-system,
client-request); the four Metro client shapes went into the existing
contracts/metro.ts so one file answers the Metro question. Largest resulting
file is 137 LOC. client/client-types.ts re-exports one wildcard per family, so
the published import path is unchanged.

Published surface verified unchanged against main two ways: the exported-name
set of all 11 published entrypoints is identical (70 names), and every
declaration in the built index.d.ts is byte-identical after normalization -- 0
names added, 0 shapes changed. index.d.ts got smaller (1,726 -> 1,682 lines):
10 declarations main duplicated into it now resolve through a shared chunk.

Also, from re-examining the two findings the review flagged as blind spots:

- CommandRequest was a third name for "a request" that no consumer needed.
  Every core/command-descriptor/ use read only command/positionals/flags, in two
  spellings (the full type and a Pick of it). Replaced by
  contracts/dispatched-command.ts DispatchedCommand -- those three fields and
  nothing else, with command/positionals Picked from the wire type so they
  cannot drift. daemon/types.ts DaemonRequest now extends the wire shape
  directly. Two request shapes again, at two ranks.
- The 7 remaining R6 inversions each get a mechanical reason rather than an
  appeal to an ADR: the 4 AgentDeviceClient edges are a real zone-level cycle
  (client-types.ts imports ProjectedNavigationCommandClient from commands/), and
  no narrower port exists (26 call sites across 13 namespaces); the 2
  DaemonCommandDescriptor edges are unavoidable because that shape is stated in
  terms of the server-private DaemonRequest; the 1 DaemonCommandRoute edge is
  unavoidable because the type is computed from the daemon's handler table.

Cleanups found on the way: three doc comments this branch had orphaned from
their declarations (SettleCommandOptions, RecordControlOptions,
ReloadMetroResult -- the last had drifted onto an unrelated type it
misdescribed) are reattached; intra-contracts imports normalized from
'../contracts/x.ts' to './x.ts', which is what the duplicate-import lint caught;
and stale references to the deleted file removed from the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

---------

Co-authored-by: Claude <noreply@anthropic.com>
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