# @zakkster/lite-devtools

> Reactive-graph inspector for @zakkster/lite-signal. Non-perturbing introspection
> (peek + enumerators only -- inspection never adds an observer), full
> auto-discovered DAG with diamond/convergence dedupe, live lifecycle feed
> (0<->1 observer transitions), zero-GC leak watcher on a drift-corrected sampler,
> and DOT / indented-tree renderers. Cold/debug path -- allocates freely; nothing
> runs in a hot loop. ESM-only, single source file, zero runtime dependencies
> beyond the two declared peers. MIT licensed.

The library exposes nineteen functions on top of lite-signal's introspection
surface. The baseline tier (`inspect`, `subscribers`, `dependencies`, `track`,
`monitor`, `leakWatch`, `report`, `graph`, `toDot`, `toTree`, `diff`, `trace`,
`findPath`, `serialize`, `deserialize`, `capabilities`) targets lite-signal
>= 1.2.0. The 1.1-added `watchGraph` push mode and `profile` require the
graph-mutation hook (lite-signal >= 1.2.1) and gracefully degrade to polling
/ null on older engines. `ownerTree` and `graph({owners: true})` require the
owner-tree APIs (lite-signal >= 1.3) and gracefully degrade. Use
`capabilities()` to probe at runtime -- consumers (lite-studio overlays,
debug panels, CI artifact viewers) write one code path. The new value at 1.2
is that the internal owner tree -- nested observers cascade-dispose when
their owner re-runs or is disposed -- becomes observable through `diff()` /
`trace()` (cascade-disposed nodes surface as `removedNodes`).

Built for the same shipping context the engine is built for: Twitch Extensions,
game HUDs, long-running browser sessions where a single dropped frame is a
visible defect. The devtools layer keeps that contract by living strictly on the
cold path -- its measurements do not perturb the system being measured.

## Core concepts

- **Non-perturbing inspection**: every read-side helper (`inspect`,
  `subscribers`, `dependencies`, `graph`, `toDot`, `toTree`, `report`) uses
  `peek()` for values (untracked) and walks the live graph via
  `forEachObserver` / `forEachSource`. None of them allocate a reactive link.
  Steady-state stats (`signals`, `computeds`, `effects`, `activeLinks`,
  `activeNodes`) are identical before and after a sweep. The headline test
  asserts this property over hundreds of iterations across the entire surface.
- **Handle**: anything `@zakkster/lite-signal` returns from `signal`,
  `computed`, or `effect`, plus the re-walkable node *descriptors* yielded by
  the enumerators (descriptors carry a hidden NODE_PTR slot, so they can be
  passed back into any introspection helper as if they were the original
  handle). Foreign handles (from another registry) are safe no-ops -- the
  per-registry `Symbol("node_ptr")` keys the identity slot, so a cross-registry
  call simply fails the lookup.
- **Descriptor**: `{ id, kind, value }` where `kind in { "signal", "computed",
  "effect" }`. `id` is monotonically assigned at node birth and stable for the
  node's lifetime. `value` is captured via `peek()` at the moment of
  enumeration.
- **One peer floor, two helper families** (peer `@zakkster/lite-signal >= 1.2.0`):
  - **Single-node / lifecycle**: `inspect`, `subscribers`, `dependencies`, `track`,
    `monitor`, `leakWatch`, `report` -- snapshots and live lifecycle.
  - **DAG + diff**: `graph`, `toDot`, `toTree`, `diff`, `trace` -- multi-node walks
    with convergence dedupe, plus structural diffing. Require `nodeId` / `describe`
    and re-walkable descriptors; `diff` / `trace` surface 1.2 owner-cascade disposals.
- **`track(handle, onEvent)`**: registers a lifecycle listener via
  `observeObservers`. Fires `{ type: "connect", id, ts }` on the 0->1 observer
  transition and `{ type: "disconnect", id, ts }` on 1->0. The single event
  contract under the engine's auto-pause mechanism -- same hook the time/raf
  packages use to start and stop tickers based on whether anyone is watching.
  Returns an idempotent unsubscribe.
