@zakkster/lite-signal-decorators 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,193 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
4
4
  The format follows Keep a Changelog; this project adheres to Semantic
5
5
  Versioning.
6
6
 
7
+ ## [1.3.0] - 2026-08-30
8
+
9
+ The introspection/migration rung of the decisions/0013 strategic-admission
10
+ ladder, and the release that proves criterion (c): `snapshotOf` ships as the
11
+ MobX-`toJS`-parity export nobody could reach cleanly by composition, and it is
12
+ the REAL, NAMED in-package consumer that finally admits `forEachReactive` under
13
+ the ORIGINAL 0009 bar (a new export needs a named consumer, and a recipe is not
14
+ one). decisions/0009 recorded both as candidates 2 and 3 with the absence
15
+ stated plainly; this release admits them together -- `forEachReactive` because
16
+ `snapshotOf` is built ON it, `snapshotOf` because it is the MobX-`toJS` parity a
17
+ recipe cannot honestly serve. The recorded absence in 0009 is preserved as
18
+ pre-admission history, not rewritten. Surface 19 -> 21.
19
+
20
+ ### Added
21
+
22
+ - **`forEachReactive(vm, fn, arg) -> count`** (20th export) -- a cold walk over
23
+ every value-bearing member. Calls `fn(key, box, kind, arg)` once per member
24
+ and returns the visit count. `kind` is `"signal" | "local" | "derived"`;
25
+ `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing -- `boxOf`
26
+ refuses them). Order is PLAN order: signals, then locals, then deriveds, each
27
+ declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is
28
+ stable across reinit). Four scalar args, no descriptor object, and the `arg`
29
+ pass-through kills the caller's closure -- a gated zero-alloc walk. Symbol
30
+ keys are visited. Fails closed on non-reactive/unwired/parked/disposed with the
31
+ same named errors as `rootOf`.
32
+ - **`snapshotOf(vm) -> object`** (21st export) -- a shallow plain-object copy of
33
+ every value-bearing member, keyed by member key, built ON `forEachReactive`.
34
+ Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo`
35
+ compare-on-read resets honestly and a `@derived` computes on read (PD-62). The
36
+ whole walk runs under ONE `untrack` when the caller is tracking, so a
37
+ `snapshotOf` inside an effect subscribes to nothing (PD-63). SHALLOW by design
38
+ (PD-64): a nested VM is copied by reference, recursion deferred to a named
39
+ consumer. Symbol keys included (`Reflect.ownKeys` law). Fails closed on
40
+ parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and
41
+ non-reactive values. It ALLOCATES the returned object by design -- reported,
42
+ never gated; the walk under it stays zero-alloc.
43
+ - **`test/18-introspection.test.mjs`** (22 cases) -- both emit lanes + buildless:
44
+ plan-order walk, symbol keys, kind tags, effect/batched exclusion, count
45
+ return + `arg` pass-through, the untracked-read law (a `snapshotOf` inside an
46
+ effect fires ONCE, then never as every member is written), r7
47
+ `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the
48
+ fail-closed non-reactive/unwired/parked/disposed matrix.
49
+ - **The `introspection-torture` lane** (scenario 18 of 18) with its own
50
+ `TORTURE_BREAK` sabotage control: 1e6 hoisted-callback `forEachReactive` walks
51
+ at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles
52
+ whose bytes/op are REPORTED in the summary line, never gated -- the snapshot
53
+ allocates by design and the harness says so out loud.
54
+
55
+ ### Changed
56
+
57
+ - **The export surface: 19 -> 21** -- an additive MINOR under the 1.0.0 semver
58
+ promise (new exports are minors). The 1.0.0 hot canon
59
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: both new exports
60
+ are cold and neither moves an accessor byte.
61
+ - Three-place version sync to 1.3.0 (`package.json`, the `VERSION` const,
62
+ `llms.txt`); the `test/15` surface-freeze recount 19 -> 21.
63
+ - `throwNoBox` message widened to name `@localTo`: `boxOf` serves `@reactive`,
64
+ `@localTo`, and `@derived` members only (locals pass `boxOf`; the message had
65
+ listed only `@reactive`/`@derived`).
66
+
67
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
68
+
69
+ - 1e6 hoisted-callback `forEachReactive` walks: **0.002 B/walk** (vs the 0.000
70
+ B/op zero-alloc control, within a +2-byte limit), gc major **0**,
71
+ `maxPauseMs <= 4.0`.
72
+ - 1e5 `snapshotOf` cycles: **95.8 B/op** -- the returned object, reported as
73
+ "allocates by design", never gated.
74
+ - 1e5 construct -> snapshot -> dispose cycles: `tracker.size()` 0, findings 0,
75
+ warnings 0, `activeNodes`/`nodes` back to exact pre-loop baseline; snapshots
76
+ hold no box reference.
77
+
78
+ Records: decisions/0013 (strategic-admission track, criterion (c)),
79
+ decisions/0009 (candidates 2 + 3, now stamped ADMITTED with the pre-admission
80
+ absence preserved).
81
+
82
+ ### Gate output (section-10 chain, archived verbatim)
83
+
84
+ ```
85
+ fixtures OK exit 0 -- emit fixtures regenerated
86
+ test OK exit 0 -- 313 pass / 0 fail
87
+ test:gc OK exit 0 -- 313 pass / 0 fail
88
+ torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.9s
89
+ torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.7s
90
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0)
91
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
92
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok; controls 8/8 fail correctly
93
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
94
+ ----------------------------------------------------------------------
95
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
96
+ ```
97
+
98
+ ## [1.2.0] - 2026-08-30
99
+
100
+ The flagship of the decisions/0013 strategic-admission track: a story-grade,
101
+ release-after-release ladder (S8 `@localTo` -> S9 `snapshotOf` -> S10
102
+ `costOfInstance` -> S11 fleet helpers), and this is its first rung. `@localTo`
103
+ clears strategic criterion (a) -- impossible to compose correctly on the shipped
104
+ surface: PD-51 measured the effect-based recipe clobbering a user write one tick
105
+ late, so glitch-free reset needs in-package compare-on-read machinery. The one
106
+ release nobody else can copy from a recipe.
107
+
108
+ ### Added
109
+
110
+ - **`@localTo(source, { equals? })`** -- upstream-keyed resettable local state
111
+ (19th export; the 18-member surface grows by exactly one). A field FOLLOWS
112
+ `source(self)` until someone writes it, a write OVERRIDES, and a changed
113
+ upstream RESETS it -- decided by compare-on-read, so it is glitch-free,
114
+ synchronous, and PURE (no box write on the read path; legal inside any
115
+ `@derived`). `source` is a REQUIRED tracked `(self) => value` fn read inline
116
+ (no extra node). `equals` (default `Object.is`) governs the upstream compare
117
+ ONLY -- the write path never compares.
118
+ - **The `locals` buildless section** -- `defineReactive(Class, { ..., locals })`
119
+ with `locals: { key: { source, equals?, initial? } }`, the buildless twin of
120
+ `@localTo` (fail closed on a missing/non-fn source), full parity with the
121
+ decorator path on both emit lanes.
122
+ - **The initial-value unification rule** (decisions/0014): a declared
123
+ initializer means the member STARTS there and resets on the first upstream
124
+ move (the `@trackedReset` flavor); an OMITTED initializer means the initial is
125
+ the source evaluated once at wiring and the member follows upstream from the
126
+ first read (the `@localCopy` flavor). One decorator, both field semantics,
127
+ selected by the natural syntax.
128
+ - **The ABA contract, stated plainly** (decisions/0014, shipped + asserted): no
129
+ public revision counter exists (NodeDescriptor is `{id, kind, value}`), so the
130
+ upstream compare is VALUE-based. Upstream A -> local write X -> upstream B ->
131
+ upstream back to an equals-A value leaves the read showing the STALE LOCAL X --
132
+ the reset requires upstream to change relative to the last adoption, not to
133
+ have moved transitively. tracked-toolbox's `@localCopy` has the same property;
134
+ it is documented in README/llms.txt, pinned in torture (S8-A6), never softened.
135
+ - **The `localto-torture` lane** (`test/torture/localto-torture.mjs`, scenario
136
+ 13 of 17) with its own `TORTURE_BREAK` sabotage control: the zero-alloc
137
+ read/write storm, the ABA-stale write/reset interleave asserted AS the
138
+ contract, pooled park/reinit box+seen reset, and tracking-edge + pure-compute
139
+ pins.
140
+ - **`test/17-localto.test.mjs`** (34 cases) -- the full lattice on both emit
141
+ lanes plus buildless `locals`: read/write/reset, both initial flavors, the ABA
142
+ contract, `equals` override survival, park/reinit reset, `costOf` accounting,
143
+ source-throw fail-closed, and the option/source rejection matrix.
144
+ - Emit fixtures extended: `fixture.src.ts` gains a `@localTo` member; the two
145
+ compiled outs + the source hash regenerate (no new fixture row or file).
146
+
147
+ ### Changed
148
+
149
+ - **The per-instance cost formula is now `P + L + D + E + 1`** (was `P + D + E +
150
+ 1`): one signal box per local plus the anchor; the plain per-instance seen-slot
151
+ is never a node (+0). `costOf` returns the `L` term; `capacityFor` sizes it.
152
+ Reflected everywhere the formula appears in README/llms.txt.
153
+ - The shipped `SignalDecorators.d.ts` header de-staged to emitter-named
154
+ phrasing (PD-49) -- a 1.1.1 zero-grep escapee (the sweep listed the `.js`
155
+ but not the `.d.ts`), caught by the S8 review.
156
+ - **The export surface: 18 -> 19** -- an additive MINOR under the 1.0.0 semver
157
+ promise (new exports are minors). The 1.0.0 hot canon
158
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `@localTo` ships
159
+ its own accessor bodies and pays its own measured cost.
160
+ - Three-place version sync to 1.2.0 (`package.json`, the `VERSION` const,
161
+ `llms.txt`); the `test/15` surface-freeze recount 18 -> 19.
162
+
163
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
164
+
165
+ - A `@localTo` read measures **1.69x** a plain decorated read (two tracked reads
166
+ + a compare, versus one box read) -- documented as-is, not softened.
167
+ - Read AND write storms at N and 8N: **0.000 B/op** (control-relative +2 B), gc
168
+ major **0**, `maxPauseMs <= 0.08`; a derived over one local holds EXACTLY 2
169
+ source edges (upstream + box), 0 extra nodes over 1e5 reads.
170
+ - 4096 park/reinit cycles: `tracker.size()` 0, findings 0, warnings 0,
171
+ `activeNodes` to exact baseline, zero pool growths; release frees exactly
172
+ `P + L + D + E + 1` nodes.
173
+
174
+ Records: decisions/0013 (strategic-admission track), decisions/0014 (the localTo
175
+ contract + the ratified spike numbers), `spikes/localto-contract.mjs` (Q1..Q6,
176
+ EXIT A green against peer 1.5.0).
177
+
178
+ ### Gate output (section-10 chain, archived verbatim)
179
+
180
+ ```
181
+ fixtures OK exit 0 -- emit fixtures regenerated
182
+ test OK exit 0 -- 291 pass / 0 fail
183
+ test:gc OK exit 0 -- 291 pass / 0 fail
184
+ torture OK exit 0 -- 15 passed, 2 skipped, 0 warned, 0 failed in 33.5s
185
+ torture:controls OK exit 0 -- 17 passed, 0 skipped, 0 warned, 0 failed in 2.8s
186
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 17/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 17/0/0/0]
187
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
188
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
189
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
190
+ ----------------------------------------------------------------------
191
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
192
+ ```
193
+
7
194
  ## [1.1.1] - 2026-08-30
8
195
 
9
196
  A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  > view-model with a measured per-property cost, one deterministic teardown, and
16
16
  > poison-on-dispose safety -- built on @zakkster/lite-signal.
17
17
 
18
- **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
18
+ **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + L + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
19
19
 
20
20
  ```js
