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,435 @@
1
+ import { CircuitJsonDocumentContext } from '../context/CircuitJsonDocumentContext.mjs'
2
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
3
+ import { CircuitTraversal } from './CircuitTraversal.mjs'
4
+ import { QueryNetlistBuilder } from './QueryNetlistBuilder.mjs'
5
+ import { RegexPattern } from './RegexPattern.mjs'
6
+
7
+ const COMPONENT_FIELDS = new Set([
8
+ 'id',
9
+ 'name',
10
+ 'designator',
11
+ 'type',
12
+ 'value',
13
+ 'footprint',
14
+ 'mpn',
15
+ 'description'
16
+ ])
17
+ const NET_FIELDS = new Set(['id', 'name'])
18
+ const CRITERIA_KEYS = new Set([
19
+ 'field',
20
+ 'pattern',
21
+ 'match',
22
+ 'flags',
23
+ 'caseSensitive'
24
+ ])
25
+
26
+ /**
27
+ * Provides repeated clone-safe queries over one prepared CircuitJSON context.
28
+ */
29
+ export class QueryService {
30
+ #context
31
+ #netlist
32
+
33
+ /**
34
+ * Creates one service around an already prepared context.
35
+ * @param {CircuitJsonDocumentContext} context Prepared document context.
36
+ */
37
+ constructor(context) {
38
+ this.#context = context
39
+ this.#netlist = context.getOrCreateDerived('query', 'netlist-v1', () =>
40
+ QueryNetlistBuilder.build(context)
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Creates a bound query service and prepares its shared indexes once.
46
+ * @param {unknown} document Document result, CircuitJSON model, or context.
47
+ * @param {Record<string, any>} [options] Context options.
48
+ * @returns {QueryService} Bound service.
49
+ */
50
+ static create(document, options = {}) {
51
+ try {
52
+ const normalizedOptions = QueryService.#record(
53
+ options,
54
+ new Set(['indexes'])
55
+ )
56
+ const context = CircuitJsonDocumentContext.prepare(document, {
57
+ ...normalizedOptions,
58
+ indexes: ['elements', 'relations', 'connectivity']
59
+ })
60
+ return new QueryService(context)
61
+ } catch (error) {
62
+ throw QueryService.#from(error)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns shared context work counters in stable query terminology.
68
+ * @returns {Record<string, number>} Query statistics.
69
+ */
70
+ get statistics() {
71
+ const statistics = this.#context.statistics
72
+ return {
73
+ validationPasses: statistics.validationPasses,
74
+ elementIndexBuilds: statistics.indexBuilds.elements || 0,
75
+ relationIndexBuilds: statistics.indexBuilds.relations || 0,
76
+ connectivityIndexBuilds: statistics.indexBuilds.connectivity || 0,
77
+ netlistBuilds: statistics.derivedBuilds['query:netlist-v1'] || 0
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Executes one canonical component or net query.
83
+ * @param {Record<string, any>} request Query request.
84
+ * @param {Record<string, any>} [options] Paging options.
85
+ * @returns {{ schema: string, items: object[], diagnostics: object[], statistics: object }} Query result.
86
+ */
87
+ query(request, options = {}) {
88
+ const normalized = QueryService.#record(
89
+ request,
90
+ new Set(['select', 'where'])
91
+ )
92
+ const select = normalized.select
93
+ if (select !== 'components' && select !== 'nets') {
94
+ throw QueryService.#error(
95
+ 'Query select must be components or nets.'
96
+ )
97
+ }
98
+ const items = this.#find(
99
+ select,
100
+ normalized.where === undefined ? {} : normalized.where,
101
+ options
102
+ )
103
+ return {
104
+ schema: 'ecad-toolkit.query.v1',
105
+ items,
106
+ diagnostics: structuredClone(this.#netlist.diagnostics),
107
+ statistics: this.statistics
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Finds components using exact, contains, or regex matching.
113
+ * @param {Record<string, any>} [criteria] Component criteria.
114
+ * @param {Record<string, any>} [options] Paging options.
115
+ * @returns {object[]} Matching component records.
116
+ */
117
+ findComponents(criteria = {}, options = {}) {
118
+ return this.#find('components', criteria, options)
119
+ }
120
+
121
+ /**
122
+ * Finds nets using exact, contains, or regex matching.
123
+ * @param {Record<string, any>} [criteria] Net criteria.
124
+ * @param {Record<string, any>} [options] Paging options.
125
+ * @returns {object[]} Matching net records.
126
+ */
127
+ findNets(criteria = {}, options = {}) {
128
+ return this.#find('nets', criteria, options)
129
+ }
130
+
131
+ /**
132
+ * Traverses stable source connectivity from canonical source ids.
133
+ * @param {Record<string, any>} request Starting source ids.
134
+ * @param {Record<string, any>} [options] Traversal bounds.
135
+ * @returns {object[]} Ordered connectivity records.
136
+ */
137
+ traceConnectivity(request, options = {}) {
138
+ const start = QueryService.#startRequest(request)
139
+ const bounds = QueryService.#traversalOptions(options)
140
+ const graph = this.#context.getOrCreateDerived(
141
+ 'query',
142
+ 'traversal-v1',
143
+ () => CircuitTraversal.prepare(this.#netlist)
144
+ )
145
+ return CircuitTraversal.trace(graph, start, bounds)
146
+ }
147
+
148
+ /**
149
+ * Returns a detached canonical query netlist.
150
+ * @param {Record<string, any>} [options] Reserved build options.
151
+ * @returns {Record<string, any>} Clone-safe netlist.
152
+ */
153
+ buildNetlist(options = {}) {
154
+ QueryService.#record(options, new Set())
155
+ return structuredClone(this.#netlist)
156
+ }
157
+
158
+ /**
159
+ * Filters one stable record family.
160
+ * @param {'components' | 'nets'} select Record family.
161
+ * @param {Record<string, any>} criteria Match criteria.
162
+ * @param {Record<string, any>} options Paging options.
163
+ * @returns {object[]} Detached matching rows.
164
+ */
165
+ #find(select, criteria, options) {
166
+ const fields = select === 'components' ? COMPONENT_FIELDS : NET_FIELDS
167
+ const matcher = QueryService.#matcher(criteria, fields)
168
+ const paging = QueryService.#paging(options)
169
+ const source =
170
+ select === 'components'
171
+ ? this.#netlist.components
172
+ : this.#netlist.nets
173
+ return structuredClone(
174
+ source
175
+ .filter((row) => QueryService.#matches(row, matcher, fields))
176
+ .slice(paging.offset, paging.offset + paging.limit)
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Normalizes safe matching criteria.
182
+ * @param {unknown} criteria Criteria candidate.
183
+ * @param {Set<string>} fields Allowed row fields.
184
+ * @returns {Record<string, any>} Matcher state.
185
+ */
186
+ static #matcher(criteria, fields) {
187
+ const normalized = QueryService.#record(criteria, CRITERIA_KEYS)
188
+ const pattern =
189
+ normalized.pattern === undefined ? '' : normalized.pattern
190
+ if (typeof pattern !== 'string' || pattern.length > 4096) {
191
+ throw QueryService.#error('Query pattern must be a bounded string.')
192
+ }
193
+ const match =
194
+ normalized.match === undefined ? 'contains' : normalized.match
195
+ if (!['exact', 'contains', 'regex'].includes(match)) {
196
+ throw QueryService.#error(
197
+ 'Query match must be exact, contains, or regex.'
198
+ )
199
+ }
200
+ const field = normalized.field
201
+ if (
202
+ field !== undefined &&
203
+ (typeof field !== 'string' || !fields.has(field))
204
+ ) {
205
+ throw QueryService.#error(
206
+ 'Query field is unavailable for this selection.'
207
+ )
208
+ }
209
+ if (
210
+ normalized.caseSensitive !== undefined &&
211
+ typeof normalized.caseSensitive !== 'boolean'
212
+ ) {
213
+ throw QueryService.#error('Query caseSensitive must be boolean.')
214
+ }
215
+ const flags = normalized.flags === undefined ? '' : normalized.flags
216
+ if (match !== 'regex' && typeof flags !== 'string') {
217
+ throw QueryService.#error('Query regex flags must be a string.')
218
+ }
219
+ return {
220
+ pattern,
221
+ match,
222
+ field,
223
+ caseSensitive: normalized.caseSensitive === true,
224
+ regex:
225
+ match === 'regex'
226
+ ? RegexPattern.compile(pattern, flags, {
227
+ caseSensitive: normalized.caseSensitive === true
228
+ })
229
+ : null
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Tests one row against normalized criteria.
235
+ * @param {Record<string, any>} row Query row.
236
+ * @param {Record<string, any>} matcher Matcher state.
237
+ * @param {Set<string>} fields Searchable fields.
238
+ * @returns {boolean} Whether the row matches.
239
+ */
240
+ static #matches(row, matcher, fields) {
241
+ if (!matcher.pattern) return true
242
+ const values = matcher.field
243
+ ? [row[matcher.field]]
244
+ : [...fields].map((field) => row[field])
245
+ return values
246
+ .filter(
247
+ (value) =>
248
+ typeof value === 'string' || typeof value === 'number'
249
+ )
250
+ .some((value) => {
251
+ if (matcher.regex)
252
+ return RegexPattern.test(matcher.regex, value)
253
+ const candidate = String(value)
254
+ const pattern = matcher.pattern
255
+ const normalizedCandidate = matcher.caseSensitive
256
+ ? candidate
257
+ : candidate.toLocaleLowerCase('en-US')
258
+ const normalizedPattern = matcher.caseSensitive
259
+ ? pattern
260
+ : pattern.toLocaleLowerCase('en-US')
261
+ return matcher.match === 'exact'
262
+ ? normalizedCandidate === normalizedPattern
263
+ : normalizedCandidate.includes(normalizedPattern)
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Normalizes bounded result paging.
269
+ * @param {unknown} options Paging options.
270
+ * @returns {{ limit: number, offset: number }} Paging bounds.
271
+ */
272
+ static #paging(options) {
273
+ const normalized = QueryService.#record(
274
+ options,
275
+ new Set(['limit', 'offset'])
276
+ )
277
+ const limit = normalized.limit === undefined ? 10000 : normalized.limit
278
+ const offset = normalized.offset === undefined ? 0 : normalized.offset
279
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
280
+ throw QueryService.#error(
281
+ 'Query limit must be an integer from 1 through 10000.'
282
+ )
283
+ }
284
+ if (!Number.isSafeInteger(offset) || offset < 0) {
285
+ throw QueryService.#error(
286
+ 'Query offset must be a non-negative safe integer.'
287
+ )
288
+ }
289
+ return { limit, offset }
290
+ }
291
+
292
+ /**
293
+ * Normalizes one connectivity start request.
294
+ * @param {unknown} request Request candidate.
295
+ * @returns {Record<string, string>} Starting ids.
296
+ */
297
+ static #startRequest(request) {
298
+ const keys = new Set([
299
+ 'sourceTraceId',
300
+ 'sourceComponentId',
301
+ 'sourcePortId',
302
+ 'sourceNetId'
303
+ ])
304
+ const normalized = QueryService.#record(request, keys)
305
+ const result = {}
306
+ for (const key of keys) {
307
+ if (normalized[key] === undefined) continue
308
+ if (
309
+ typeof normalized[key] !== 'string' ||
310
+ !normalized[key].trim()
311
+ ) {
312
+ throw QueryService.#error(
313
+ 'Connectivity source ids must be non-empty strings.'
314
+ )
315
+ }
316
+ result[key] = normalized[key].trim()
317
+ }
318
+ if (!Object.keys(result).length) {
319
+ throw QueryService.#error(
320
+ 'Connectivity traversal requires a canonical source id.'
321
+ )
322
+ }
323
+ return result
324
+ }
325
+
326
+ /**
327
+ * Normalizes traversal work limits.
328
+ * @param {unknown} options Traversal options.
329
+ * @returns {{ maxDepth: number, maxResults: number }} Work bounds.
330
+ */
331
+ static #traversalOptions(options) {
332
+ const normalized = QueryService.#record(
333
+ options,
334
+ new Set(['maxDepth', 'maxResults'])
335
+ )
336
+ const maxDepth =
337
+ normalized.maxDepth === undefined ? 64 : normalized.maxDepth
338
+ const maxResults =
339
+ normalized.maxResults === undefined ? 10000 : normalized.maxResults
340
+ if (
341
+ !Number.isSafeInteger(maxDepth) ||
342
+ maxDepth < 0 ||
343
+ maxDepth > 1024
344
+ ) {
345
+ throw QueryService.#error(
346
+ 'Connectivity maxDepth must be an integer from 0 through 1024.'
347
+ )
348
+ }
349
+ if (
350
+ !Number.isSafeInteger(maxResults) ||
351
+ maxResults < 1 ||
352
+ maxResults > 10000
353
+ ) {
354
+ throw QueryService.#error(
355
+ 'Connectivity maxResults must be an integer from 1 through 10000.'
356
+ )
357
+ }
358
+ return { maxDepth, maxResults }
359
+ }
360
+
361
+ /**
362
+ * Reads a plain record through own data properties only.
363
+ * @param {unknown} value Record candidate.
364
+ * @param {Set<string>} allowedKeys Allowed own keys.
365
+ * @returns {Record<string, any>} Safe shallow record.
366
+ */
367
+ static #record(value, allowedKeys) {
368
+ try {
369
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
370
+ throw QueryService.#error(
371
+ 'Query arguments must be plain objects.'
372
+ )
373
+ }
374
+ const prototype = Object.getPrototypeOf(value)
375
+ if (prototype !== Object.prototype && prototype !== null) {
376
+ throw QueryService.#error(
377
+ 'Query arguments must be plain objects.'
378
+ )
379
+ }
380
+ const descriptors = Object.getOwnPropertyDescriptors(value)
381
+ const result = {}
382
+ for (const key of Reflect.ownKeys(descriptors)) {
383
+ const descriptor = descriptors[key]
384
+ if (
385
+ typeof key !== 'string' ||
386
+ !allowedKeys.has(key) ||
387
+ descriptor.get ||
388
+ descriptor.set ||
389
+ descriptor.enumerable !== true
390
+ ) {
391
+ throw QueryService.#error(
392
+ 'Query arguments contain an unsupported field.'
393
+ )
394
+ }
395
+ result[key] = descriptor.value
396
+ }
397
+ return result
398
+ } catch (error) {
399
+ if (error instanceof ToolkitError) throw error
400
+ throw QueryService.#error(
401
+ 'Query arguments could not be inspected safely.',
402
+ error
403
+ )
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Normalizes construction failures into the public query boundary.
409
+ * @param {unknown} error Failure candidate.
410
+ * @returns {ToolkitError} Typed query error.
411
+ */
412
+ static #from(error) {
413
+ return error instanceof ToolkitError
414
+ ? error
415
+ : QueryService.#error(
416
+ error?.message || 'Query service preparation failed.',
417
+ error
418
+ )
419
+ }
420
+
421
+ /**
422
+ * Creates one stable query request error.
423
+ * @param {string} message Failure message.
424
+ * @param {unknown} [cause] Native cause.
425
+ * @returns {ToolkitError} Typed query error.
426
+ */
427
+ static #error(message, cause) {
428
+ return new ToolkitError(message, {
429
+ code: 'ERR_QUERY_REQUEST',
430
+ category: 'validation',
431
+ format: 'circuitjson',
432
+ cause
433
+ })
434
+ }
435
+ }
@@ -0,0 +1,75 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ const ALLOWED_FLAGS = new Set(['i', 'm', 's', 'u'])
4
+ const MAX_PATTERN_LENGTH = 4096
5
+
6
+ /**
7
+ * Compiles bounded regular-expression data without evaluating caller code.
8
+ */
9
+ export class RegexPattern {
10
+ /**
11
+ * Compiles one validated pattern and deterministic flag set.
12
+ * @param {unknown} pattern Pattern source.
13
+ * @param {unknown} [flags] JavaScript regular-expression flags.
14
+ * @param {{ caseSensitive?: boolean }} [options] Matching options.
15
+ * @returns {RegExp} Compiled regular expression.
16
+ */
17
+ static compile(pattern, flags = '', options = {}) {
18
+ if (
19
+ typeof pattern !== 'string' ||
20
+ pattern.length > MAX_PATTERN_LENGTH
21
+ ) {
22
+ throw RegexPattern.#error(
23
+ 'Query regex patterns must be bounded strings.'
24
+ )
25
+ }
26
+ if (typeof flags !== 'string') {
27
+ throw RegexPattern.#error('Query regex flags must be a string.')
28
+ }
29
+
30
+ const normalizedFlags = [...flags]
31
+ if (
32
+ new Set(normalizedFlags).size !== normalizedFlags.length ||
33
+ normalizedFlags.some((flag) => !ALLOWED_FLAGS.has(flag))
34
+ ) {
35
+ throw RegexPattern.#error(
36
+ 'Query regex flags must be unique and limited to i, m, s, and u.'
37
+ )
38
+ }
39
+ if (options.caseSensitive !== true && !normalizedFlags.includes('i')) {
40
+ normalizedFlags.push('i')
41
+ }
42
+
43
+ try {
44
+ return new RegExp(pattern, normalizedFlags.join(''))
45
+ } catch (error) {
46
+ throw RegexPattern.#error('Query regex pattern is invalid.', error)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Tests one value while resetting state defensively.
52
+ * @param {RegExp} regex Compiled expression.
53
+ * @param {unknown} value Candidate value.
54
+ * @returns {boolean} Whether the value matches.
55
+ */
56
+ static test(regex, value) {
57
+ regex.lastIndex = 0
58
+ return regex.test(String(value ?? ''))
59
+ }
60
+
61
+ /**
62
+ * Creates a stable query-pattern validation error.
63
+ * @param {string} message Failure message.
64
+ * @param {unknown} [cause] Native compilation failure.
65
+ * @returns {ToolkitError} Typed query error.
66
+ */
67
+ static #error(message, cause) {
68
+ return new ToolkitError(message, {
69
+ code: 'ERR_QUERY_PATTERN',
70
+ category: 'validation',
71
+ format: 'circuitjson',
72
+ cause
73
+ })
74
+ }
75
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Provides one total deterministic ordering for canonical BOM presentation.
3
+ */
4
+ export class CanonicalBomOrder {
5
+ /**
6
+ * Compares natural designator strings with a raw code-point tie break.
7
+ * @param {unknown} left Left designator.
8
+ * @param {unknown} right Right designator.
9
+ * @returns {number} Ordering value.
10
+ */
11
+ static compareDesignators(left, right) {
12
+ const leftText = String(left)
13
+ const rightText = String(right)
14
+ const natural = leftText.localeCompare(rightText, 'en-US', {
15
+ numeric: true,
16
+ sensitivity: 'base'
17
+ })
18
+ return natural || CanonicalBomOrder.#compareText(leftText, rightText)
19
+ }
20
+
21
+ /**
22
+ * Compares every rendered row field after its complete designator list.
23
+ * @param {object} left Left BOM row.
24
+ * @param {object} right Right BOM row.
25
+ * @returns {number} Total rendered-row order.
26
+ */
27
+ static compareRows(left, right) {
28
+ const leftDesignators = left.designators || []
29
+ const rightDesignators = right.designators || []
30
+ const common = Math.min(leftDesignators.length, rightDesignators.length)
31
+ for (let index = 0; index < common; index += 1) {
32
+ const order = CanonicalBomOrder.compareDesignators(
33
+ leftDesignators[index],
34
+ rightDesignators[index]
35
+ )
36
+ if (order) return order
37
+ }
38
+ if (leftDesignators.length !== rightDesignators.length) {
39
+ return leftDesignators.length - rightDesignators.length
40
+ }
41
+ for (const field of ['value', 'pattern', 'source', 'quantity']) {
42
+ const order = CanonicalBomOrder.#compareScalar(
43
+ left[field],
44
+ right[field]
45
+ )
46
+ if (order) return order
47
+ }
48
+ return 0
49
+ }
50
+
51
+ /**
52
+ * Compares primitive rendered scalar values by type and value.
53
+ * @param {unknown} left Left scalar.
54
+ * @param {unknown} right Right scalar.
55
+ * @returns {number} Ordering value.
56
+ */
57
+ static #compareScalar(left, right) {
58
+ if (left === right) return 0
59
+ const leftType = typeof left
60
+ const rightType = typeof right
61
+ if (leftType !== rightType) {
62
+ return CanonicalBomOrder.#compareText(leftType, rightType)
63
+ }
64
+ if (leftType === 'number') return left - right
65
+ if (leftType === 'boolean') return left ? 1 : -1
66
+ return CanonicalBomOrder.#compareText(
67
+ String(left ?? ''),
68
+ String(right ?? '')
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Compares strings by code point.
74
+ * @param {string} left Left text.
75
+ * @param {string} right Right text.
76
+ * @returns {number} Ordering value.
77
+ */
78
+ static #compareText(left, right) {
79
+ return left < right ? -1 : left > right ? 1 : 0
80
+ }
81
+ }
@@ -0,0 +1,92 @@
1
+ import { CircuitJsonBomBuilder } from '../CircuitJsonBomBuilder.mjs'
2
+ import { CanonicalBomOrder } from './CanonicalBomOrder.mjs'
3
+
4
+ /**
5
+ * Normalizes deterministic canonical BOM presentation without changing legacy grouping.
6
+ */
7
+ export class CanonicalBomRows {
8
+ /**
9
+ * Builds stable rows while keeping canonical electrical values as group identity.
10
+ * @param {object[]} model CircuitJSON model.
11
+ * @returns {object[]} Canonical BOM rows.
12
+ */
13
+ static build(model) {
14
+ const labelsByDesignator = new Map()
15
+ for (const element of model || []) {
16
+ if (element?.type !== 'source_component') continue
17
+ const designator = CanonicalBomRows.#designator(element)
18
+ if (!designator) continue
19
+ const label = CanonicalBomRows.#displayValue(element)
20
+ if (!label) continue
21
+ const state = labelsByDesignator.get(designator)
22
+ if (!state) {
23
+ labelsByDesignator.set(designator, {
24
+ label,
25
+ ambiguous: false
26
+ })
27
+ } else if (state.label !== label) {
28
+ state.ambiguous = true
29
+ }
30
+ }
31
+ return CircuitJsonBomBuilder.build(model)
32
+ .map((row) => {
33
+ const designators = [...row.designators].sort(
34
+ CanonicalBomOrder.compareDesignators
35
+ )
36
+ let displayLabel = ''
37
+ let ambiguous = false
38
+ for (const designator of designators) {
39
+ const state = labelsByDesignator.get(designator)
40
+ if (!state) continue
41
+ if (
42
+ state.ambiguous ||
43
+ (displayLabel && displayLabel !== state.label)
44
+ ) {
45
+ ambiguous = true
46
+ break
47
+ }
48
+ displayLabel = state.label
49
+ }
50
+ return {
51
+ ...row,
52
+ designators,
53
+ value:
54
+ displayLabel && !ambiguous
55
+ ? displayLabel
56
+ : String(row.value)
57
+ }
58
+ })
59
+ .sort(CanonicalBomOrder.compareRows)
60
+ }
61
+
62
+ /**
63
+ * Resolves the designator used by the legacy BOM grouping contract.
64
+ * @param {object} component Source component.
65
+ * @returns {string} Designator.
66
+ */
67
+ static #designator(component) {
68
+ return String(
69
+ component.name ||
70
+ component.reference ||
71
+ component.designator ||
72
+ component.source_component_id ||
73
+ ''
74
+ ).trim()
75
+ }
76
+
77
+ /**
78
+ * Resolves standard display-only electrical fields.
79
+ * @param {object} component Source component.
80
+ * @returns {string} Display label.
81
+ */
82
+ static #displayValue(component) {
83
+ return String(
84
+ component.display_value ??
85
+ component.display_resistance ??
86
+ component.display_capacitance ??
87
+ component.display_inductance ??
88
+ component.display_max_resistance ??
89
+ ''
90
+ ).trim()
91
+ }
92
+ }