Files
callstack__agent-device/scripts/depgraph
Michał Pierzchała 76453add71 refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) (#1494)
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0)

Extend the workspace with packages/* and move the kernel behind an
enforced public API: packages/kernel with nine consumer-earned subpath
exports (errors, device, snapshot, contracts, collections, rect,
redaction, daemon-error, bounds — the last absorbed from utils as Rect
vocabulary). Every kernel import repo-wide becomes the
@agent-device/kernel/<sub> specifier; kernel tests move to
src/__tests__/kernel/ and exercise the package surface. The root
declares the package in devDependencies (workspace:*), tsdown bundles
it (noExternal) so the published artifact and its runtime dependency
manifest are unchanged.

Gate rewiring in the same change, per the W0 brief:
- R1 kernel-sink retires (physically subsumed); new R11
  package-boundaries guards no-root-back-imports, relative tunnelling
  past exports maps, undeclared workspace deps, and non-exported
  subpaths, with runtime resolution pins via import.meta.resolve.
- resolveImportEdges and mutation ownership follow workspace
  specifiers through exports maps, keeping R4 cycle checks, depgraph,
  and derived test ownership connected across the seam (kernel-errors
  still owns 495 tests). listSourceFiles includes packages/*/src.
- kernel becomes an unranked zone; mutation registry, stryker mutate
  globs, and the mutation-affected workflow path filter move to
  packages/kernel/src/errors.ts.
- check:affected gains packages/ ownership (manifests fail open);
  vitest and coverage include packages/*/src; fallow ignores
  packages/** (its resolver cannot follow workspace specifiers).
- The affected-selector CI job installs dependencies: its closure now
  crosses workspace specifiers, and the R8 relative exception is
  unsafe for production src files (Node ESM does not realpath, so dual
  specifier/relative loads would instantiate modules twice). The R8
  zero-dep set is pinned empty with that rationale.

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

* fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b

Review findings on #1494, all five:

1. contracts-schema-public.test.ts reads the kernel source at its
   packages/ path (fs access invisible to the codemod and typecheck).
2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules
   symlink resolves @agent-device/* back to the real repo, so mutants
   in the sandbox never load and vitest.related finds no tests.
   vitest.mutation.config.ts now aliases each EXPORTED specifier to
   its source (derived from exports maps, never a wildcard), keeping
   resolution inside the mutated tree. Validated: kernel-errors module
   runs end to end (dry run 3,984 tests, mutants killed, exit 0).
3. Layering/depgraph resolve workspace specifiers through the
   exports-derived map (workspaceSpecifierTargets) instead of
   reconstructing paths, so '.'-facade packages resolve; the
   positional fallback remains only for map-less fixtures (P0 pin).
4. Per-package project references implemented: packages/kernel is
   composite (emitDeclarationOnly -> dist-types, gitignored), the root
   references it, and typecheck becomes tsc -b — probed to catch type
   errors on both sides under TypeScript 7 native.
5. R11's relative-route exception now requires membership in an actual
   R8 zero-dep job closure (zeroDepClosureFiles walks entries), not
   mere scripts/ placement — closing the dual-instantiation bypass.

Also from review discussion: daemon-error moves out of the kernel
package to src/client/ — its consumers (cli, client facade) rehydrate
wire DaemonErrors client-side; the daemon only produces them. Kernel
drops to 8 exported subpaths before any of them ship.

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

* refactor: one exports-map reader for mutation alias and ownership

Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The
manifest-reading logic already exists as workspaceSpecifierTargets in
scripts/layering/package-boundaries.ts, so both the Stryker sandbox
alias table and the mutation ownership walker now consume it instead
of carrying near-clones. Behavior unchanged; mutation suite 45/45 and
changed-code fallow green.

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

* fix: composite kernel without a root references edge

FreeRange runs plain `tsc -p tsconfig.json`, and a root `references`
entry makes non-build-mode TypeScript demand the referenced project's
built declarations (TS6305) — a standing "build first" tax on every
plain -p consumer (fr, editors). Keep the per-package composite
project and build it in typecheck (`tsc -b packages/kernel` before the
root and examples/sdk passes), but drop the root references edge: root
consumption resolves through exports to source, identical to runtime
and to the bundler. Probed: plain -p green with no prebuilt output;
kernel-side type errors still caught by its own build.

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

* fix: R11 uses the layering parser; mutation config is a fallow entry

Review blockers on #1494:

- R11's private single-quote regex could miss a double-quoted or
  re-export route into packages/*/src. specifierSites now delegates to
  the layering model's parseImports (both quote styles, side-effect
  imports, re-exports, dynamic imports), with direct regressions for
  each formerly-invisible form.
- vitest.mutation.config.ts becomes a declared fallow entry instead of
  a tolerated unused-file finding: the full-repo audit now reports it
  reachable (unused files 2 -> 1; the remainder predates this PR).

FreeRange clean-checkout evidence: with packages/kernel/dist-types and
every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings
on this head — the TS6305 topology died with the root references edge
in the previous commit; check:freerange has no build precondition.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 12:12:46 +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.