21
21
  import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
@@ -100,7 +100,7 @@ class Player {
100
100
  }
101
101
  }
102
102
 
103
- const p = new Player(); // exactly 2 + 2 + 1 + 1 = 6 pool nodes (P + D + E + 1)
103
+ const p = new Player(); // exactly 2 + 0 + 2 + 1 + 1 = 6 pool nodes (P + L + D + E + 1)
104
104
  p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
105
105
  p.hit(20); // hp 50 -> "critical": effect runs once
106
106
  disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
@@ -138,6 +138,7 @@ const ReactivePlayer = defineReactive(Player, {
138
138
 
139
139
  - **`@reactive accessor x = v`** -- a per-instance signal box in a unique symbol slot. The read body is one slot load + one monomorphic box call: zero branches, zero allocation.
140
140
  - **`@derived get y()`** -- a lazy computed owned by the instance's anchor, with optional custom `equals` for change-cutoff.
141
+ - **`@localTo(source) accessor x = v`** -- upstream-keyed resettable local state: reads follow `source(self)` until you write, a write overrides, and a changed upstream resets it -- glitch-free by compare-on-read, no effect, no extra tick. With an initializer the field starts there and resets on the first upstream move; without one it follows upstream from wiring. One signal box + one plain seen-slot per member; the read is pure, so it is legal inside any `@derived`.
141
142
  - **`@reactiveEffect m()`** -- a method that auto-runs as an effect after wiring. Manual calls are leak-guarded (a call inside a foreign tracking scope is untracked, so it records zero stray dependencies) and identity-guarded (a foreign receiver throws by name instead of running against garbage).
142
143
  - **`@batched m()`** -- the method body inside one engine batch: N writes, one flush. Action-grade by design, with a measured per-call cost -- not a per-frame path.
143
144
  - **`@reactiveHost`** -- the single wiring site. Its most-derived constructor builds the anchor, every derived, and every effect exactly once, after all fields of all classes in the chain initialize. `@reactiveHost({ registry })` binds the whole chain to an isolated lite-signal registry.
@@ -145,6 +146,7 @@ const ReactivePlayer = defineReactive(Player, {
145
146
  - **One deterministic teardown** -- `disposeReactive(vm)` (or a `using` block): anchor cascade, box disposal, poison swap, idempotent, allocation-free on the success path.
146
147
  - **Fail-closed everything** -- statics, private `#` members, unknown options, duplicate keys, orphaned members, invalid registries, half-valid specs: all named throws at decoration time, with a nearest-key did-you-mean where a typo is likely.
147
148
  - **Interop that stays raw** -- `boxOf(vm, key)` hands you the live engine box; `rootOf(vm)` hands the anchor descriptor to `forEachOwned` / lite-devtools. Decorated and hand-written signals share one graph.
149
+ - **Introspection & migration (1.3.0)** -- `forEachReactive(vm, fn, arg)` walks every value-bearing member in plan order (`signal`/`local`/`derived`, effects excluded) with a zero-alloc `fn(key, box, kind, arg)` callback; `snapshotOf(vm)` returns a shallow plain-object copy read through the accessors under one `untrack` -- the native `toJS` this package now ships, safe to call inside an effect.
148
150
 
149
151
  ---
150
152
 
@@ -205,7 +207,7 @@ The failure mode this package is built against is not "reactivity doesn't work"
205
207
 
206
208
  ### Member decorators
207
209
 
208
- All four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work).
210
+ The first four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work); `@localTo` is the exception -- it always takes a required `source` argument (detailed below the table).
209
211
 
210
212
  | Decorator | Placement | Options | Behavior |
211
213
  |---|---|---|---|
@@ -213,6 +215,24 @@ All four take a bare form and a factory form (`@reactive` and `@reactive({...})`
213
215
  | `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
214
216
  | `@reactiveEffect` | `m()` | `scheduler(run)` | Auto-runs as an effect at wiring, re-runs on tracked changes. `scheduler` defers re-runs (frame coalescing etc.). Manual calls: leak-guarded + identity-guarded. |
215
217
  | `@batched` | `m()` | -- | Runs the body inside one engine batch: all writes flush once, at close. Nesting flushes at the outermost close. Action-grade -- see [the numbers](#the-numbers). |
218
+ | `@localTo` | `accessor x = v` | `equals(a, b)` | Upstream-keyed resettable local state. Read follows `source(self)` until written, a write overrides, a changed upstream resets. Compare-on-read (pure); `equals` governs the upstream compare only. Takes a REQUIRED `source` argument -- see below. |
219
+
220
+ ### `localTo(source, { equals? }?)`
221
+
222
+ `@localTo(source)` declares a field that **follows an upstream value until someone writes it, then resets when upstream changes** -- the "local copy you can edit, that re-syncs on a real update" pattern, done glitch-free with no effect and no extra tick. `source` is a REQUIRED tracked `(self) => value` function, read inline on every get (no extra node). The get is **pure** -- it compares `source(self)` to a per-instance last-seen slot and returns the local box when upstream is unchanged, else the upstream value; it never writes a box, so a `@localTo` read is legal inside any `@derived`. A write always overrides (the write path never compares). `{ equals }` (default `Object.is`) governs the **upstream** compare only.
223
+
224
+ Two field flavors, selected by the natural syntax (the [initial-value unification rule](decisions/0014-localto-contract.md)):
225
+
226
+ ```js
227
+ @reactiveHost
228
+ class Field {
229
+ @reactive accessor upstream = "server";
230
+ @localTo((self) => self.upstream) accessor draft; // no initial: FOLLOWS upstream from wiring
231
+ @localTo((self) => self.upstream) accessor pinned = ""; // initial: STARTS "", resets on first upstream move
232
+ }
233
+ ```
234
+
235
+ **The ABA contract (honest, shipped, never softened).** The upstream compare is VALUE-based -- lite-signal exposes no public revision counter, and reaching for a private one would be impure. So the reset triggers when upstream *changes relative to the last adoption*, not when it has moved transitively: upstream `A` -> local write `X` -> upstream `B` -> upstream back to an equals-`A` value leaves the read showing the **stale local `X`**. tracked-toolbox's `@localCopy` has the same property. A coarse custom `equals` widens override survival on purpose. See the [compare-on-read design bullet](#design-decisions-worth-knowing).
216
236
 
217
237
  ### Class decorator
218
238
 
@@ -229,6 +249,7 @@ The buildless twin. Installs the members on `Class.prototype`, wraps the class t
229
249
  |---|---|---|
230
250
  | `signals` | `["a", "b"]` or `{ key: value \| { initial \| init \| equals } }` | A plain non-function value is the initial. `initial` is taken verbatim; `init(self)` computes per instance; a bare function is a named throw (ambiguous -- wrap it). |
231
251
  | `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
252
+ | `locals` | `{ key: { source, equals?, initial? } }` | Map only. The buildless twin of `@localTo`. `source` REQUIRED and a `(self) => value` fn (missing/non-fn is a named throw); `equals` governs the upstream compare; `initial` (verbatim) selects the reset-from flavor, its absence the follow-from-wiring flavor. |
232
253
  | `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
233
254
  | `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
234
255
 
@@ -248,19 +269,26 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
248
269
 
249
270
  | Export | Signature | Behavior |
250
271
  |---|---|---|
251
- | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + D + E + 1; `links` is the first-full-read link count. |
272
+ | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + L + D + E + 1; `links` is the first-full-read link count. |
252
273
  | `capacityFor` | `(inventory, { headroom }?) => RegistryConfig` | Sizes a `createRegistry` config from `[Factory, count]` pairs: nodes exact, links x `headroom` (floored at the engine minimum of 1), `prealloc: "eager"`, `onCapacityExceeded: "throw"`. Fail-closed inventory and options validation. Link policy + caveats: [decisions/0007](decisions/0007-capacity-policy.md). |
253
274
  | `enableLabels` / `labelOf` | `(on)` / `(idOrHandle, registry?) => string \| undefined` | Opt-in devtools identity (default OFF): while on, wiring registers per-registry `nodeId -> "Class.prop"` / `"Class#method"` / `"Class@anchor"`; dispose unregisters. `labelOf` misses return `undefined`, never throw. |
254
275
  | `auditReactive` | `(on)` | Opt-in leak auditor (default OFF): a lazily-created `FinalizationRegistry` reports any instance collected WITHOUT `disposeReactive`, naming class and shape. Holds no instance references itself; zero cost and zero registrations while off. |
255
276
 
256
277
  With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- the hot accessor canon is untouched by all four (review-diffed against the published 0.3.0 tarball).
257
278
 
279
+ ### Introspection walk & snapshot (1.3.0)
280
+
281
+ | Export | Signature | Behavior |
282
+ |---|---|---|
283
+ | `forEachReactive` | `(vm, fn, arg) => count` | Cold value-member walk. Calls `fn(key, box, kind, arg)` once per value-bearing member and returns the visit count. `kind` is `"signal" \| "local" \| "derived"`; `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing). Order is PLAN order -- signals, then locals, then deriveds, each declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is stable across reinit). Four scalar args, zero descriptor object, and the `arg` pass-through kills the caller's closure: the walk is a gated zero-alloc body. Symbol keys are visited. Fails closed on a non-reactive, unwired, parked, or disposed value with the same named errors as `rootOf`. |
284
+ | `snapshotOf` | `(vm) => object` | A shallow plain-object copy of every value-bearing member, keyed by member key. Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo` compare-on-read resets honestly and a `@derived` computes on read (PD-62: reading the box directly would show a stale local after an untracked upstream move -- the accessor is the documented read). The whole walk runs under ONE `untrack` when the caller is tracking, so `snapshotOf` inside an effect subscribes to nothing. SHALLOW by design: a nested VM is copied by reference, not recursed. Symbol keys included. Fails closed on parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and non-reactive values. This export ALLOCATES the returned object by design (~96 B/op measured) -- reported, never gated; the walk under it stays zero-alloc. |
285
+
258
286
  ### Errors & constants
259
287
 
260
288
  | Export | Value |
261
289
  |---|---|
262
290
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
263
- | `VERSION` | `"1.1.1"` |
291
+ | `VERSION` | `"1.3.0"` |
264
292
 
265
293
  ### The rejection matrix
266
294
 
@@ -349,6 +377,8 @@ The fair baseline for a decorated property is a hand-written instance field (`th
349
377
 
350
378
  A decorated reactive property costs **~1.0x a hand-written instance-field signal read**; both are ~2x a module-level signal because that is the cost of per-instance storage, paid either way -- an engine indirection, not a decorator tax. Writes are ~2.5-3x a module-const read across all instance layouts (box `.set` propagation dominates; inherent to any reactive write). The rejected dictionary layout is the one that *degrades at fleet scale* (cross-instance IC megamorphism) -- the hazard only a class-shaped benchmark exposes, and the reason this package doesn't use one.
351
379
 
380
+ A `@localTo` read measures **1.69x** a plain decorated read -- two tracked reads (upstream `source` + the local box) plus a value compare, versus the one box read of `@reactive` -- and it pays that cost with **0.000 B/op** under the read/write storms (`gc.major 0`, observed `maxPauseMs` 0.07-0.08 against the 4.0 gate); the compare-on-read is honest arithmetic, not free.
381
+
352
382
  ### `@batched` per call (`spikes/batched-cost.mjs`)
353
383
 
354
384
  | Path | ns/op |
@@ -361,7 +391,7 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
361
391
 
362
392
  ### Per instance
363
393
 
364
- `P + D + E + 1` pool nodes -- one per signal, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
394
+ `P + L + D + E + 1` pool nodes -- one per signal, per local, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
365
395
 
366
396
  <details>
367
397
  <summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
@@ -373,15 +403,17 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
373
403
  | `@derived get` read | none | lazy computed read |
374
404
  | Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
375
405
  | `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
376
- | `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
406
+ | `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
377
407
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
378
408
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
409
+ | `forEachReactive` walk | none | gated: 1e6 hoisted-callback walks measure **0.002 B/walk** (vs the 0.000 B/op zero-alloc control -- within a +2-byte limit), `gc.major === 0`; the 4-scalar `fn(key, box, kind, arg)` carries no descriptor object and the `arg` pass-through kills the caller's closure |
410
+ | `snapshotOf(vm)` | 1 plain object | **by design** -- the returned copy allocates (**95.8 B/op measured**, 1e5 cycles); REPORTED in the torture summary line, never gated. The walk *under* it stays zero-alloc; cold, off any frame path |
379
411
 
380
- The gates that hold it (run on every change, all green at 1.1.1):
412
+ The gates that hold it (run on every change, all green at 1.3.0):
381
413
 
382
- - `npm test` / `npm run test:gc` -- **257/257** on both lanes.
414
+ - `npm test` / `npm run test:gc` -- **313/313** on both lanes.
383
415
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
384
- - Torture: **16 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate) -- **14 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **16/16 controls** prove each gate can actually fail.
416
+ - Torture: **18 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit; the `introspection-torture` 1e6 hoisted-callback `forEachReactive` walk at `maxMajor 0` with the snapshot-allocates figure reported, never gated) -- **16 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **18/18 controls** prove each gate can actually fail.
385
417
  - `churn-soak` + `fleet-soak`: sustained construct/use/dispose and a 10s 2k-VM fleet tick; pools at floor and retained heap flat at every sample.
386
418
 
387
419
  The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across eight class-shaped scenarios (including the `churn-reuse` acquire/release lane, where the lite tiers pool with zero retained growth and MobX/signal-utils/alien-class are structurally `unsupported` -- no disposable instance lifecycle to pool), checksum-verified for identical work, stamped into `bench/results.txt`. The formal verdicts are in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md): the decorated path measured **0.94x** the hand-written baseline on vm-write and **1.10x** on a 10k-instance fleet read (the 2.0x kill line cleared with margin), and **0 major + 0 minor GC over 4096 construct/use/dispose cycles** with pools at floor -- while emitting ~12.6x less transient garbage per churn run than the hand-rolled class it replaces.
@@ -406,18 +438,19 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
406
438
  - **Symbol-slot storage.** Chosen over the emitter's private backing (emitter-dependent codegen) and a dict (fleet megamorphism, measured) -- and it is the same mechanism poison uses, so storage, dispose, and poison are one design.
407
439
  - **Statics and `#` privates are rejected, not half-supported.** A module-level signal belongs to raw lite-signal; a private member can't be reached by the wiring protocol -- both are named decoration-time throws.
408
440
  - **Pooled reinit is an identity-stable arena tool, not a speed shortcut (1.1.0).** `releaseReactive(vm)` parks a live instance to the engine pool and `reinitReactive(vm, initials?)` revives it -- a three-state lattice (live / parked / disposed) over the same instance, so consumers keep the object reference across turnover. It holds its gate: over 4096 acquire/release cycles at the churn shape (P=4, D=2, E=1) it measures **0 major GC**, retained delta-heap **at or below the in-process zero-alloc control**, and exact pool conservation (`activeNodes` back to baseline, zero pool growths, a parked instance holding 0 engine nodes). The honest throughput number, same shape, 2026-08-30 stamp (module 1.1.0): plain construct/dispose CHURN is *faster* -- **1323K ops/s** vs reuse's **1159K** -- because construction is already allocation-light and pool-conserving, so reinit is not a per-op win. Reach for it when you need identity-stable pooled instances under sustained turnover with zero retained growth (an arena/fleet primitive), not when you want raw op speed. MobX has no equivalent lifecycle at all: its instances are never disposable, so there is no release/reinit cycle to pool ([decisions/0010](decisions/0010-reinit-contract.md), [0011](decisions/0011-reinit-api.md)).
441
+ - **`@localTo` resets by compare-on-read, and its ABA limit is stated, not hidden (1.2.0).** The reset is decided *on the read* -- `source(self)` compared to a per-instance last-seen slot -- so it is glitch-free, synchronous, and pure (no box write on read, legal inside a `@derived`). The rejected alternative was an effect that watches upstream and clears the local: PD-51 measured that recipe clobbering a user's write one tick late, which is the whole reason the feature lives in-package instead of a cookbook recipe. Because the compare is value-based (lite-signal exposes no public revision counter and a private one would be impure), the honest limit is ABA: upstream `A` -> local write -> upstream `B` -> upstream back to an equals-`A` value shows the stale local -- the same property tracked-toolbox's `@localCopy` ships, documented rather than papered over ([decisions/0013](decisions/0013-strategic-admission-track.md), [0014](decisions/0014-localto-contract.md)).
409
442
 
410
443
  ---
411
444
 
412
445
  ## Testing (for clients & QA)
413
446
 
414
447
  ```bash
415
- npm test # node --test, 257 tests
416
- npm run test:gc # the same 257 with --expose-gc (enables the allocation assertions)
448
+ npm test # node --test, 313 tests
449
+ npm run test:gc # the same 313 with --expose-gc (enables the allocation assertions)
417
450
  npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
418
451
  ```
419
452
 
420
- **257 tests** across sixteen files, all green at 1.1.1. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
453
+ **313 tests** across eighteen files, all green at 1.3.0. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
421
454
 
422
455
  | File | Tests | Covers |
423
456
  |---|---:|---|
@@ -435,8 +468,10 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
435
468
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
436
469
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
437
470
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
438
- | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 18 exports), citation allowlist, link law, static-cost probe |
471
+ | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 21 exports), citation allowlist, link law, static-cost probe |
439
472
  | `16-reinit` | 29 | Pooled-reinit lattice on both emit lanes: park/reinit/dispose transitions, the five `reinitReactive` fail-closed states, parked-touch throws by name, `initials` boundary matrix (0..N+1 keys, null/undefined, NaN/-0 verbatim), `Symbol.dispose` on a parked instance, accessor descriptors byte-identical across reinit, self-release re-entrancy, ledger conservation |
