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,660 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds a read-only schematic hierarchy report from parsed project sheets.
7
+ */
8
+ export class ProjectHierarchyReportBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.project.hierarchy.a1'
10
+
11
+ /**
12
+ * Builds a schematic hierarchy report from parsed project and sheet models.
13
+ * @param {{ projectModel?: object, documentModels?: object[] }} [options] Report options.
14
+ * @returns {object}
15
+ */
16
+ static build(options = {}) {
17
+ const project =
18
+ options.projectModel?.project || options.projectModel || {}
19
+ const schematicModels = (options.documentModels || []).filter(
20
+ (model) => model?.kind === 'schematic'
21
+ )
22
+ const sheets = ProjectHierarchyReportBuilder.#sheetRows(
23
+ project,
24
+ schematicModels
25
+ )
26
+ const sheetByPath = new Map(
27
+ sheets.map((sheet) => [sheet.normalizedPath, sheet])
28
+ )
29
+ const links = ProjectHierarchyReportBuilder.#linkRows(
30
+ schematicModels,
31
+ sheetByPath
32
+ )
33
+ const roots = ProjectHierarchyReportBuilder.#treeRoots(
34
+ sheets,
35
+ links,
36
+ project
37
+ )
38
+ const diagnostics = ProjectHierarchyReportBuilder.#diagnostics(links)
39
+
40
+ return {
41
+ schema: ProjectHierarchyReportBuilder.SCHEMA_ID,
42
+ summary: {
43
+ sheetCount: sheets.length,
44
+ rootSheetCount: roots.length,
45
+ linkCount: links.length,
46
+ resolvedLinkCount: links.filter(
47
+ (link) => link.status === 'resolved'
48
+ ).length,
49
+ missingSheetCount: links.filter(
50
+ (link) => link.status === 'missing'
51
+ ).length,
52
+ cycleCount: links.filter((link) => link.status === 'cycle')
53
+ .length,
54
+ repeatedReferenceCount: links.filter(
55
+ (link) => link.status === 'repeated'
56
+ ).length
57
+ },
58
+ hierarchyMode: {
59
+ value: String(project?.design?.HierarchyMode || ''),
60
+ name: ProjectHierarchyReportBuilder.#hierarchyModeName(
61
+ project?.design?.HierarchyMode
62
+ )
63
+ },
64
+ sheets,
65
+ links: links.sort(
66
+ (left, right) =>
67
+ left.parentSheetFileName.localeCompare(
68
+ right.parentSheetFileName
69
+ ) ||
70
+ left.childSheetFileName.localeCompare(
71
+ right.childSheetFileName
72
+ ) ||
73
+ left.symbolKey.localeCompare(right.symbolKey)
74
+ ),
75
+ roots,
76
+ diagnostics
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Builds sheet rows from project document rows and parsed sheet models.
82
+ * @param {object} project Parsed project model.
83
+ * @param {object[]} schematicModels Parsed schematic models.
84
+ * @returns {object[]}
85
+ */
86
+ static #sheetRows(project, schematicModels) {
87
+ const documentsByFileName = new Map()
88
+ const documents = (project?.documents || []).filter((document) =>
89
+ ProjectHierarchyReportBuilder.#isSchematicDocument(document)
90
+ )
91
+
92
+ for (const document of documents) {
93
+ documentsByFileName.set(
94
+ ProjectHierarchyReportBuilder.#basename(
95
+ document.normalizedPath ||
96
+ document.path ||
97
+ document.fileName
98
+ ).toLowerCase(),
99
+ document
100
+ )
101
+ }
102
+
103
+ const rows = schematicModels.map((model, index) => {
104
+ const document = documentsByFileName.get(
105
+ String(model.fileName || '').toLowerCase()
106
+ )
107
+ const normalizedPath = ProjectHierarchyReportBuilder.#normalizePath(
108
+ document?.normalizedPath ||
109
+ document?.path ||
110
+ model.fileName ||
111
+ ''
112
+ )
113
+
114
+ return ProjectHierarchyReportBuilder.#stripUndefined({
115
+ index,
116
+ fileName: model.fileName || document?.fileName || '',
117
+ title: model.summary?.title || model.fileName || '',
118
+ documentPath: document?.path || normalizedPath,
119
+ normalizedPath,
120
+ uniqueId: document?.uniqueId || '',
121
+ sheetSymbolCount: (model.schematic?.sheetSymbols || []).length,
122
+ sheetEntryCount: (model.schematic?.sheetEntries || []).length,
123
+ status: 'present'
124
+ })
125
+ })
126
+ const modelPathKeys = new Set(
127
+ rows.map((row) => row.normalizedPath.toLowerCase())
128
+ )
129
+
130
+ for (const document of documents) {
131
+ const normalizedPath = ProjectHierarchyReportBuilder.#normalizePath(
132
+ document.normalizedPath || document.path || document.fileName
133
+ )
134
+ if (modelPathKeys.has(normalizedPath.toLowerCase())) {
135
+ continue
136
+ }
137
+
138
+ rows.push(
139
+ ProjectHierarchyReportBuilder.#stripUndefined({
140
+ index: rows.length,
141
+ fileName:
142
+ document.fileName ||
143
+ ProjectHierarchyReportBuilder.#basename(normalizedPath),
144
+ title:
145
+ document.fileName ||
146
+ ProjectHierarchyReportBuilder.#basename(normalizedPath),
147
+ documentPath: document.path || normalizedPath,
148
+ normalizedPath,
149
+ uniqueId: document.uniqueId || '',
150
+ sheetSymbolCount: 0,
151
+ sheetEntryCount: 0,
152
+ status: 'unparsed'
153
+ })
154
+ )
155
+ }
156
+
157
+ return rows
158
+ }
159
+
160
+ /**
161
+ * Builds hierarchy link rows from sheet symbols.
162
+ * @param {object[]} schematicModels Parsed schematic models.
163
+ * @param {Map<string, object>} sheetByPath Sheets by normalized path.
164
+ * @returns {object[]}
165
+ */
166
+ static #linkRows(schematicModels, sheetByPath) {
167
+ const modelPathByFileName = new Map(
168
+ [...sheetByPath.values()].map((sheet) => [
169
+ sheet.fileName.toLowerCase(),
170
+ sheet.normalizedPath
171
+ ])
172
+ )
173
+ const links = []
174
+
175
+ for (const model of schematicModels) {
176
+ const parentPath =
177
+ modelPathByFileName.get(
178
+ String(model.fileName || '').toLowerCase()
179
+ ) ||
180
+ ProjectHierarchyReportBuilder.#normalizePath(model.fileName)
181
+ const parentSheet = sheetByPath.get(parentPath) || {
182
+ fileName: model.fileName || '',
183
+ normalizedPath: parentPath
184
+ }
185
+
186
+ for (const [symbolIndex, sheetSymbol] of (
187
+ model.schematic?.sheetSymbols || []
188
+ ).entries()) {
189
+ const childSheetFileName =
190
+ ProjectHierarchyReportBuilder.#childSheetFileName(
191
+ sheetSymbol
192
+ )
193
+ if (!childSheetFileName) {
194
+ continue
195
+ }
196
+
197
+ const resolvedChildPath =
198
+ ProjectHierarchyReportBuilder.#resolveChildPath(
199
+ parentPath,
200
+ childSheetFileName,
201
+ modelPathByFileName
202
+ )
203
+ const childSheet = sheetByPath.get(resolvedChildPath)
204
+ const sheetEntryNames =
205
+ ProjectHierarchyReportBuilder.#sheetEntryNames(
206
+ sheetSymbol,
207
+ model.schematic?.sheetEntries || []
208
+ )
209
+
210
+ links.push(
211
+ ProjectHierarchyReportBuilder.#stripUndefined({
212
+ key:
213
+ parentPath +
214
+ '->' +
215
+ resolvedChildPath +
216
+ '#' +
217
+ symbolIndex,
218
+ parentSheetFileName: parentSheet.fileName,
219
+ parentDocumentPath: parentPath,
220
+ childSheetFileName:
221
+ childSheet?.fileName ||
222
+ ProjectHierarchyReportBuilder.#basename(
223
+ childSheetFileName
224
+ ),
225
+ childDocumentPath: resolvedChildPath,
226
+ symbolKey:
227
+ sheetSymbol.uniqueId ||
228
+ 'sheet-symbol-' + symbolIndex,
229
+ symbolName: sheetSymbol.name || '',
230
+ sheetEntryNames,
231
+ status: childSheet ? 'resolved' : 'missing'
232
+ })
233
+ )
234
+ }
235
+ }
236
+
237
+ ProjectHierarchyReportBuilder.#markTraversalStatuses(links, [
238
+ ...sheetByPath.values()
239
+ ])
240
+
241
+ return links
242
+ }
243
+
244
+ /**
245
+ * Updates resolved link rows with cycle and repeated-reference statuses.
246
+ * @param {object[]} links Link rows.
247
+ * @param {object[]} sheets Sheet rows.
248
+ */
249
+ static #markTraversalStatuses(links, sheets) {
250
+ const linksByParent =
251
+ ProjectHierarchyReportBuilder.#linksByParent(links)
252
+ const roots = ProjectHierarchyReportBuilder.#rootSheetsFromLinks(
253
+ sheets,
254
+ links
255
+ )
256
+ const visited = new Set()
257
+
258
+ for (const root of roots) {
259
+ ProjectHierarchyReportBuilder.#walkLinks(
260
+ root.normalizedPath,
261
+ linksByParent,
262
+ visited,
263
+ []
264
+ )
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Walks resolved links from one parent path.
270
+ * @param {string} parentPath Parent sheet path.
271
+ * @param {Map<string, object[]>} linksByParent Links by parent path.
272
+ * @param {Set<string>} visited Globally visited sheet paths.
273
+ * @param {string[]} stack Current traversal stack.
274
+ */
275
+ static #walkLinks(parentPath, linksByParent, visited, stack) {
276
+ if (stack.includes(parentPath)) {
277
+ return
278
+ }
279
+
280
+ visited.add(parentPath)
281
+ const nextStack = [...stack, parentPath]
282
+
283
+ for (const link of linksByParent.get(parentPath) || []) {
284
+ if (link.status !== 'resolved') {
285
+ continue
286
+ }
287
+
288
+ if (nextStack.includes(link.childDocumentPath)) {
289
+ link.status = 'cycle'
290
+ continue
291
+ }
292
+
293
+ if (visited.has(link.childDocumentPath)) {
294
+ link.status = 'repeated'
295
+ continue
296
+ }
297
+
298
+ ProjectHierarchyReportBuilder.#walkLinks(
299
+ link.childDocumentPath,
300
+ linksByParent,
301
+ visited,
302
+ nextStack
303
+ )
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Builds root tree rows.
309
+ * @param {object[]} sheets Sheet rows.
310
+ * @param {object[]} links Link rows.
311
+ * @param {object} project Parsed project model.
312
+ * @returns {object[]}
313
+ */
314
+ static #treeRoots(sheets, links, project) {
315
+ const sheetByPath = new Map(
316
+ sheets.map((sheet) => [sheet.normalizedPath, sheet])
317
+ )
318
+
319
+ return ProjectHierarchyReportBuilder.#rootSheetsFromLinks(
320
+ sheets,
321
+ links,
322
+ project
323
+ ).map((root) =>
324
+ ProjectHierarchyReportBuilder.#treeNode(
325
+ root.normalizedPath,
326
+ sheetByPath,
327
+ links,
328
+ []
329
+ )
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Selects likely hierarchy roots.
335
+ * @param {object[]} sheets Sheet rows.
336
+ * @param {object[]} links Link rows.
337
+ * @param {object} [project] Parsed project model.
338
+ * @returns {object[]}
339
+ */
340
+ static #rootSheetsFromLinks(sheets, links, project = {}) {
341
+ const referencedPaths = new Set(
342
+ links
343
+ .filter((link) => link.status === 'resolved')
344
+ .map((link) => link.childDocumentPath)
345
+ )
346
+ const primaryRootPath =
347
+ ProjectHierarchyReportBuilder.#primaryProjectSheetPath(
348
+ project,
349
+ sheets
350
+ )
351
+ const rootPaths = new Set()
352
+ if (primaryRootPath) {
353
+ rootPaths.add(primaryRootPath)
354
+ }
355
+
356
+ for (const sheet of sheets) {
357
+ if (!referencedPaths.has(sheet.normalizedPath)) {
358
+ rootPaths.add(sheet.normalizedPath)
359
+ }
360
+ }
361
+
362
+ return sheets.filter((sheet) => rootPaths.has(sheet.normalizedPath))
363
+ }
364
+
365
+ /**
366
+ * Builds one tree node.
367
+ * @param {string} path Sheet path.
368
+ * @param {Map<string, object>} sheetByPath Sheet lookup.
369
+ * @param {object[]} links Link rows.
370
+ * @param {string[]} stack Current traversal stack.
371
+ * @returns {object}
372
+ */
373
+ static #treeNode(path, sheetByPath, links, stack) {
374
+ const sheet = sheetByPath.get(path)
375
+ const childLinks = links.filter(
376
+ (link) => link.parentDocumentPath === path
377
+ )
378
+ const nextStack = [...stack, path]
379
+
380
+ return ProjectHierarchyReportBuilder.#stripUndefined({
381
+ fileName:
382
+ sheet?.fileName ||
383
+ ProjectHierarchyReportBuilder.#basename(path),
384
+ documentPath: path,
385
+ status: sheet?.status || 'missing',
386
+ children: childLinks.map((link) =>
387
+ ProjectHierarchyReportBuilder.#treeChild(
388
+ link,
389
+ sheetByPath,
390
+ links,
391
+ nextStack
392
+ )
393
+ )
394
+ })
395
+ }
396
+
397
+ /**
398
+ * Builds one child tree node from a hierarchy link.
399
+ * @param {object} link Link row.
400
+ * @param {Map<string, object>} sheetByPath Sheet lookup.
401
+ * @param {object[]} links Link rows.
402
+ * @param {string[]} stack Current traversal stack.
403
+ * @returns {object}
404
+ */
405
+ static #treeChild(link, sheetByPath, links, stack) {
406
+ if (
407
+ link.status !== 'resolved' ||
408
+ stack.includes(link.childDocumentPath)
409
+ ) {
410
+ return {
411
+ fileName: link.childSheetFileName,
412
+ documentPath: link.childDocumentPath,
413
+ status: link.status,
414
+ children: []
415
+ }
416
+ }
417
+
418
+ return ProjectHierarchyReportBuilder.#treeNode(
419
+ link.childDocumentPath,
420
+ sheetByPath,
421
+ links,
422
+ stack
423
+ )
424
+ }
425
+
426
+ /**
427
+ * Builds diagnostics for non-resolved hierarchy links.
428
+ * @param {object[]} links Link rows.
429
+ * @returns {object[]}
430
+ */
431
+ static #diagnostics(links) {
432
+ return links
433
+ .filter((link) => link.status !== 'resolved')
434
+ .map((link) =>
435
+ ProjectHierarchyReportBuilder.#stripUndefined({
436
+ code: 'project.hierarchy.' + link.status + '-sheet',
437
+ severity: 'warning',
438
+ parentSheetFileName: link.parentSheetFileName,
439
+ childSheetFileName: link.childSheetFileName,
440
+ symbolKey: link.symbolKey,
441
+ message:
442
+ link.status === 'missing'
443
+ ? 'Sheet symbol references a child sheet that was not present.'
444
+ : link.status === 'cycle'
445
+ ? 'Sheet symbol creates a recursive sheet hierarchy.'
446
+ : 'Sheet is referenced more than once in the hierarchy.'
447
+ })
448
+ )
449
+ .map((diagnostic) =>
450
+ diagnostic.code === 'project.hierarchy.cycle-sheet'
451
+ ? { ...diagnostic, code: 'project.hierarchy.cycle' }
452
+ : diagnostic
453
+ )
454
+ .map((diagnostic) =>
455
+ diagnostic.code === 'project.hierarchy.missing-sheet'
456
+ ? diagnostic
457
+ : diagnostic
458
+ )
459
+ .sort(
460
+ (left, right) =>
461
+ left.parentSheetFileName.localeCompare(
462
+ right.parentSheetFileName
463
+ ) ||
464
+ left.childSheetFileName.localeCompare(
465
+ right.childSheetFileName
466
+ ) ||
467
+ left.code.localeCompare(right.code)
468
+ )
469
+ }
470
+
471
+ /**
472
+ * Groups links by parent document path.
473
+ * @param {object[]} links Link rows.
474
+ * @returns {Map<string, object[]>}
475
+ */
476
+ static #linksByParent(links) {
477
+ const byParent = new Map()
478
+
479
+ for (const link of links) {
480
+ byParent.set(link.parentDocumentPath, [
481
+ ...(byParent.get(link.parentDocumentPath) || []),
482
+ link
483
+ ])
484
+ }
485
+
486
+ return byParent
487
+ }
488
+
489
+ /**
490
+ * Selects the first schematic document as the primary project root.
491
+ * @param {object} project Parsed project model.
492
+ * @param {object[]} sheets Sheet rows.
493
+ * @returns {string}
494
+ */
495
+ static #primaryProjectSheetPath(project, sheets) {
496
+ const firstDocument = (project?.documents || []).find((document) =>
497
+ ProjectHierarchyReportBuilder.#isSchematicDocument(document)
498
+ )
499
+ const firstDocumentPath = ProjectHierarchyReportBuilder.#normalizePath(
500
+ firstDocument?.normalizedPath ||
501
+ firstDocument?.path ||
502
+ firstDocument?.fileName ||
503
+ ''
504
+ )
505
+
506
+ if (firstDocumentPath) {
507
+ return (
508
+ sheets.find(
509
+ (sheet) =>
510
+ sheet.normalizedPath.toLowerCase() ===
511
+ firstDocumentPath.toLowerCase()
512
+ )?.normalizedPath || ''
513
+ )
514
+ }
515
+
516
+ return sheets[0]?.normalizedPath || ''
517
+ }
518
+
519
+ /**
520
+ * Returns child sheet file name metadata from a sheet symbol.
521
+ * @param {object} sheetSymbol Sheet symbol row.
522
+ * @returns {string}
523
+ */
524
+ static #childSheetFileName(sheetSymbol) {
525
+ return String(
526
+ sheetSymbol?.fileName ||
527
+ sheetSymbol?.sheetFileName ||
528
+ sheetSymbol?.childSheetFileName ||
529
+ ''
530
+ ).trim()
531
+ }
532
+
533
+ /**
534
+ * Resolves sheet-entry names owned by one sheet symbol.
535
+ * @param {object} sheetSymbol Sheet symbol row.
536
+ * @param {object[]} sheetEntries Sheet entries.
537
+ * @returns {string[]}
538
+ */
539
+ static #sheetEntryNames(sheetSymbol, sheetEntries) {
540
+ const ownerKeys = new Set(
541
+ [
542
+ sheetSymbol.ownerIndex,
543
+ sheetSymbol.indexInSheet,
544
+ Number.isInteger(sheetSymbol.indexInSheet)
545
+ ? sheetSymbol.indexInSheet + 1
546
+ : undefined
547
+ ]
548
+ .filter((value) => value !== undefined && value !== '')
549
+ .map((value) => String(value))
550
+ )
551
+
552
+ return (sheetEntries || [])
553
+ .filter((entry) => ownerKeys.has(String(entry.ownerIndex || '')))
554
+ .map((entry) => String(entry.name || '').trim())
555
+ .filter(Boolean)
556
+ .sort((left, right) => left.localeCompare(right))
557
+ }
558
+
559
+ /**
560
+ * Resolves a child sheet path against known sheet file names.
561
+ * @param {string} parentPath Parent sheet path.
562
+ * @param {string} childSheetFileName Raw child filename.
563
+ * @param {Map<string, string>} modelPathByFileName Known sheet paths.
564
+ * @returns {string}
565
+ */
566
+ static #resolveChildPath(
567
+ parentPath,
568
+ childSheetFileName,
569
+ modelPathByFileName
570
+ ) {
571
+ const normalizedChild =
572
+ ProjectHierarchyReportBuilder.#normalizePath(childSheetFileName)
573
+ const byFileName = modelPathByFileName.get(
574
+ ProjectHierarchyReportBuilder.#basename(
575
+ normalizedChild
576
+ ).toLowerCase()
577
+ )
578
+ if (byFileName) {
579
+ return byFileName
580
+ }
581
+ if (normalizedChild.includes('/')) {
582
+ return normalizedChild
583
+ }
584
+
585
+ const parentDirectory = parentPath.includes('/')
586
+ ? parentPath.replace(/\/[^/]*$/u, '')
587
+ : ''
588
+ return parentDirectory
589
+ ? parentDirectory + '/' + normalizedChild
590
+ : normalizedChild
591
+ }
592
+
593
+ /**
594
+ * Returns true when a project document row refers to a schematic sheet.
595
+ * @param {object} document Project document row.
596
+ * @returns {boolean}
597
+ */
598
+ static #isSchematicDocument(document) {
599
+ const kind = String(document?.kind || '').toLowerCase()
600
+ const path = String(
601
+ document?.normalizedPath ||
602
+ document?.path ||
603
+ document?.fileName ||
604
+ ''
605
+ ).toLowerCase()
606
+
607
+ return kind === 'schematic' || path.endsWith('.schdoc')
608
+ }
609
+
610
+ /**
611
+ * Resolves a display name for a project hierarchy mode.
612
+ * @param {string | number | undefined} mode Raw hierarchy mode.
613
+ * @returns {string}
614
+ */
615
+ static #hierarchyModeName(mode) {
616
+ switch (String(mode || '')) {
617
+ case '2':
618
+ return 'hierarchical'
619
+ case '1':
620
+ return 'flat'
621
+ case '3':
622
+ return 'global'
623
+ default:
624
+ return 'unspecified'
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Normalizes path separators.
630
+ * @param {unknown} path Path value.
631
+ * @returns {string}
632
+ */
633
+ static #normalizePath(path) {
634
+ return String(path || '').replace(/\\/gu, '/')
635
+ }
636
+
637
+ /**
638
+ * Extracts a basename from a normalized or native path.
639
+ * @param {unknown} path Path value.
640
+ * @returns {string}
641
+ */
642
+ static #basename(path) {
643
+ const parts =
644
+ ProjectHierarchyReportBuilder.#normalizePath(path).split('/')
645
+ return parts.at(-1) || ''
646
+ }
647
+
648
+ /**
649
+ * Removes undefined and empty-string fields.
650
+ * @param {object} row Source row.
651
+ * @returns {object}
652
+ */
653
+ static #stripUndefined(row) {
654
+ return Object.fromEntries(
655
+ Object.entries(row || {}).filter(
656
+ ([, value]) => value !== undefined && value !== ''
657
+ )
658
+ )
659
+ }
660
+ }
@@ -308,6 +308,8 @@ export class ProjectNetlistExporter {
308
308
  */
309
309
  static #sourceAliases(source) {
310
310
  return ProjectNetlistExporter.#dedupe([
311
+ source?.autoName,
312
+ ...(source?.aliasCandidates || []),
311
313
  ...(source?.labels || []).map((label) => label.text),
312
314
  ...(source?.powerPorts || []).map((port) => port.text),
313
315
  ...(source?.ports || []).map((port) => port.name),