- **`leakWatch({ sampleMs, growth, onSample })`**: samples
  `stats().activeNodes` at the requested cadence via lite-time's
  drift-corrected, boundary-aligned `every()` (not `setInterval` -- one
  wall-clock authority for the toolkit, and the sampler stays *out* of the
  reactive graph it measures; a leak detector must not instrument itself into
  the graph). Marks samples with `leakSuspected: true` when delta meets or
  exceeds `growth`. Internal ring buffer caps `samples` at the most recent 128
  records. Returns `{ stop, samples }`.
- **`graph(roots, { maxNodes })`**: breadth-first walk in both directions
  (observers and sources) from the supplied roots, using a cursor-walk queue
  (integer head index, O(1) dequeue -- `queue.shift()` would be O(N) per step
  and degrade to O(N^2) on large graphs). Nodes are deduped by id; edges are
  deduped via a `from > to` key set and directed source -> observer. Convergent
  diamonds are folded automatically. `maxNodes` caps the visited-set size as a
  between-iteration ceiling (during a single expansion all neighbours of the
  current node enqueue and are noted, so the final node count may slightly
  exceed the cap; edges remain consistent with whatever nodes are present).
- **`toDot(g, { name, maxLabel })`**: renders a graph to Graphviz DOT. Signals
  render as ellipses, computeds as boxes, effects as diamonds. Labels are
  truncated to `maxLabel` chars and escaped for `"`, `\`, and newlines. Paste
  into any DOT viewer or pipe through `dot -Tpng`.
- **`toTree(root, { direction, maxDepth })`**: indented console tree from a
  single root. `direction: "down"` follows observers (who depends on this);
  `direction: "up"` follows sources (what this depends on). Already-visited
  nodes are rendered with a a `(seen)` marker rather than re-expanded -- the
  reactive graph is a DAG, not a tree, and re-convergence at a diamond would
  otherwise duplicate entire subtrees.

## Architecture invariants

- **No own state**: lite-devtools holds no module-level mutable state of its
  own. Every call is self-contained. Snapshots are plain objects; the live
  feeds (`track`, `leakWatch`) return explicit unsubscribers. Multiple
  instances and multiple registries coexist cleanly.
- **Non-perturbation is a contract, not a guideline**: the headline test
  (`test/07-non-perturbing.test.mjs`) exercises every read-side helper
  hundreds of times and asserts `signals` / `computeds` / `effects` /
  `activeLinks` / `activeNodes` are byte-identical before and after the sweep.
  Any change that breaks this property is a publish-blocker.
- **The only thing that touches the graph is `track`** -- and even that touches
  the *lifecycle* surface, not the dependency graph. `observeObservers`
  registers a hook outside the dep/observer link pool, so it does not allocate
  a `ReactiveLink` and does not appear in `subscribers()` walks.
- **`inspect()` on a stale computed will pull**: the contract is "no observer
  added", not "no compute". `peek()` is untracked, but it does refresh the
  computed's cache if `evalVersion` is behind. This is the engine's design --
  reading a value, even untracked, must return a coherent value. Documented
  here so that nobody is surprised when a leak-watch sample triggers a stale
  computed's body.
- **Cursor-walk queue, not `queue.shift()`**: the BFS in `graph()` advances an
  integer head over a flat array. `queue.shift()` would reindex the entire
  tail on every dequeue and is O(N) per step -> O(N^2) on large graphs.
- **Re-walkable descriptors**: in lite-signal v1.1.5 the descriptors yielded
  by `forEachObserver` / `forEachSource` carry the underlying NODE_PTR via
  `Object.defineProperty` (non-enumerable). This is what lets `graph()` enqueue
  a descriptor and pass it back into the same enumerators on the next
  iteration -- the BFS recurses on descriptors directly, no second lookup.
- **Effect dispose handles are opaque**: the function returned by `effect(fn)`
  is a dispose handle, not a node API -- it intentionally lacks the NODE_PTR
  slot, so `inspect(disposeFn)` / `dependencies(disposeFn)` return an empty
  neighbourhood. To inspect an effect's deps, obtain the effect *descriptor*
  from a `subscribers()` walk on one of its sources and pass *that* in. This is
  by design: an effect's identity within the dep graph is the descriptor
  reachable from its sources, not the user-facing teardown closure.

## Performance characteristics

- All helpers are cold-path. Use freely in dev overlays, periodic snapshots,
  PR-diff inspection, post-mortem dumps. Do not call inside an effect body, a
  computed body, or a render loop -- not because they would be slow, but
  because allocating debug objects in the hot path defeats the engine's
  zero-GC contract for the rest of the frame.
- **`inspect(handle)`** -- allocates one snapshot object plus one array per
  direction (observers, sources). O(observers + sources).
- **`subscribers` / `dependencies`** -- allocate one array. O(observers) /
  O(sources).
- **`track(handle, fn)`** -- registers via `observeObservers`. Per the engine's
  lifecycle-counter design, zero steady-state cost on the handle until someone
  observes it. The first connect and last disconnect are the only events that
  cross the bridge.
- **`monitor()`** -- direct delegate to `stats()`. O(1), no allocations beyond
  the returned plain object.
- **`leakWatch`** -- one `every()` registration. Per sample: one snapshot
  object, one possible array shift when the 128-sample ring fills.
- **`graph(roots, { maxNodes })`** -- O(nodes + edges). Two Maps and one Set for
  dedupe; one array as the BFS queue (cursor-walk, no reindexing).
- **`toDot` / `toTree`** -- O(nodes + edges); pure string assembly.

## When to use

- Building a dev overlay for a signal-driven game HUD, scoreboard, or telemetry
  panel -- wire `track` to a log strip, `monitor` to a stats badge, and
  `graph` -> `toDot` to a "dump" button.
- Diagnosing a suspected leak in a mount/unmount-heavy SPA -- `leakWatch` with
  `growth: 32` and `sampleMs: 1000` catches scopes that forgot to dispose.
- Visualising a complex reactive topology before code review -- `toDot` ->
  `dot -Tpng` produces a stable, reviewable artefact.
- Writing QA scenarios that assert glitch-freeness, lazy deferral, untrack
  semantics, batch coalescing, dispose cleanup, deep-chain integrity, or cycle
  protection -- `monitor` + `inspect` give the engine-level signals needed for
  PASS/FAIL panels.
- Driving an interactive demo (see `demo/index.html`) that updates a live SVG
  DAG every time the user mutates a signal.

## When NOT to use

- Inside a hot render loop or per-frame body -- these are debug allocations.
  The engine itself runs zero-GC; the devtools layer does not, and is not
  designed to.
- As a substitute for unit tests -- `inspect()` snapshots are diagnostic, not
  contracts. Assert against engine `stats()` and your own observable behaviour.
- For production telemetry over the wire -- the snapshots embed `value` fields
  which may contain user data. Strip / redact before exfiltrating.
- For time-travel debugging -- these are point-in-time snapshots; there is no
  history. Build a snapshot recorder on top if you need it.

## API summary

```ts
import type { Signal, Computed, RegistryStats } from "@zakkster/lite-signal";