473
+ | `17-localto` | 34 | `@localTo` on both emit lanes + buildless `locals`: the read/write/upstream-reset lattice, both initial flavors (follow-from-wiring vs reset-from-initial), the ABA stale-local contract, `equals` override survival, park/reinit box+seen reset, `costOf` = P+L+D+E+1, source-throw fail-closed, and the fail-closed option/source matrix |
474
+ | `18-introspection` | 22 | `forEachReactive`/`snapshotOf` on both emit lanes + buildless: plan-order walk (signals, locals, deriveds; ancestor-first), symbol keys, the `signal`/`local`/`derived` kind tags, effect/batched exclusion, count return + `arg` pass-through, the untracked-read law (snapshotOf inside an effect fires once), r7 `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the fail-closed non-reactive/unwired/parked/disposed matrix |
440
475
 
441
476
  ### Emit-support matrix
442
477
 
@@ -447,14 +482,14 @@ Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.jso
447
482
 
448
483
  | Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
449
484
  |---|---|---|---|---|---|
450
- | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
451
- | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
485
+ | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `5d6837710b34` | accepted -- full decorator surface wired + pinned green |
486
+ | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `f7d8b3e5ed19` | accepted -- full decorator surface wired + pinned green |
452
487
  | `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
453
488
  | `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
454
489
  | `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
