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,296 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { LibraryQaReportBuilder } from './LibraryQaReportBuilder.mjs'
6
+ import { LibraryRenderManifestBuilder } from './LibraryRenderManifestBuilder.mjs'
7
+
8
+ /**
9
+ * Builds deterministic static catalog artifacts for parsed libraries.
10
+ */
11
+ export class LibraryCatalogArtifactBuilder {
12
+ static SCHEMA_ID = 'altium-toolkit.library.catalog.a1'
13
+
14
+ /**
15
+ * Builds catalog entries, search metadata, and static HTML.
16
+ * @param {{ schematicLibraries?: object[], pcbLibraries?: object[], qaReport?: object }} [options] Catalog options.
17
+ * @returns {object}
18
+ */
19
+ static build(options = {}) {
20
+ const schematicLibraries = options.schematicLibraries || []
21
+ const pcbLibraries = options.pcbLibraries || []
22
+ const qaReport =
23
+ options.qaReport ||
24
+ LibraryQaReportBuilder.build({ schematicLibraries, pcbLibraries })
25
+ const issuesByTarget =
26
+ LibraryCatalogArtifactBuilder.#issuesByTarget(qaReport)
27
+ const entries = [
28
+ ...LibraryCatalogArtifactBuilder.#symbolEntries(
29
+ schematicLibraries,
30
+ issuesByTarget
31
+ ),
32
+ ...LibraryCatalogArtifactBuilder.#footprintEntries(
33
+ pcbLibraries,
34
+ issuesByTarget
35
+ )
36
+ ].sort(
37
+ (left, right) =>
38
+ left.kind.localeCompare(right.kind) ||
39
+ left.name.localeCompare(right.name) ||
40
+ left.libraryFileName.localeCompare(right.libraryFileName)
41
+ )
42
+ const searchIndex = {
43
+ schema: 'altium-toolkit.library.catalog-search.a1',
44
+ entries: entries.map((entry) => ({
45
+ key: entry.key,
46
+ kind: entry.kind,
47
+ name: entry.name,
48
+ libraryFileName: entry.libraryFileName,
49
+ text: entry.searchText
50
+ }))
51
+ }
52
+
53
+ return {
54
+ schema: LibraryCatalogArtifactBuilder.SCHEMA_ID,
55
+ summary: {
56
+ schematicLibraryCount: schematicLibraries.length,
57
+ pcbLibraryCount: pcbLibraries.length,
58
+ entryCount: entries.length,
59
+ symbolCount: entries.filter((entry) => entry.kind === 'symbol')
60
+ .length,
61
+ footprintCount: entries.filter(
62
+ (entry) => entry.kind === 'footprint'
63
+ ).length,
64
+ issueCount: entries.reduce(
65
+ (count, entry) => count + entry.issueCodes.length,
66
+ 0
67
+ )
68
+ },
69
+ entries,
70
+ searchIndex,
71
+ html: LibraryCatalogArtifactBuilder.#html(entries)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Builds catalog entries for schematic symbols.
77
+ * @param {object[]} schematicLibraries Schematic libraries.
78
+ * @param {Map<string, object[]>} issuesByTarget Issues by target.
79
+ * @returns {object[]}
80
+ */
81
+ static #symbolEntries(schematicLibraries, issuesByTarget) {
82
+ return (schematicLibraries || []).flatMap((library) => {
83
+ const schematicLibrary = library.schematicLibrary || library || {}
84
+ const manifest =
85
+ LibraryRenderManifestBuilder.buildSchematicLibraryManifest(
86
+ schematicLibrary
87
+ )
88
+
89
+ return (schematicLibrary.symbols || []).map(
90
+ (symbol, symbolIndex) => {
91
+ const name = String(symbol.name || '').trim()
92
+ const output = manifest.outputs.find(
93
+ (entry) => entry.name === name
94
+ )
95
+ const issues =
96
+ issuesByTarget.get('symbol:' + name) ||
97
+ issuesByTarget.get(name) ||
98
+ []
99
+
100
+ return LibraryCatalogArtifactBuilder.#stripUndefined({
101
+ key: 'symbol:' + (library.fileName || '') + ':' + name,
102
+ kind: 'symbol',
103
+ name,
104
+ libraryFileName: library.fileName || '',
105
+ index: symbolIndex,
106
+ pinCount: (symbol.pins || []).length,
107
+ partCount: (symbol.parts || []).length,
108
+ outputSvgKey: output?.outputSvgKey,
109
+ issueCodes: issues.map((issue) => issue.code),
110
+ issues,
111
+ searchText: [
112
+ name,
113
+ library.fileName || '',
114
+ 'symbol',
115
+ symbol.parameters?.Description || '',
116
+ 'pins:' + (symbol.pins || []).length
117
+ ]
118
+ .filter(Boolean)
119
+ .join(' ')
120
+ })
121
+ }
122
+ )
123
+ })
124
+ }
125
+
126
+ /**
127
+ * Builds catalog entries for PCB footprints.
128
+ * @param {object[]} pcbLibraries PCB footprint libraries.
129
+ * @param {Map<string, object[]>} issuesByTarget Issues by target.
130
+ * @returns {object[]}
131
+ */
132
+ static #footprintEntries(pcbLibraries, issuesByTarget) {
133
+ return (pcbLibraries || []).flatMap((library) => {
134
+ const pcbLibrary = library.pcbLibrary || library || {}
135
+ const manifest =
136
+ LibraryRenderManifestBuilder.buildPcbLibraryManifest(pcbLibrary)
137
+
138
+ return (pcbLibrary.footprints || []).map(
139
+ (footprint, footprintIndex) => {
140
+ const name = String(footprint.name || '').trim()
141
+ const output = manifest.outputs.find(
142
+ (entry) => entry.name === name
143
+ )
144
+ const issues =
145
+ issuesByTarget.get('footprint:' + name) ||
146
+ issuesByTarget.get(name) ||
147
+ []
148
+
149
+ return LibraryCatalogArtifactBuilder.#stripUndefined({
150
+ key:
151
+ 'footprint:' +
152
+ (library.fileName || '') +
153
+ ':' +
154
+ name,
155
+ kind: 'footprint',
156
+ name,
157
+ libraryFileName: library.fileName || '',
158
+ index: footprintIndex,
159
+ primitiveCount:
160
+ (footprint.pads || []).length +
161
+ (footprint.tracks || []).length +
162
+ (footprint.arcs || []).length +
163
+ (footprint.vias || []).length +
164
+ (footprint.fills || []).length +
165
+ (footprint.regions || []).length,
166
+ rawRecordCount: (footprint.rawRecords || []).length,
167
+ outputSvgKey: output?.outputSvgKey,
168
+ issueCodes: issues.map((issue) => issue.code),
169
+ issues,
170
+ searchText: [
171
+ name,
172
+ library.fileName || '',
173
+ 'footprint',
174
+ 'pads:' + (footprint.pads || []).length
175
+ ]
176
+ .filter(Boolean)
177
+ .join(' ')
178
+ })
179
+ }
180
+ )
181
+ })
182
+ }
183
+
184
+ /**
185
+ * Builds a target-key lookup for QA issues.
186
+ * @param {object} qaReport QA report.
187
+ * @returns {Map<string, object[]>}
188
+ */
189
+ static #issuesByTarget(qaReport) {
190
+ const byTarget = new Map()
191
+ const issues = [
192
+ ...(qaReport?.libraryLint?.issues || []),
193
+ ...(qaReport?.issues || [])
194
+ ]
195
+
196
+ for (const issue of issues) {
197
+ const keys = new Set([
198
+ issue.target,
199
+ issue.symbolName ? 'symbol:' + issue.symbolName : '',
200
+ issue.footprintName ? 'footprint:' + issue.footprintName : ''
201
+ ])
202
+
203
+ for (const key of keys) {
204
+ if (!key) continue
205
+ byTarget.set(key, [...(byTarget.get(key) || []), issue])
206
+ }
207
+ }
208
+
209
+ return byTarget
210
+ }
211
+
212
+ /**
213
+ * Builds a self-contained static HTML catalog.
214
+ * @param {object[]} entries Catalog entries.
215
+ * @returns {string}
216
+ */
217
+ static #html(entries) {
218
+ const items = entries
219
+ .map((entry) => {
220
+ const badges = entry.issueCodes
221
+ .map(
222
+ (code) =>
223
+ '<span class="badge">' +
224
+ LibraryCatalogArtifactBuilder.#escapeHtml(code) +
225
+ '</span>'
226
+ )
227
+ .join('')
228
+
229
+ return (
230
+ '<article data-catalog-entry="' +
231
+ LibraryCatalogArtifactBuilder.#escapeHtml(entry.kind) +
232
+ '">' +
233
+ '<h2>' +
234
+ LibraryCatalogArtifactBuilder.#escapeHtml(entry.name) +
235
+ '</h2>' +
236
+ '<p>' +
237
+ LibraryCatalogArtifactBuilder.#escapeHtml(
238
+ entry.libraryFileName
239
+ ) +
240
+ '</p>' +
241
+ '<p data-preview-key="' +
242
+ LibraryCatalogArtifactBuilder.#escapeHtml(
243
+ entry.outputSvgKey || ''
244
+ ) +
245
+ '">' +
246
+ LibraryCatalogArtifactBuilder.#escapeHtml(
247
+ entry.outputSvgKey || ''
248
+ ) +
249
+ '</p>' +
250
+ '<div class="badges">' +
251
+ badges +
252
+ '</div>' +
253
+ '</article>'
254
+ )
255
+ })
256
+ .join('')
257
+
258
+ return (
259
+ '<!doctype html><html><head><meta charset="utf-8">' +
260
+ '<title>Library Catalog</title>' +
261
+ '<style>body{font-family:sans-serif;margin:24px}' +
262
+ 'main{display:grid;gap:12px}' +
263
+ 'article{border:1px solid #d0d7de;border-radius:6px;padding:12px}' +
264
+ '.badge{display:inline-block;margin:4px 4px 0 0;padding:2px 6px;' +
265
+ 'border:1px solid #d0d7de;border-radius:999px;font-size:12px}' +
266
+ '</style></head><body><main data-library-catalog>' +
267
+ items +
268
+ '</main></body></html>'
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Escapes text for HTML output.
274
+ * @param {unknown} value Source value.
275
+ * @returns {string}
276
+ */
277
+ static #escapeHtml(value) {
278
+ return String(value ?? '')
279
+ .replace(/&/gu, '&amp;')
280
+ .replace(/</gu, '&lt;')
281
+ .replace(/>/gu, '&gt;')
282
+ .replace(/"/gu, '&quot;')
283
+ .replace(/'/gu, '&#39;')
284
+ }
285
+
286
+ /**
287
+ * Removes undefined fields.
288
+ * @param {object} row Source row.
289
+ * @returns {object}
290
+ */
291
+ static #stripUndefined(row) {
292
+ return Object.fromEntries(
293
+ Object.entries(row).filter(([, value]) => value !== undefined)
294
+ )
295
+ }
296
+ }
@@ -0,0 +1,260 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic read-only diffs between parsed library models.
7
+ */
8
+ export class LibraryDiffReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.library.diff.a1'
10
+
11
+ /**
12
+ * Builds a library diff report.
13
+ * @param {{ left?: object[] | object, right?: object[] | object }} input Diff input.
14
+ * @returns {object}
15
+ */
16
+ static build(input = {}) {
17
+ const leftLibraries = LibraryDiffReportBuilder.#libraries(input.left)
18
+ const rightLibraries = LibraryDiffReportBuilder.#libraries(input.right)
19
+ const symbols = LibraryDiffReportBuilder.#diffCollection(
20
+ LibraryDiffReportBuilder.#symbolRows(leftLibraries),
21
+ LibraryDiffReportBuilder.#symbolRows(rightLibraries),
22
+ 'symbol'
23
+ )
24
+ const footprints = LibraryDiffReportBuilder.#diffCollection(
25
+ LibraryDiffReportBuilder.#footprintRows(leftLibraries),
26
+ LibraryDiffReportBuilder.#footprintRows(rightLibraries),
27
+ 'footprint'
28
+ )
29
+
30
+ return {
31
+ schema: LibraryDiffReportBuilder.SCHEMA,
32
+ summary: {
33
+ leftLibraryCount: leftLibraries.length,
34
+ rightLibraryCount: rightLibraries.length,
35
+ addedSymbolCount: symbols.added.length,
36
+ removedSymbolCount: symbols.removed.length,
37
+ changedSymbolCount: symbols.changed.length,
38
+ addedFootprintCount: footprints.added.length,
39
+ removedFootprintCount: footprints.removed.length,
40
+ changedFootprintCount: footprints.changed.length,
41
+ differenceCount:
42
+ symbols.added.length +
43
+ symbols.removed.length +
44
+ symbols.changed.length +
45
+ footprints.added.length +
46
+ footprints.removed.length +
47
+ footprints.changed.length
48
+ },
49
+ symbols,
50
+ footprints
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Normalizes library input to an array.
56
+ * @param {object[] | object | undefined} value Input value.
57
+ * @returns {object[]}
58
+ */
59
+ static #libraries(value) {
60
+ if (!value) return []
61
+ return Array.isArray(value) ? value : [value]
62
+ }
63
+
64
+ /**
65
+ * Collects schematic symbol comparison rows.
66
+ * @param {object[]} libraries Library models.
67
+ * @returns {object[]}
68
+ */
69
+ static #symbolRows(libraries) {
70
+ return (libraries || []).flatMap((library) =>
71
+ (library.schematicLibrary?.symbols || []).map((symbol, index) => ({
72
+ name: String(symbol.name || ''),
73
+ libraryFileName: library.fileName || '',
74
+ index,
75
+ pinCount: (symbol.pins || []).length,
76
+ partCount: (symbol.parts || []).length,
77
+ parameters: symbol.parameters || {}
78
+ }))
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Collects PCB footprint comparison rows.
84
+ * @param {object[]} libraries Library models.
85
+ * @returns {object[]}
86
+ */
87
+ static #footprintRows(libraries) {
88
+ return (libraries || []).flatMap((library) =>
89
+ (library.pcbLibrary?.footprints || []).map((footprint, index) => ({
90
+ name: String(footprint.name || ''),
91
+ libraryFileName: library.fileName || '',
92
+ index,
93
+ padCount: (footprint.pads || []).length,
94
+ textCount: (footprint.texts || []).length,
95
+ parameters: footprint.parameters || {}
96
+ }))
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Diffs one named library item collection.
102
+ * @param {object[]} leftRows Left rows.
103
+ * @param {object[]} rightRows Right rows.
104
+ * @param {'symbol' | 'footprint'} kind Collection kind.
105
+ * @returns {{ added: object[], removed: object[], changed: object[] }}
106
+ */
107
+ static #diffCollection(leftRows, rightRows, kind) {
108
+ const leftByName = LibraryDiffReportBuilder.#byName(leftRows)
109
+ const rightByName = LibraryDiffReportBuilder.#byName(rightRows)
110
+ const names = [
111
+ ...new Set([...leftByName.keys(), ...rightByName.keys()])
112
+ ]
113
+ .filter(Boolean)
114
+ .sort((left, right) => left.localeCompare(right))
115
+ const added = []
116
+ const removed = []
117
+ const changed = []
118
+
119
+ for (const name of names) {
120
+ const left = leftByName.get(name)
121
+ const right = rightByName.get(name)
122
+
123
+ if (!left && right) {
124
+ added.push(
125
+ LibraryDiffReportBuilder.#publicRow(right, kind, {
126
+ includeName: true
127
+ })
128
+ )
129
+ continue
130
+ }
131
+
132
+ if (left && !right) {
133
+ removed.push(
134
+ LibraryDiffReportBuilder.#publicRow(left, kind, {
135
+ includeName: true
136
+ })
137
+ )
138
+ continue
139
+ }
140
+
141
+ const differences = LibraryDiffReportBuilder.#differences(
142
+ left,
143
+ right,
144
+ kind
145
+ )
146
+ if (Object.keys(differences).length) {
147
+ changed.push({
148
+ name,
149
+ left: LibraryDiffReportBuilder.#publicRow(left, kind),
150
+ right: LibraryDiffReportBuilder.#publicRow(right, kind),
151
+ differences
152
+ })
153
+ }
154
+ }
155
+
156
+ return { added, removed, changed }
157
+ }
158
+
159
+ /**
160
+ * Indexes rows by name.
161
+ * @param {object[]} rows Rows.
162
+ * @returns {Map<string, object>}
163
+ */
164
+ static #byName(rows) {
165
+ return new Map(
166
+ (rows || []).filter((row) => row.name).map((row) => [row.name, row])
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Builds public row shape without private comparison-only data.
172
+ * @param {object} row Source row.
173
+ * @param {'symbol' | 'footprint'} kind Collection kind.
174
+ * @param {{ includeName?: boolean }} options Row options.
175
+ * @returns {object}
176
+ */
177
+ static #publicRow(row, kind, options = {}) {
178
+ const base = {
179
+ libraryFileName: row.libraryFileName,
180
+ index: row.index
181
+ }
182
+ if (options.includeName) {
183
+ base.name = row.name
184
+ }
185
+
186
+ if (kind === 'symbol') {
187
+ return {
188
+ ...base,
189
+ pinCount: row.pinCount,
190
+ partCount: row.partCount
191
+ }
192
+ }
193
+
194
+ return {
195
+ ...base,
196
+ padCount: row.padCount,
197
+ textCount: row.textCount
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Builds differences between two matching rows.
203
+ * @param {object} left Left row.
204
+ * @param {object} right Right row.
205
+ * @param {'symbol' | 'footprint'} kind Collection kind.
206
+ * @returns {object}
207
+ */
208
+ static #differences(left, right, kind) {
209
+ const differences = {}
210
+
211
+ for (const key of kind === 'symbol'
212
+ ? ['pinCount', 'partCount']
213
+ : ['padCount', 'textCount']) {
214
+ if (left[key] !== right[key]) {
215
+ differences[key] = { left: left[key], right: right[key] }
216
+ }
217
+ }
218
+
219
+ const parameterDifferences =
220
+ LibraryDiffReportBuilder.#parameterDifferences(
221
+ left.parameters,
222
+ right.parameters
223
+ )
224
+ if (Object.keys(parameterDifferences).length) {
225
+ differences.parameters = parameterDifferences
226
+ }
227
+
228
+ return differences
229
+ }
230
+
231
+ /**
232
+ * Builds per-parameter value differences.
233
+ * @param {Record<string, string>} left Left parameters.
234
+ * @param {Record<string, string>} right Right parameters.
235
+ * @returns {object}
236
+ */
237
+ static #parameterDifferences(left, right) {
238
+ const differences = {}
239
+ const keys = [
240
+ ...new Set([
241
+ ...Object.keys(left || {}),
242
+ ...Object.keys(right || {})
243
+ ])
244
+ ].sort((leftKey, rightKey) => leftKey.localeCompare(rightKey))
245
+
246
+ for (const key of keys) {
247
+ const leftValue = String(left?.[key] ?? '')
248
+ const rightValue = String(right?.[key] ?? '')
249
+
250
+ if (leftValue !== rightValue) {
251
+ differences[key] = {
252
+ left: leftValue,
253
+ right: rightValue
254
+ }
255
+ }
256
+ }
257
+
258
+ return differences
259
+ }
260
+ }