altium-toolkit 1.0.10 → 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 (63) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,386 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { ParserUtils } from './ParserUtils.mjs'
7
+
8
+ /**
9
+ * Parses Altium script-project files into a read-only project-script digest.
10
+ */
11
+ export class PrjScrModelParser {
12
+ /**
13
+ * Parses one script-project ArrayBuffer.
14
+ * @param {string} fileName Source file name.
15
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
16
+ * @param {{ existingPaths?: string[] }} options Parser options.
17
+ * @returns {object}
18
+ */
19
+ static parse(fileName, arrayBuffer, options = {}) {
20
+ return PrjScrModelParser.parseText(
21
+ fileName,
22
+ PrjScrModelParser.#decodeText(arrayBuffer),
23
+ options
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Parses one script-project text payload.
29
+ * @param {string} fileName Source file name.
30
+ * @param {string} text Source text.
31
+ * @param {{ existingPaths?: string[] }} options Parser options.
32
+ * @returns {object}
33
+ */
34
+ static parseText(fileName, text, options = {}) {
35
+ const sections = PrjScrModelParser.#parseIniSections(text)
36
+ const design = PrjScrModelParser.#sectionFields(
37
+ PrjScrModelParser.#findSection(sections, 'Design')
38
+ )
39
+ const existingPaths = new Set(
40
+ (options.existingPaths || []).map((path) =>
41
+ PrjScrModelParser.#normalizePath(path)
42
+ )
43
+ )
44
+ const documents = PrjScrModelParser.#extractDocuments(
45
+ sections,
46
+ existingPaths,
47
+ options
48
+ )
49
+ const scripts = documents
50
+ .filter((document) => document.kind === 'script')
51
+ .map((document) => PrjScrModelParser.#publicScript(document))
52
+ const diagnostics = PrjScrModelParser.#diagnostics(documents)
53
+
54
+ return NormalizedModelSchema.attach({
55
+ kind: 'project-script',
56
+ fileType: 'PrjScr',
57
+ fileName,
58
+ summary: {
59
+ title: ParserUtils.stripExtension(fileName),
60
+ documentCount: documents.length,
61
+ scriptCount: scripts.length,
62
+ missingPathCount: scripts.filter(
63
+ (script) => script.exists === false
64
+ ).length,
65
+ diagnosticCount: diagnostics.length
66
+ },
67
+ diagnostics,
68
+ projectScript: {
69
+ name: ParserUtils.stripExtension(fileName),
70
+ design,
71
+ documents,
72
+ scripts,
73
+ sections: PrjScrModelParser.#serializeSections(sections)
74
+ },
75
+ bom: []
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Decodes text with common project-file encodings.
81
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
82
+ * @returns {string}
83
+ */
84
+ static #decodeText(arrayBuffer) {
85
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
86
+ for (const encoding of ['utf-8', 'windows-1252']) {
87
+ try {
88
+ return new TextDecoder(encoding, { fatal: true })
89
+ .decode(bytes)
90
+ .replace(/^\uFEFF/u, '')
91
+ } catch {
92
+ // Try the next legacy-compatible project encoding.
93
+ }
94
+ }
95
+
96
+ return new TextDecoder('windows-1252')
97
+ .decode(bytes)
98
+ .replace(/^\uFEFF/u, '')
99
+ }
100
+
101
+ /**
102
+ * Parses INI sections while preserving option order.
103
+ * @param {string} text Source text.
104
+ * @returns {{ name: string, index: number, entries: object[] }[]}
105
+ */
106
+ static #parseIniSections(text) {
107
+ const sections = []
108
+ let current = null
109
+ const lines = String(text || '')
110
+ .replace(/\r\n?/gu, '\n')
111
+ .split('\n')
112
+
113
+ for (const [lineIndex, rawLine] of lines.entries()) {
114
+ const trimmed = rawLine.trim()
115
+ if (
116
+ !trimmed ||
117
+ trimmed.startsWith(';') ||
118
+ trimmed.startsWith('#')
119
+ ) {
120
+ continue
121
+ }
122
+
123
+ const sectionMatch = /^\[([^\]]+)\]$/u.exec(trimmed)
124
+ if (sectionMatch) {
125
+ current = {
126
+ name: sectionMatch[1].trim(),
127
+ index: sections.length,
128
+ entries: []
129
+ }
130
+ sections.push(current)
131
+ continue
132
+ }
133
+
134
+ if (!current) continue
135
+ const separatorIndex = rawLine.indexOf('=')
136
+ if (separatorIndex < 0) continue
137
+
138
+ current.entries.push({
139
+ key: rawLine.slice(0, separatorIndex).trim(),
140
+ value: rawLine.slice(separatorIndex + 1).trim(),
141
+ line: lineIndex + 1
142
+ })
143
+ }
144
+
145
+ return sections
146
+ }
147
+
148
+ /**
149
+ * Extracts numbered document entries.
150
+ * @param {object[]} sections Parsed sections.
151
+ * @param {Set<string>} existingPaths Normalized existing paths.
152
+ * @param {{ existingPaths?: string[] }} options Parser options.
153
+ * @returns {object[]}
154
+ */
155
+ static #extractDocuments(sections, existingPaths, options) {
156
+ return PrjScrModelParser.#numberedSections(sections, 'Document').map(
157
+ ({ section, number }) => {
158
+ const fields = PrjScrModelParser.#sectionFields(section)
159
+ const path = String(fields.DocumentPath || '')
160
+ const normalizedPath = PrjScrModelParser.#normalizePath(path)
161
+ const base = {
162
+ index: number,
163
+ section: section.name,
164
+ path,
165
+ normalizedPath,
166
+ fileName: PrjScrModelParser.#basename(path),
167
+ extension: PrjScrModelParser.#extension(path),
168
+ kind:
169
+ PrjScrModelParser.#extension(path).toLowerCase() ===
170
+ '.pas'
171
+ ? 'script'
172
+ : 'unsupported',
173
+ ...(options.existingPaths
174
+ ? { exists: existingPaths.has(normalizedPath) }
175
+ : {}),
176
+ annotationEnabled: PrjScrModelParser.#optionalBoolean(
177
+ fields.AnnotationEnabled
178
+ ),
179
+ classGeneration: PrjScrModelParser.#classGeneration(fields),
180
+ updatePolicies: PrjScrModelParser.#updatePolicies(fields),
181
+ options: fields
182
+ }
183
+
184
+ return PrjScrModelParser.#stripEmpty(base)
185
+ }
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Builds class-generation option metadata.
191
+ * @param {Record<string, string>} fields Document fields.
192
+ * @returns {object | undefined}
193
+ */
194
+ static #classGeneration(fields) {
195
+ return PrjScrModelParser.#stripEmpty({
196
+ classGenCcAutoEnabled: PrjScrModelParser.#optionalBoolean(
197
+ fields.ClassGenCCAutoEnabled
198
+ ),
199
+ classGenCcAutoRoomEnabled: PrjScrModelParser.#optionalBoolean(
200
+ fields.ClassGenCCAutoRoomEnabled
201
+ ),
202
+ classGenNcAutoScope: fields.ClassGenNCAutoScope
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Builds update-policy metadata.
208
+ * @param {Record<string, string>} fields Document fields.
209
+ * @returns {object | undefined}
210
+ */
211
+ static #updatePolicies(fields) {
212
+ return PrjScrModelParser.#stripEmpty({
213
+ doLibraryUpdate: PrjScrModelParser.#optionalBoolean(
214
+ fields.DoLibraryUpdate
215
+ ),
216
+ doDatabaseUpdate: PrjScrModelParser.#optionalBoolean(
217
+ fields.DoDatabaseUpdate
218
+ )
219
+ })
220
+ }
221
+
222
+ /**
223
+ * Builds structured parser diagnostics.
224
+ * @param {object[]} documents Parsed documents.
225
+ * @returns {object[]}
226
+ */
227
+ static #diagnostics(documents) {
228
+ return [
229
+ ...documents
230
+ .filter(
231
+ (document) =>
232
+ document.kind === 'script' && document.exists === false
233
+ )
234
+ .map((document) => ({
235
+ code: 'project-script.missing-document-path',
236
+ severity: 'warning',
237
+ message: 'Script project document path was not found.',
238
+ path: document.path,
239
+ normalizedPath: document.normalizedPath
240
+ })),
241
+ ...documents
242
+ .filter((document) => document.kind === 'unsupported')
243
+ .map((document) => ({
244
+ code: 'project-script.unsupported-document-kind',
245
+ severity: 'warning',
246
+ message:
247
+ 'Script project document is not a supported script file.',
248
+ path: document.path,
249
+ normalizedPath: document.normalizedPath
250
+ }))
251
+ ]
252
+ }
253
+
254
+ /**
255
+ * Builds the public script convenience row.
256
+ * @param {object} document Parsed document row.
257
+ * @returns {object}
258
+ */
259
+ static #publicScript(document) {
260
+ const { kind: _kind, ...script } = document
261
+ return script
262
+ }
263
+
264
+ /**
265
+ * Finds numbered sections with a common prefix.
266
+ * @param {object[]} sections Parsed sections.
267
+ * @param {string} prefix Section prefix.
268
+ * @returns {{ section: object, number: number }[]}
269
+ */
270
+ static #numberedSections(sections, prefix) {
271
+ const pattern = new RegExp('^' + prefix + '(\\d+)$', 'iu')
272
+
273
+ return (sections || [])
274
+ .map((section) => ({
275
+ section,
276
+ match: pattern.exec(section.name)
277
+ }))
278
+ .filter(({ match }) => match)
279
+ .map(({ section, match }) => ({
280
+ section,
281
+ number: Number.parseInt(match[1], 10)
282
+ }))
283
+ .sort((left, right) => left.number - right.number)
284
+ }
285
+
286
+ /**
287
+ * Finds one section by case-insensitive name.
288
+ * @param {object[]} sections Parsed sections.
289
+ * @param {string} name Section name.
290
+ * @returns {object | undefined}
291
+ */
292
+ static #findSection(sections, name) {
293
+ const normalized = String(name || '').toLowerCase()
294
+ return (sections || []).find(
295
+ (section) => section.name.toLowerCase() === normalized
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Converts one section to a key-value map.
301
+ * @param {{ entries?: { key: string, value: string }[] } | undefined} section Parsed section.
302
+ * @returns {Record<string, string>}
303
+ */
304
+ static #sectionFields(section) {
305
+ return Object.fromEntries(
306
+ (section?.entries || []).map((entry) => [entry.key, entry.value])
307
+ )
308
+ }
309
+
310
+ /**
311
+ * Serializes preserved sections.
312
+ * @param {object[]} sections Parsed sections.
313
+ * @returns {object[]}
314
+ */
315
+ static #serializeSections(sections) {
316
+ return (sections || []).map((section) => ({
317
+ name: section.name,
318
+ entries: (section.entries || []).map((entry) => ({
319
+ key: entry.key,
320
+ value: entry.value
321
+ }))
322
+ }))
323
+ }
324
+
325
+ /**
326
+ * Parses optional boolean option values.
327
+ * @param {string | undefined} value Raw value.
328
+ * @returns {boolean | undefined}
329
+ */
330
+ static #optionalBoolean(value) {
331
+ const normalized = String(value ?? '')
332
+ .trim()
333
+ .toLowerCase()
334
+ if (!normalized) return undefined
335
+ return ['1', 'true', 't', 'yes'].includes(normalized)
336
+ }
337
+
338
+ /**
339
+ * Normalizes project-relative paths.
340
+ * @param {string} path Source path.
341
+ * @returns {string}
342
+ */
343
+ static #normalizePath(path) {
344
+ return String(path || '').replace(/\\/gu, '/')
345
+ }
346
+
347
+ /**
348
+ * Returns a basename from a project path.
349
+ * @param {string} path Source path.
350
+ * @returns {string}
351
+ */
352
+ static #basename(path) {
353
+ return PrjScrModelParser.#normalizePath(path).split('/').pop() || ''
354
+ }
355
+
356
+ /**
357
+ * Returns a lower-level extension token.
358
+ * @param {string} path Source path.
359
+ * @returns {string}
360
+ */
361
+ static #extension(path) {
362
+ const name = PrjScrModelParser.#basename(path)
363
+ const dotIndex = name.lastIndexOf('.')
364
+ return dotIndex >= 0 ? name.slice(dotIndex) : ''
365
+ }
366
+
367
+ /**
368
+ * Removes undefined and empty object fields.
369
+ * @param {Record<string, unknown>} value Source object.
370
+ * @returns {object}
371
+ */
372
+ static #stripEmpty(value) {
373
+ return Object.fromEntries(
374
+ Object.entries(value || {}).filter(([, entryValue]) => {
375
+ if (
376
+ entryValue &&
377
+ typeof entryValue === 'object' &&
378
+ !Array.isArray(entryValue)
379
+ ) {
380
+ return Object.keys(entryValue).length > 0
381
+ }
382
+ return entryValue !== undefined && entryValue !== ''
383
+ })
384
+ )
385
+ }
386
+ }
@@ -0,0 +1,237 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic BOM/PnP reconciliation metadata for a project bundle.
7
+ */
8
+ export class ProjectBomPnpReconciliationBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.project.bom-pnp-reconciliation.a1'
10
+
11
+ /**
12
+ * Builds a reconciliation report from bundle and source document models.
13
+ * @param {{ bundle?: object, documentModels?: object[], effectiveVariant?: object }} options Report options.
14
+ * @returns {object}
15
+ */
16
+ static build(options = {}) {
17
+ const bundle = options.bundle || {}
18
+ const documentModels = Array.isArray(options.documentModels)
19
+ ? options.documentModels
20
+ : []
21
+ const schematicBomDesignators =
22
+ ProjectBomPnpReconciliationBuilder.#bomDesignators(
23
+ documentModels.filter((model) => model?.kind === 'schematic')
24
+ )
25
+ const pcbBomDesignators =
26
+ ProjectBomPnpReconciliationBuilder.#bomDesignators(
27
+ documentModels.filter((model) => model?.kind === 'pcb')
28
+ )
29
+ const pnpDesignators =
30
+ ProjectBomPnpReconciliationBuilder.#pnpDesignators(documentModels)
31
+ const effectiveBomDesignators =
32
+ ProjectBomPnpReconciliationBuilder.#effectiveBomDesignators(
33
+ bundle,
34
+ options.effectiveVariant
35
+ )
36
+ const noBomDesignators =
37
+ ProjectBomPnpReconciliationBuilder.#noBomDesignators(documentModels)
38
+ const issues = [
39
+ ...ProjectBomPnpReconciliationBuilder.#missingIssues(
40
+ schematicBomDesignators,
41
+ pcbBomDesignators,
42
+ 'reconciliation.schematic-bom-without-pcb-bom',
43
+ 'Schematic BOM designator was not present in the PCB-backed BOM.'
44
+ ),
45
+ ...ProjectBomPnpReconciliationBuilder.#missingIssues(
46
+ pcbBomDesignators,
47
+ schematicBomDesignators,
48
+ 'reconciliation.pcb-bom-without-schematic-bom',
49
+ 'PCB-backed BOM designator was not present in the schematic BOM.'
50
+ ),
51
+ ...ProjectBomPnpReconciliationBuilder.#missingIssues(
52
+ pcbBomDesignators,
53
+ pnpDesignators,
54
+ 'reconciliation.bom-without-pnp',
55
+ 'PCB-backed BOM designator did not have a PnP placement.'
56
+ ),
57
+ ...ProjectBomPnpReconciliationBuilder.#missingIssues(
58
+ pnpDesignators,
59
+ pcbBomDesignators,
60
+ 'reconciliation.pnp-without-bom',
61
+ 'PnP placement designator was not present in the PCB-backed BOM.'
62
+ ),
63
+ ...ProjectBomPnpReconciliationBuilder.#intersectionIssues(
64
+ noBomDesignators,
65
+ pcbBomDesignators,
66
+ 'reconciliation.no-bom-component-in-pcb-bom',
67
+ 'Component marked as no-BOM appeared in the PCB-backed BOM.'
68
+ )
69
+ ]
70
+
71
+ return {
72
+ schema: ProjectBomPnpReconciliationBuilder.SCHEMA_ID,
73
+ summary: {
74
+ schematicBomDesignatorCount: schematicBomDesignators.length,
75
+ pcbBomDesignatorCount: pcbBomDesignators.length,
76
+ pnpDesignatorCount: pnpDesignators.length,
77
+ effectiveBomDesignatorCount: effectiveBomDesignators.length,
78
+ noBomComponentCount: noBomDesignators.length,
79
+ issueCount: issues.length
80
+ },
81
+ schematicBomDesignators,
82
+ pcbBomDesignators,
83
+ pnpDesignators,
84
+ effectiveBomDesignators,
85
+ noBomDesignators,
86
+ issues
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Extracts designators from BOM rows.
92
+ * @param {object[]} models Parsed document models.
93
+ * @returns {string[]}
94
+ */
95
+ static #bomDesignators(models) {
96
+ const designators = new Set()
97
+
98
+ for (const model of models) {
99
+ for (const row of model?.bom || []) {
100
+ for (const designator of row.designators || []) {
101
+ ProjectBomPnpReconciliationBuilder.#addDesignator(
102
+ designators,
103
+ designator
104
+ )
105
+ }
106
+ ProjectBomPnpReconciliationBuilder.#addDesignator(
107
+ designators,
108
+ row.designator
109
+ )
110
+ }
111
+ }
112
+
113
+ return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
114
+ }
115
+
116
+ /**
117
+ * Extracts designators from pick-place entries.
118
+ * @param {object[]} models Parsed document models.
119
+ * @returns {string[]}
120
+ */
121
+ static #pnpDesignators(models) {
122
+ const designators = new Set()
123
+
124
+ for (const model of models.filter((item) => item?.kind === 'pcb')) {
125
+ const pnp = model.pnp || model.pcb?.pickPlace || {}
126
+ for (const entry of pnp.entries || []) {
127
+ ProjectBomPnpReconciliationBuilder.#addDesignator(
128
+ designators,
129
+ entry.designator
130
+ )
131
+ }
132
+ }
133
+
134
+ return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
135
+ }
136
+
137
+ /**
138
+ * Extracts designators from the active effective variant or bundle BOM.
139
+ * @param {object} bundle Project design bundle.
140
+ * @param {object | undefined} effectiveVariant Effective variant view.
141
+ * @returns {string[]}
142
+ */
143
+ static #effectiveBomDesignators(bundle, effectiveVariant) {
144
+ if (effectiveVariant?.bom) {
145
+ return ProjectBomPnpReconciliationBuilder.#bomDesignators([
146
+ { bom: effectiveVariant.bom }
147
+ ])
148
+ }
149
+
150
+ return ProjectBomPnpReconciliationBuilder.#bomDesignators([
151
+ { bom: bundle.bom || [] }
152
+ ])
153
+ }
154
+
155
+ /**
156
+ * Extracts component designators explicitly excluded from BOMs.
157
+ * @param {object[]} models Parsed document models.
158
+ * @returns {string[]}
159
+ */
160
+ static #noBomDesignators(models) {
161
+ const designators = new Set()
162
+
163
+ for (const model of models.filter((item) => item?.kind === 'pcb')) {
164
+ for (const component of model.pcb?.components || []) {
165
+ if (component.componentKind?.includeInBom !== false) continue
166
+ ProjectBomPnpReconciliationBuilder.#addDesignator(
167
+ designators,
168
+ component.designator
169
+ )
170
+ }
171
+ }
172
+
173
+ return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
174
+ }
175
+
176
+ /**
177
+ * Adds a normalized designator to a set.
178
+ * @param {Set<string>} designators Target set.
179
+ * @param {unknown} value Raw designator value.
180
+ * @returns {void}
181
+ */
182
+ static #addDesignator(designators, value) {
183
+ const designator = String(value || '').trim()
184
+ if (designator) designators.add(designator)
185
+ }
186
+
187
+ /**
188
+ * Builds missing-designator issue rows.
189
+ * @param {string[]} source Source designators.
190
+ * @param {string[]} target Target designators.
191
+ * @param {string} code Diagnostic code.
192
+ * @param {string} message Diagnostic message.
193
+ * @returns {object[]}
194
+ */
195
+ static #missingIssues(source, target, code, message) {
196
+ const targetSet = new Set(target)
197
+ return source
198
+ .filter((designator) => !targetSet.has(designator))
199
+ .map((designator) => ({
200
+ severity: 'warning',
201
+ code,
202
+ designator,
203
+ message
204
+ }))
205
+ }
206
+
207
+ /**
208
+ * Builds issue rows for designators present in both sets.
209
+ * @param {string[]} left Left designators.
210
+ * @param {string[]} right Right designators.
211
+ * @param {string} code Diagnostic code.
212
+ * @param {string} message Diagnostic message.
213
+ * @returns {object[]}
214
+ */
215
+ static #intersectionIssues(left, right, code, message) {
216
+ const rightSet = new Set(right)
217
+ return left
218
+ .filter((designator) => rightSet.has(designator))
219
+ .map((designator) => ({
220
+ severity: 'warning',
221
+ code,
222
+ designator,
223
+ message
224
+ }))
225
+ }
226
+
227
+ /**
228
+ * Sorts designators in a stable human-friendly order.
229
+ * @param {string[]} values Designator values.
230
+ * @returns {string[]}
231
+ */
232
+ static #sorted(values) {
233
+ return [...values].sort((left, right) =>
234
+ left.localeCompare(right, undefined, { numeric: true })
235
+ )
236
+ }
237
+ }