circuitjson-toolkit 1.4.0 → 1.4.2

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/README.md CHANGED
@@ -8,8 +8,9 @@ SPDX-License-Identifier: CC-BY-SA-4.0
8
8
 
9
9
  CircuitJSON Toolkit is the dependency-free common runtime for the ECAD toolkit
10
10
  family. It provides the same parser, project, rendering, interaction, query,
11
- manufacturing, simulation, 3D scene, capability, error, and worker contracts
12
- used by `gerber-toolkit`, `altium-toolkit`, and `kicad-toolkit`.
11
+ manufacturing, simulation, 3D scene, self-adjusting computation, capability,
12
+ error, and worker contracts used by `gerber-toolkit`, `altium-toolkit`, and
13
+ `kicad-toolkit`.
13
14
 
14
15
  CircuitJSON is the shared immutable model. Source-format packages keep their
15
16
  native decoders and fidelity data in explicit extension namespaces while
@@ -77,6 +78,17 @@ of defensively copying them again. The resulting model, extensions,
77
78
  parameters, and `DocumentResult` shape are unchanged. See the
78
79
  [1.4.0 release notes](docs/release-notes-v1.4.0.md).
79
80
 
81
+ Version 1.4.1 adds the canonical `SelfAdjustingComputation` runtime with
82
+ dynamic data/control dependencies, changed-root reader lists, stale-trace
83
+ replacement, explicit reclamation, and from-scratch consistency coverage. See
84
+ the [1.4.1 release notes](docs/release-notes-v1.4.1.md).
85
+
86
+ Version 1.4.2 accelerates immutable document rebuilding and cooperative
87
+ structured-clone adoption. Toolkit-owned frozen extension roots retain their
88
+ identity, and cooperative finalization freezes each validated container
89
+ atomically instead of redefining every property. See the
90
+ [1.4.2 release notes](docs/release-notes-v1.4.2.md).
91
+
80
92
  Before 1.1.0:
81
93
 
