altium-toolkit 1.1.22 → 1.1.23

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 (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +2 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,323 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds fixture-oriented parser value verification reports from path maps.
7
+ */
8
+ export class ParserValueVerificationReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.parser-value-verification.a1'
10
+
11
+ /**
12
+ * Builds a value verification report.
13
+ * @param {{ cases?: object[] }} [input] Report input.
14
+ * @returns {object}
15
+ */
16
+ static build(input = {}) {
17
+ const cases = (input.cases || []).map((entry, index) =>
18
+ ParserValueVerificationReportBuilder.#caseReport(entry, index)
19
+ )
20
+ const failures = cases.flatMap((entry) =>
21
+ entry.failures.map((failure) => ({
22
+ caseKey: entry.key,
23
+ ...(entry.source ? { source: entry.source } : {}),
24
+ ...failure
25
+ }))
26
+ )
27
+ const passedCount = cases.reduce(
28
+ (total, entry) => total + entry.passedCount,
29
+ 0
30
+ )
31
+ const failedCount = cases.reduce(
32
+ (total, entry) => total + entry.failedCount,
33
+ 0
34
+ )
35
+
36
+ return {
37
+ schema: ParserValueVerificationReportBuilder.SCHEMA,
38
+ summary: {
39
+ caseCount: cases.length,
40
+ assertionCount: cases.reduce(
41
+ (total, entry) => total + entry.assertionCount,
42
+ 0
43
+ ),
44
+ passedCount,
45
+ failedCount,
46
+ mismatchCount: failures.filter(
47
+ (failure) => failure.status === 'mismatch'
48
+ ).length,
49
+ missingCount: failures.filter(
50
+ (failure) => failure.status === 'missing'
51
+ ).length,
52
+ status: failedCount ? 'failed' : 'passed'
53
+ },
54
+ cases,
55
+ failures
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Builds one case report.
61
+ * @param {object} entry Case input.
62
+ * @param {number} index Case index.
63
+ * @returns {object}
64
+ */
65
+ static #caseReport(entry, index) {
66
+ const key = String(entry.key || 'case-' + index)
67
+ const actual = entry.actual || entry.model || entry.documentModel || {}
68
+ const assertionInput =
69
+ entry.expectedValues || entry.expected || entry.assertions || []
70
+ const assertions =
71
+ ParserValueVerificationReportBuilder.#assertions(assertionInput)
72
+ const failures = []
73
+ const passedAssertions = []
74
+
75
+ for (const assertion of assertions) {
76
+ const actualValue = ParserValueVerificationReportBuilder.#pathValue(
77
+ actual,
78
+ assertion.path
79
+ )
80
+
81
+ if (!actualValue.present) {
82
+ failures.push(
83
+ ParserValueVerificationReportBuilder.#failure(
84
+ assertion,
85
+ actualValue,
86
+ 'missing'
87
+ )
88
+ )
89
+ continue
90
+ }
91
+
92
+ if (
93
+ !ParserValueVerificationReportBuilder.#equal(
94
+ actualValue.value,
95
+ assertion.expected
96
+ )
97
+ ) {
98
+ failures.push(
99
+ ParserValueVerificationReportBuilder.#failure(
100
+ assertion,
101
+ actualValue,
102
+ 'mismatch'
103
+ )
104
+ )
105
+ continue
106
+ }
107
+
108
+ passedAssertions.push({
109
+ path: assertion.path,
110
+ expected: assertion.expected,
111
+ ...(assertion.label ? { label: assertion.label } : {})
112
+ })
113
+ }
114
+
115
+ return ParserValueVerificationReportBuilder.#stripUndefined({
116
+ key,
117
+ source: entry.source,
118
+ status: failures.length ? 'failed' : 'passed',
119
+ assertionCount: assertions.length,
120
+ passedCount: assertions.length - failures.length,
121
+ failedCount: failures.length,
122
+ failures,
123
+ assertions:
124
+ ParserValueVerificationReportBuilder.#shouldIncludeAssertions(
125
+ assertionInput
126
+ )
127
+ ? passedAssertions
128
+ : undefined
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Converts path-map or assertion-list input into assertion rows.
134
+ * @param {Record<string, unknown> | object[]} input Assertion input.
135
+ * @returns {{ path: string, expected: unknown, label?: string }[]}
136
+ */
137
+ static #assertions(input) {
138
+ if (Array.isArray(input)) {
139
+ return input
140
+ .filter((entry) => entry?.path)
141
+ .map((entry) => ({
142
+ path: String(entry.path),
143
+ expected: entry.expected,
144
+ label: entry.label
145
+ }))
146
+ }
147
+
148
+ return Object.entries(input || {}).map(([path, expected]) => ({
149
+ path,
150
+ expected
151
+ }))
152
+ }
153
+
154
+ /**
155
+ * Returns true when passing assertion detail should be emitted.
156
+ * @param {Record<string, unknown> | object[]} input Assertion input.
157
+ * @returns {boolean}
158
+ */
159
+ static #shouldIncludeAssertions(input) {
160
+ return Array.isArray(input)
161
+ }
162
+
163
+ /**
164
+ * Builds one failure row.
165
+ * @param {{ path: string, expected: unknown }} assertion Expected assertion.
166
+ * @param {{ present: boolean, value: unknown }} actualValue Actual path lookup.
167
+ * @param {'missing' | 'mismatch'} status Failure status.
168
+ * @returns {object}
169
+ */
170
+ static #failure(assertion, actualValue, status) {
171
+ const actual = actualValue.present
172
+ ? ParserValueVerificationReportBuilder.#jsonValue(actualValue.value)
173
+ : null
174
+ const expected = ParserValueVerificationReportBuilder.#jsonValue(
175
+ assertion.expected
176
+ )
177
+
178
+ return {
179
+ path: assertion.path,
180
+ status,
181
+ expected,
182
+ actual,
183
+ message:
184
+ status === 'missing'
185
+ ? 'Expected ' +
186
+ assertion.path +
187
+ ' to equal ' +
188
+ ParserValueVerificationReportBuilder.#formatValue(
189
+ expected
190
+ ) +
191
+ ' but the path was missing.'
192
+ : 'Expected ' +
193
+ assertion.path +
194
+ ' to equal ' +
195
+ ParserValueVerificationReportBuilder.#formatValue(
196
+ expected
197
+ ) +
198
+ ' but received ' +
199
+ ParserValueVerificationReportBuilder.#formatValue(
200
+ actual
201
+ ) +
202
+ '.'
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Resolves one dot/bracket path from an object.
208
+ * @param {object} source Source object.
209
+ * @param {string} path Path expression.
210
+ * @returns {{ present: boolean, value: unknown }}
211
+ */
212
+ static #pathValue(source, path) {
213
+ const parts = String(path || '')
214
+ .replace(/\[(\d+)\]/gu, '.$1')
215
+ .split('.')
216
+ .filter(Boolean)
217
+ let current = source
218
+
219
+ for (const part of parts) {
220
+ if (Array.isArray(current)) {
221
+ const index = Number(part)
222
+ if (
223
+ !Number.isInteger(index) ||
224
+ index < 0 ||
225
+ index >= current.length
226
+ ) {
227
+ return { present: false, value: undefined }
228
+ }
229
+ current = current[index]
230
+ continue
231
+ }
232
+
233
+ if (!current || typeof current !== 'object' || !(part in current)) {
234
+ return { present: false, value: undefined }
235
+ }
236
+
237
+ current = current[part]
238
+ }
239
+
240
+ return { present: true, value: current }
241
+ }
242
+
243
+ /**
244
+ * Compares two values by stable JSON representation.
245
+ * @param {unknown} left First value.
246
+ * @param {unknown} right Second value.
247
+ * @returns {boolean}
248
+ */
249
+ static #equal(left, right) {
250
+ return (
251
+ ParserValueVerificationReportBuilder.#stableJson(left) ===
252
+ ParserValueVerificationReportBuilder.#stableJson(right)
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Converts values into JSON-safe report payloads.
258
+ * @param {unknown} value Source value.
259
+ * @returns {unknown}
260
+ */
261
+ static #jsonValue(value) {
262
+ return value === undefined ? null : value
263
+ }
264
+
265
+ /**
266
+ * Formats a value for a concise diagnostic message.
267
+ * @param {unknown} value Report value.
268
+ * @returns {string}
269
+ */
270
+ static #formatValue(value) {
271
+ return JSON.stringify(value)
272
+ }
273
+
274
+ /**
275
+ * Produces a stable JSON representation for comparison.
276
+ * @param {unknown} value Source value.
277
+ * @returns {string}
278
+ */
279
+ static #stableJson(value) {
280
+ if (!value || typeof value !== 'object') {
281
+ return JSON.stringify(value)
282
+ }
283
+
284
+ if (Array.isArray(value)) {
285
+ return (
286
+ '[' +
287
+ value
288
+ .map((entry) =>
289
+ ParserValueVerificationReportBuilder.#stableJson(entry)
290
+ )
291
+ .join(',') +
292
+ ']'
293
+ )
294
+ }
295
+
296
+ return (
297
+ '{' +
298
+ Object.keys(value)
299
+ .sort()
300
+ .map(
301
+ (key) =>
302
+ JSON.stringify(key) +
303
+ ':' +
304
+ ParserValueVerificationReportBuilder.#stableJson(
305
+ value[key]
306
+ )
307
+ )
308
+ .join(',') +
309
+ '}'
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Removes undefined properties from one row.
315
+ * @param {object} row Source row.
316
+ * @returns {object}
317
+ */
318
+ static #stripUndefined(row) {
319
+ return Object.fromEntries(
320
+ Object.entries(row).filter(([, value]) => value !== undefined)
321
+ )
322
+ }
323
+ }
@@ -0,0 +1,366 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic summaries for normalized PCB class records.
7
+ */
8
+ export class PcbClassReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.class-report.a1'
10
+
11
+ /**
12
+ * Builds a PCB class report.
13
+ * @param {object} pcb Normalized PCB model.
14
+ * @returns {object}
15
+ */
16
+ static build(pcb = {}) {
17
+ const indexes = PcbClassReportBuilder.#memberIndexes(pcb)
18
+ const classes = (Array.isArray(pcb?.classes) ? pcb.classes : []).map(
19
+ (classRecord, index) =>
20
+ PcbClassReportBuilder.#classRow(classRecord, index, indexes)
21
+ )
22
+ const issues = PcbClassReportBuilder.#issues(classes)
23
+
24
+ return {
25
+ schema: PcbClassReportBuilder.SCHEMA,
26
+ summary: PcbClassReportBuilder.#summary(classes, issues),
27
+ byKind: PcbClassReportBuilder.#byKind(classes),
28
+ classes,
29
+ issues
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Builds lookup sets used to resolve class members.
35
+ * @param {object} pcb Normalized PCB model.
36
+ * @returns {Record<string, Set<string>>}
37
+ */
38
+ static #memberIndexes(pcb) {
39
+ return {
40
+ net: PcbClassReportBuilder.#namedSet(pcb?.nets, ['name']),
41
+ component: PcbClassReportBuilder.#namedSet(pcb?.components, [
42
+ 'designator',
43
+ 'name'
44
+ ]),
45
+ pad: PcbClassReportBuilder.#namedSet(pcb?.pads, [
46
+ 'designator',
47
+ 'padNumber',
48
+ 'pinName',
49
+ 'name'
50
+ ]),
51
+ layer: PcbClassReportBuilder.#namedSet(
52
+ [
53
+ ...PcbClassReportBuilder.#array(pcb?.layers),
54
+ ...PcbClassReportBuilder.#array(pcb?.primitiveLayers)
55
+ ],
56
+ ['name', 'displayName', 'id', 'layerId']
57
+ ),
58
+ polygon: PcbClassReportBuilder.#namedSet(pcb?.polygons, [
59
+ 'name',
60
+ 'netName'
61
+ ]),
62
+ 'diff-pair': PcbClassReportBuilder.#namedSet(
63
+ pcb?.differentialPairs,
64
+ ['name']
65
+ )
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Returns an array value or an empty array for non-array input.
71
+ * @param {unknown} value Candidate array value.
72
+ * @returns {unknown[]}
73
+ */
74
+ static #array(value) {
75
+ return Array.isArray(value) ? value : []
76
+ }
77
+
78
+ /**
79
+ * Builds a set of names from candidate row fields.
80
+ * @param {object[] | undefined} rows Candidate rows.
81
+ * @param {string[]} keys Candidate field names.
82
+ * @returns {Set<string>}
83
+ */
84
+ static #namedSet(rows, keys) {
85
+ const values = new Set()
86
+
87
+ for (const row of Array.isArray(rows) ? rows : []) {
88
+ for (const key of keys) {
89
+ const value = String(row?.[key] ?? '').trim()
90
+ if (value) {
91
+ values.add(value)
92
+ }
93
+ }
94
+ }
95
+
96
+ return values
97
+ }
98
+
99
+ /**
100
+ * Builds one class summary row.
101
+ * @param {object} classRecord Normalized class record.
102
+ * @param {number} index Class index.
103
+ * @param {Record<string, Set<string>>} indexes Member lookup sets.
104
+ * @returns {object}
105
+ */
106
+ static #classRow(classRecord, index, indexes) {
107
+ const kindName = PcbClassReportBuilder.#kindName(classRecord)
108
+ const members = (
109
+ Array.isArray(classRecord?.members) ? classRecord.members : []
110
+ )
111
+ .map((member) => String(member || '').trim())
112
+ .filter(Boolean)
113
+ const resolvedMembers = []
114
+ const unresolvedMembers = []
115
+
116
+ for (const member of members) {
117
+ const resolved = PcbClassReportBuilder.#resolveMember(
118
+ member,
119
+ kindName,
120
+ indexes
121
+ )
122
+ if (resolved) {
123
+ resolvedMembers.push(resolved)
124
+ } else {
125
+ unresolvedMembers.push(member)
126
+ }
127
+ }
128
+
129
+ return PcbClassReportBuilder.#stripEmpty({
130
+ classIndex: Number.isInteger(classRecord?.classIndex)
131
+ ? classRecord.classIndex
132
+ : index,
133
+ name: String(classRecord?.name || '').trim(),
134
+ kind: Number.isFinite(Number(classRecord?.kind))
135
+ ? Number(classRecord.kind)
136
+ : undefined,
137
+ kindName,
138
+ enabled: classRecord?.enabled !== false,
139
+ memberCount: members.length,
140
+ resolvedMemberCount: resolvedMembers.length,
141
+ unresolvedMemberCount: unresolvedMembers.length,
142
+ members,
143
+ resolvedMembers,
144
+ unresolvedMembers,
145
+ references: PcbClassReportBuilder.#references(resolvedMembers),
146
+ uniqueId: String(classRecord?.uniqueId || '').trim()
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Resolves the normalized class kind name.
152
+ * @param {object} classRecord Normalized class record.
153
+ * @returns {string}
154
+ */
155
+ static #kindName(classRecord) {
156
+ const kindName = String(classRecord?.kindName || '').trim()
157
+ if (kindName) {
158
+ return kindName
159
+ }
160
+
161
+ return (
162
+ {
163
+ 0: 'net',
164
+ 1: 'component',
165
+ 2: 'from-to',
166
+ 3: 'pad',
167
+ 4: 'layer',
168
+ 6: 'diff-pair',
169
+ 7: 'polygon'
170
+ }[Number(classRecord?.kind)] || 'unknown'
171
+ )
172
+ }
173
+
174
+ /**
175
+ * Resolves one class member against the expected or known object indexes.
176
+ * @param {string} member Member name.
177
+ * @param {string} kindName Class kind name.
178
+ * @param {Record<string, Set<string>>} indexes Member lookup sets.
179
+ * @returns {{ name: string, kind: string } | null}
180
+ */
181
+ static #resolveMember(member, kindName, indexes) {
182
+ const expectedKind = kindName === 'diff-pair' ? 'diff-pair' : kindName
183
+ const expectedIndex = indexes[expectedKind]
184
+ if (expectedIndex?.has(member)) {
185
+ return { name: member, kind: expectedKind }
186
+ }
187
+
188
+ if (kindName !== 'unknown') {
189
+ return null
190
+ }
191
+
192
+ for (const [kind, values] of Object.entries(indexes)) {
193
+ if (values.has(member)) {
194
+ return { name: member, kind }
195
+ }
196
+ }
197
+
198
+ return null
199
+ }
200
+
201
+ /**
202
+ * Builds grouped reference lists from resolved members.
203
+ * @param {{ name: string, kind: string }[]} resolvedMembers Resolved member rows.
204
+ * @returns {object}
205
+ */
206
+ static #references(resolvedMembers) {
207
+ const references = {
208
+ netNames: [],
209
+ componentDesignators: [],
210
+ padDesignators: [],
211
+ layerNames: [],
212
+ polygonNames: [],
213
+ differentialPairNames: []
214
+ }
215
+
216
+ for (const member of resolvedMembers) {
217
+ if (member.kind === 'net') references.netNames.push(member.name)
218
+ if (member.kind === 'component') {
219
+ references.componentDesignators.push(member.name)
220
+ }
221
+ if (member.kind === 'pad')
222
+ references.padDesignators.push(member.name)
223
+ if (member.kind === 'layer') references.layerNames.push(member.name)
224
+ if (member.kind === 'polygon') {
225
+ references.polygonNames.push(member.name)
226
+ }
227
+ if (member.kind === 'diff-pair') {
228
+ references.differentialPairNames.push(member.name)
229
+ }
230
+ }
231
+
232
+ return Object.fromEntries(
233
+ Object.entries(references)
234
+ .map(([key, values]) => [
235
+ key,
236
+ [...values].sort(PcbClassReportBuilder.#naturalCompare)
237
+ ])
238
+ .filter(([, values]) => values.length > 0)
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Builds deterministic class issues.
244
+ * @param {object[]} classes Class rows.
245
+ * @returns {object[]}
246
+ */
247
+ static #issues(classes) {
248
+ const issues = []
249
+
250
+ for (const classRow of classes) {
251
+ for (const member of classRow.unresolvedMembers || []) {
252
+ issues.push({
253
+ code: 'pcb.class.unresolved-member',
254
+ severity: 'warning',
255
+ className: classRow.name,
256
+ kindName: classRow.kindName,
257
+ memberName: member
258
+ })
259
+ }
260
+
261
+ if (classRow.memberCount === 0) {
262
+ issues.push({
263
+ code: 'pcb.class.empty',
264
+ severity: 'info',
265
+ className: classRow.name,
266
+ kindName: classRow.kindName
267
+ })
268
+ }
269
+ }
270
+
271
+ return issues
272
+ }
273
+
274
+ /**
275
+ * Builds top-level class summary counters.
276
+ * @param {object[]} classes Class rows.
277
+ * @param {object[]} issues Issue rows.
278
+ * @returns {object}
279
+ */
280
+ static #summary(classes, issues) {
281
+ return {
282
+ classCount: classes.length,
283
+ enabledClassCount: classes.filter((row) => row.enabled !== false)
284
+ .length,
285
+ disabledClassCount: classes.filter((row) => row.enabled === false)
286
+ .length,
287
+ netClassCount: PcbClassReportBuilder.#kindCount(classes, 'net'),
288
+ componentClassCount: PcbClassReportBuilder.#kindCount(
289
+ classes,
290
+ 'component'
291
+ ),
292
+ padClassCount: PcbClassReportBuilder.#kindCount(classes, 'pad'),
293
+ differentialPairClassCount: PcbClassReportBuilder.#kindCount(
294
+ classes,
295
+ 'diff-pair'
296
+ ),
297
+ emptyClassCount: classes.filter((row) => row.memberCount === 0)
298
+ .length,
299
+ unresolvedMemberCount: classes.reduce(
300
+ (total, row) => total + Number(row.unresolvedMemberCount || 0),
301
+ 0
302
+ ),
303
+ issueCount: issues.length
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Counts classes by kind.
309
+ * @param {object[]} classes Class rows.
310
+ * @param {string} kindName Class kind name.
311
+ * @returns {number}
312
+ */
313
+ static #kindCount(classes, kindName) {
314
+ return classes.filter((row) => row.kindName === kindName).length
315
+ }
316
+
317
+ /**
318
+ * Builds sorted class counts by kind.
319
+ * @param {object[]} classes Class rows.
320
+ * @returns {{ kindName: string, count: number }[]}
321
+ */
322
+ static #byKind(classes) {
323
+ const counts = new Map()
324
+
325
+ for (const classRow of classes) {
326
+ counts.set(
327
+ classRow.kindName,
328
+ Number(counts.get(classRow.kindName) || 0) + 1
329
+ )
330
+ }
331
+
332
+ return [...counts.entries()]
333
+ .map(([kindName, count]) => ({ kindName, count }))
334
+ .sort((left, right) =>
335
+ PcbClassReportBuilder.#naturalCompare(
336
+ left.kindName,
337
+ right.kindName
338
+ )
339
+ )
340
+ }
341
+
342
+ /**
343
+ * Sorts strings with numeric chunks in human order.
344
+ * @param {string} left Left value.
345
+ * @param {string} right Right value.
346
+ * @returns {number}
347
+ */
348
+ static #naturalCompare(left, right) {
349
+ return String(left).localeCompare(String(right), undefined, {
350
+ numeric: true
351
+ })
352
+ }
353
+
354
+ /**
355
+ * Removes undefined and blank-string values from a shallow object.
356
+ * @param {Record<string, unknown>} row Input row.
357
+ * @returns {Record<string, unknown>}
358
+ */
359
+ static #stripEmpty(row) {
360
+ return Object.fromEntries(
361
+ Object.entries(row).filter(
362
+ ([, value]) => value !== undefined && value !== ''
363
+ )
364
+ )
365
+ }
366
+ }