@zakkster/lite-signal-decorators 0.2.0 → 0.4.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,131 @@ 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
+ ## [0.4.0] - 2026-08-26
8
+
9
+ The introspection release: the surface grows 11 -> 16, every addition cold-path
10
+ or opt-in, the hot accessor canon byte-identical to 0.3.0 (review-diffed
11
+ against the published tarball). Stage gate, measured at closeout: 213/213
12
+ tests on both lanes; torture 15 scenarios (13 pass + the two forward-compat
13
+ scenarios legitimately skipping under the installed 1.5.0 peer) with 15/15
14
+ sabotage controls breaking as required; the peer-preview lane SUITE-GREEN
15
+ (15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
16
+ 1.9.0-canary.1; pack 7 files.
17
+
18
+ ### Added
19
+
20
+ - `costOf(Factory)` -- the measured, settled per-instance cost on the class's
21
+ bound registry: `{ nodes, links, signals, deriveds, effects }`, frozen and
22
+ cached per class. Quiet-required, floor-verified, DOUBLE-probed with
23
+ identical-deltas-or-throw: an inconclusive probe (a derived whose read set
24
+ changes between runs, a polluted registry) throws a named error, never
25
+ guesses. `nodes` reproduces the 0002 Q3 grid exactly (P + D + E + 1);
26
+ `links` is the first-full-read count.
27
+ - `capacityFor(inventory, { headroom }?)` -- sizes a ready `createRegistry`
28
+ config from `[Factory, count]` pairs: nodes exact, links x `headroom`
29
+ floored at the engine minimum, `prealloc: "eager"`,
30
+ `onCapacityExceeded: "throw"`; fail-closed inventory and options
31
+ validation. The link-headroom policy -- nodes exact; fixed-shape deriveds
32
+ provision exactly at first-full-read; branchy-read deriveds fail LOUD with
33
+ the `headroom` knob as the documented escape -- is recorded with its
34
+ measured evidence in `decisions/0007-capacity-policy.md`, closing 0002's
35
+ open question.
36
+ - `enableLabels(on)` / `labelOf(idOrHandle, registry?)` -- opt-in devtools
37
+ identity (default OFF): per-registry `nodeId -> "Class.prop" /
38
+ "Class#method" / "Class@anchor"` maps, per-class shared label strings,
39
+ dispose unregisters, misses return `undefined`. A feature-detected
40
+ integration test walks lite-devtools `graph()`/`toTree` from `rootOf(vm)`
41
+ and resolves every walked node. The one-line devtools `labelResolver`
42
+ upstream proposal is recorded in `decisions/0008-introspection.md`.
43
+ - `auditReactive(on)` -- opt-in leak auditor (default OFF): a lazily-created
44
+ `FinalizationRegistry` reports any instance collected without
45
+ `disposeReactive`, naming class and shape; it holds no instance references
46
+ itself, unregisters on dispose, and registers nothing while off (proven by
47
+ a child-process `--expose-gc` test).
48
+ - Forward-compat torture: `scope-adoption` (floor 1.6.0) and `using-dispose`
49
+ (floor 1.9.0), written against the REAL probed future engine surfaces
50
+ (1.6.0's `createScope` adoption; 1.9.0's native `[Symbol.dispose]` on
51
+ handles) via a typeof-only feature probe (`test/shared/peer-probe.mjs` --
52
+ never version parsing). Their sabotage controls lie about the probe, so
53
+ they fail loudly even under the current peer. Plus `torture:peer-preview`:
54
+ a scratch-install lane that runs the whole suite against the peer's
55
+ `preview` and `canary` dist-tags and reports per-tag verdicts.
56
+ - Tests: `12-accounting` (11), `13-labels-audit` (10, incl. the child-process
57
+ audit fixture), `14-qa-s4-boundary` (21 adversarial pins), and two
58
+ capacityFor round-trip lanes (node-bound and link-bound) in
59
+ capacity-torture. Suite 171 -> 213. Dev-only devDependency:
60
+ `@zakkster/lite-devtools` (integration walk).
61
+
62
+ ### Fixed
63
+
64
+ - (Found in review) A registry passing the 11-method duck-check but lacking
65
+ `stats()` -- constructible from the public surface -- made `costOf` and
66
+ `capacityFor` die with a raw `TypeError` instead of a named error. Now a
67
+ named, ERR-prefixed throw explains that probing needs a `createRegistry()`
68
+ registry with its stats ledger. The falsified "no reachable raw path"
69
+ claim in 0008 is amended with the counterexample preserved, rejection-
70
+ history style.
71
+ - (Found in QA) `capacityFor`'s options guard borrowed decorator-flavored
72
+ usage wording for a plain function call, and accepted an ARRAY as an
73
+ options bag; it now throws the call-form message
74
+ (`options must be a plain object like { headroom: 1.25 }`) for both.
75
+ `null` still means "omitted".
76
+ - (Found writing tests) `capacityFor` on a signals-only inventory produced
77
+ `maxLinks: 0`, which `createRegistry` rejects; the returned links are now
78
+ floored at the engine minimum of 1 so every valid inventory yields a
79
+ constructible config.
80
+
81
+ ## [0.3.0] - 2026-08-26
82
+
83
+ The class-reactivity benchmark release. Zero runtime changes: the 11-export
84
+ surface ships logic-identical to 0.2.0 (version constants aside); what this
85
+ release adds is the proof. Stage gate, all measured at closeout: 171/171
86
+ tests on both lanes; torture 13 scenarios / 13 sabotage controls all
87
+ green-and-breakable; the full benchmark matrix 42/42 lanes `sink=ok`, exit 0.
88
+
89
+ ### Added
90
+
91
+ - `bench/` -- a private, never-shipped benchmark sub-package: six admitted
92
+ engines (`lsd` decorators, `lsd-define` buildless, the `lite-raw-boxes`
93
+ honesty baseline, MobX 7, signal-utils + signal-polyfill, a hand-rolled
94
+ alien-signals class) x seven class-shaped scenarios (vm-write, fleet-read
95
+ and fleet-tick over 10k instances, cascade 64/16+aggregate, deep-vm 64-deep,
96
+ churn as the headline, retention as gates). Ported lite-signal rig: anti-DCE
97
+ sink with analytic expected-sum oracles (skipped work rejects the lane),
98
+ median-of-5 + min, GC-fenced runs with real delta/retained heap columns,
99
+ machine provenance stamps with resolved adapter versions, a sabotaged-adapter
100
+ self-test proving the harness can fail, and machine-gated effect liveness on
101
+ lifecycle lanes. Candidates `classy-solid` and `@reactively/decorate` probed
102
+ and excluded with recorded one-line blockers (solid's stock-Node SSR
103
+ resolution; legacy-only decorators). Reproducible from a clean clone:
104
+ `cd bench && npm install && npm run bench`.
105
+ - `fleet-soak` -- the 13th torture scenario (soak group): 2000 VMs (16000 pool
106
+ nodes exactly) under sustained ticks with partial churn rotations for a
107
+ wall-clock budget; per-sample F-0, flat retained heap, gcGate maxMajor 0;
108
+ its sabotage control leaks one VM per rotation and is caught at the first
109
+ sample.
110
+ - `decisions/0006-kill-criteria.md` -- the formal verdicts. Criterion 1 (the
111
+ 2.0x line vs the hand-written instance-field baseline) CLEARED WITH MARGIN:
112
+ vm-write 0.94x, fleet-read 1.10x (parity, per 0003's tied-layout finding).
113
+ Criterion 2 (churn cleanliness) CLEARED: 0 major and 0 minor collections
114
+ over 4096 lifecycle cycles on every lite lane, pools at floor (asserted
115
+ in-lane), retained settled at-or-below baseline -- and the decorated churn
116
+ loop emits ~12.6x less transient garbage than the hand-rolled equivalent.
117
+ Every competitor number published under the same stamp (AD-6 honesty
118
+ tiering); the wide-shape cascade/deep-vm gap vs raw boxes is recorded as an
119
+ S4 investigation item, not hidden.
120
+
121
+ ### Changed
122
+
123
+ - README: the measured-numbers section now cites the cross-framework matrix
124
+ and the 0006 verdicts alongside the package's own committed probes; version
125
+ references bumped.
126
+ - CHANGELOG release links now point at the package `repository`
127
+ (github.com/PeshoVurtoleta/lite-signal-decorators), matching the new
128
+ `repository`/`homepage`/`funding` fields in package.json.
129
+ - `llms.txt` scope note: 0.3.0 adds only dev-side proof; the export surface
130
+ is unchanged and frozen for 0.x.
131
+
7
132
  ## [0.2.0] - 2026-08-26
8
133
 
9
134
  The adversarial-torture release: the 0.2 surface is frozen for 0.x, proven by
@@ -54,9 +179,8 @@ gc major=0 minor=0`.
54
179
 
55
180
  ## [0.2.0-preview.1] - 2026-08-26
56
181
 
57
- The full runtime surface. Exports grow from 8 to 11. Never published: the
58
- preview publish was pending npm authentication when 0.2.0 superseded it; the
59
- changes below first shipped with 0.2.0.
182
+ The full runtime surface. Exports grow from 8 to 11. Published under the
183
+ `preview` dist-tag on the same day 0.2.0 went to `latest`.
60
184
 
61
185
  ### Added
62
186
 
@@ -152,6 +276,8 @@ Initial release -- the decorator core.
152
276
  - Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
153
277
  retention, conservation, lifecycle, and zero-GC lanes.
154
278
 
155
- [0.2.0]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.2.0
156
- [0.2.0-preview.1]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.2.0-preview.1
157
- [0.1.0]: https://github.com/zakkster/lite-signal-decorators/releases/tag/v0.1.0
279
+ [0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
280
+ [0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
281
+ [0.2.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0
282
+ [0.2.0-preview.1]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0-preview.1
283
+ [0.1.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.1.0
package/README.md CHANGED
@@ -240,12 +240,23 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
240
240
  | `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
241
  | `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
242
242
 
243
+ ### Introspection & audit (0.4.0)
244
+
245
+ | Export | Signature | Behavior |
246
+ |---|---|---|
247
+ | `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. |
248
+ | `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). |
249
+ | `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. |
250
+ | `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. |
251
+
252
+ 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).
253
+
243
254
  ### Errors & constants
244
255
 
245
256
  | Export | Value |
246
257
  |---|---|
247
258
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
248
- | `VERSION` | `"0.2.0"` |
259
+ | `VERSION` | `"0.4.0"` |
249
260
 
250
261
  ### The rejection matrix
251
262
 
@@ -348,7 +359,7 @@ The gates that hold it (run on every change, all green at 0.2.0):
348
359
  - 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
360
  - `churn-soak`: sustained construct/use/dispose for a wall-clock budget; pools at floor and retained heap flat at every sample.
350
361
 
351
- The cross-framework churn benchmark (vs MobX-style layers and signal-utils, with the same anti-DCE rig) is the 0.3.0 milestone; the numbers above are the package's own committed probes.
362
+ 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
363
 
353
364
  </details>
354
365
 
@@ -473,7 +484,7 @@ Because an instance whose base-class boxes live in one pool and whose subclass b
473
484
  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
485
 
475
486
  **Where are `costOf`, labels, the audit hook, private members?**
476
- Not in 0.2.0 -- the runtime surface is complete and frozen for 0.x, and the introspection layer is roadmapped behind the 0.3.0 benchmark. The `llms.txt` scope note tracks exactly what is and isn't included.
487
+ The first three landed in 0.4.0 -- `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
488
 
478
489
  ---
479
490
 
@@ -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.2.0";
369
+ export const VERSION: "0.4.0";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v0.2.0
2
+ * @zakkster/lite-signal-decorators v0.4.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.2.0";
1664
+ export const VERSION = "0.4.0";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 0.2.0
3
+ VERSION 0.4.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.2.0"`.
71
+ - `VERSION` -- `"0.4.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
@@ -137,7 +189,10 @@ routed per-registry via `createRegistry()`).
137
189
 
138
190
  ## Scope note
139
191
 
140
- 0.2.0 ships the full runtime surface: the decorator core plus
141
- `@reactiveEffect`, `@batched`, `@reactiveHost({ registry })`, and
142
- `defineReactive`. Not yet included: cost introspection (`costOf`/`capacityFor`),
143
- labels, an audit hook, and private-member support.
192
+ 0.4.0 adds the S4 introspection surface -- 16 exports: the 11 runtime
193
+ exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
194
+ `auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
195
+ 0.3.0). Also present since 0.3.0: the dev-side class-reactivity benchmark
196
+ (`bench/`, private, never shipped) and the fleet-soak torture scenario; the
197
+ formal kill-criteria verdicts live in decisions/0006-kill-criteria.md. Not yet
198
+ included: private-member support.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "0.2.0",
3
+ "version": "0.4.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,7 @@
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",
69
70
  "spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
70
71
  "spike:emit": "node --expose-gc spikes/emit/probe.mjs",
71
72
  "spike:ownership": "node --expose-gc spikes/ownership.mjs",
@@ -82,6 +83,7 @@
82
83
  "@babel/core": "^7.25.0",
83
84
  "@babel/plugin-proposal-decorators": "^7.25.0",
84
85
  "@babel/preset-typescript": "^7.29.7",
86
+ "@zakkster/lite-devtools": "^1.4.0",
85
87
  "@zakkster/lite-gc-profiler": "^1.16.0",
86
88
  "@zakkster/lite-leak": "^1.10.0",
87
89
  "@zakkster/lite-signal": "1.5.0",