mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
dcd8b65d4c
* 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>
154 lines
8.1 KiB
Markdown
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.
|