Files
callstack__agent-device/scripts/depgraph
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

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

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

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

* chore: drop the migration inventories and trim the touched comments

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

* docs: trim the touched host-kit and mutation-lane comments

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

* docs: keep tool directives only in the touched files

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

* docs: keep tool directives only across the touched tree

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

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

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

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
..

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 src/utils/exec.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 a imports { c } while b only re-exports it as { c as b }, the path exists and deleting a -> c still breaks a), 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.

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, spine rank (null when intentionally unranked), classification, file count, LOC.
  • zoneEdges[] — per zone pair: total count, valueCount, and backEdge (R5 value back-edges only — see the note above).
  • nodes[] — per file: zone index, LOC, in/out degree, lvl (longest path to a sink over value edges; R4 guarantees that subgraph is a DAG), and cyc (index into cycles, or -1).
  • edges[] — index-addressed [from, to, kind, flags]. Kind: 0 value, 1 type-only, 2 dynamic. Flags bitfield: 1 spine back-edge, 2 target also reachable at distance >= 2, 4 type-only inversion.
  • cycles[] — each with kind (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.