* test(coverage): declare every public command's six platform coverage judgments once One row per public command in test/integration/command-coverage/declarations.ts carries the android-emulator, ios-simulator, macOS, tvOS, web and Linux classifications with the same fields the six per-platform manifests use today. Each platform's Record<PublicCommand, ...> is projected from that table at load time by a small per-platform view module, so no projected record is committed. No judgment is derived from another platform's: all six stay authored per command. * test(coverage): read the projected per-platform coverage view The six coverage smoke tests, live harnesses and coverage reports now import the platform view module that projects the declaration table. Both the depgraph blast-radius query and the device-lane test follow the iOS and macOS paths. * test(coverage): delete the six per-platform coverage manifests Their rows now live once, per command, in the declaration table; each platform's record is projected from it at load time. * fix(check-affected): extend macos-coverage and integration-node ownership to command-coverage/ test/integration/command-coverage/declarations.ts now carries the per-command coverage judgments that used to live directly under test/integration/macos-e2e/. Its nested path wasn't matched by macosCoverageOwnership (top-level or macos-e2e/ only) or isNodeIntegrationPath (no nested segments), so it fell through to vitest-related, which can't actually run its node --test consumers. Extend both rules to also match test/integration/command-coverage/.
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.
Blast radius of one file
pnpm depgraph affected packages/host-kit/src/command.ts # bounded text
pnpm depgraph affected src/daemon/ref-frame.ts --json --limit 25
Reverse reachability over the value-edge graph, plus the three lookups that used to follow it:
the scripts/check-affected/ gate plan for the dependent set, the public commands whose handler
chain reaches the file (value + dynamic edges, because handlers load through import()) with
their owning live iOS scenarios when that manifest is in the tree, and the ADR 0011
guarantee-matrix cells the file implements. See docs/agents/testing.md § "Before editing a shared
module".
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.
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 report reads the same model (scripts/layering/model.ts) and applies
the gate's own counting rule — typeInversionsByPair counts once per file pair over the raw edges,
exactly as typeInversionCounts in scripts/layering/model.ts does — so typeInversions reproduces
the gate's R6 measurement by construction, not by a second measurement. The gate compares that
measurement with the merge-base's; CI used to assert the report agreed with a recorded baseline,
which was a duplicate detector of the same code path and was removed. In particular the count
does NOT come from the collapsed edge list, where dynamic outranks type and a module imported
both lazily and for its types would drop out.
If the report ever disagrees with the gate, the gate is right.
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.
Declared-authority overlay
The report also carries edgeAuthorities[], aligned with edges[]. Each entry is a compact list
of labels, so a collapsed edge may carry more than one label. The labels are derived from exact
roots, exports, and named live-state symbols in scripts/layering/architecture-ownership.ts:
vocabulary— the target is a declared contract facade root.capability— the target is a declared capability root and the import names a declared export.live-state-shape— the edge names the exactSessionStatetype fromsrc/daemon/session-state.ts.live-state-authority— the edge names the exactSessionStoreclass fromsrc/daemon/session-store.ts.executable-policy— the source is under a declared executable-policy root.ordinary— no declared authority evidence matches the edge.
edges[][2] remains the independent import-kind code (0 value, 1 type-only, 2 dynamic), and
authorityCounts reports stable counts of labels across the collapsed edges. This is a report-only
overlay: it reports declared authority, not behavioral ownership quality, safe removability, or a
composite score/pass threshold.
For reproducible inspection outside the repository's .tmp directory:
pnpm depgraph --out /tmp/agent-device-2128-depgraph.json
jq '{generated, authorityCounts}' /tmp/agent-device-2128-depgraph.json
jq -r '
. as $graph
| range(0; ($graph.edges | length)) as $i
| select($graph.edgeAuthorities[$i] != ["ordinary"])
| [($graph.edgeAuthorities[$i] | join("+")),
$graph.nodes[$graph.edges[$i][0]].id,
$graph.nodes[$graph.edges[$i][1]].id,
["value", "type", "dynamic"][$graph.edges[$i][2]]]
| @tsv
' /tmp/agent-device-2128-depgraph.json
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.