455
490
  | `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
456
491
 
457
- Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
492
+ Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
458
493
  <!-- EMIT-MATRIX:END -->
459
494
 
460
495
  ### The torture suite (dev-side, never shipped)
@@ -462,13 +497,13 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
462
497
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
463
498
 
464
499
  ```bash
465
- npm run torture # all 16 scenarios (14 run + 2 floor-gated skips)
500
+ npm run torture # all 18 scenarios (16 run + 2 floor-gated skips)
466
501
  npm run torture:semantic # the correctness lane (CI)
467
502
  npm run torture:soak # the wall-clock churn + fleet soaks
468
503
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
469
504
  ```
470
505
 
471
- Sixteen scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 14 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
506
+ Eighteen scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the `localto-torture` gate (zero-alloc `@localTo` read/write storm at `maxMajor 0`, the ABA-stale write/reset interleave asserted AS the shipped contract, pooled park/reinit box+seen reset, tracking-edge and pure-compute-read pins), the `introspection-torture` gate (1e6 hoisted-callback `forEachReactive` walks at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles whose bytes/op are REPORTED in the summary line, never gated -- the snapshot allocates by design), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 16 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
472
507
 
473
508
  ### The cookbook lane (dev-side, never shipped)
474
509
 
@@ -523,6 +558,7 @@ The decorator vocabulary maps almost one-to-one; what changes is the lifetime st
523
558
  | `@action m()` | `@batched m()` |
524
559
  | `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