// Anything the engine returns, plus the descriptors yielded by enumerators.
type Handle = Signal<any> | Computed<any> | (() => void) | Descriptor;

interface Descriptor {
  id: number;                              // stable for node lifetime
  kind: "signal" | "computed" | "effect";
  value: unknown;                          // peek'd at enumeration time
}

interface InspectSnapshot {
  id: number | undefined;
  kind: "signal" | "computed" | "effect" | undefined;
  stale: boolean;                          // (1.1) handle looks live but engine refuses to resolve it
                                            //       (slot recycled, owner-cascade re-run, or explicit dispose)
                                            //       requires lite-signal >= 1.2.1's gen-guarded describe()
  observed: boolean;                       // hasObservers(handle)
  value: unknown;                          // peek'd safely (try/catch -> undefined on throw)
  observerCount: number;
  sourceCount: number;
  observers: Descriptor[];
  sources: Descriptor[];
}

interface LifecycleEvent {
  type: "connect" | "disconnect" | "dispose";  // "dispose" (1.1, requires graph-mutation hook)
  id: number;
  observed: boolean;
  ts: number;                              // performance.now() if available, else Date.now()
}

interface LeakSample {
  ts: number;
  activeNodes: number;
  delta: number;                           // change since previous sample
  leakSuspected: boolean;                  // delta >= options.growth
}

interface LeakWatchOptions {
  sampleMs?: number;                       // default 1000
  growth?: number;                         // default 32
  onSample?: (s: LeakSample) => void;
}

interface ReactiveGraph {
  nodes: Descriptor[];                     // deduped by id
  edges: ReactiveEdge[];                   // directed source -> observer
}

