circuitjson-toolkit 1.0.16 → 1.1.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.
Files changed (155) hide show
  1. package/AGENTS.md +15 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +267 -107
  4. package/docs/api.md +501 -96
  5. package/docs/capabilities.md +70 -0
  6. package/docs/migration/behaviors.md +45 -0
  7. package/docs/migration/parser.md +60 -0
  8. package/docs/migration/renderers.md +515 -0
  9. package/docs/migration/root.md +740 -0
  10. package/docs/migration.md +120 -0
  11. package/docs/model-format.md +175 -57
  12. package/docs/provenance.md +206 -0
  13. package/docs/release-notes-v1.1.0.md +154 -0
  14. package/docs/testing.md +117 -7
  15. package/package.json +31 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +997 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +623 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -0,0 +1,975 @@
1
+ import { ArchiveEntryPath } from './ArchiveEntryPath.mjs'
2
+ import { ArchiveLimits } from './ArchiveLimits.mjs'
3
+ import { AsyncInputOwnership } from './AsyncInputOwnership.mjs'
4
+ import { AttachedValueLimits } from './AttachedValueLimits.mjs'
5
+ import { Parser } from './Parser.mjs'
6
+ import { ParserOptions } from './ParserOptions.mjs'
7
+ import { ProjectAsyncInputOwner } from './ProjectAsyncInputOwner.mjs'
8
+ import { ProjectResult } from './contracts/ProjectResult.mjs'
9
+ import { ToolkitDiagnostic } from './contracts/ToolkitDiagnostic.mjs'
10
+ import { ToolkitError } from './contracts/ToolkitError.mjs'
11
+ import { ToolkitProgress } from './contracts/ToolkitProgress.mjs'
12
+ import { ToolkitAsset } from './contracts/ToolkitAsset.mjs'
13
+ import { ParserWorkerClient } from './worker/ParserWorkerClient.mjs'
14
+
15
+ const ABORTED_GETTER = Object.getOwnPropertyDescriptor(
16
+ AbortSignal.prototype,
17
+ 'aborted'
18
+ )?.get
19
+ const PARSER_OPTION_KEYS = [
20
+ 'preserveRaw',
21
+ 'decodeAssets',
22
+ 'extensions',
23
+ 'reports',
24
+ 'retainSource',
25
+ 'worker',
26
+ 'transferInput',
27
+ 'signal',
28
+ 'onProgress'
29
+ ]
30
+
31
+ /**
32
+ * Loads one or more named CircuitJSON entries into a canonical project result.
33
+ */
34
+ export class ProjectLoader {
35
+ /**
36
+ * Loads supported entries synchronously with deterministic partial success.
37
+ * @param {Array<{ name: string, data: string | ArrayBuffer | Uint8Array }>} entries Named entries.
38
+ * @param {Record<string, any>} [options] Common project/parser options.
39
+ * @returns {Record<string, any>} Canonical project result.
40
+ */
41
+ static load(entries, options = {}) {
42
+ try {
43
+ const normalizedOptions = ProjectLoader.#normalizeOptions(options)
44
+ if (normalizedOptions.worker === true) {
45
+ throw ProjectLoader.#workerSyncError()
46
+ }
47
+ const snapshot = ProjectLoader.#snapshotEntries(
48
+ entries,
49
+ normalizedOptions.archiveLimits.maxEntries
50
+ )
51
+ const classified = ProjectLoader.#classify(
52
+ snapshot,
53
+ normalizedOptions.archiveLimits,
54
+ normalizedOptions.decodeAssets
55
+ )
56
+ ProjectLoader.#assertCandidates(classified)
57
+
58
+ const documents = []
59
+ const diagnostics = []
60
+ for (const entry of classified.candidates) {
61
+ ProjectLoader.#parseEntry(
62
+ entry,
63
+ normalizedOptions,
64
+ documents,
65
+ diagnostics
66
+ )
67
+ }
68
+ return ProjectLoader.#result(
69
+ classified,
70
+ documents,
71
+ diagnostics,
72
+ normalizedOptions
73
+ )
74
+ } catch (error) {
75
+ throw ProjectLoader.#errorFrom(error)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Loads entries without throwing public project-loading failures.
81
+ * @param {Array<{ name: string, data: string | ArrayBuffer | Uint8Array }>} entries Named entries.
82
+ * @param {Record<string, any>} [options] Common project/parser options.
83
+ * @returns {{ ok: true, value: Record<string, any> } | { ok: false, error: ToolkitError, diagnostics: object[] }} Discriminated load result.
84
+ */
85
+ static tryLoad(entries, options = {}) {
86
+ try {
87
+ return { ok: true, value: ProjectLoader.load(entries, options) }
88
+ } catch (error) {
89
+ const normalized = ProjectLoader.#errorFrom(error)
90
+ const diagnostics = Array.isArray(normalized.details?.diagnostics)
91
+ ? normalized.details.diagnostics.map((diagnostic) =>
92
+ ToolkitDiagnostic.create(diagnostic)
93
+ )
94
+ : []
95
+ if (!diagnostics.length) {
96
+ diagnostics.push(
97
+ ToolkitDiagnostic.create({
98
+ code: normalized.code,
99
+ severity: 'error',
100
+ message: normalized.message,
101
+ source: normalized.source
102
+ })
103
+ )
104
+ }
105
+ return { ok: false, error: normalized, diagnostics }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Loads entries incrementally while yielding and checking cancellation.
111
+ * @param {Array<{ name: string, data: string | ArrayBuffer | Uint8Array }>} entries Named entries.
112
+ * @param {Record<string, any>} [options] Common project/parser options.
113
+ * @returns {Promise<Record<string, any>>} Canonical project result.
114
+ */
115
+ static async loadAsync(entries, options = {}) {
116
+ let normalizedOptions
117
+ let snapshot
118
+ let classified
119
+ const entriesOwned = AsyncInputOwnership.ownsProject(entries)
120
+ try {
121
+ normalizedOptions = ProjectLoader.#normalizeOptions(options)
122
+ ProjectLoader.#assertNotCancelled(normalizedOptions.signal)
123
+ snapshot = ProjectLoader.#snapshotEntries(
124
+ entries,
125
+ normalizedOptions.archiveLimits.maxEntries
126
+ )
127
+ classified = ProjectLoader.#classify(
128
+ snapshot,
129
+ normalizedOptions.archiveLimits,
130
+ normalizedOptions.decodeAssets
131
+ )
132
+ ProjectLoader.#assertCandidates(classified)
133
+ } catch (error) {
134
+ throw ProjectLoader.#errorFrom(error)
135
+ }
136
+ const useWorker =
137
+ normalizedOptions.worker === true ||
138
+ (normalizedOptions.worker === 'auto' &&
139
+ normalizedOptions.retainSource !== 'reference' &&
140
+ ParserWorkerClient.isDefaultAvailable())
141
+ if (useWorker) {
142
+ const attempt = await ParserWorkerClient.loadProjectDefault(
143
+ snapshot,
144
+ normalizedOptions
145
+ )
146
+ if (attempt.ok) return attempt.value
147
+ if (normalizedOptions.worker !== 'auto' || !attempt.unavailable) {
148
+ throw attempt.error
149
+ }
150
+ ParserWorkerClient.disposeDefault()
151
+ }
152
+
153
+ try {
154
+ if (!entriesOwned) {
155
+ ProjectAsyncInputOwner.own(
156
+ classified,
157
+ normalizedOptions.decodeAssets
158
+ )
159
+ }
160
+ } catch (error) {
161
+ throw ProjectLoader.#errorFrom(error)
162
+ }
163
+
164
+ let progress = ProjectLoader.#progress(
165
+ normalizedOptions,
166
+ { stage: 'detect', message: 'Classifying project entries.' },
167
+ null
168
+ )
169
+ await ProjectLoader.#yieldToHost(Boolean(normalizedOptions.signal))
170
+ ProjectLoader.#assertNotCancelled(normalizedOptions.signal)
171
+
172
+ progress = ProjectLoader.#progress(
173
+ normalizedOptions,
174
+ {
175
+ stage: 'project',
176
+ completed: 0,
177
+ total: classified.candidates.length,
178
+ message: 'Loading CircuitJSON project entries.'
179
+ },
180
+ progress
181
+ )
182
+
183
+ const documents = []
184
+ const diagnostics = []
185
+ for (let index = 0; index < classified.candidates.length; index += 1) {
186
+ await ProjectLoader.#yieldToHost(Boolean(normalizedOptions.signal))
187
+ ProjectLoader.#assertNotCancelled(normalizedOptions.signal)
188
+ ProjectLoader.#parseEntry(
189
+ classified.candidates[index],
190
+ normalizedOptions,
191
+ documents,
192
+ diagnostics
193
+ )
194
+ progress = ProjectLoader.#progress(
195
+ normalizedOptions,
196
+ {
197
+ stage: 'project',
198
+ completed: index + 1,
199
+ total: classified.candidates.length,
200
+ detail: classified.candidates[index].name,
201
+ message: 'Loaded CircuitJSON project entry.'
202
+ },
203
+ progress
204
+ )
205
+ ProjectLoader.#assertNotCancelled(normalizedOptions.signal)
206
+ }
207
+
208
+ const result = ProjectLoader.#result(
209
+ classified,
210
+ documents,
211
+ diagnostics,
212
+ normalizedOptions
213
+ )
214
+ ProjectLoader.#progress(
215
+ normalizedOptions,
216
+ {
217
+ stage: 'complete',
218
+ completed: classified.candidates.length,
219
+ total: classified.candidates.length,
220
+ message: 'CircuitJSON project loading complete.'
221
+ },
222
+ progress
223
+ )
224
+ ProjectLoader.#assertNotCancelled(normalizedOptions.signal)
225
+ return result
226
+ }
227
+
228
+ /**
229
+ * Performs bounded name/prefix classification without parsing every entry.
230
+ * @param {unknown} entries Named entry candidates.
231
+ * @returns {boolean} Whether a canonical CircuitJSON candidate is present.
232
+ */
233
+ static supports(entries) {
234
+ try {
235
+ const entryDescriptors = ProjectLoader.#entryArray(entries)
236
+ const length = entryDescriptors.length.value
237
+ if (!length || length > ArchiveLimits.defaults.maxEntries) {
238
+ return false
239
+ }
240
+
241
+ const names = []
242
+ let supported = false
243
+ for (let index = 0; index < length; index += 1) {
244
+ const entry = entryDescriptors[String(index)].value
245
+ const fields = ProjectLoader.#entryFields(entry)
246
+ const name = ArchiveEntryPath.normalize(fields.name)
247
+ names.push(name)
248
+ if (
249
+ name.toLowerCase().endsWith('.json') &&
250
+ Parser.supports({ fileName: name, data: fields.data })
251
+ ) {
252
+ supported = true
253
+ }
254
+ }
255
+ ArchiveEntryPath.unique(names)
256
+ return supported
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Normalizes project and parser options without decoding an entry.
264
+ * @param {unknown} options Caller options.
265
+ * @returns {Record<string, any>} Normalized options.
266
+ */
267
+ static #normalizeOptions(options) {
268
+ try {
269
+ const descriptors = ProjectLoader.#plainDescriptors(
270
+ options,
271
+ 'Project loader options must be a plain object.'
272
+ )
273
+ const parserOptions = {}
274
+ for (const key of PARSER_OPTION_KEYS) {
275
+ if (descriptors[key]) {
276
+ parserOptions[key] = descriptors[key].value
277
+ }
278
+ }
279
+ const normalized = ParserOptions.normalize(
280
+ { fileName: '', data: '[]' },
281
+ parserOptions
282
+ ).options
283
+ if (normalized.signal !== undefined && normalized.signal !== null) {
284
+ ProjectLoader.#signalState(normalized.signal)
285
+ }
286
+ const archiveLimits = ArchiveLimits.normalize(
287
+ descriptors.archiveLimits
288
+ ? descriptors.archiveLimits.value
289
+ : undefined
290
+ )
291
+ return { ...normalized, archiveLimits }
292
+ } catch (error) {
293
+ throw ProjectLoader.#inputError(error)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Validates, measures, and classifies all entries before parsing.
299
+ * @param {unknown} entries Named entry candidates.
300
+ * @param {Record<string, number>} limits Normalized safety limits.
301
+ * @param {'none' | 'metadata' | 'full'} assetMode Asset decode mode.
302
+ * @returns {{ entries: object[], candidates: object[], entryNames: string[], totalBytes: number }} Classified entries.
303
+ */
304
+ static #classify(entries, limits, assetMode) {
305
+ const entryDescriptors = ProjectLoader.#entryArray(entries)
306
+ const entryCount = entryDescriptors.length.value
307
+ if (!entryCount) {
308
+ throw ProjectLoader.#inputError(
309
+ new TypeError('Project entries must be a non-empty array.')
310
+ )
311
+ }
312
+ ProjectLoader.#assertLimit('maxEntries', limits.maxEntries, entryCount)
313
+
314
+ const prepared = []
315
+ let totalBytes = 0
316
+ for (let index = 0; index < entryCount; index += 1) {
317
+ const entry = entryDescriptors[String(index)].value
318
+ const fields = ProjectLoader.#entryFields(entry)
319
+ const name = ArchiveEntryPath.normalize(fields.name)
320
+ const byteLength = ProjectLoader.#byteLength(fields.data)
321
+ let entryBytes = byteLength
322
+ ProjectLoader.#assertLimit(
323
+ 'maxEntryBytes',
324
+ limits.maxEntryBytes,
325
+ entryBytes,
326
+ name
327
+ )
328
+ totalBytes += byteLength
329
+ ProjectLoader.#assertLimit(
330
+ 'maxTotalBytes',
331
+ limits.maxTotalBytes,
332
+ totalBytes
333
+ )
334
+
335
+ const archiveDepth = ProjectLoader.#metadataInteger(
336
+ fields.archiveDepth,
337
+ 'archiveDepth',
338
+ 0
339
+ )
340
+ ProjectLoader.#assertLimit(
341
+ 'maxArchiveDepth',
342
+ limits.maxArchiveDepth,
343
+ archiveDepth,
344
+ name
345
+ )
346
+ ProjectLoader.#assertCompressionRatio(
347
+ byteLength,
348
+ fields.compressedByteLength,
349
+ limits.maxCompressionRatio,
350
+ name
351
+ )
352
+
353
+ const input = { fileName: name, data: fields.data }
354
+ if (fields.assets !== undefined) {
355
+ try {
356
+ input.assets = ToolkitAsset.prepareAll(fields.assets, {
357
+ mode: assetMode,
358
+ acceptPayload: (assetBytes) => {
359
+ entryBytes += assetBytes
360
+ ProjectLoader.#assertLimit(
361
+ 'maxEntryBytes',
362
+ limits.maxEntryBytes,
363
+ entryBytes,
364
+ name
365
+ )
366
+ totalBytes += assetBytes
367
+ ProjectLoader.#assertLimit(
368
+ 'maxTotalBytes',
369
+ limits.maxTotalBytes,
370
+ totalBytes,
371
+ name
372
+ )
373
+ }
374
+ })
375
+ } catch (error) {
376
+ if (error instanceof ToolkitError) throw error
377
+ throw ProjectLoader.#inputError(error)
378
+ }
379
+ }
380
+ prepared.push({ name, byteLength, input })
381
+ }
382
+
383
+ const entryNames = ArchiveEntryPath.unique(
384
+ prepared.map((entry) => entry.name)
385
+ )
386
+ const candidates = prepared
387
+ .filter((entry) => entry.name.toLowerCase().endsWith('.json'))
388
+ .sort((left, right) =>
389
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
390
+ )
391
+ return { entries: prepared, candidates, entryNames, totalBytes }
392
+ }
393
+
394
+ /**
395
+ * Captures only public project fields before any callback, yield, or worker
396
+ * boundary. This isolates classification from later caller mutation and
397
+ * gives direct and worker paths the same descriptor policy.
398
+ * @param {unknown} entries Named entry candidates.
399
+ * @param {number} maximumEntries Configured project-entry ceiling.
400
+ * @returns {object[]} Dense stable request snapshot.
401
+ */
402
+ static #snapshotEntries(entries, maximumEntries) {
403
+ const descriptors = ProjectLoader.#entryArray(entries, maximumEntries)
404
+ const length = descriptors.length.value
405
+ let attachedValues = 0
406
+ const snapshot = new Array(length)
407
+ for (let index = 0; index < length; index += 1) {
408
+ const fields = ProjectLoader.#entryFields(
409
+ descriptors[String(index)].value
410
+ )
411
+ const captured = { name: fields.name, data: fields.data }
412
+ if (fields.assets !== undefined) {
413
+ attachedValues = AttachedValueLimits.add(
414
+ fields.assets,
415
+ attachedValues
416
+ )
417
+ captured.assets = ProjectLoader.#attachedValueSnapshot(
418
+ fields.assets
419
+ )
420
+ }
421
+ if (fields.compressedByteLength !== undefined) {
422
+ captured.compressedByteLength = fields.compressedByteLength
423
+ }
424
+ if (fields.archiveDepth !== undefined) {
425
+ captured.archiveDepth = fields.archiveDepth
426
+ }
427
+ snapshot[index] = captured
428
+ }
429
+ return snapshot
430
+ }
431
+
432
+ /**
433
+ * Captures one dense attached-value array without reading item properties.
434
+ * @param {unknown} values Attached-value array candidate.
435
+ * @returns {unknown[]} Stable array of attached value identities.
436
+ */
437
+ static #attachedValueSnapshot(values) {
438
+ let prototype
439
+ let descriptors
440
+ try {
441
+ prototype = Object.getPrototypeOf(values)
442
+ descriptors = Object.getOwnPropertyDescriptors(values)
443
+ } catch {
444
+ throw ProjectLoader.#inputError(
445
+ new TypeError(
446
+ 'Project entry assets must be a dense plain array.'
447
+ )
448
+ )
449
+ }
450
+ const length = descriptors.length?.value
451
+ if (
452
+ prototype !== Array.prototype ||
453
+ !Number.isSafeInteger(length) ||
454
+ length < 0 ||
455
+ Reflect.ownKeys(descriptors).length !== length + 1
456
+ ) {
457
+ throw ProjectLoader.#inputError(
458
+ new TypeError(
459
+ 'Project entry assets must be a dense plain array.'
460
+ )
461
+ )
462
+ }
463
+ const snapshot = new Array(length)
464
+ for (let index = 0; index < length; index += 1) {
465
+ const descriptor = descriptors[String(index)]
466
+ if (
467
+ !descriptor ||
468
+ !Object.hasOwn(descriptor, 'value') ||
469
+ descriptor.enumerable !== true
470
+ ) {
471
+ throw ProjectLoader.#inputError(
472
+ new TypeError(
473
+ 'Project entry assets must contain enumerable data properties.'
474
+ )
475
+ )
476
+ }
477
+ snapshot[index] = descriptor.value
478
+ }
479
+ return snapshot
480
+ }
481
+
482
+ /**
483
+ * Reads one entry through own data properties only.
484
+ * @param {unknown} entry Entry candidate.
485
+ * @returns {{ name: unknown, data: unknown, assets?: unknown, compressedByteLength?: unknown, archiveDepth?: unknown }} Entry fields.
486
+ */
487
+ static #entryFields(entry) {
488
+ const descriptors = ProjectLoader.#plainDescriptors(
489
+ entry,
490
+ 'Each project entry must be a plain object.'
491
+ )
492
+ if (!descriptors.name || !descriptors.data) {
493
+ throw ProjectLoader.#inputError(
494
+ new TypeError('Each project entry requires name and data.')
495
+ )
496
+ }
497
+ if (descriptors.assets && !Array.isArray(descriptors.assets.value)) {
498
+ throw ProjectLoader.#inputError(
499
+ new TypeError('Project entry assets must be an array.')
500
+ )
501
+ }
502
+ return {
503
+ name: descriptors.name.value,
504
+ data: descriptors.data.value,
505
+ assets: descriptors.assets?.value,
506
+ compressedByteLength: descriptors.compressedByteLength?.value,
507
+ archiveDepth: descriptors.archiveDepth?.value
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Reads an exact dense project-entry array without caller iteration.
513
+ * @param {unknown} entries Entry array candidate.
514
+ * @param {number} [maximum] Maximum permitted entries.
515
+ * @returns {Record<string, PropertyDescriptor>} Array descriptors.
516
+ */
517
+ static #entryArray(entries, maximum = ArchiveLimits.defaults.maxEntries) {
518
+ if (!Array.isArray(entries)) {
519
+ throw ProjectLoader.#inputError(
520
+ new TypeError('Project entries must be a non-empty array.')
521
+ )
522
+ }
523
+ let prototype
524
+ let lengthDescriptor
525
+ let descriptors
526
+ try {
527
+ prototype = Object.getPrototypeOf(entries)
528
+ lengthDescriptor = Object.getOwnPropertyDescriptor(
529
+ entries,
530
+ 'length'
531
+ )
532
+ } catch {
533
+ throw ProjectLoader.#inputError(
534
+ new TypeError('Project entries must be a dense plain array.')
535
+ )
536
+ }
537
+ const length = lengthDescriptor?.value
538
+ if (
539
+ prototype !== Array.prototype ||
540
+ !lengthDescriptor ||
541
+ !Object.hasOwn(lengthDescriptor, 'value') ||
542
+ !Number.isSafeInteger(length) ||
543
+ length < 0
544
+ ) {
545
+ throw ProjectLoader.#inputError(
546
+ new TypeError('Project entries must be a dense plain array.')
547
+ )
548
+ }
549
+ ProjectLoader.#assertLimit('maxEntries', maximum, length)
550
+ try {
551
+ descriptors = Object.getOwnPropertyDescriptors(entries)
552
+ } catch {
553
+ throw ProjectLoader.#inputError(
554
+ new TypeError('Project entries must be a dense plain array.')
555
+ )
556
+ }
557
+ if (Reflect.ownKeys(descriptors).length !== length + 1) {
558
+ throw ProjectLoader.#inputError(
559
+ new TypeError('Project entries must be a dense plain array.')
560
+ )
561
+ }
562
+ for (let index = 0; index < length; index += 1) {
563
+ const descriptor = descriptors[String(index)]
564
+ if (
565
+ !descriptor ||
566
+ !Object.hasOwn(descriptor, 'value') ||
567
+ descriptor.enumerable !== true
568
+ ) {
569
+ throw ProjectLoader.#inputError(
570
+ new TypeError(
571
+ 'Project entries must contain enumerable data properties.'
572
+ )
573
+ )
574
+ }
575
+ }
576
+ return descriptors
577
+ }
578
+
579
+ /**
580
+ * Returns own data-property descriptors for one plain record.
581
+ * @param {unknown} value Record candidate.
582
+ * @param {string} message Error message.
583
+ * @returns {Record<string, PropertyDescriptor>} Own descriptors.
584
+ */
585
+ static #plainDescriptors(value, message) {
586
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
587
+ throw ProjectLoader.#inputError(new TypeError(message))
588
+ }
589
+ const prototype = Object.getPrototypeOf(value)
590
+ if (prototype !== Object.prototype && prototype !== null) {
591
+ throw ProjectLoader.#inputError(new TypeError(message))
592
+ }
593
+ const descriptors = Object.getOwnPropertyDescriptors(value)
594
+ for (const descriptor of Object.values(descriptors)) {
595
+ if (descriptor.get || descriptor.set) {
596
+ throw ProjectLoader.#inputError(
597
+ new TypeError('Accessor-backed project fields are invalid.')
598
+ )
599
+ }
600
+ }
601
+ return descriptors
602
+ }
603
+
604
+ /**
605
+ * Measures a canonical parser payload without copying it.
606
+ * @param {unknown} data Entry payload.
607
+ * @returns {number} Payload byte length.
608
+ */
609
+ static #byteLength(data) {
610
+ if (typeof data === 'string') {
611
+ return ProjectLoader.#stringByteLength(data)
612
+ }
613
+ if (data instanceof ArrayBuffer) return data.byteLength
614
+ if (data instanceof Uint8Array) return data.byteLength
615
+ throw ProjectLoader.#inputError(
616
+ new TypeError(
617
+ 'Project entry data must be a string, ArrayBuffer, or Uint8Array.'
618
+ )
619
+ )
620
+ }
621
+
622
+ /**
623
+ * Measures UTF-8 text without allocating a duplicate encoded payload.
624
+ * @param {string} value Text payload.
625
+ * @returns {number} UTF-8 byte length.
626
+ */
627
+ static #stringByteLength(value) {
628
+ let byteLength = 0
629
+ for (let index = 0; index < value.length; index += 1) {
630
+ const codeUnit = value.charCodeAt(index)
631
+ if (codeUnit <= 0x7f) {
632
+ byteLength += 1
633
+ } else if (codeUnit <= 0x7ff) {
634
+ byteLength += 2
635
+ } else if (
636
+ codeUnit >= 0xd800 &&
637
+ codeUnit <= 0xdbff &&
638
+ index + 1 < value.length &&
639
+ value.charCodeAt(index + 1) >= 0xdc00 &&
640
+ value.charCodeAt(index + 1) <= 0xdfff
641
+ ) {
642
+ byteLength += 4
643
+ index += 1
644
+ } else {
645
+ byteLength += 3
646
+ }
647
+ }
648
+ return byteLength
649
+ }
650
+
651
+ /**
652
+ * Normalizes optional non-negative archive metadata.
653
+ * @param {unknown} value Metadata value.
654
+ * @param {string} key Metadata key.
655
+ * @param {number} fallback Missing-value fallback.
656
+ * @returns {number} Normalized integer.
657
+ */
658
+ static #metadataInteger(value, key, fallback) {
659
+ if (value === undefined) return fallback
660
+ if (!Number.isSafeInteger(value) || value < 0) {
661
+ throw ProjectLoader.#inputError(
662
+ new TypeError(`${key} must be a non-negative safe integer.`)
663
+ )
664
+ }
665
+ return value
666
+ }
667
+
668
+ /**
669
+ * Enforces optional compressed-size metadata before parsing.
670
+ * @param {number} byteLength Uncompressed payload bytes.
671
+ * @param {unknown} compressedValue Optional compressed byte length.
672
+ * @param {number} maximum Maximum allowed ratio.
673
+ * @param {string} entryName Entry name.
674
+ * @returns {void}
675
+ */
676
+ static #assertCompressionRatio(
677
+ byteLength,
678
+ compressedValue,
679
+ maximum,
680
+ entryName
681
+ ) {
682
+ if (compressedValue === undefined) return
683
+ const compressed = ProjectLoader.#metadataInteger(
684
+ compressedValue,
685
+ 'compressedByteLength',
686
+ 0
687
+ )
688
+ const ratio =
689
+ compressed === 0
690
+ ? byteLength === 0
691
+ ? 1
692
+ : Number.POSITIVE_INFINITY
693
+ : byteLength / compressed
694
+ ProjectLoader.#assertLimit(
695
+ 'maxCompressionRatio',
696
+ maximum,
697
+ ratio,
698
+ entryName
699
+ )
700
+ }
701
+
702
+ /**
703
+ * Throws when one measured value exceeds its normalized limit.
704
+ * @param {string} limit Limit name.
705
+ * @param {number} maximum Maximum value.
706
+ * @param {number} actual Measured value.
707
+ * @param {string} [entryName] Associated entry name.
708
+ * @returns {void}
709
+ */
710
+ static #assertLimit(limit, maximum, actual, entryName = '') {
711
+ if (actual <= maximum) return
712
+ throw new ToolkitError(`Archive limit exceeded: ${limit}.`, {
713
+ code: 'ERR_ARCHIVE_LIMIT_EXCEEDED',
714
+ category: 'validation',
715
+ format: 'archive',
716
+ source: entryName,
717
+ details: { limit, maximum, actual, entryName }
718
+ })
719
+ }
720
+
721
+ /**
722
+ * Rejects projects without any source-format candidate.
723
+ * @param {{ candidates: object[] }} classified Classified entries.
724
+ * @returns {void}
725
+ */
726
+ static #assertCandidates(classified) {
727
+ if (classified.candidates.length) return
728
+ throw new ToolkitError(
729
+ 'No supported CircuitJSON project entry was found.',
730
+ {
731
+ code: 'ERR_PROJECT_UNSUPPORTED',
732
+ category: 'unsupported',
733
+ format: 'circuitjson'
734
+ }
735
+ )
736
+ }
737
+
738
+ /**
739
+ * Parses one candidate and records a project-level failure diagnostic.
740
+ * @param {{ name: string, input: object }} entry Prepared entry.
741
+ * @param {Record<string, any>} options Normalized options.
742
+ * @param {object[]} documents Successful documents.
743
+ * @param {object[]} diagnostics Project diagnostics.
744
+ * @returns {void}
745
+ */
746
+ static #parseEntry(entry, options, documents, diagnostics) {
747
+ try {
748
+ documents.push(
749
+ Parser.parse(entry.input, ProjectLoader.#parserOptions(options))
750
+ )
751
+ } catch (error) {
752
+ const normalized = ProjectLoader.#errorFrom(error)
753
+ if (normalized.code === 'ERR_CAPABILITY_UNAVAILABLE') {
754
+ throw normalized
755
+ }
756
+ diagnostics.push(
757
+ ToolkitDiagnostic.create({
758
+ code: normalized.code,
759
+ severity: 'error',
760
+ message: normalized.message,
761
+ source: entry.name,
762
+ location: normalized.location,
763
+ details: {
764
+ category: normalized.category,
765
+ format: normalized.format,
766
+ cause: normalized.cause
767
+ }
768
+ })
769
+ )
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Selects normalized options owned by the standalone parser.
775
+ * @param {Record<string, any>} options Normalized project options.
776
+ * @returns {Record<string, any>} Parser options.
777
+ */
778
+ static #parserOptions(options) {
779
+ const selected = {}
780
+ for (const key of PARSER_OPTION_KEYS) {
781
+ if (key === 'onProgress' || key === 'signal') continue
782
+ selected[key] = options[key]
783
+ }
784
+ return selected
785
+ }
786
+
787
+ /**
788
+ * Builds the canonical result or throws the typed zero-success failure.
789
+ * @param {{ entries: object[], candidates: object[], entryNames: string[], totalBytes: number }} classified Classified entries.
790
+ * @param {object[]} documents Successful documents.
791
+ * @param {object[]} diagnostics Project diagnostics.
792
+ * @param {Record<string, any>} options Normalized options.
793
+ * @returns {Record<string, any>} Canonical project result.
794
+ */
795
+ static #result(classified, documents, diagnostics, options) {
796
+ if (!documents.length) {
797
+ throw new ToolkitError(
798
+ 'No requested CircuitJSON project document could be loaded.',
799
+ {
800
+ code: 'ERR_PROJECT_NO_DOCUMENTS',
801
+ category: 'parse',
802
+ format: 'circuitjson',
803
+ details: { diagnostics }
804
+ }
805
+ )
806
+ }
807
+ return ProjectResult.create({
808
+ source: {
809
+ format: 'circuitjson',
810
+ entryNames: classified.entryNames
811
+ },
812
+ documents,
813
+ project: null,
814
+ extensions: {},
815
+ assets: ProjectLoader.#companionAssets(
816
+ classified.entries,
817
+ options.decodeAssets
818
+ ),
819
+ diagnostics,
820
+ statistics: {
821
+ entryCount: classified.entries.length,
822
+ candidateCount: classified.candidates.length,
823
+ documentCount: documents.length,
824
+ failureCount: diagnostics.length,
825
+ totalBytes: classified.totalBytes
826
+ }
827
+ })
828
+ }
829
+
830
+ /**
831
+ * Selects non-document entries through the common asset decode modes.
832
+ * @param {Array<{ name: string, byteLength: number, input: { data: unknown } }>} entries Prepared entries.
833
+ * @param {'none' | 'metadata' | 'full'} mode Asset selection mode.
834
+ * @returns {object[]} Project companion assets.
835
+ */
836
+ static #companionAssets(entries, mode) {
837
+ if (mode === 'none') return []
838
+ return entries
839
+ .filter((entry) => !entry.name.toLowerCase().endsWith('.json'))
840
+ .map(
841
+ (entry) =>
842
+ entry.companionAsset ||
843
+ ToolkitAsset.prepare(
844
+ {
845
+ kind: 'companion',
846
+ name: entry.name,
847
+ mediaType: 'application/octet-stream',
848
+ byteLength: entry.byteLength,
849
+ data: entry.input.data,
850
+ source: { entryName: entry.name }
851
+ },
852
+ { mode }
853
+ )
854
+ )
855
+ }
856
+
857
+ /**
858
+ * Emits one clone-safe progress row without swallowing host callback errors.
859
+ * @param {Record<string, any>} options Normalized options.
860
+ * @param {Record<string, any>} fields Progress fields.
861
+ * @param {Record<string, any> | null} previous Previous row.
862
+ * @returns {Record<string, any> | null} Current or previous row.
863
+ */
864
+ static #progress(options, fields, previous) {
865
+ if (!options.onProgress) return previous
866
+ const row = ToolkitProgress.create(fields, previous)
867
+ options.onProgress(row)
868
+ return row
869
+ }
870
+
871
+ /**
872
+ * Yields to a real host task so timer, I/O, and UI cancellation can run.
873
+ * @param {boolean} cancellationResponsive Whether timer-backed aborts must run before the next entry.
874
+ * @returns {Promise<void>} Yield completion.
875
+ */
876
+ static async #yieldToHost(cancellationResponsive) {
877
+ if (cancellationResponsive) {
878
+ await new Promise((resolve) => setTimeout(resolve, 0))
879
+ return
880
+ }
881
+ if (typeof globalThis.scheduler?.yield === 'function') {
882
+ await globalThis.scheduler.yield()
883
+ return
884
+ }
885
+ if (typeof setImmediate === 'function') {
886
+ await new Promise((resolve) => setImmediate(resolve))
887
+ return
888
+ }
889
+ if (typeof globalThis.MessageChannel === 'function') {
890
+ await new Promise((resolve) => {
891
+ const channel = new globalThis.MessageChannel()
892
+ channel.port1.onmessage = () => {
893
+ channel.port1.close()
894
+ channel.port2.close()
895
+ resolve()
896
+ }
897
+ channel.port2.postMessage(null)
898
+ })
899
+ return
900
+ }
901
+ await new Promise((resolve) => setTimeout(resolve, 0))
902
+ }
903
+
904
+ /**
905
+ * Throws the shared cancellation error for an aborted signal.
906
+ * @param {unknown} signal Abort signal candidate.
907
+ * @returns {void}
908
+ */
909
+ static #assertNotCancelled(signal) {
910
+ if (signal === undefined || signal === null) return
911
+ if (!ProjectLoader.#signalState(signal)) return
912
+ throw new ToolkitError('CircuitJSON project loading was cancelled.', {
913
+ code: 'ERR_CANCELLED',
914
+ category: 'cancelled',
915
+ format: 'circuitjson'
916
+ })
917
+ }
918
+
919
+ /**
920
+ * Reads only a genuine AbortSignal through the captured platform getter.
921
+ * @param {unknown} signal Signal candidate.
922
+ * @returns {boolean} Aborted state.
923
+ */
924
+ static #signalState(signal) {
925
+ if (!ABORTED_GETTER) {
926
+ throw new TypeError('AbortSignal state is unavailable.')
927
+ }
928
+ try {
929
+ return Boolean(Reflect.apply(ABORTED_GETTER, signal, []))
930
+ } catch {
931
+ throw new TypeError('Project signal must be an AbortSignal.')
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Converts option/input failures into the project validation boundary.
937
+ * @param {unknown} error Failure candidate.
938
+ * @returns {ToolkitError} Typed input error.
939
+ */
940
+ static #inputError(error) {
941
+ return ToolkitError.from(error, {
942
+ code: 'ERR_PROJECT_INPUT',
943
+ category: 'validation',
944
+ format: 'circuitjson'
945
+ })
946
+ }
947
+
948
+ /**
949
+ * Converts unexpected public failures into clone-safe toolkit errors.
950
+ * @param {unknown} error Failure candidate.
951
+ * @returns {ToolkitError} Typed project error.
952
+ */
953
+ static #errorFrom(error) {
954
+ return ToolkitError.from(error, {
955
+ code: 'ERR_PROJECT_LOAD',
956
+ category: 'runtime',
957
+ format: 'circuitjson'
958
+ })
959
+ }
960
+
961
+ /**
962
+ * Creates the synchronous worker-mode boundary error.
963
+ * @returns {ToolkitError} Typed unsupported error.
964
+ */
965
+ static #workerSyncError() {
966
+ return new ToolkitError(
967
+ 'Synchronous CircuitJSON project loading cannot use a worker.',
968
+ {
969
+ code: 'ERR_WORKER_SYNC_UNAVAILABLE',
970
+ category: 'unsupported',
971
+ format: 'circuitjson'
972
+ }
973
+ )
974
+ }
975
+ }