525
560
  | `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
561
+ | `toJS(obj)` | `snapshotOf(vm)` -- a shallow plain-object copy of every member, read through the accessors under one `untrack` (safe inside an effect); nested VMs stay by reference (1.3.0) |
526
562
  | reaction disposers only; the instance itself is never disposable | **`disposeReactive(vm)` -- one call, idempotent, node-exact, and every later touch throws by name. MobX has no equivalent; its per-instance graph ends when the collector decides.** |
527
563
 
528
564
  ### From signal-utils
@@ -546,7 +582,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
546
582
  - **Not a deep/proxy observation layer.** No `observable.deep`, no wrapped Arrays/Maps/Sets, no proxy magic -- the reactive unit is a declared member, not a traversed object graph. Collections are `@zakkster/lite-project` territory.
547
583
  - **Not a per-frame action system.** `@batched` costs a measured thunk per call -- fine for "one call per user intent", wrong inside a render loop. Per-frame hot lanes stay on plain accessor writes (and frame *scheduling* belongs to `lite-raf`).
548
584
  - **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
549
- - **Not a general meta-programming kit.** Five decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
585
+ - **Not a general meta-programming kit.** Six decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
550
586
  - **Not a MobX API shim.** No `makeObservable`, no administration objects -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
551
587
  - **Not a legacy-decorators consumer.** TypeScript `experimentalDecorators` emit is detected by call shape at decoration time and rejected with a named error -- never "works differently under legacy".
552
588
  - **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
@@ -566,7 +602,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
566
602
 
567
603
  ### The cookbook
568
604
 
569
- [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 18-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
605
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 21-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
570
606
 
571
607
  ---
572
608
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators -- Stage-3 decorator layer over
2
+ * @zakkster/lite-signal-decorators -- Standard-decorators layer over
3
3
  * @zakkster/lite-signal.
4
4
  *
5
5
  * Public type surface for the JavaScript implementation in
@@ -30,6 +30,16 @@ export interface DerivedOptions<V> {
30
30
  equals?: (a: V, b: V) => boolean;
31
31
  }
32
32
 
33
+ /** Options for a `@localTo` member. `equals` governs the UPSTREAM compare only. */
34
+ export interface LocalToOptions<V> {
35
+ /**
36
+ * Custom equality predicate for the UPSTREAM compare (source vs the last-seen
37
+ * value). A coarse predicate widens how long a local override survives an
38
+ * upstream move. Default: `Object.is`. The write path never compares.
39
+ */
40
+ equals?: (a: V, b: V) => boolean;
41
+ }
42
+
33
43
  /** Options for a `@reactiveEffect` method. */
34
44
  export interface ReactiveEffectOptions {
35
45
  /** Optional scheduler forwarded straight to the underlying `effect(fn, { scheduler })`. */
@@ -101,6 +111,37 @@ export function derived<V>(
101
111
  ctx: ClassGetterDecoratorContext<This, V>,
102
112
  ) => (this: This) => V;
103
113
 
114
+ // --- localTo ------------------------------------------------------------------
115
+
116
+ /**
117
+ * `@localTo(source) accessor x = v` -- upstream-keyed resettable local state.
118
+ * Each read compares the tracked `source(self)` against a per-instance last-seen
119
+ * slot: an unchanged upstream yields the local override, a changed upstream
120
+ * resets to the upstream value (no write on read -- pure). A write always
121
+ * overrides. With an initializer the member STARTS there and resets on the first
122
+ * upstream move (the `@trackedReset` flavor); without one it FOLLOWS upstream
123
+ * from wiring (the `@localCopy` flavor). `source` is REQUIRED. `equals` governs
124
+ * the upstream compare only.
125
+ *
126
+ * The ABA contract (shipped, documented): the reset requires the upstream to
127
+ * change relative to the last adoption, not to have merely moved -- upstream
128
+ * A -> local write X -> upstream B -> upstream back to an equals-A value shows
129
+ * the STALE local X.
130
+ *
131
+ * @example
132
+ * class Editor {
133
+ * `@reactive` accessor saved = "";
134
+ * `@localTo`((self) => self.saved) accessor draft = "";
135
+ * }
136
+ */
137
+ export function localTo<This, V>(
138
+ source: (this: This, self: This) => V,
139
+ options?: LocalToOptions<V>,
140
+ ): <T>(
141
+ target: ClassAccessorDecoratorTarget<T, V>,
142
+ ctx: ClassAccessorDecoratorContext<T, V>,
143
+ ) => ClassAccessorDecoratorResult<T, V>;
144
+
104
145
  // --- reactiveHost -------------------------------------------------------------
105
146
 
106
147
  /**
@@ -177,6 +218,20 @@ export interface SignalSpec<This = unknown, V = unknown> {
177
218
  equals?: (a: V, b: V) => boolean;
178
219
  }
179
220
 
221
+ /** Local descriptor for a `defineReactive` `locals` map entry (`@localTo` twin). */
222
+ export interface LocalSpec<This = unknown, V = unknown> {
223
+ /** The tracked upstream read `(self) => value`. REQUIRED. */
224
+ source: (this: This, self: This) => V;
225
+ /** Custom equality predicate for the upstream compare. Default: `Object.is`. */
226
+ equals?: (a: V, b: V) => boolean;
227
+ /**
228
+ * The initial value. Present -> the member starts here and resets to upstream
229
+ * on the first upstream move (`@trackedReset`). Absent -> the member follows
230
+ * upstream from wiring (`@localCopy`).
231
+ */
232
+ initial?: V;
233
+ }
234
+
180
235
  /** Derived descriptor for a `defineReactive` `deriveds` map entry. */
181
236
  export interface DerivedSpec<This = unknown, V = unknown> {
182
237
  /** The compute body `(self) => value`. */
@@ -204,6 +259,8 @@ export interface DefineReactiveSpec<This = any> {
204
259
  * throw (ambiguous -- use `{ initial: fn }` or `{ init: (self) => value }`).
205
260
  */
206
261
  signals?: PropertyKey[] | Record<PropertyKey, unknown | SignalSpec<This>>;
262
+ /** Upstream-keyed locals (`@localTo` twin): a map of `key -> LocalSpec`. */
263
+ locals?: Record<PropertyKey, LocalSpec<This>>;
207
264
  /** Lazy deriveds: a map of `key -> (self) => value | DerivedSpec`. */
208
265
  deriveds?: Record<PropertyKey, ((this: This, self: This) => unknown) | DerivedSpec<This>>;
209
266
  /** Auto-effects: a map of `key -> (self) => void | EffectSpec`. */
@@ -285,16 +342,69 @@ export function boxOf<T = unknown>(vm: object, key: PropertyKey): SignalBox<T> |
285
342
  */
286
343
  export function rootOf(vm: object): NodeDescriptor;
287
344
 
345
+ // --- Reactive walk & snapshot (S9) --------------------------------------------
346
+
347
+ /**
348
+ * The literal kind tag {@link forEachReactive} passes for each visited member: a
349
+ * `@reactive` signal, a `@localTo` local, or a `@derived` computed. Effects and
350
+ * batched actions are non-value-bearing and never appear.
351
+ */
352
+ export type ReactiveKind = "signal" | "local" | "derived";
353
+
354
+ /**
355
+ * Visit every value-bearing reactive member of `vm` in PLAN order -- all signals,
356
+ * then all `@localTo` locals, then all deriveds; within each group declaration-
357
+ * ordered and ancestor-first (a subclass's own members follow its ancestors').
358
+ * `@reactiveEffect` and `@batched` members are EXCLUDED -- they back no box.
359
+ * `fn` receives the member key (symbol keys included), the live {@link SignalBox}
360
+ * / {@link ComputedBox} (exactly what {@link boxOf} returns), the
361
+ * {@link ReactiveKind} literal, and the pass-through `arg` -- which threads caller
362
+ * state without a closure, so the walk is zero-allocation per call and per visit.
363
+ * Returns the number of members visited.
364
+ *
365
+ * @throws {TypeError} if `fn` is not a function.
366
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
367
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
368
+ */
369
+ export function forEachReactive<A = unknown>(
370
+ vm: object,
371
+ fn: (
372
+ key: PropertyKey,
373
+ box: SignalBox<unknown> | ComputedBox<unknown>,
374
+ kind: ReactiveKind,
375
+ arg: A,
376
+ ) => void,
377
+ arg?: A,
378
+ ): number;
379
+
380
+ /**
381
+ * Return a plain object snapshot of every value-bearing reactive member of `vm`
382
+ * -- signals, `@localTo` locals, and deriveds -- keyed by member key (symbol keys
383
+ * included). Each value is read through the ACCESSOR `vm[key]`, NOT the raw box,
384
+ * so `@localTo` compare-on-read and derived compute stay honest. SHALLOW by
385
+ * design: a nested reactive VM is copied by reference, never recursed. The whole
386
+ * read pass runs under one untracked scope when a tracking context is active, so
387
+ * calling this inside an effect does NOT subscribe that effect to every member.
388
+ * The returned object allocates by design -- this is a cold introspection call,
389
+ * never a gated hot path.
390
+ *
391
+ * @throws {ReactiveDisposedError} if the instance was disposed or parked.
392
+ * @throws if `vm` is not wired yet, or is not a reactive instance.
393
+ */
394
+ export function snapshotOf(vm: object): Record<PropertyKey, unknown>;
395
+
288
396
  // --- Introspection & audit (S4) -----------------------------------------------
289
397
 
290
398
  /** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
291
399
  export interface ReactiveCost {
292
- /** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
400
+ /** Total nodes = `signals + locals + deriveds + effects + 1` (the anchor). */
293
401
  nodes: number;
294
402
  /** Dependency links held after every `@derived` has been read once (0007). */
295
403
  links: number;
296
404
  /** Count of `@reactive` members. */
297
405
  signals: number;
406
+ /** Count of `@localTo` members (each one box node; its seen slot is a plain field). */
407
+ locals: number;
298
408
  /** Count of `@derived` members. */
299
409
  deriveds: number;
300
410
  /** Count of `@reactiveEffect` members. */