interface ReactiveEdge {                   // (1.1) edges may carry a "kind" discriminator
  from: number;
  to: number;
  kind?: "owner";                          // present on owner edges from graph({owners: true})
}

interface GraphDiff {                       // diff() result (1.1)
  addedNodes: Descriptor[];
  removedNodes: Descriptor[];              // includes 1.2 owner-cascade disposals
  changedNodes: { id: number; kind: string; from: unknown; to: unknown }[];
  addedEdges: ReactiveEdge[];
  removedEdges: ReactiveEdge[];
}
interface TraceResult { before: ReactiveGraph; after: ReactiveGraph; diff: GraphDiff; }

interface GraphOptions {                   // (1.1) `owners` added
  maxNodes?: number;                       // default 10000
  owners?: boolean;                        // default false; lite-signal >= 1.3 only
}
interface ToDotOptions   { name?: string; maxLabel?: number; }  // default "reactive" / 24
interface ToTreeOptions  { direction?: "down" | "up"; maxDepth?: number; }
                                                          // default "down" / Infinity

interface Report {
  stats: RegistryStats;
  nodes: InspectSnapshot[];
}

interface Capabilities {                   // (1.1)
  floor: string;                           // ESM-load floor ("1.1.5")
  owners: boolean;                         // engine has forEachOwned / ownerOf
  mutationHook: boolean;                   // engine has onGraphMutation
}

interface OwnerNode {                      // (1.1) ownerTree() return shape
  id: number; kind: "signal" | "computed" | "effect"; value: unknown;
  owned: OwnerNode[];
}

interface WatchGraphPayload {              // (1.1) watchGraph() callback argument
  graph: ReactiveGraph;
  diff: GraphDiff | null;                  // null on the immediate seed fire
  mutations: number;                       // coalesced count (>=1 in push, 0 in poll)
  mode: "push" | "poll";
}

interface SerializedGraph {                // (1.1) deserialize() return shape
  v: number;                               // schema version (1)
  ts: number;                              // wall-clock at serialize() time
  nodes: { id: number; kind: string; value: unknown }[];
                                            // values sanitized: "[object]" / "[function]" /
                                            //   bigint -> "<n>n" string / primitives verbatim
  edges: ReactiveEdge[];
}

// Baseline introspection (lite-signal >= 1.1.5; peer floor is >= 1.2.0)
function capabilities(): Capabilities;                                                       // (1.1)
function inspect(handle: Handle): InspectSnapshot;                                            // 1.1: +stale
function subscribers(handle: Handle): Descriptor[];
function dependencies(handle: Handle): Descriptor[];
function track(handle: Handle, onEvent: (e: LifecycleEvent) => void): () => void;             // 1.1: +dispose event
function monitor(): RegistryStats;
function leakWatch(opts?: LeakWatchOptions): { stop: () => void; samples: LeakSample[] };
function report(handles: Handle[]): Report;

// DAG + diff (peer floor is lite-signal >= 1.2.0)
function graph(roots: Handle | Handle[], opts?: GraphOptions): ReactiveGraph;                 // 1.1: +owners option
function toDot(g: ReactiveGraph, opts?: ToDotOptions): string;
function toTree(root: Handle, opts?: ToTreeOptions): string;
function diff(before: ReactiveGraph, after: ReactiveGraph): GraphDiff;                        // 1.1
function trace(roots: Handle | Handle[], fn: () => void, opts?: GraphOptions): TraceResult;   // 1.1
function findPath(                                                                            // 1.1
    from: Handle, to: Handle,
    opts?: { direction?: "down" | "up"; maxNodes?: number },
): Descriptor[] | null;

// Owner-tree walk (lite-signal >= 1.3; returns null otherwise)
function ownerTree(root: Handle, opts?: { maxDepth?: number; maxNodes?: number }): OwnerNode | null;  // 1.1

// Push observation + profiling (lite-signal >= 1.2.1; degrade gracefully otherwise)
function watchGraph(                                                                          // 1.1
    roots: Handle | Handle[],
    cb: (p: WatchGraphPayload) => void,
    opts?: GraphOptions & { pollMs?: number; immediate?: boolean },
): { stop: () => void; mode: "push" | "poll" };
function profile(opts?: { onSample?: (id: number, count: number) => void }):                  // 1.1
    | { stop: () => Map<number, number>;
        counts: Map<number, number>;
        top: (n?: number) => { id: number; count: number }[] }
    | null;