82
94
  ```js
@@ -386,6 +398,8 @@ copy while keeping sync, direct async, and worker results mutation-isolated.
386
398
  - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
387
399
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
388
400
  - [1.4.0 release notes](docs/release-notes-v1.4.0.md)
401
+ - [1.4.1 release notes](docs/release-notes-v1.4.1.md)
402
+ - [1.4.2 release notes](docs/release-notes-v1.4.2.md)
389
403
  - [Library scope](spec/library-scope.md)
390
404
 
391
405
  ## Package scope
package/docs/api.md CHANGED
@@ -16,6 +16,38 @@ this document for new code. Thirty-seven previous CircuitJSON-specific classes
16
16
  remain under `circuitjson-toolkit/extensions`; the three documented viewer
17
17
  compatibility classes remain on the root. See [migration.md](migration.md).
18
18
 
19
+ ### `SelfAdjustingComputation`
20
+
21
+ The root API exports the shared synchronous change-propagation runtime used by
22
+ ECAD Forge and reusable toolkit consumers. It records dynamic property reads
23
+ for stable named computations, maintains reverse reader lists, validates only
24
+ the traces reached from explicit changed roots, and replaces stale control-flow
25
+ dependencies after re-execution.
26
+
27
+ ```js
28
+ import { SelfAdjustingComputation } from 'circuitjson-toolkit'
29
+
30
+ const runtime = new SelfAdjustingComputation()
31
+ const results = runtime.propagate(
32
+ { locale: 'en', status: 'ready' },
33
+ [['status']],
34
+ [
35
+ {
36
+ name: 'status-label',
37
+ computation: (state) => state.locale + ':' + state.status
38
+ }
39
+ ]
40
+ )
41
+ ```
42
+
43
+ Computations must be synchronous and treat their tracked input as read-only.
44
+ `forget(name)` reclaims one trace, `clear()` reclaims the graph, and
45
+ `getStatistics()` exposes bounded trace counts. Plain objects and arrays are
46
+ traversed; non-plain objects are atomic identity dependencies unless the
47
+ constructor's `isAtomic(value, path)` option selects an earlier boundary.
48
+ Callers must supply conservative changed paths and test propagated results
49
+ against a fresh execution for the same input.
50
+
19
51
  ## Common conventions
20
52
 
21
53
  ### Document input
@@ -171,7 +203,7 @@ adoption or `prepare()` for arbitrary caller-owned graphs.
171
203
 
172
204
  ## Root entrypoint
173
205
 
174
- `circuitjson-toolkit` has an exact 17-class root. The 14 canonical classes are:
206
+ `circuitjson-toolkit` has an exact 18-class root. The 15 canonical classes are:
175
207
 
176
208
  - `Parser`
177
209
  - `ProjectLoader`
@@ -185,6 +217,7 @@ adoption or `prepare()` for arbitrary caller-owned graphs.
185
217
  - `SimulationService`
186
218
  - `PcbScene3dBuilder`
187
219
  - `PcbScene3dPreparator`
220
+ - `SelfAdjustingComputation`
188
221
  - `ToolkitCapabilities`
189
222
  - `ToolkitError`
190
223
 
@@ -0,0 +1,27 @@
1
+ # circuitjson-toolkit 1.4.1
2
+
3
+ This patch release adds a shared self-adjusting-computation runtime for
4
+ persistent toolkit and application state. It implements dynamic dependency
5
+ tracing and ordered change propagation without coupling the common package to
6
+ DOM, Three.js, or source-format parser state.
7
+
8
+ ## Self-adjusting computation
9
+
10
+ - `SelfAdjustingComputation` is available from the package root and is shared
11
+ by identity through the Gerber, Altium, KiCad, and PCB Scene3D packages.
12
+ - Named synchronous computations record the data and control-flow paths they
13
+ observe. Explicit changed roots start propagation from reverse reader lists.
14
+ - Potentially affected computations compare their previous observations and
15
+ reuse successful results when values, presence, key structure, and selected
16
+ atomic identities remain unchanged.
17
+ - Re-execution replaces the previous trace and its abandoned reader edges.
18
+ `forget()` and `clear()` reclaim trace storage explicitly.
19
+ - Tracked snapshots reject mutation and asynchronous trace escape. Callers can
20
+ choose an atomic boundary for immutable documents and native objects.
21
+
22
+ ## Verification
23
+
24
+ The unit suite covers nested and structural reads, control-flow replacement,
25
+ stale reader removal, atomic document identity, failed and asynchronous
26
+ computations, write rejection, explicit trace reclamation, and equality with a
27
+ fresh runtime after each propagated change.
@@ -0,0 +1,32 @@
1
+ # circuitjson-toolkit 1.4.2
2
+
3
+ This patch release removes repeated immutable-graph work from toolkit-owned
4
+ document rebuilds and from cooperative structured-clone adoption. Public
5
+ document shapes, validation rules, cancellation behavior, and extension
6
+ selection semantics remain unchanged.
7
+
8
+ ## Owned extension root reuse
9
+
10
+ - `DocumentResult.createValidated()` retains an extension namespace that was
11
+ already captured, bounded, sealed, and branded by the same toolkit runtime.
12
+ - Rebuilding a canonical document no longer traverses or copies a large frozen
13
+ native extension graph when only other document fields changed.
14
+ - Arbitrary caller objects still use the defensive validation and ownership
15
+ path; frozen state alone never grants trusted ownership.
16
+
17
+ ## Atomic cooperative finalization
18
+
19
+ - Cooperative structured-clone adoption validates a container's descriptors
20
+ before freezing the container in one atomic operation.
21
+ - A yield can no longer expose a container with only some properties locked.
22
+ - Large graphs avoid one `defineProperty()` call per ordinary property while
23
+ retaining the same deeply immutable result and cancellation checkpoints.
24
+
25
+ ## Verification
26
+
27
+ - Ownership tests cover identity reuse for toolkit-built frozen namespaces and
28
+ defensive behavior for untrusted objects.
29
+ - Cooperative snapshot tests prove that no partial property locking is visible
30
+ across yields.
31
+ - The complete package suite, formatting check, packed-entrypoint checks, and
32
+ npm dry-run gate the release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuitjson-toolkit",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Canonical CircuitJSON parsing, project, rendering, query, manufacturing, simulation, and scene contracts",
5
5
  "keywords": [
6
6
  "circuitjson",
@@ -57,6 +57,8 @@
57
57
  "docs/release-notes-v1.2.1.md",
58
58
  "docs/release-notes-v1.3.0.md",
59
59
  "docs/release-notes-v1.4.0.md",
60
+ "docs/release-notes-v1.4.1.md",
61
+ "docs/release-notes-v1.4.2.md",
60
62
  "docs/testing.md",
61
63
  "spec",
62
64
  "LICENSE",
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Memoizes named computations and dynamically tracks their observed input paths.
3
+ */
4
+ export class SelfAdjustingComputation {
5
+ /** @type {Map<string, { dependencies: object[], value: any, readerRoots: Set<PropertyKey>, readsWholeInput: boolean }>} */
6
+ #computations
7
+
8
+ /** @type {Map<PropertyKey, Set<string>>} */
9
+ #readersByRoot
10
+
11
+ /** @type {Set<string>} */
12
+ #wholeInputReaders
13
+
14
+ /** @type {object | null} */
15
+ #propagationInput
16
+
17
+ /** @type {Set<string> | null} */
18
+ #affectedComputations
19
+
20
+ /** @type {(value: object, path: PropertyKey[]) => boolean} */
21
+ #isAtomic
22
+
23
+ /** @type {Map<symbol, number>} */
24
+ #symbolIds
25
+
26
+ /** @type {number} */
27
+ #nextSymbolId
28
+
29
+ /**
30
+ * @param {{ isAtomic?: (value: object, path: PropertyKey[]) => boolean }} [options] Dependency-tracking options.
31
+ */
32
+ constructor(options = {}) {
33
+ this.#computations = new Map()
34
+ this.#readersByRoot = new Map()
35
+ this.#wholeInputReaders = new Set()
36
+ this.#propagationInput = null
37
+ this.#affectedComputations = null
38
+ this.#isAtomic =
39
+ typeof options.isAtomic === 'function'
40
+ ? options.isAtomic
41
+ : () => false
42
+ this.#symbolIds = new Map()
43
+ this.#nextSymbolId = 1
44
+ }
45
+
46
+ /**
47
+ * Propagates changed modifiable roots through named computations in order.
48
+ * A null change set performs dependency validation for every existing trace.
49
+ * @param {object} input Current input snapshot.
50
+ * @param {PropertyKey[][] | null} changedPaths Changed input paths, or null when unknown.
51
+ * @param {{ name: string, computation: (trackedInput: object) => any }[]} computations Ordered computations.
52
+ * @returns {Map<string, { value: any, recomputed: boolean }>} Results by computation name.
53
+ */
54
+ propagate(input, changedPaths, computations) {
55
+ this.#validateInput(input)
56
+ if (!Array.isArray(computations)) {
57
+ throw new TypeError(
58
+ 'Self-adjusting propagation requires an ordered computation array.'
59
+ )
60
+ }
61
+
62
+ this.#propagationInput = input
63
+ this.#affectedComputations =
64
+ changedPaths === null
65
+ ? null
66
+ : this.#findAffectedComputations(changedPaths)
67
+ const results = new Map()
68
+ try {
69
+ for (const entry of computations) {
70
+ if (!entry || typeof entry !== 'object') {
71
+ throw new TypeError(
72
+ 'Self-adjusting propagation entries must be objects.'
73
+ )
74
+ }
75
+ const key = String(entry.name)
76
+ results.set(key, this.evaluate(key, input, entry.computation))
77
+ }
78
+ return results
79
+ } finally {
80
+ this.#propagationInput = null
81
+ this.#affectedComputations = null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Evaluates one named computation or reuses its last successful result.
87
+ * @param {string} name Stable computation name.
88
+ * @param {object} input Current input snapshot.
89
+ * @param {(trackedInput: object) => any} computation Synchronous computation.
90
+ * @returns {{ value: any, recomputed: boolean }} Evaluation result.
91
+ */
92
+ evaluate(name, input, computation) {
93
+ this.#validateInput(input)
94
+ if (typeof computation !== 'function') {
95
+ throw new TypeError(
96
+ 'Self-adjusting computation requires a computation function.'
97
+ )
98
+ }
99
+
100
+ const key = String(name)
101
+ const previous = this.#computations.get(key)
102
+ if (previous && !this.#isPotentiallyAffected(key, input)) {
103
+ return { value: previous.value, recomputed: false }
104
+ }
105
+ if (previous && this.#dependenciesMatch(previous.dependencies, input)) {
106
+ return { value: previous.value, recomputed: false }
107
+ }
108
+
109
+ const trace = this.#trace(input, computation)
110
+ this.#replaceTrace(key, trace)
111
+ return { value: trace.value, recomputed: true }
112
+ }
113
+
114
+ /**
115
+ * Removes one named trace and its reader-list entries.
116
+ * @param {string} name Stable computation name.
117
+ * @returns {boolean} Whether a trace was removed.
118
+ */
119
+ forget(name) {
120
+ const key = String(name)
121
+ if (!this.#computations.has(key)) return false
122
+ this.#removeTrace(key)
123
+ return true
124
+ }
125
+
126
+ /**
127
+ * Removes all traces and reader-list entries.
128
+ * @returns {void}
129
+ */
130
+ clear() {
131
+ this.#computations.clear()
132
+ this.#readersByRoot.clear()
133
+ this.#wholeInputReaders.clear()
134
+ this.#propagationInput = null
135
+ this.#affectedComputations = null
136
+ }
137
+
138
+ /**
139
+ * Returns bounded trace-storage counts for diagnostics and tests.
140
+ * @returns {{ computations: number, dependencies: number, readerEdges: number }} Trace statistics.
141
+ */
142
+ getStatistics() {
143
+ return {
144
+ computations: this.#computations.size,
145
+ dependencies: [...this.#computations.values()].reduce(
146
+ (total, trace) => total + trace.dependencies.length,
147
+ 0
148
+ ),
149
+ readerEdges:
150
+ [...this.#readersByRoot.values()].reduce(
151
+ (total, readers) => total + readers.size,
152
+ 0
153
+ ) + this.#wholeInputReaders.size
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Validates one root input snapshot.
159
+ * @param {unknown} input Candidate input.
160
+ * @returns {void}
161
+ */
162
+ #validateInput(input) {
163
+ if (!input || typeof input !== 'object') {
164
+ throw new TypeError(
165
+ 'Self-adjusting computation input must be an object.'
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Finds prior computations that read a changed root.
172
+ * @param {PropertyKey[][]} changedPaths Changed input paths.
173
+ * @returns {Set<string>} Potentially affected computation names.
174
+ */
175
+ #findAffectedComputations(changedPaths) {
176
+ if (!Array.isArray(changedPaths)) {
177
+ throw new TypeError(
178
+ 'Self-adjusting change sets must be arrays of property paths.'
179
+ )
180
+ }
181
+
182
+ const affected = new Set(this.#wholeInputReaders)
183
+ for (const path of changedPaths) {
184
+ if (!Array.isArray(path)) {
185
+ throw new TypeError(
186
+ 'Self-adjusting change-set entries must be property paths.'
187
+ )
188
+ }
189
+ if (path.length === 0) {
190
+ return new Set(this.#computations.keys())
191
+ }
192
+ const readers = this.#readersByRoot.get(path[0])
193
+ readers?.forEach((name) => affected.add(name))
194
+ }
195
+ return affected
196
+ }
197
+
198
+ /**
199
+ * Returns whether a prior trace must be checked in the active propagation.
200
+ * @param {string} name Computation name.
201
+ * @param {object} input Current input snapshot.
202
+ * @returns {boolean} Whether dependency validation is required.
203
+ */
204
+ #isPotentiallyAffected(name, input) {
205
+ return !(
206
+ this.#propagationInput === input &&
207
+ this.#affectedComputations instanceof Set &&
208
+ !this.#affectedComputations.has(name)
209
+ )
210
+ }
211
+
212
+ /**
213
+ * Replaces a trace and its dynamic reader-list entries atomically.
214
+ * @param {string} name Computation name.
215
+ * @param {{ dependencies: object[], value: any }} trace New successful trace.
216
+ * @returns {void}
217
+ */
218
+ #replaceTrace(name, trace) {
219
+ this.#removeTrace(name)
220
+ const readerRoots = new Set()
221
+ let readsWholeInput = false
222
+ for (const dependency of trace.dependencies) {
223
+ if (dependency.path.length === 0) {
224
+ readsWholeInput = true
225
+ } else {
226
+ readerRoots.add(dependency.path[0])
227
+ }
228
+ }
229
+ const storedTrace = {
230
+ ...trace,
231
+ readerRoots,
232
+ readsWholeInput
233
+ }
234
+ this.#computations.set(name, storedTrace)
235
+ readerRoots.forEach((root) => {
236
+ const readers = this.#readersByRoot.get(root) || new Set()
237
+ readers.add(name)
238
+ this.#readersByRoot.set(root, readers)
239
+ })
240
+ if (readsWholeInput) this.#wholeInputReaders.add(name)
241
+ }
242
+
243
+ /**
244
+ * Removes one stored trace and all of its reverse dependency edges.
245
+ * @param {string} name Computation name.
246
+ * @returns {void}
247
+ */
248
+ #removeTrace(name) {
249
+ const previous = this.#computations.get(name)
250
+ if (!previous) return
251
+ previous.readerRoots.forEach((root) => {
252
+ const readers = this.#readersByRoot.get(root)
253
+ readers?.delete(name)
254
+ if (readers?.size === 0) this.#readersByRoot.delete(root)
255
+ })
256
+ if (previous.readsWholeInput) this.#wholeInputReaders.delete(name)
257
+ this.#computations.delete(name)
258
+ }
259
+
260
+ /**
261
+ * Executes one computation while collecting its dynamic dependencies.
262
+ * @param {object} input Raw input snapshot.
263
+ * @param {(trackedInput: object) => any} computation Computation callback.
264
+ * @returns {{ dependencies: object[], value: any }} Successful trace.
265
+ */
266
+ #trace(input, computation) {
267
+ const dependencies = new Map()
268
+ const proxyCache = new WeakMap()
269
+ const proxyMetadata = new WeakMap()
270
+ const trackedInput = this.#createProxy({
271
+ target: input,
272
+ path: [],
273
+ dependencies,
274
+ proxyCache,
275
+ proxyMetadata
276
+ })
277
+ const trackedValue = computation(trackedInput)
278
+ if (SelfAdjustingComputation.#isPromiseLike(trackedValue)) {
279
+ throw new TypeError(
280
+ 'Self-adjusting computations must be synchronous so dependency tracing cannot escape.'
281
+ )
282
+ }
283
+ const metadata =
284
+ trackedValue &&
285
+ (typeof trackedValue === 'object' ||
286
+ typeof trackedValue === 'function')
287
+ ? proxyMetadata.get(trackedValue)
288
+ : null
289
+ if (metadata) {
290
+ this.#record(dependencies, {
291
+ type: 'value',
292
+ path: metadata.path,
293
+ expected: metadata.target
294
+ })
295
+ }
296
+
297
+ return {
298
+ dependencies: [...dependencies.values()],
299
+ value: metadata?.target ?? trackedValue
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Creates a read-tracking proxy for one traversable input container.
305
+ * @param {{ target: object, path: PropertyKey[], dependencies: Map<string, object>, proxyCache: WeakMap<object, Map<string, object>>, proxyMetadata: WeakMap<object, { target: object, path: PropertyKey[] }> }} context Tracking context.
306
+ * @returns {object} Read-tracking proxy.
307
+ */
308
+ #createProxy(context) {
309
+ const pathKey = this.#pathKey(context.path)
310
+ const cachedByPath = context.proxyCache.get(context.target)
311
+ if (cachedByPath?.has(pathKey)) {
312
+ return cachedByPath.get(pathKey)
313
+ }
314
+
315
+ const proxy = new Proxy(context.target, {
316
+ get: (target, property) =>
317
+ this.#readProperty(context, target, property),
318
+ has: (target, property) =>
319
+ this.#readPresence(context, target, property),
320
+ ownKeys: (target) => this.#readKeys(context, target),
321
+ getOwnPropertyDescriptor: (target, property) =>
322
+ this.#readDescriptor(context, target, property),
323
+ set: () => this.#rejectWrite(),
324
+ defineProperty: () => this.#rejectWrite(),
325
+ deleteProperty: () => this.#rejectWrite(),
326
+ setPrototypeOf: () => this.#rejectWrite(),
327
+ preventExtensions: () => this.#rejectWrite()
328
+ })
329
+ const nextCachedByPath = cachedByPath || new Map()
330
+ nextCachedByPath.set(pathKey, proxy)
331
+ if (!cachedByPath) {
332
+ context.proxyCache.set(context.target, nextCachedByPath)
333
+ }
334
+ context.proxyMetadata.set(proxy, {
335
+ target: context.target,
336
+ path: [...context.path]
337
+ })
338
+ return proxy
339
+ }
340
+
341
+ /**
342
+ * Reads and records one property dependency.
343
+ * @param {object} context Tracking context.
344
+ * @param {object} target Current raw target.
345
+ * @param {PropertyKey} property Requested property.
346
+ * @returns {any} Raw atomic value or tracked container.
347
+ */
348
+ #readProperty(context, target, property) {
349
+ const value = Reflect.get(target, property, target)
350
+ const path = [...context.path, property]
351
+ if (this.#isTraversable(value, path)) {
352
+ this.#record(context.dependencies, {
353
+ type: 'kind',
354
+ path,
355
+ expected: SelfAdjustingComputation.#valueKind(value)
356
+ })
357
+ return this.#createProxy({ ...context, target: value, path })
358
+ }
359
+
360
+ this.#record(context.dependencies, {
361
+ type: 'value',
362
+ path,
363
+ expected: value
364
+ })
365
+ return value
366
+ }
367
+
368
+ /**
369
+ * Reads and records a property-existence dependency.
370
+ * @param {object} context Tracking context.
371
+ * @param {object} target Current raw target.
372
+ * @param {PropertyKey} property Requested property.
373
+ * @returns {boolean} Whether the property exists.
374
+ */
375
+ #readPresence(context, target, property) {
376
+ const path = [...context.path, property]
377
+ const expected = Reflect.has(target, property)
378
+ this.#record(context.dependencies, {
379
+ type: 'has',
380
+ path,
381
+ expected
382
+ })
383
+ return expected
384
+ }
385
+
386
+ /**
387
+ * Reads and records the ordered own-key set for one container.
388
+ * @param {object} context Tracking context.
389
+ * @param {object} target Current raw target.
390
+ * @returns {PropertyKey[]} Own keys.
391
+ */
392
+ #readKeys(context, target) {
393
+ const expected = Reflect.ownKeys(target)
394
+ this.#record(context.dependencies, {
395
+ type: 'keys',
396
+ path: [...context.path],
397
+ expected
398
+ })
399
+ return expected
400
+ }
401
+
402
+ /**
403
+ * Reads and records whether one own property is enumerable.
404
+ * @param {object} context Tracking context.
405
+ * @param {object} target Current raw target.
406
+ * @param {PropertyKey} property Requested property.
407
+ * @returns {PropertyDescriptor | undefined} Raw property descriptor.
408
+ */
409
+ #readDescriptor(context, target, property) {
410
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, property)
411
+ this.#record(context.dependencies, {
412
+ type: 'descriptor',
413
+ path: [...context.path, property],
414
+ expected: SelfAdjustingComputation.#descriptorState(descriptor)
415
+ })
416
+ return descriptor
417
+ }
418
+
419
+ /**
420
+ * Returns whether a value should expose nested dependency reads.
421
+ * @param {any} value Candidate value.
422
+ * @param {PropertyKey[]} path Input path.
423
+ * @returns {boolean} Whether the value is a traversable container.
424
+ */
425
+ #isTraversable(value, path) {
426
+ if (!value || typeof value !== 'object') return false
427
+ if (this.#isAtomic(value, [...path])) return false
428
+ if (Array.isArray(value)) return true
429
+ const prototype = Reflect.getPrototypeOf(value)
430
+ return prototype === Object.prototype || prototype === null
431
+ }
432
+
433
+ /**
434
+ * Stores or replaces one dependency by type and input path.
435
+ * @param {Map<string, object>} dependencies Dependency registry.
436
+ * @param {{ type: string, path: PropertyKey[], expected: any }} dependency Dependency record.
437
+ * @returns {void}
438
+ */
439
+ #record(dependencies, dependency) {
440
+ dependencies.set(
441
+ dependency.type + ':' + this.#pathKey(dependency.path),
442
+ dependency
443
+ )
444
+ }
445
+
446
+ /**
447
+ * Returns whether every dependency still matches a new input.
448
+ * @param {object[]} dependencies Previous successful dependencies.
449
+ * @param {object} input Current raw input.
450
+ * @returns {boolean} Whether the computation can be reused.
451
+ */
452
+ #dependenciesMatch(dependencies, input) {
453
+ return dependencies.every((dependency) =>
454
+ this.#dependencyMatches(dependency, input)
455
+ )
456
+ }
457
+
458
+ /**
459
+ * Compares one recorded dependency against a new input.
460
+ * @param {{ type: string, path: PropertyKey[], expected: any }} dependency Recorded dependency.
461
+ * @param {object} input Current raw input.
462
+ * @returns {boolean} Whether the dependency is unchanged.
463
+ */
464
+ #dependencyMatches(dependency, input) {
465
+ if (dependency.type === 'has') {
466
+ const parent = this.#readPath(input, dependency.path.slice(0, -1))
467
+ return (
468
+ parent.found &&
469
+ Reflect.has(Object(parent.value), dependency.path.at(-1)) ===
470
+ dependency.expected
471
+ )
472
+ }
473
+
474
+ const current = this.#readPath(input, dependency.path)
475
+ if (dependency.type === 'value') {
476
+ return (
477
+ current.found && Object.is(current.value, dependency.expected)
478
+ )
479
+ }
480
+ if (dependency.type === 'kind') {
481
+ return (
482
+ current.found &&
483
+ SelfAdjustingComputation.#valueKind(current.value) ===
484
+ dependency.expected
485
+ )
486
+ }
487
+ if (dependency.type === 'keys') {
488
+ return (
489
+ current.found &&
490
+ current.value !== null &&
491
+ typeof current.value === 'object' &&
492
+ SelfAdjustingComputation.#sameKeys(
493
+ Reflect.ownKeys(current.value),
494
+ dependency.expected
495
+ )
496
+ )
497
+ }
498
+ if (dependency.type === 'descriptor') {
499
+ const parent = this.#readPath(input, dependency.path.slice(0, -1))
500
+ if (!parent.found || parent.value === null) return false
501
+ const descriptor = Reflect.getOwnPropertyDescriptor(
502
+ Object(parent.value),
503
+ dependency.path.at(-1)
504
+ )
505
+ return (
506
+ SelfAdjustingComputation.#descriptorState(descriptor) ===
507
+ dependency.expected
508
+ )
509
+ }
510
+ return false
511
+ }
512
+
513
+ /**
514
+ * Resolves one raw input path without invoking dependency tracking.
515
+ * @param {object} input Root input.
516
+ * @param {PropertyKey[]} path Input path.
517
+ * @returns {{ found: boolean, value: any }} Resolved value.
518
+ */
519
+ #readPath(input, path) {
520
+ let value = input
521
+ for (const property of path) {
522
+ if (
523
+ value === null ||
524
+ (typeof value !== 'object' && typeof value !== 'function')
525
+ ) {
526
+ return { found: false, value: undefined }
527
+ }
528
+ value = Reflect.get(value, property, value)
529
+ }
530
+ return { found: true, value }
531
+ }
532
+
533
+ /**
534
+ * Creates a collision-free string key for a property path.
535
+ * @param {PropertyKey[]} path Input path.
536
+ * @returns {string} Registry key.
537
+ */
538
+ #pathKey(path) {
539
+ return path
540
+ .map((property) => {
541
+ if (typeof property === 'symbol') {
542
+ if (!this.#symbolIds.has(property)) {
543
+ this.#symbolIds.set(property, this.#nextSymbolId)
544
+ this.#nextSymbolId += 1
545
+ }
546
+ return 'y' + this.#symbolIds.get(property)
547
+ }
548
+ const text = String(property)
549
+ return 's' + text.length + ':' + text
550
+ })
551
+ .join('|')
552
+ }
553
+
554
+ /**
555
+ * Rejects mutation through a tracked snapshot.
556
+ * @returns {never}
557
+ */
558
+ #rejectWrite() {
559
+ throw new TypeError(
560
+ 'Self-adjusting computation inputs are read-only while tracked.'
561
+ )
562
+ }
563
+
564
+ /**
565
+ * Returns the comparison category for one container value.
566
+ * @param {any} value Candidate value.
567
+ * @returns {string} Value category.
568
+ */
569
+ static #valueKind(value) {
570
+ if (value === null) return 'null'
571
+ if (Array.isArray(value)) return 'array'
572
+ return typeof value
573
+ }
574
+
575
+ /**
576
+ * Returns the dependency-relevant state of a property descriptor.
577
+ * @param {PropertyDescriptor | undefined} descriptor Property descriptor.
578
+ * @returns {string} Descriptor state.
579
+ */
580
+ static #descriptorState(descriptor) {
581
+ if (!descriptor) return 'missing'
582
+ return descriptor.enumerable ? 'enumerable' : 'non-enumerable'
583
+ }
584
+
585
+ /**
586
+ * Compares two ordered property-key arrays.
587
+ * @param {PropertyKey[]} left Current keys.
588
+ * @param {PropertyKey[]} right Recorded keys.
589
+ * @returns {boolean} Whether both key sets and orders match.
590
+ */
591
+ static #sameKeys(left, right) {
592
+ return (
593
+ left.length === right.length &&
594
+ left.every((key, index) => Object.is(key, right[index]))
595
+ )
596
+ }
597
+
598
+ /**
599
+ * Returns whether a computation result is a promise or thenable.
600
+ * @param {unknown} value Candidate result.
601
+ * @returns {boolean} Whether the result is asynchronous.
602
+ */
603
+ static #isPromiseLike(value) {
604
+ return Boolean(
605
+ value &&
606
+ (typeof value === 'object' || typeof value === 'function') &&
607
+ typeof value.then === 'function'
608
+ )
609
+ }
610
+ }
@@ -506,10 +506,9 @@ class StructuredCloneAdoptionTraversal {
506
506
  }
507
507
 
508
508
  /**
509
- * Locks one target property-by-property so large dense containers never
510
- * require one monolithic Object.freeze operation.
509
+ * Validates and atomically freezes one acquired target.
511
510
  * @param {object} seal Target sealing snapshot.
512
- * @returns {Generator<void, void, void>} Bounded-work locking pass.
511
+ * @returns {Generator<void, void, void>} Cooperative locking pass.
513
512
  */
514
513
  *#sealTargetCooperatively(seal) {
515
514
  StructuredCloneAdoptionTraversal.#requireTargetShape(seal)
@@ -530,31 +529,16 @@ class StructuredCloneAdoptionTraversal {
530
529
  'Canonical asset source changed during adoption.'
531
530
  )
532
531
  }
533
- if (!property.binary) {
534
- try {
535
- Object.defineProperty(seal.target, property.key, {
536
- ...descriptor,
537
- configurable: false,
538
- writable: false
539
- })
540
- } catch {
541
- throw new TypeError(
542
- 'Canonical document values could not be frozen safely.'
543
- )
544
- }
545
- }
546
- yield* this.#checkpoint()
547
532
  }
548
533
  StructuredCloneAdoptionTraversal.#requireTargetShape(seal)
549
- if (!seal.shapeLocked) {
550
- try {
551
- Object.preventExtensions(seal.target)
552
- } catch {
553
- throw new TypeError(
554
- 'Canonical document values could not be frozen safely.'
555
- )
556
- }
534
+ try {
535
+ Object.freeze(seal.target)
536
+ } catch {
537
+ throw new TypeError(
538
+ 'Canonical document values could not be frozen safely.'
539
+ )
557
540
  }
541
+ yield* this.#checkpoint(seal.properties.length || 1)
558
542
  }
559
543
 
560
544
  /**
@@ -1,4 +1,5 @@
1
1
  import { CircuitJsonValidationProof } from '../context/CircuitJsonValidationProof.mjs'
2
+ import { CircuitJsonExtensionBoundary } from '../context/CircuitJsonExtensionBoundary.mjs'
2
3
  import { CircuitJsonReadOnlyDocument } from '../context/CircuitJsonReadOnlyDocument.mjs'
3
4
  import { CircuitJsonSerializedInputAudit } from '../CircuitJsonSerializedInputAudit.mjs'
4
5
  import { ToolkitAsset } from './ToolkitAsset.mjs'
@@ -147,6 +148,14 @@ export class DocumentResult {
147
148
  const hasCandidate = candidate && typeof candidate === 'object'
148
149
  if (!hasCandidate) return {}
149
150
  if (options.readonly === true && hasCandidate) {
151
+ // This private brand proves the frozen namespace was already
152
+ // captured, bounded, and sealed by this runtime.
153
+ if (
154
+ CircuitJsonExtensionBoundary.owns(extensions) &&
155
+ Object.isFrozen(extensions)
156
+ ) {
157
+ return extensions
158
+ }
150
159
  if (options.standardBuiltins === true) {
151
160
  return CircuitJsonReadOnlyDocument.copyReadonlyExtensionValue(
152
161
  DocumentResult.#normalizedExtension(format, candidate),
package/src/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  export { CircuitJsonDocument } from './core/CircuitJsonDocument.mjs'
2
2
  export { CircuitJsonIndexer } from './core/CircuitJsonIndexer.mjs'
3
3
  export { CircuitJsonUnits } from './core/CircuitJsonUnits.mjs'
4
+ export { SelfAdjustingComputation } from './core/SelfAdjustingComputation.mjs'
4
5
  export { Parser } from './core/Parser.mjs'
5
6
  export { ProjectLoader } from './core/ProjectLoader.mjs'
6
7
  export { CircuitJsonDocumentContext } from './core/context/CircuitJsonDocumentContext.mjs'
@@ -11,6 +11,7 @@ const CANONICAL_CLASS_NAMES = Object.freeze([
11
11
  'SimulationService',
12
12
  'PcbScene3dBuilder',
13
13
  'PcbScene3dPreparator',
14
+ 'SelfAdjustingComputation',
14
15
  'ToolkitCapabilities',
15
16
  'ToolkitError'
16
17
  ])