@zakkster/lite-signal-decorators 0.3.0 → 1.0.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,162 @@ 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.0.0] - 2026-08-29
8
+
9
+ The 1.0 release: docs freeze, the fleet playground, and the standing pre-publish
10
+ gate. Zero runtime changes -- the 16-export surface ships byte-identical to
11
+ 0.4.0 (the hot accessor canon is review-diffed to a zero-line diff), and this
12
+ release freezes that surface under semver: any later signature change is a major
13
+ recorded in a decision file. Dist-tag `latest`. Stage gate, measured at
14
+ closeout by the full section-10 chain (archived verbatim below): 214/214 tests
15
+ on both lanes; torture 15 scenarios (13 pass + the two forward-compat scenarios
16
+ legitimately skipping under the installed 1.5.0 peer) with 15/15 sabotage
17
+ controls breaking as required; the peer-preview lane SUITE-GREEN per tag
18
+ (15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
19
+ 1.9.0-canary.1; the bench sink self-test catching its sabotaged adapter; pack 7
20
+ files, no `demo/` or `Publications/`.
21
+
22
+ ### Added
23
+
24
+ - The fleet-playground demo (`demo/`, dev-only, never in `files[]`): a
25
+ single-file instrument console over a two-plane architecture -- Plane A a
26
+ decorated entity-VM fleet in a `capacityFor`-sized custom registry with
27
+ enforced ceilings, Plane B the telemetry signals in the default registry
28
+ driving five `@zakkster/lite-watch-ex` watchers (`watchUntil`,
29
+ `pausableWatch`, `watchChanged`, `watchMany`, `watchPrevious`). The DOM-free
30
+ core runs headless under the same GC-budget and dispose-storm gates the
31
+ library uses; the PD-29 registry wall is proven by a stats-delta (no watcher
32
+ ever forms an edge into the custom-registry fleet), not by assertion.
33
+ - README migration section: MobX 7 and signal-utils translation tables,
34
+ including the closing row MobX cannot write -- `disposeReactive(vm)` is one
35
+ idempotent, node-exact call after which every later touch throws by name.
36
+ - README emit-support matrix, generated from `test/fixtures/hashes.json` (9
37
+ entries) under drift-proof `EMIT-MATRIX` markers; `04-fixture-freshness`
38
+ asserts the README block equals the generator, so a re-emit that changes a
39
+ byte is loud, not silent.
40
+ - `llms.txt` per-feature forward floors documented alongside the peer range:
41
+ 1.6.0 for `createScope`, 1.9.0 for `Symbol.dispose`; the peer range floor
42
+ stays `>=1.5.0 <2.0.0`.
43
+ - `gate` script (`test/gate.mjs`): the section-10 pre-publish chain as captured
44
+ child processes -- fixtures, test, test:gc, torture (semantic + soak),
45
+ the TORTURE_BREAK control sweep, the non-blocking peer-preview lane,
46
+ the bench sink self-test, and `npm pack --dry-run` asserting exactly 7 files.
47
+ Every blocking step exits 0 or the gate exits non-zero; peer-preview is
48
+ reported, never gated.
49
+ - `Publications/` (dev-only, never shipped): per-channel release drafts and the
50
+ GitHub release notes, centered on the class-reactivity benchmark methodology
51
+ with an explicit invitation for competitor adapter PRs.
52
+
53
+ ### Changed
54
+
55
+ - Docs re-stamped to reality: Testing sections now read 214 tests across
56
+ fourteen files and 15 torture scenarios (13 run + 2 floor-gated skips), and
57
+ every numeric claim traces to a live stamp (`bench/results.txt`,
58
+ `decisions/0006`, or a gate tail). Version references across README and
59
+ llms.txt move to 1.0.0.
60
+ - The 16-export surface is frozen under semver at 1.0.0. The 0.x "frozen for
61
+ 0.x" note is retired in favor of the semver promise.
62
+
63
+ ### Fixed
64
+
65
+ - (Found in review, pass 1 charge C4) An inline-style violation in the demo
66
+ HTML -- a non-custom-property `style=` attribute against the demo CSS law --
67
+ was hoisted into the stylesheet. Custom-property `style="--var: value"` hooks
68
+ remain, per the law.
69
+ - (Found in the planner audit) The Testing and gates sections carried
70
+ 0.2.0-era numbers (171 tests, "12/12 scenarios", an 11-row file table) two
71
+ stages stale, in violation of the no-claim-without-a-stamp rule. Re-stamped
72
+ against the 1.0.0 tree.
73
+
74
+ ### Gate output (section-10 chain, archived verbatim)
75
+
76
+ ```
77
+ fixtures OK exit 0 -- emit fixtures regenerated
78
+ test OK exit 0 -- 214 pass / 0 fail
79
+ test:gc OK exit 0 -- 214 pass / 0 fail
80
+ torture OK exit 0 -- 13 passed, 2 skipped, 0 warned, 0 failed in 32.7s
81
+ torture:controls OK exit 0 -- 15 passed, 0 skipped, 0 warned, 0 failed in 1.9s
82
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed; canary 1.9.0-canary.1 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed]
83
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
84
+ pack OK exit 0 -- 7/7 files, no demo/ no Publications/
85
+ ----------------------------------------------------------------------
86
+ GATE PASS -- 7 blocking steps + 1 non-blocking (peer-preview)
87
+ ```
88
+
89
+ ## [0.4.0] - 2026-08-26
90
+
91
+ The introspection release: the surface grows 11 -> 16, every addition cold-path
92
+ or opt-in, the hot accessor canon byte-identical to 0.3.0 (review-diffed
93
+ against the published tarball). Stage gate, measured at closeout: 213/213
94
+ tests on both lanes; torture 15 scenarios (13 pass + the two forward-compat
95
+ scenarios legitimately skipping under the installed 1.5.0 peer) with 15/15
96
+ sabotage controls breaking as required; the peer-preview lane SUITE-GREEN
97
+ (15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
98
+ 1.9.0-canary.1; pack 7 files.
99
+
100
+ ### Added
101
+
102
+ - `costOf(Factory)` -- the measured, settled per-instance cost on the class's
103
+ bound registry: `{ nodes, links, signals, deriveds, effects }`, frozen and
104
+ cached per class. Quiet-required, floor-verified, DOUBLE-probed with
105
+ identical-deltas-or-throw: an inconclusive probe (a derived whose read set
106
+ changes between runs, a polluted registry) throws a named error, never
107
+ guesses. `nodes` reproduces the 0002 Q3 grid exactly (P + D + E + 1);
108
+ `links` is the first-full-read count.
109
+ - `capacityFor(inventory, { headroom }?)` -- sizes a ready `createRegistry`
110
+ config from `[Factory, count]` pairs: nodes exact, links x `headroom`
111
+ floored at the engine minimum, `prealloc: "eager"`,
112
+ `onCapacityExceeded: "throw"`; fail-closed inventory and options
113
+ validation. The link-headroom policy -- nodes exact; fixed-shape deriveds
114
+ provision exactly at first-full-read; branchy-read deriveds fail LOUD with
115
+ the `headroom` knob as the documented escape -- is recorded with its
116
+ measured evidence in `decisions/0007-capacity-policy.md`, closing 0002's
117
+ open question.
118
+ - `enableLabels(on)` / `labelOf(idOrHandle, registry?)` -- opt-in devtools
119
+ identity (default OFF): per-registry `nodeId -> "Class.prop" /
120
+ "Class#method" / "Class@anchor"` maps, per-class shared label strings,
121
+ dispose unregisters, misses return `undefined`. A feature-detected
122
+ integration test walks lite-devtools `graph()`/`toTree` from `rootOf(vm)`
123
+ and resolves every walked node. The one-line devtools `labelResolver`
124
+ upstream proposal is recorded in `decisions/0008-introspection.md`.
125
+ - `auditReactive(on)` -- opt-in leak auditor (default OFF): a lazily-created
126
+ `FinalizationRegistry` reports any instance collected without
127
+ `disposeReactive`, naming class and shape; it holds no instance references
128
+ itself, unregisters on dispose, and registers nothing while off (proven by
129
+ a child-process `--expose-gc` test).
130
+ - Forward-compat torture: `scope-adoption` (floor 1.6.0) and `using-dispose`
131
+ (floor 1.9.0), written against the REAL probed future engine surfaces
132
+ (1.6.0's `createScope` adoption; 1.9.0's native `[Symbol.dispose]` on
133
+ handles) via a typeof-only feature probe (`test/shared/peer-probe.mjs` --
134
+ never version parsing). Their sabotage controls lie about the probe, so
135
+ they fail loudly even under the current peer. Plus `torture:peer-preview`:
136
+ a scratch-install lane that runs the whole suite against the peer's
137
+ `preview` and `canary` dist-tags and reports per-tag verdicts.
138
+ - Tests: `12-accounting` (11), `13-labels-audit` (10, incl. the child-process
139
+ audit fixture), `14-qa-s4-boundary` (21 adversarial pins), and two
140
+ capacityFor round-trip lanes (node-bound and link-bound) in
141
+ capacity-torture. Suite 171 -> 213. Dev-only devDependency:
142
+ `@zakkster/lite-devtools` (integration walk).
143
+
144
+ ### Fixed
145
+
146
+ - (Found in review) A registry passing the 11-method duck-check but lacking
147
+ `stats()` -- constructible from the public surface -- made `costOf` and
148
+ `capacityFor` die with a raw `TypeError` instead of a named error. Now a
149
+ named, ERR-prefixed throw explains that probing needs a `createRegistry()`
150
+ registry with its stats ledger. The falsified "no reachable raw path"
151
+ claim in 0008 is amended with the counterexample preserved, rejection-
152
+ history style.
153
+ - (Found in QA) `capacityFor`'s options guard borrowed decorator-flavored
154
+ usage wording for a plain function call, and accepted an ARRAY as an
155
+ options bag; it now throws the call-form message
156
+ (`options must be a plain object like { headroom: 1.25 }`) for both.
157
+ `null` still means "omitted".
158
+ - (Found writing tests) `capacityFor` on a signals-only inventory produced
159
+ `maxLinks: 0`, which `createRegistry` rejects; the returned links are now
160
+ floored at the engine minimum of 1 so every valid inventory yields a
161
+ constructible config.
162
+
7
163
  ## [0.3.0] - 2026-08-26
8
164
 
9
165
  The class-reactivity benchmark release. Zero runtime changes: the 11-export
@@ -202,6 +358,8 @@ Initial release -- the decorator core.
202
358
  - Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
203
359
  retention, conservation, lifecycle, and zero-GC lanes.
204
360
 
361
+ [1.0.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.0.0
362
+ [0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
205
363
  [0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
206
364
  [0.2.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0
207
365
  [0.2.0-preview.1]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0-preview.1
package/README.md CHANGED
@@ -49,6 +49,7 @@ No base class to extend. No `makeObservable(this, {...})` mirror object to keep
49
49
  - [Design decisions worth knowing](#design-decisions-worth-knowing)
50
50
  - [Testing (for clients & QA)](#testing-for-clients--qa)
51
51
  - [Compatibility](#compatibility)
52
+ - [Migrating from MobX 7 & signal-utils](#migrating-from-mobx-7--signal-utils)
52
53
  - [What this is not](#what-this-is-not)
53
54
  - [Ecosystem](#ecosystem)
54
55
  - [FAQ](#faq) - [License](#license)
@@ -240,12 +241,23 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
240
241
  | `boxOf` | `(vm, key) => SignalBox \| ComputedBox` | The live engine box behind a `@reactive`/`@derived` member -- `.peek()`, `.subscribe()`, raw interop. Unknown key: named throw with a did-you-mean. After dispose: `ReactiveDisposedError`. |
241
242
  | `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
242
243
 
244
+ ### Introspection & audit (1.0.0)
245
+
246
+ | Export | Signature | Behavior |
247
+ |---|---|---|
248
+ | `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. |
249
+ | `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). |
250
+ | `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. |
251
+ | `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. |
252
+
253
+ 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).
254
+
243
255
  ### Errors & constants
244
256
 
245
257
  | Export | Value |
246
258
  |---|---|
247
259
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
248
- | `VERSION` | `"0.3.0"` |
260
+ | `VERSION` | `"1.0.0"` |
249
261
 
250
262
  ### The rejection matrix
251
263
 
@@ -294,6 +306,27 @@ console.log(world.stats().activeNodes); // 0 -- every node returned to the pool
294
306
 
295
307
  The default registry never notices any of it: bound-registry churn leaves outside `stats()` frozen (torture-pinned). And because `boxOf` returns the *engine's* box, everything lite-signal composes with -- subscriptions, `peek`, batch, untrack, lite-raf frame effects -- composes with decorated members too.
296
308
 
309
+ ### Predicate-gated watchers (`@zakkster/lite-watch-ex`)
310
+
311
+ `lite-watch-ex` adds one-shot, pausable, and change-gated watchers over the same engine. Its sources are plain **thunks** (`() => vm.hp`, never a box handle), and every watcher creates its effect node in the **default registry** -- so wire one only to a **default-registry** instance (one with no `host.registry`), never across a custom-registry fleet, where the edge would cross a boundary the engine's default `dispose` cannot see:
312
+
313
+ ```js
314
+ import { watchUntil } from "@zakkster/lite-watch-ex";
315
+ import { defineReactive } from "@zakkster/lite-signal-decorators";
316
+
317
+ // Default-registry instance -- no host.registry, so it lives in the default graph.
318
+ const ReactivePlayer = defineReactive(class Player {}, { signals: { hp: 100 } });
319
+ const vm = new ReactivePlayer();
320
+
321
+ // Fires ONCE when hp crosses the threshold, then self-disposes:
322
+ watchUntil(() => vm.hp, (h) => h <= 25, (h) => console.log("low hp:", h));
323
+
324
+ vm.hp = 40; // predicate false -> no fire
325
+ vm.hp = 20; // predicate true -> "low hp: 20", watcher disposes itself
326
+ ```
327
+
328
+ The [`fleet-playground` demo](demo/fleet-playground.html) shows the safe split at scale: decorated entity VMs in an enforced custom registry, all watchers on a separate default-registry telemetry plane.
329
+
297
330
  ---
298
331
 
299
332
  ## The numbers
@@ -341,12 +374,12 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
341
374
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
342
375
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
343
376
 
344
- The gates that hold it (run on every change, all green at 0.2.0):
377
+ The gates that hold it (run on every change, all green at 1.0.0):
345
378
 
346
- - `npm test` / `npm run test:gc` -- **171/171** on both lanes.
379
+ - `npm test` / `npm run test:gc` -- **214/214** on both lanes.
347
380
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
348
- - Torture: **12/12 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) -- plus **12/12 sabotage controls** proving each gate can actually fail.
349
- - `churn-soak`: sustained construct/use/dispose for a wall-clock budget; pools at floor and retained heap flat at every sample.
381
+ - Torture: **15 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) -- **13 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 -- **15/15 controls** prove each gate can actually fail.
382
+ - `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.
350
383
 
351
384
  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 seven class-shaped scenarios, 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.
352
385
 
@@ -371,18 +404,19 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
371
404
  ## Testing (for clients & QA)
372
405
 
373
406
  ```bash
374
- npm test # node --test, 171 tests
375
- npm run test:gc # the same 171 with --expose-gc (enables the allocation assertions)
407
+ npm test # node --test, 214 tests
408
+ npm run test:gc # the same 214 with --expose-gc (enables the allocation assertions)
409
+ npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> pack
376
410
  ```
377
411
 
378
- **171 tests** across eleven files, all green at 0.2.0. The decorator protocol is tested three times over: against a mock Stage-3 emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
412
+ **214 tests** across fourteen files, all green at 1.0.0. The decorator protocol is tested three times over: against a mock Stage-3 emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
379
413
 
380
414
  | File | Tests | Covers |
381
415
  |---|---:|---|
382
416
  | `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
383
417
  | `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
384
418
  | `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
385
- | `04-fixture-freshness` | 1 | Fixture hashes match the sources (stale-emit guard) |
419
+ | `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
386
420
  | `05-wiring` | 5 | Anchor creation, wiring order, leaf-wires-once |
387
421
  | `06-dispose` | 6 | Cascade, idempotency, poison, `using` |
388
422
  | `07-qa-boundary` | 13 | S1 adversarial boundary pins |
@@ -390,19 +424,54 @@ npm run test:gc # the same 171 with --expose-gc (enables the allocation asse
390
424
  | `09-buildless` | 16 | `defineReactive` parity + the spec rejection matrix |
391
425
  | `10-qa-s2a-boundary` | 34 | Adversarial pins: identity guard, frozen dispose, registry heterogeneity, stacking |
392
426
  | `11-qa-s2b-boundary` | 8 | Construction-throw boundaries: init-phase drain, chain-base throws, overflow storms |
427
+ | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
428
+ | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
429
+ | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
430
+
431
+ ### Emit-support matrix
432
+
433
+ Three fixture sources, two Stage-3 emitters, both emit lanes -- every cell below is a committed, hash-pinned fixture (the `04-fixture-freshness` guard above). The table is generated from the fixture manifest, so a re-emit that changes a byte is loud, not silent:
434
+
435
+ <!-- EMIT-MATRIX:START -->
436
+ Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.json` -- do not hand-edit. Toolchain pinned by the committed fixtures: **TypeScript 5.9.3**, **@babel/core 7.29.7** + **@babel/plugin-proposal-decorators 7.29.7** (`version: 2023-11`). Each `sha256` is the first 12 hex of the committed emit; `npm run fixtures` regenerates and `test/04-fixture-freshness` fails loudly on any drift.
437
+
438
+ | Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
439
+ |---|---|---|---|---|---|
440
+ | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
441
+ | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
442
+ | `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
443
+ | `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
444
+ | `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
445
+ | `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
446
+
447
+ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
448
+ <!-- EMIT-MATRIX:END -->
393
449
 
394
450
  ### The torture suite (dev-side, never shipped)
395
451
 
396
452
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
397
453
 
398
454
  ```bash
399
- npm run torture # all 12 scenarios
455
+ npm run torture # all 15 scenarios (13 run + 2 floor-gated skips)
400
456
  npm run torture:semantic # the correctness lane (CI)
401
- npm run torture:soak # the wall-clock churn soak
457
+ npm run torture:soak # the wall-clock churn + fleet soaks
402
458
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
403
459
  ```
404
460
 
405
- Twelve 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, and the churn soak. 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`.
461
+ Fifteen 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 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: 13 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`.
462
+
463
+ ### The fleet demo (dev-side, never shipped)
464
+
465
+ A single-file instrument console -- [`demo/fleet-playground.html`](demo/fleet-playground.html) -- drives a two-plane capacity fleet: decorated entity VMs in an enforced custom registry, telemetry watchers in the default registry. Its DOM-free core runs headless under the same gates the library uses:
466
+
467
+ ```bash
468
+ npm run demo:build # esbuild bundle -> demo/bundle.js + rewrite demo/bundle.sha256
469
+ npm run demo:check # verify the committed bundle matches its recorded hash
470
+ npm run demo:gc # headless GC-budget lane over the fleet core (maxMajor 0)
471
+ npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
472
+ ```
473
+
474
+ The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
406
475
 
407
476
  ---
408
477
 
@@ -419,12 +488,45 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
419
488
 
420
489
  ---
421
490
 
491
+ ## Migrating from MobX 7 & signal-utils
492
+
493
+ The decorator vocabulary maps almost one-to-one; what changes is the lifetime story. Both libraries below leave teardown to the garbage collector -- this package makes it a single deterministic call.
494
+
495
+ ### From MobX 7
496
+
497
+ | MobX 7 | lite-signal-decorators |
498
+ |---|---|
499
+ | `@observable accessor x` | `@reactive accessor x` |
500
+ | `@computed get y()` | `@derived get y()` |
501
+ | `@action m()` | `@batched m()` |
502
+ | `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
503
+ | `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
504
+ | 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.** |
505
+
506
+ ### From signal-utils
507
+
508
+ Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or getters) and `@cached` (on getters) are the two decorators in its surface.
509
+
510
+ | signal-utils 0.21 | lite-signal-decorators |
511
+ |---|---|
512
+ | `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
513
+ | `@cached get y()` | `@derived get y()` |
514
+ | no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
515
+ | Stage-3 build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
516
+
517
+ The cross-framework numbers behind this table are stamped in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md) (both engines measured through their documented class APIs at checksum-identical work).
518
+
519
+ ---
520
+
422
521
  ## What this is not
423
522
 
424
523
  - **Not a home for module-level or global signals.** That is raw `lite-signal` territory; `static` members are rejected by design.
524
+ - **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.
425
525
  - **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`).
426
526
  - **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
427
- - **Not a MobX API shim.** No `makeObservable`, no administration objects, no proxy magic -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
527
+ - **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.
528
+ - **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.
529
+ - **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".
428
530
  - **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
429
531
 
430
532
  ---
@@ -437,6 +539,7 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
437
539
  | `@zakkster/lite-signal-dom` | DOM bindings for the same engine -- where a view-model meets actual elements. |
438
540
  | `@zakkster/lite-raf` | Frame-rate scheduling for the same graph; the frame-coalescing pattern the `scheduler` option on `@reactiveEffect` exists to plug into. |
439
541
  | `@zakkster/lite-devtools` | Graph inspection; `rootOf(vm)` + `forEachOwned` is the hook it walks. |
542
+ | [`@zakkster/lite-watch-ex`](https://www.npmjs.com/package/@zakkster/lite-watch-ex) | One-shot / predicate-gated / pausable watchers over the same engine; thunk sources, default-registry effects -- see the [registry note](#composability) before pointing one at a decorated member. |
440
543
  | `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler` | The dev-side harness that proves every retention and allocation claim in this README. Never shipped to consumers. |
441
544
 
442
545
  ---
@@ -473,7 +576,7 @@ Because an instance whose base-class boxes live in one pool and whose subclass b
473
576
  No, and the README says so with numbers: 22.12 ns/op vs 15.25 raw vs 11.67 unbatched on the reference rig -- a thunk + rest-array per call. Use it for actions; keep per-frame writes on plain accessors.
474
577
 
475
578
  **Where are `costOf`, labels, the audit hook, private members?**
476
- Not in 0.3.0 -- the runtime surface froze for 0.x at 0.2.0, and the introspection layer (capacity accounting, devtools identity) is the 0.4.0 milestone. The `llms.txt` scope note tracks exactly what is and isn't included.
579
+ All three are part of the frozen 1.0.0 surface -- `costOf`/`capacityFor` (measured capacity accounting), `enableLabels`/`labelOf` (devtools identity), and `auditReactive` (leak audit), all cold-path or opt-in with the hot canon untouched. Private-member support remains out. The `llms.txt` scope note tracks exactly what is and isn't included.
477
580
 
478
581
  ---
479
582
 
@@ -11,6 +11,8 @@ import type {
11
11
  ComputedBox,
12
12
  NodeDescriptor,
13
13
  Registry,
14
+ RegistryConfig,
15
+ ReactiveHandle,
14
16
  EffectScheduler,
15
17
  } from "@zakkster/lite-signal";
16
18
 
@@ -256,6 +258,97 @@ export function boxOf<T = unknown>(vm: object, key: PropertyKey): SignalBox<T> |
256
258
  */
257
259
  export function rootOf(vm: object): NodeDescriptor;
258
260
 
261
+ // --- Introspection & audit (S4) -----------------------------------------------
262
+
263
+ /** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
264
+ export interface ReactiveCost {
265
+ /** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
266
+ nodes: number;
267
+ /** Dependency links held after every `@derived` has been read once (0007). */
268
+ links: number;
269
+ /** Count of `@reactive` members. */
270
+ signals: number;
271
+ /** Count of `@derived` members. */
272
+ deriveds: number;
273
+ /** Count of `@reactiveEffect` members. */
274
+ effects: number;
275
+ }
276
+
277
+ /**
278
+ * Measure the settled per-instance cost of a reactive class on its bound
279
+ * registry. Constructs a probe instance (with NO arguments), reads every
280
+ * `@derived` once to force the lazy links, disposes, and verifies the registry
281
+ * floor -- twice, requiring identical deltas. The result is frozen and cached
282
+ * per class.
283
+ *
284
+ * @param Factory the class returned by `@reactiveHost` or `defineReactive`.
285
+ * @throws if `Factory` is not a reactive wrapper class, if the two probes
286
+ * disagree (a data-dependent read or a polluted registry -- costOf never
287
+ * guesses), or if dispose does not return the registry to its floor.
288
+ */
289
+ export function costOf(Factory: new (...args: any[]) => any): Readonly<ReactiveCost>;
290
+
291
+ /** A `[Factory, count]` pair for {@link capacityFor}. */
292
+ export type InventoryEntry = [new (...args: any[]) => any, number];
293
+
294
+ /** Options for {@link capacityFor}. */
295
+ export interface CapacityForOptions {
296
+ /**
297
+ * Link-budget multiplier (`>= 1`, default `1` = exact). Applied to the link
298
+ * total for workloads with dynamic-dependency (branchy) deriveds whose active
299
+ * branch can read more members than the probe measured. See decisions/0007.
300
+ */
301
+ headroom?: number;
302
+ }
303
+
304
+ /**
305
+ * Size a `createRegistry` config for a stated inventory of `[Factory, count]`
306
+ * pairs. Nodes are exact; links are `sum(cost.links x count)` scaled by
307
+ * `headroom`. Returns a ready `RegistryConfig` with `prealloc: "eager"` and
308
+ * `onCapacityExceeded: "throw"`.
309
+ *
310
+ * @throws on an empty inventory, a non-factory entry, a non-positive/non-integer
311
+ * count, or a bad `headroom`.
312
+ */
313
+ export function capacityFor(
314
+ inventory: InventoryEntry[],
315
+ options?: CapacityForOptions,
316
+ ): RegistryConfig;
317
+
318
+ /**
319
+ * Toggle devtools labels (default OFF). While ON, wiring registers a
320
+ * `nodeId -> label` for every node an instance creates (`"Class.prop"`,
321
+ * `"Class#method"`, `"Class@anchor"`) into a per-registry map, and
322
+ * `disposeReactive` unregisters them. OFF adds no hot-path cost.
323
+ *
324
+ * @throws if `on` is not a boolean.
325
+ */
326
+ export function enableLabels(on: boolean): void;
327
+
328
+ /**
329
+ * Resolve a node id (or a handle, via the registry's `nodeId`) to its label, or
330
+ * `undefined` if unlabeled/unknown -- an introspection miss is never an error.
331
+ * `registry` defaults to the default registry.
332
+ */
333
+ export function labelOf(
334
+ idOrHandle: number | ReactiveHandle | NodeDescriptor,
335
+ registry?: Registry,
336
+ ): string | undefined;
337
+
338
+ /**
339
+ * Toggle the leak auditor (default OFF). While ON, a lazily-created
340
+ * `FinalizationRegistry` reports (one `console.error`) any instance
341
+ * garbage-collected WITHOUT `disposeReactive`, naming the class + shape. OFF: no
342
+ * `FinalizationRegistry` exists and nothing is registered.
343
+ *
344
+ * Note: an instance pinned by its own undisposed derived/effect nodes on a
345
+ * long-lived registry is never collected, so audit cannot fire for it -- that
346
+ * retention is caught by leak torture instead (see decisions/0008).
347
+ *
348
+ * @throws if `on` is not a boolean.
349
+ */
350
+ export function auditReactive(on: boolean): void;
351
+
259
352
  // --- Errors -------------------------------------------------------------------
260
353
 
261
354
  /**
@@ -273,4 +366,4 @@ export class ReactiveDisposedError extends Error {
273
366
  // --- Version ------------------------------------------------------------------
274
367
 
275
368
  /** Package version. Kept in lockstep with package.json and llms.txt. */
276
- export const VERSION: "0.3.0";
369
+ export const VERSION: "1.0.0";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v0.3.0
2
+ * @zakkster/lite-signal-decorators v1.0.0
3
3
  * --------------------
4
4
  * Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into
5
5
  * a reactive view-model with measured per-instance cost and deterministic
@@ -44,6 +44,7 @@ import {
44
44
  isTracking,
45
45
  batch,
46
46
  untrack,
47
+ stats,
47
48
  } from "@zakkster/lite-signal";
48
49
 
49
50
  // --- Module state -------------------------------------------------------------
@@ -85,10 +86,40 @@ const SCRATCH = [];
85
86
  // Hoisted package prefix (PD-15): every cold error message begins with this.
86
87
  const ERR = "@zakkster/lite-signal-decorators: ";
87
88
 
89
+ // --- Introspection state (S4; all opt-in, all cold/off by default) -----------
90
+
91
+ // PD-23/PD-24: labels and audit are opt-in debug features. INTROSPECT_ON is the
92
+ // single wiring/dispose gate (= LABELS_ON || AUDIT_ON), so the OFF path pays at
93
+ // most one flag test at wiring and one at dispose -- the hot accessor canon is
94
+ // never touched in either mode.
95
+ let LABELS_ON = false;
96
+ let AUDIT_ON = false;
97
+ let INTROSPECT_ON = false;
98
+
99
+ // PD-23: per-registry nodeId -> label. nodeIds are per-registry, so a
100
+ // module-global Map would collide across registries; the key is the plan's reg
101
+ // object (DEFAULT_REG facade or a custom Registry).
102
+ const LABEL_MAPS = new WeakMap(); // reg -> Map<number, string>
103
+ // Per-plan label strings, built once at first labeled wiring and shared by every
104
+ // instance of the class.
105
+ const LABEL_STRINGS = new WeakMap(); // plan -> { anchor, signals[], deriveds[], effects[] }
106
+ // The instance's registered label ids, so disposeReactive can unregister exactly
107
+ // them (effect handles are otherwise discarded). Only written while LABELS_ON.
108
+ const LABEL_IDS = Symbol("lite-signal-decorators.labelIds");
109
+
110
+ // PD-24: costOf result cache (per wrapper class; shape is frozen at decoration).
111
+ const COST_CACHE = new WeakMap(); // Factory -> frozen cost object
112
+
113
+ // PD-24: the audit FinalizationRegistry is created lazily on first enable and
114
+ // never torn down (a FR holds no strong refs to its targets). Its held value is
115
+ // a plain { className, shape } record -- it must NOT close over the instance.
116
+ let AUDIT_FR = null;
117
+
88
118
  // Known option keys per decorator (unknown-key did-you-mean sets, PD-8/PD-11).
89
119
  const KNOWN_OPTION_KEYS = ["equals"];
90
120
  const EFFECT_OPTION_KEYS = ["scheduler"];
91
121
  const HOST_OPTION_KEYS = ["registry"];
122
+ const CAP_OPTION_KEYS = ["headroom"];
92
123
 
93
124
  // --- DEFAULT_REG facade (PD-11) ----------------------------------------------
94
125
 
@@ -107,6 +138,10 @@ const DEFAULT_REG = Object.freeze({
107
138
  isTracking,
108
139
  batch,
109
140
  untrack,
141
+ // `stats` is not in REG_METHODS (the duck-check stays at the 11 methods the
142
+ // wiring/dispose paths use); costOf reads it here for the default registry,
143
+ // and every custom Registry from createRegistry() exposes it natively.
144
+ stats,
110
145
  });
111
146
 
112
147
  // The 11 method names a valid Registry must expose (duck-check set, PD-11).
@@ -864,11 +899,22 @@ function wireInstance(inst, plan) {
864
899
  }
865
900
  // Effects wire AFTER every derived (D-4a): the first synchronous run
866
901
  // sees every field and every derived. Dispose handles are DISCARDED --
867
- // teardown is the anchor cascade.
902
+ // teardown is the anchor cascade. The ONE introspection flag test
903
+ // (S4, PD-23/24): the OFF branch is byte-identical to 0.3.0; the ON
904
+ // branch captures effect handles for labeling + registers audit.
868
905
  const effs = plan.effects;
869
- for (let i = 0; i < effs.length; i++) {
870
- const e = effs[i];
871
- reg.effect(makeEffectBody(inst, e.fn), e.opts);
906
+ if (INTROSPECT_ON) {
907
+ const effHandles = LABELS_ON ? [] : null;
908
+ for (let i = 0; i < effs.length; i++) {
909
+ const h = reg.effect(makeEffectBody(inst, effs[i].fn), effs[i].opts);
910
+ if (effHandles !== null) effHandles.push(h);
911
+ }
912
+ introspectWire(inst, plan, reg, effHandles);
913
+ } else {
914
+ for (let i = 0; i < effs.length; i++) {
915
+ const e = effs[i];
916
+ reg.effect(makeEffectBody(inst, e.fn), e.opts);
917
+ }
872
918
  }
873
919
  });
874
920
  } catch (e) {
@@ -1258,6 +1304,10 @@ export function disposeReactive(vm) {
1258
1304
  }
1259
1305
  }
1260
1306
  }
1307
+ // S4 introspection cleanup (one flag test on the OFF dispose path): drop this
1308
+ // instance's label entries and unregister it from the audit FR so a proper
1309
+ // dispose is never mistaken for a silent death.
1310
+ if (INTROSPECT_ON) introspectDispose(vm, reg);
1261
1311
  disposeCore(vm, plan);
1262
1312
  return true;
1263
1313
  }
@@ -1295,7 +1345,320 @@ export function rootOf(vm) {
1295
1345
  return a;
1296
1346
  }
1297
1347
 
1348
+ // --- Introspection & audit (S4; all cold / opt-in) ----------------------------
1349
+
1350
+ function throwCostFactory() {
1351
+ throw new TypeError(
1352
+ `${ERR}costOf(Factory) -- Factory must be a @reactiveHost / defineReactive wrapper class.`,
1353
+ );
1354
+ }
1355
+
1356
+ function throwCostNoPlan() {
1357
+ throw new Error(
1358
+ `${ERR}costOf(Factory) -- that class has no reactive plan; pass the class returned by @reactiveHost or defineReactive, not the undecorated inner class.`,
1359
+ );
1360
+ }
1361
+
1362
+ function throwCostNoStats(name) {
1363
+ throw new TypeError(
1364
+ `${ERR}costOf(${name}) -- the bound registry has no stats() ledger; costOf/capacityFor need a createRegistry() registry (which always carries the stats ledger). A hand-rolled 11-method registry facade cannot be probed.`,
1365
+ );
1366
+ }
1367
+
1368
+ function throwCostInconclusive(name, a, b) {
1369
+ throw new Error(
1370
+ `${ERR}costOf(${name}) -- inconclusive: two probes disagreed (nodes ${a.nodes}/${b.nodes}, links ${a.links}/${b.links}). A data-dependent derived read, or a registry mutated mid-probe, makes the cost non-deterministic; costOf fails closed rather than guess.`,
1371
+ );
1372
+ }
1373
+
1374
+ function throwCostNodeMismatch(name, got, want) {
1375
+ throw new Error(
1376
+ `${ERR}costOf(${name}) -- probed node count ${got} != P+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
1377
+ );
1378
+ }
1379
+
1380
+ function throwCostFloor(name) {
1381
+ throw new Error(
1382
+ `${ERR}costOf(${name}) -- dispose did not return the bound registry to its pre-probe floor; the probe could not run against a quiet registry.`,
1383
+ );
1384
+ }
1385
+
1386
+ // costOf runs the probe twice and requires identical deltas: an inconclusive
1387
+ // probe is a fail-closed THROW, never a guessed number (PD-21).
1388
+ function probeCost(Factory, plan, reg) {
1389
+ const before = reg.stats();
1390
+ const inst = new Factory();
1391
+ const ders = plan.deriveds;
1392
+ for (let i = 0; i < ders.length; i++) void inst[ders[i].key]; // force lazy links
1393
+ const mid = reg.stats();
1394
+ const nodes = mid.activeNodes - before.activeNodes;
1395
+ const links = mid.activeLinks - before.activeLinks;
1396
+ disposeReactive(inst);
1397
+ const after = reg.stats();
1398
+ if (after.activeNodes !== before.activeNodes || after.activeLinks !== before.activeLinks) {
1399
+ throwCostFloor(plan.ctorName);
1400
+ }
1401
+ return { nodes, links };
1402
+ }
1403
+
1404
+ /**
1405
+ * Measure the settled per-instance cost of a reactive class on its bound
1406
+ * registry: construct, read every `@derived` once (forcing the lazy links),
1407
+ * snapshot, dispose, verify the floor -- twice, requiring identical deltas.
1408
+ * Returns a frozen `{ nodes, links, signals, deriveds, effects }`; `nodes`
1409
+ * equals P+D+E+1. Cached per class. Throws (never guesses) on an inconclusive
1410
+ * or polluted probe. Constructs the probe instance with no arguments.
1411
+ */
1412
+ export function costOf(Factory) {
1413
+ if (typeof Factory !== "function") throwCostFactory();
1414
+ const cached = COST_CACHE.get(Factory);
1415
+ if (cached !== undefined) return cached;
1416
+ const plan = PLANS.get(Factory);
1417
+ if (plan === undefined) throwCostNoPlan();
1418
+ const reg = plan.reg;
1419
+ // The 11-method REG_METHODS duck-check excludes stats (the wiring/dispose
1420
+ // paths never need it), so a hand-rolled facade can be duck-valid yet lack
1421
+ // stats -- guard here rather than let probeCost throw a raw TypeError.
1422
+ if (typeof reg.stats !== "function") throwCostNoStats(plan.ctorName);
1423
+ const first = probeCost(Factory, plan, reg);
1424
+ const second = probeCost(Factory, plan, reg);
1425
+ if (first.nodes !== second.nodes || first.links !== second.links) {
1426
+ throwCostInconclusive(plan.ctorName, first, second);
1427
+ }
1428
+ const sig = plan.signals.length;
1429
+ const der = plan.deriveds.length;
1430
+ const eff = plan.effects.length;
1431
+ const expected = sig + der + eff + 1;
1432
+ if (first.nodes !== expected) throwCostNodeMismatch(plan.ctorName, first.nodes, expected);
1433
+ const result = Object.freeze({
1434
+ nodes: first.nodes,
1435
+ links: first.links,
1436
+ signals: sig,
1437
+ deriveds: der,
1438
+ effects: eff,
1439
+ });
1440
+ COST_CACHE.set(Factory, result);
1441
+ return result;
1442
+ }
1443
+
1444
+ function throwCapInventory() {
1445
+ throw new TypeError(
1446
+ `${ERR}capacityFor(inventory) -- inventory must be a non-empty array of [Factory, count] pairs.`,
1447
+ );
1448
+ }
1449
+
1450
+ function throwCapPair(i) {
1451
+ throw new TypeError(
1452
+ `${ERR}capacityFor -- inventory[${i}] must be a [Factory, count] pair.`,
1453
+ );
1454
+ }
1455
+
1456
+ function throwCapFactory(i) {
1457
+ throw new TypeError(
1458
+ `${ERR}capacityFor -- inventory[${i}][0] must be a @reactiveHost / defineReactive wrapper class.`,
1459
+ );
1460
+ }
1461
+
1462
+ function throwCapCount(i) {
1463
+ throw new TypeError(
1464
+ `${ERR}capacityFor -- inventory[${i}][1] must be a positive integer count.`,
1465
+ );
1466
+ }
1467
+
1468
+ function throwCapHeadroom() {
1469
+ throw new TypeError(
1470
+ `${ERR}capacityFor -- headroom must be a finite number >= 1.`,
1471
+ );
1472
+ }
1473
+
1474
+ function throwCapOptions(options) {
1475
+ const got = Array.isArray(options) ? "an array" : typeof options;
1476
+ throw new TypeError(
1477
+ `${ERR}capacityFor(inventory, options?) -- options must be a plain object like { headroom: 1.25 }; got ${got}.`,
1478
+ );
1479
+ }
1480
+
1481
+ /**
1482
+ * Size a `createRegistry` config for a stated inventory of `[Factory, count]`
1483
+ * pairs. Nodes are exact (`sum(cost.nodes x count)`); links are
1484
+ * `sum(cost.links x count)` scaled by `headroom` (default 1 -- exact; see
1485
+ * decisions/0007). Returns
1486
+ * `{ maxNodes, maxLinks, prealloc: "eager", onCapacityExceeded: "throw" }`.
1487
+ * Fail-closed on a non-factory, a non-positive/non-integer count, an empty
1488
+ * inventory, or a bad `headroom`.
1489
+ */
1490
+ export function capacityFor(inventory, options) {
1491
+ if (!Array.isArray(inventory) || inventory.length === 0) throwCapInventory();
1492
+ let headroom = 1;
1493
+ if (options !== undefined && options !== null) { // null == omitted (preserved)
1494
+ if (typeof options !== "object" || Array.isArray(options)) throwCapOptions(options);
1495
+ const okeys = Object.keys(options);
1496
+ for (let i = 0; i < okeys.length; i++) {
1497
+ if (okeys[i] !== "headroom") throwUnknownOption("capacityFor", okeys[i], CAP_OPTION_KEYS);
1498
+ }
1499
+ if (options.headroom !== undefined) {
1500
+ const h = options.headroom;
1501
+ if (typeof h !== "number" || !isFinite(h) || h < 1) throwCapHeadroom();
1502
+ headroom = h;
1503
+ }
1504
+ }
1505
+ let totalNodes = 0;
1506
+ let totalLinks = 0;
1507
+ for (let i = 0; i < inventory.length; i++) {
1508
+ const pair = inventory[i];
1509
+ if (!Array.isArray(pair) || pair.length !== 2) throwCapPair(i);
1510
+ const Factory = pair[0];
1511
+ const count = pair[1];
1512
+ if (typeof Factory !== "function") throwCapFactory(i);
1513
+ if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) throwCapCount(i);
1514
+ const cost = costOf(Factory);
1515
+ totalNodes += cost.nodes * count;
1516
+ totalLinks += cost.links * count;
1517
+ }
1518
+ return {
1519
+ maxNodes: totalNodes, // exact -- nodes are deterministic
1520
+ // links x headroom, floored at the engine minimum of 1 (createRegistry
1521
+ // rejects maxLinks: 0) so a signals-only inventory still yields a
1522
+ // constructible config (0007). Floor is applied AFTER the multiplier.
1523
+ maxLinks: Math.max(1, Math.ceil(totalLinks * headroom)),
1524
+ prealloc: "eager",
1525
+ onCapacityExceeded: "throw",
1526
+ };
1527
+ }
1528
+
1529
+ function throwFlagArg(what) {
1530
+ throw new TypeError(`${ERR}${what}(on) -- on must be a boolean.`);
1531
+ }
1532
+
1533
+ // Per-class label strings, built once and shared by every instance (PD-23).
1534
+ function labelStringsFor(plan) {
1535
+ let s = LABEL_STRINGS.get(plan);
1536
+ if (s !== undefined) return s;
1537
+ const name = plan.ctorName;
1538
+ const sig = [];
1539
+ for (let i = 0; i < plan.signals.length; i++) sig.push(`${name}.${keyLabel(plan.signals[i].key)}`);
1540
+ const der = [];
1541
+ for (let i = 0; i < plan.deriveds.length; i++) der.push(`${name}.${keyLabel(plan.deriveds[i].key)}`);
1542
+ const eff = [];
1543
+ for (let i = 0; i < plan.effects.length; i++) eff.push(`${name}#${keyLabel(plan.effects[i].key)}`);
1544
+ s = { anchor: `${name}@anchor`, signals: sig, deriveds: der, effects: eff };
1545
+ LABEL_STRINGS.set(plan, s);
1546
+ return s;
1547
+ }
1548
+
1549
+ function registerLabels(inst, plan, reg, effHandles) {
1550
+ let map = LABEL_MAPS.get(reg);
1551
+ if (map === undefined) {
1552
+ map = new Map();
1553
+ LABEL_MAPS.set(reg, map);
1554
+ }
1555
+ const strings = labelStringsFor(plan);
1556
+ const ids = [];
1557
+ const anchorId = reg.nodeId(inst[ANCHOR]);
1558
+ if (anchorId !== undefined) { map.set(anchorId, strings.anchor); ids.push(anchorId); }
1559
+ const sigs = plan.signals;
1560
+ for (let i = 0; i < sigs.length; i++) {
1561
+ const id = reg.nodeId(inst[sigs[i].slot]);
1562
+ if (id !== undefined) { map.set(id, strings.signals[i]); ids.push(id); }
1563
+ }
1564
+ const ders = plan.deriveds;
1565
+ for (let i = 0; i < ders.length; i++) {
1566
+ const id = reg.nodeId(inst[ders[i].slot]);
1567
+ if (id !== undefined) { map.set(id, strings.deriveds[i]); ids.push(id); }
1568
+ }
1569
+ if (effHandles !== null) {
1570
+ for (let i = 0; i < effHandles.length; i++) {
1571
+ const id = reg.nodeId(effHandles[i]);
1572
+ if (id !== undefined) { map.set(id, strings.effects[i]); ids.push(id); }
1573
+ }
1574
+ }
1575
+ inst[LABEL_IDS] = ids;
1576
+ }
1577
+
1578
+ function auditShape(plan) {
1579
+ return `P=${plan.signals.length} D=${plan.deriveds.length} E=${plan.effects.length}`;
1580
+ }
1581
+
1582
+ function auditFinalize(held) {
1583
+ // held is a plain { className, shape } record -- it never references the
1584
+ // (now-collected) instance, so the FR cannot itself retain what it watches.
1585
+ console.error(
1586
+ `${ERR}auditReactive: an instance of ${held.className} (${held.shape}) was garbage-collected without disposeReactive() -- its reactive graph was reclaimed by GC, not by you. Dispose at end of life: disposeReactive(vm), or a \`using\` block.`,
1587
+ );
1588
+ }
1589
+
1590
+ // Wiring-time introspection (S4): only reached when INTROSPECT_ON. Registers
1591
+ // per-node labels (while LABELS_ON) and the audit FR entry (while AUDIT_ON).
1592
+ function introspectWire(inst, plan, reg, effHandles) {
1593
+ if (LABELS_ON) registerLabels(inst, plan, reg, effHandles);
1594
+ if (AUDIT_ON && AUDIT_FR !== null) {
1595
+ AUDIT_FR.register(inst, { className: plan.ctorName, shape: auditShape(plan) }, inst);
1596
+ }
1597
+ }
1598
+
1599
+ // Dispose-time introspection (S4): only reached when INTROSPECT_ON. Drops this
1600
+ // instance's label entries and unregisters it from the audit FR.
1601
+ function introspectDispose(vm, reg) {
1602
+ const ids = vm[LABEL_IDS];
1603
+ if (ids !== undefined) {
1604
+ const map = LABEL_MAPS.get(reg);
1605
+ if (map !== undefined) for (let i = 0; i < ids.length; i++) map.delete(ids[i]);
1606
+ vm[LABEL_IDS] = undefined;
1607
+ }
1608
+ if (AUDIT_FR !== null) AUDIT_FR.unregister(vm);
1609
+ }
1610
+
1611
+ /**
1612
+ * Toggle devtools labels (default OFF). While ON, wiring registers a
1613
+ * `nodeId -> "Class.prop" / "Class#method" / "Class@anchor"` label for every
1614
+ * node an instance creates, into a per-registry map; `disposeReactive`
1615
+ * unregisters them. OFF adds no hot-path cost (the accessor canon is untouched).
1616
+ */
1617
+ export function enableLabels(on) {
1618
+ if (typeof on !== "boolean") throwFlagArg("enableLabels");
1619
+ LABELS_ON = on;
1620
+ INTROSPECT_ON = LABELS_ON || AUDIT_ON;
1621
+ }
1622
+
1623
+ /**
1624
+ * Resolve a node id (or a handle, via the registry's `nodeId`) to its label, or
1625
+ * `undefined` if unlabeled/unknown -- an introspection miss is never an error.
1626
+ * `registry` defaults to the default registry; pass a custom `Registry` to look
1627
+ * up nodes it owns.
1628
+ */
1629
+ export function labelOf(idOrHandle, registry) {
1630
+ const reg = registry === undefined || registry === null ? DEFAULT_REG : registry;
1631
+ if (typeof reg !== "object" || reg === null) return undefined;
1632
+ let id;
1633
+ if (typeof idOrHandle === "number") {
1634
+ id = idOrHandle;
1635
+ } else if (idOrHandle !== null && typeof idOrHandle === "object" && typeof reg.nodeId === "function") {
1636
+ id = reg.nodeId(idOrHandle);
1637
+ if (id === undefined) return undefined;
1638
+ } else {
1639
+ return undefined;
1640
+ }
1641
+ const map = LABEL_MAPS.get(reg);
1642
+ if (map === undefined) return undefined;
1643
+ return map.get(id);
1644
+ }
1645
+
1646
+ /**
1647
+ * Toggle the leak auditor (default OFF). While ON, a lazily-created
1648
+ * FinalizationRegistry reports (one `console.error`) any instance that is
1649
+ * garbage-collected WITHOUT `disposeReactive` -- naming the class + shape. OFF:
1650
+ * no FinalizationRegistry exists and nothing is registered.
1651
+ */
1652
+ export function auditReactive(on) {
1653
+ if (typeof on !== "boolean") throwFlagArg("auditReactive");
1654
+ if (on && AUDIT_FR === null && typeof FinalizationRegistry === "function") {
1655
+ AUDIT_FR = new FinalizationRegistry(auditFinalize);
1656
+ }
1657
+ AUDIT_ON = on;
1658
+ INTROSPECT_ON = LABELS_ON || AUDIT_ON;
1659
+ }
1660
+
1298
1661
  // --- Version ------------------------------------------------------------------
1299
1662
 
1300
1663
  /** Package version. Kept in lockstep with package.json and llms.txt. */
1301
- export const VERSION = "0.3.0";
1664
+ export const VERSION = "1.0.0";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 0.3.0
3
+ VERSION 1.0.0
4
4
 
5
5
  > Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into a
6
6
  > reactive view-model where each instance has a measured per-property cost, a
@@ -20,7 +20,7 @@ zero decorator syntax, sharing the SAME core by function identity.
20
20
  slot for a poison handle, so any later read/write throws a named
21
21
  `ReactiveDisposedError`.
22
22
 
23
- ## Exports (11)
23
+ ## Exports (16)
24
24
 
25
25
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
26
26
  (factory). Declares a per-instance signal.
@@ -53,9 +53,22 @@ slot for a poison handle, so any later read/write throws a named
53
53
  dispose.
54
54
  - `rootOf(vm) -> NodeDescriptor` -- the instance's anchor descriptor; feeds
55
55
  `forEachOwned` / lite-devtools. Throws `ReactiveDisposedError` after dispose.
56
+ - `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
57
+ measured settled per-instance cost on the class's bound registry (frozen,
58
+ cached). Double-probed: an inconclusive or polluted probe throws, never
59
+ guesses. `nodes` = P+D+E+1; `links` = the first-full-read link count. See
60
+ "Introspection & audit".
61
+ - `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
62
+ `createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
63
+ `headroom` (default 1). Fail-closed inventory validation.
64
+ - `enableLabels(on)` / `labelOf(idOrHandle, registry?) -> string | undefined` --
65
+ opt-in devtools labels (default OFF); per-registry `nodeId -> "Class.prop" /
66
+ "Class#method" / "Class@anchor"`.
67
+ - `auditReactive(on)` -- opt-in leak auditor (default OFF); a lazy
68
+ `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
56
69
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
57
70
  fields `className` and `key`.
58
- - `VERSION` -- `"0.3.0"`.
71
+ - `VERSION` -- `"1.0.0"`.
59
72
 
60
73
  ## Registry law (one registry per host chain)
61
74
 
@@ -118,6 +131,45 @@ with the decorator path: values, deriveds, effect methods, disposal, poison, and
118
131
  capacity accounting are identical because both front doors call the SAME wiring
119
132
  core.
120
133
 
134
+ ## Introspection & audit (S4; all cold, all opt-in)
135
+
136
+ `costOf(Factory)` measures a class's settled per-instance cost by a stats-delta
137
+ probe on its bound registry: construct, read every `@derived` once (forcing the
138
+ lazy links), snapshot, dispose, verify the floor -- run TWICE, requiring
139
+ identical deltas. An inconclusive probe (a data-dependent derived read, or a
140
+ registry mutated mid-probe) or a floor-verify failure THROWS a named error;
141
+ costOf never returns a guess. `nodes` equals P+D+E+1 (the R-A law); `links` is
142
+ the first-full-read count. The result is frozen and cached per class. costOf
143
+ constructs the probe instance with NO constructor arguments.
144
+
145
+ `capacityFor(inventory, { headroom } = {})` turns `[Factory, count]` pairs into a
146
+ ready `createRegistry` config `{ maxNodes, maxLinks, prealloc: "eager",
147
+ onCapacityExceeded: "throw" }`. Nodes are exact (`sum(cost.nodes x count)`);
148
+ links are `sum(cost.links x count)` scaled by `headroom` (default 1 = exact),
149
+ floored at the engine minimum of 1 so a signals-only inventory stays
150
+ constructible.
151
+ FIXED-shape deriveds provision exactly; a BRANCHY derived whose active branch
152
+ reads more members than the probe measured under-provisions and the engine throws
153
+ `CapacityError` at link formation (loud, named -- raise `headroom` for such
154
+ workloads). See decisions/0007.
155
+
156
+ Labels (`enableLabels(true)`, default OFF) register, at wiring, a per-registry
157
+ `nodeId -> label` for every node an instance creates
158
+ (`"Class.prop"` / `"Class#method"` / `"Class@anchor"`); `labelOf(idOrHandle,
159
+ registry?)` resolves them (an unknown id returns `undefined` -- a miss is not an
160
+ error); `disposeReactive` unregisters. Maps are per-registry (node ids are
161
+ per-registry). OFF adds no hot-path cost -- the accessor canon is byte-identical
162
+ to 0.3.0; labels feed a `graph()`/`toTree()` devtools walk from `rootOf(vm)`.
163
+
164
+ Audit (`auditReactive(true)`, default OFF) lazily creates one
165
+ `FinalizationRegistry` and reports (one `console.error`) any instance
166
+ garbage-collected WITHOUT `disposeReactive`, naming the class + shape. OFF: no
167
+ `FinalizationRegistry` exists, nothing is registered, wiring measures unchanged.
168
+ Reach: an instance still pinned by its own undisposed derived/effect nodes on a
169
+ long-lived registry is never collected, so audit cannot fire for it -- that
170
+ retention is what the leak/retention torture catches; audit catches instances
171
+ that reach GC without dispose (e.g. a per-scope registry dropped whole).
172
+
121
173
  ## Rejections (all named, all fail-closed, all at decoration/definition time)
122
174
 
123
175
  Legacy emit, wrong decorator kind, static members, private (#) members, unknown
@@ -133,12 +185,26 @@ throw a named error.
133
185
  Requires `@zakkster/lite-signal` `>=1.5.0 <2.0.0` (peer dependency). Uses
134
186
  `signalBox`, `computedBox`, `effect`, `createRoot`, `getOwner`, `runWithOwner`,
135
187
  `dispose`, `nodeId`, `isTracking`, `batch`, and `untrack` from it (all 11 also
136
- routed per-registry via `createRegistry()`).
188
+ routed per-registry via `createRegistry()`). The RANGE floor is 1.5.0 and stays
189
+ 1.5.0 -- everything the runtime surface needs shipped in 1.5.0 stable. Two
190
+ forward-compat torture scenarios are feature-detected above that floor and SKIP
191
+ below it (a skip below the floor is correct; at or above it is a failure): the
192
+ `createScope` adoption scenario needs peer 1.6.0, and the engine
193
+ `[Symbol.dispose]`/`using` interop scenario needs peer 1.9.0. Neither is a
194
+ runtime requirement -- the shipped surface runs on 1.5.0.
137
195
 
138
196
  ## Scope note
139
197
 
140
- 0.3.0 = the 0.2.0 runtime surface (unchanged -- the same 11 exports, frozen for
141
- 0.x) plus the dev-side class-reactivity benchmark (`bench/`, private, never
198
+ 1.0.0 FREEZES the export surface -- 16 exports: the 11 runtime exports plus
199
+ `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and `auditReactive` (all
200
+ cold / opt-in; the hot accessor canon is byte-identical to 0.3.0). The semver
201
+ promise from here: any change to an existing export's signature or behavior is a
202
+ MAJOR, recorded in a decision file; new exports are minors; the hot accessor
203
+ canon (`makeGet`/`makeSet`) does not move without a major. Also present since
204
+ 0.3.0: the dev-side class-reactivity benchmark (`bench/`, private, never
142
205
  shipped) and the fleet-soak torture scenario; the formal kill-criteria verdicts
143
- live in decisions/0006-kill-criteria.md. Not yet included: cost introspection
144
- (`costOf`/`capacityFor`), labels, an audit hook, and private-member support.
206
+ live in decisions/0006-kill-criteria.md. Still OUT of the 1.0 surface (each a
207
+ deliberate, documented exclusion, not an oversight): private (`#`) members,
208
+ static members, module/global signals (raw lite-signal territory), and any
209
+ instance reinitialization / pooling API -- an instance is single-lifetime,
210
+ disposed once, never revived.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "Stage-3 decorator layer over @zakkster/lite-signal. The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
@@ -66,6 +66,8 @@
66
66
  "torture:semantic": "node test/torture/run.mjs --group semantic",
67
67
  "torture:soak": "node test/torture/run.mjs --group soak",
68
68
  "torture:controls": "node test/torture/run.mjs --controls",
69
+ "torture:peer-preview": "node test/torture/peer-preview.mjs",
70
+ "gate": "node test/gate.mjs",
69
71
  "spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
70
72
  "spike:emit": "node --expose-gc spikes/emit/probe.mjs",
71
73
  "spike:ownership": "node --expose-gc spikes/ownership.mjs",
@@ -73,7 +75,11 @@
73
75
  "spike:manual": "node --expose-gc spikes/manual-call.mjs",
74
76
  "spike:poison": "node --expose-gc spikes/poison.mjs",
75
77
  "spike:buildless": "node --expose-gc spikes/buildless.mjs",
76
- "fixtures:regen": "node spikes/emit/regen.mjs"
78
+ "fixtures:regen": "node spikes/emit/regen.mjs",
79
+ "demo:build": "node demo/build.mjs",
80
+ "demo:check": "node demo/build.mjs --check",
81
+ "demo:gc": "node --expose-gc demo/gc-lane.mjs",
82
+ "demo:storm": "node --expose-gc demo/storm-lane.mjs"
77
83
  },
78
84
  "peerDependencies": {
79
85
  "@zakkster/lite-signal": ">=1.5.0 <2.0.0"
@@ -82,9 +88,12 @@
82
88
  "@babel/core": "^7.25.0",
83
89
  "@babel/plugin-proposal-decorators": "^7.25.0",
84
90
  "@babel/preset-typescript": "^7.29.7",
91
+ "@zakkster/lite-devtools": "^1.4.0",
85
92
  "@zakkster/lite-gc-profiler": "^1.16.0",
86
93
  "@zakkster/lite-leak": "^1.10.0",
87
94
  "@zakkster/lite-signal": "1.5.0",
95
+ "@zakkster/lite-watch-ex": "^1.1.0",
96
+ "esbuild": "0.28.2",
88
97
  "typescript": "^5.6.0"
89
98
  }
90
99
  }