// Snapshot serialization
function serialize(g: ReactiveGraph): string;                                                  // 1.1
function deserialize(json: string): SerializedGraph;                                           // 1.1
```

## Common idioms

```js
// Live dev overlay: stats badge + lifecycle log + DAG dump button.
const stop = setInterval(() => {
  const m = monitor();
  badge.textContent = `${m.signals}s ${m.computeds}c ${m.effects}e`;
}, 250);
track(rootSignal, e => logEl.append(`${e.type} #${e.id}\n`));
dumpBtn.onclick = () => navigator.clipboard.writeText(toDot(graph(rootSignal)));
```

```js
// Leak detection in dev. If the dashboard sits at ~300 active nodes in steady
// state and grows by >32 per second, something is keeping a scope alive.
const watcher = leakWatch({
  sampleMs: 1000,
  growth: 32,
  onSample: s => { if (s.leakSuspected) console.warn("[leak?]", s); }
});
// later: watcher.stop();
```

```js
// Auto-pausing clock. lite-time tickers already use observeObservers to
// auto-pause when nothing is watching; track() is the inverse -- instrumenting
// from outside to log when the engine actually engages.
const clock = signal(0);
track(clock, e => console.log("clock observed?", e.observed));
const display = computed(() => `tick: ${clock()}`);
display.subscribe(v => render(v));   // -> connect id=clock.id  observed=true
// later, dispose(display) -> disconnect id=clock.id  observed=false
```

```js
// DAG -> PNG via dot. Useful for code review and post-mortems.
require("fs").writeFileSync("dag.dot", toDot(graph(rootSignal)));
// $ dot -Tpng dag.dot -o dag.png
```

```js
// QA harness: assert glitch-freeness of a diamond by counting effect runs.
const a = signal(1);
const b = computed(() => a() + 1);
const c = computed(() => a() * 2);
const d = computed(() => b() + c());
let runs = 0;
effect(() => { d(); runs++; });        // initial -> 1
a.set(2);                              // -> 2  (exactly once, not twice)
console.assert(runs === 2, "diamond glitch detected");
console.log(toTree(a));                // visualises the convergence
```

## File layout

- `Devtools.js`        -- full implementation, single source file.
- `Devtools.d.ts`      -- TypeScript declarations for the entire public surface.
- `README.md`          -- long-form prose with API reference, workflows, FAQ.
- `llms.txt`           -- this file.
- `test/01-inspect.test.mjs`       -- single-node snapshot, value peeking,
                                     observer/source enumeration.
- `test/02-track.test.mjs`         -- lifecycle event shape, 0<->1 transitions,
                                     unsubscribe idempotency.
- `test/03-monitor-report.test.mjs`-- `monitor()` delegation, `report()` shape,
                                     stat consistency.
- `test/04-leak-watch.test.mjs`    -- sampler cadence, delta arithmetic,
                                     `leakSuspected` threshold, ring buffer
                                     cap, stop semantics.
- `test/05-graph.test.mjs`         -- BFS in both directions, diamond dedupe,
                                     edge consistency, `maxNodes` cap.
- `test/06-render.test.mjs`        -- `toDot` shape and escaping, `toTree`
                                     up/down direction, recycle-glyph on
                                     re-convergence.
- `test/07-non-perturbing.test.mjs`-- the headline contract: stats are
                                     byte-identical before and after a full
                                     read-side sweep over hundreds of
                                     iterations.
- `test/08-edge-cases.test.mjs`    -- cross-registry no-ops, disposed handles,
                                     descriptor-as-handle, `track` on
                                     non-handle throws, churn baseline.
- `demo/index.html`                -- single-file interactive QA demo: live SVG
                                     DAG, pool-occupancy strip, lifecycle log,
                                     inspect panel, and a bug-tracking scenario
                                     bank with PASS/FAIL indicators driven by
                                     `monitor()` / `inspect()`.

## Install

```bash
npm install @zakkster/lite-devtools
# peers (you almost certainly already have lite-signal):
npm install @zakkster/lite-signal @zakkster/lite-time
```

## License

MIT (c) Zahary Shinikchiev
