Files
Michał Pierzchała dcd8b65d4c refactor(daemon): split src/daemon/types.ts into request types and session state (#2346)
* refactor(daemon): split daemon/types.ts into request and session-state modules

`src/daemon/types.ts` served two audiences from one file: the dispatch request
shape and the daemon's live session record. It also sat in the only daemon type
cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState`
back — so neither file could be read in isolation.

Three modules replace it, each importing only downward:

- `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request
  with no `internal` key and no property path to `SessionState` or `DeviceLease`,
  so a consumer can read a request's command, flags and public metadata without
  depending on the session record.
- `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which
  stays unexported) plus the response vocabulary.
- `session-state.ts` owns `SessionState` and the shapes only it holds.

The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and
`session-state.ts`: it owns the frame VALUE (the class stays unexported, so the
type remains nominal and unconstructible from outside), while `ref-frame.ts`
keeps every lifetime transition and every `session.refFrame` write.

No behavior change: every importer moves to the module owning the symbol it
uses, with no re-export shim at the old path. `client-normalizers.ts` takes
`SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it.

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

* test(daemon): assert the wire request shape cannot reach session state

A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an
`internal` key or grows a property path back to `SessionState` or `DeviceLease`.
Positive controls over `DaemonRequest` prove the walk finds both when they are
there, so a walk that never matches anything cannot pass by accident.

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

* test(daemon): keep the three over-budget test files at their base length

Splitting `daemon/types.ts` turns one combined import into two in every file
that used both halves. Three of those test files are already over the 1,000-line
tripwire, where the size ratchet allows no growth, so each sheds one line that
was carrying nothing:

- `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength`
  assertion an adjacent `toEqual` on an explicit array literal already makes.
- `session-replay-repair-transaction.test.ts` names the filtered close actions
  instead of wrapping the expression across three lines inside `expect`.

No assertion is weakened and no test content is removed. Splitting these files
along the modules they mirror is the standing remedy, but none of those modules
split here, so it stays out of this change and is tracked in #2353.

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

* chore(gates): point the daemon modularity and wire-compat gates at the split modules

R7 now locates the `SessionState` declaration by the declaration itself rather
than by a recorded path: `sessionStateWritePressure` measures the merge-base
tree too, and that tree still declares it in `daemon/types.ts` — a path constant
would measure it as zero pressure and bank the headroom.

R10's external-importer ratchet covers all three modules that replaced
`daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to
a new outside zone. The recorded membership is unchanged: `client-normalizers.ts`
and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only.

The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and
`DaemonArtifact` by path, so those three keys follow the declarations to
`daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the
server-side narrowing of the kernel declaration that fixes the wire shape —
rather than calling it a re-export alias.

The `live-state-shape` and session-resource declaration sites move with
`SessionState`; the depgraph lookalike fixture takes a new plausible path now
that `daemon/session-state.ts` is the real root.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:36:54 +02:00

154 lines
8.1 KiB
Markdown

# Dependency graph report
```sh
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
```sh
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 `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?*
```sh
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, 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.
## 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 exact `SessionState` type from `src/daemon/session-state.ts`.
- `live-state-authority` — the edge names the exact `SessionStore` class from
`src/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:
```sh
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.