altium-toolkit 1.1.3 → 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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -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 +513 -3
  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 +7 -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/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 André Fiedler
3
+ //
4
+ // SPDX-License-Identifier: GPL-3.0-or-later
5
+
6
+ import { readdir, readFile } from 'node:fs/promises'
7
+ import { basename, extname, join, relative, sep } from 'node:path'
8
+
9
+ import { AltiumParser } from '../src/index.mjs'
10
+ import {
11
+ hasHelpFlag,
12
+ inputPathFromArgs,
13
+ printJson,
14
+ wantsJson
15
+ } from './cli-utils.mjs'
16
+
17
+ const INPUT_EXTENSIONS = new Set([
18
+ '.schdoc',
19
+ '.pcbdoc',
20
+ '.schlib',
21
+ '.pcblib',
22
+ '.prjpcb',
23
+ '.prjscr',
24
+ '.intlib',
25
+ '.pcbdwf'
26
+ ])
27
+ const TOP_FIELD_GAP_LIMIT = 20
28
+ const TEXT_FIELD_GAP_LIMIT = 5
29
+
30
+ /**
31
+ * Prints script help.
32
+ * @returns {void}
33
+ */
34
+ function printCorpusHelp() {
35
+ console.log(
36
+ [
37
+ 'Usage: corpus-smoke <directory> [--json] [--coverage]',
38
+ '',
39
+ 'Parse every supported local design file under a directory and summarize results.',
40
+ '',
41
+ 'This is a read-only example. It reads input files and writes report output to stdout.',
42
+ 'Supported extensions: ' + [...INPUT_EXTENSIONS].sort().join(', ')
43
+ ].join('\n')
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Prints an input usage error.
49
+ * @returns {void}
50
+ */
51
+ function printMissingDirectory() {
52
+ console.error('Usage: corpus-smoke <directory> [--json] [--coverage]')
53
+ console.error('Run `corpus-smoke --help` for details.')
54
+ process.exitCode = 1
55
+ }
56
+
57
+ /**
58
+ * Returns true when parser coverage output was requested.
59
+ * @param {string[]} args Command-line arguments.
60
+ * @returns {boolean}
61
+ */
62
+ function wantsCoverage(args) {
63
+ return args.includes('--coverage')
64
+ }
65
+
66
+ /**
67
+ * Recursively lists supported input files under one directory.
68
+ * @param {string} directory Root directory.
69
+ * @returns {Promise<string[]>}
70
+ */
71
+ async function listInputFiles(directory) {
72
+ const entries = await readdir(directory, { withFileTypes: true })
73
+ const files = []
74
+
75
+ for (const entry of entries.sort((left, right) =>
76
+ left.name.localeCompare(right.name)
77
+ )) {
78
+ const filePath = join(directory, entry.name)
79
+
80
+ if (entry.isDirectory()) {
81
+ files.push(...(await listInputFiles(filePath)))
82
+ continue
83
+ }
84
+
85
+ if (
86
+ entry.isFile() &&
87
+ INPUT_EXTENSIONS.has(extname(entry.name).toLowerCase())
88
+ ) {
89
+ files.push(filePath)
90
+ }
91
+ }
92
+
93
+ return files
94
+ }
95
+
96
+ /**
97
+ * Parses one corpus file into a report row.
98
+ * @param {string} rootDirectory Corpus root directory.
99
+ * @param {string} filePath File to parse.
100
+ * @returns {Promise<object>}
101
+ */
102
+ async function parseCorpusFile(rootDirectory, filePath) {
103
+ const bytes = await readFile(filePath)
104
+ const arrayBuffer = bytes.buffer.slice(
105
+ bytes.byteOffset,
106
+ bytes.byteOffset + bytes.byteLength
107
+ )
108
+ const result = AltiumParser.tryParseArrayBufferToRendererModel(
109
+ basename(filePath),
110
+ arrayBuffer
111
+ )
112
+ const relativePath = relative(rootDirectory, filePath).split(sep).join('/')
113
+
114
+ if (!result.ok) {
115
+ return {
116
+ relativePath,
117
+ status: 'failed',
118
+ message: result.diagnostics[0]?.message || 'Parse failed.'
119
+ }
120
+ }
121
+
122
+ return {
123
+ relativePath,
124
+ status: 'parsed',
125
+ fileType: result.model.fileType,
126
+ kind: result.model.kind,
127
+ title: result.model.summary?.title || result.model.fileName,
128
+ diagnosticCount: result.diagnostics.length,
129
+ coverage: {
130
+ recordTypes: extractRecordTypes(result.model),
131
+ fieldCoverage: extractFieldCoverage(result.model)
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Builds a deterministic corpus parse report.
138
+ * @param {string} rootDirectory Corpus root directory.
139
+ * @param {boolean} includeCoverage Whether to include coverage counters.
140
+ * @returns {Promise<{ summary: object, files: object[] }>}
141
+ */
142
+ async function buildCorpusReport(rootDirectory, includeCoverage = false) {
143
+ const filePaths = await listInputFiles(rootDirectory)
144
+ const files = []
145
+
146
+ for (const filePath of filePaths) {
147
+ files.push(await parseCorpusFile(rootDirectory, filePath))
148
+ }
149
+
150
+ const report = {
151
+ summary: {
152
+ fileCount: files.length,
153
+ parsedCount: files.filter((file) => file.status === 'parsed')
154
+ .length,
155
+ failedCount: files.filter((file) => file.status === 'failed').length
156
+ },
157
+ files: files.map((file) => publicFileRow(file))
158
+ }
159
+
160
+ if (includeCoverage) {
161
+ report.coverage = buildCoverageReport(files)
162
+ }
163
+
164
+ return report
165
+ }
166
+
167
+ /**
168
+ * Removes private coverage helpers from one public file row.
169
+ * @param {object} file Corpus file row.
170
+ * @returns {object}
171
+ */
172
+ function publicFileRow(file) {
173
+ const { coverage, ...publicRow } = file
174
+ return publicRow
175
+ }
176
+
177
+ /**
178
+ * Extracts model record-type coverage rows.
179
+ * @param {object} model Parsed renderer model.
180
+ * @returns {object[]}
181
+ */
182
+ function extractRecordTypes(model) {
183
+ return [
184
+ ...(model?.schematic?.recordTypes || []),
185
+ ...(model?.schematicLibrary?.recordTypes || [])
186
+ ]
187
+ }
188
+
189
+ /**
190
+ * Extracts schematic field-coverage rows from one parsed model.
191
+ * @param {object} model Parsed renderer model.
192
+ * @returns {object}
193
+ */
194
+ function extractFieldCoverage(model) {
195
+ return model?.schematic?.qa?.fieldCoverage || { recordTypes: [] }
196
+ }
197
+
198
+ /**
199
+ * Builds aggregate parser coverage counters from parsed corpus rows.
200
+ * @param {object[]} files Internal corpus file rows.
201
+ * @returns {object}
202
+ */
203
+ function buildCoverageReport(files) {
204
+ const parsedFiles = files.filter((file) => file.status === 'parsed')
205
+ const recordTypes = aggregateRecordTypes(parsedFiles)
206
+ const unsupportedRecordTypes = recordTypes.filter(
207
+ (recordType) => !recordType.supported
208
+ )
209
+ const fieldGaps = aggregateFieldGaps(parsedFiles)
210
+ const topUnrecognizedFields = buildTopUnrecognizedFields(fieldGaps)
211
+
212
+ return {
213
+ summary: {
214
+ parsedFileCount: parsedFiles.length,
215
+ fileTypeCount: countByProperty(parsedFiles, 'fileType').length,
216
+ kindCount: countByProperty(parsedFiles, 'kind').length,
217
+ recordTypeCount: recordTypes.length,
218
+ unsupportedRecordTypeCount: unsupportedRecordTypes.length,
219
+ diagnosticCount: parsedFiles.reduce(
220
+ (total, file) => total + file.diagnosticCount,
221
+ 0
222
+ ),
223
+ fieldGapRecordTypeCount: fieldGaps.length,
224
+ unrecognizedFieldCount: fieldGaps.reduce(
225
+ (total, row) => total + row.unrecognizedFieldCount,
226
+ 0
227
+ ),
228
+ unrecognizedFieldOccurrenceCount: fieldGaps.reduce(
229
+ (total, row) => total + row.unrecognizedOccurrenceCount,
230
+ 0
231
+ )
232
+ },
233
+ fileTypes: countByProperty(parsedFiles, 'fileType'),
234
+ kinds: countByProperty(parsedFiles, 'kind'),
235
+ recordTypes,
236
+ unsupportedRecordTypes,
237
+ fieldGaps,
238
+ topUnrecognizedFields
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Prints a compact text report.
244
+ * @param {{ summary: object, files: object[] }} report Corpus report.
245
+ * @returns {void}
246
+ */
247
+ function printTextReport(report) {
248
+ console.log('Corpus smoke report')
249
+ console.log('Files: ' + report.summary.fileCount)
250
+ console.log('Parsed: ' + report.summary.parsedCount)
251
+ console.log('Failed: ' + report.summary.failedCount)
252
+ if (report.coverage) {
253
+ console.log('Record types: ' + report.coverage.summary.recordTypeCount)
254
+ console.log(
255
+ 'Unsupported record types: ' +
256
+ report.coverage.summary.unsupportedRecordTypeCount
257
+ )
258
+ console.log('Diagnostics: ' + report.coverage.summary.diagnosticCount)
259
+ console.log(
260
+ 'Field gap record types: ' +
261
+ report.coverage.summary.fieldGapRecordTypeCount
262
+ )
263
+ console.log(
264
+ 'Unrecognized fields: ' +
265
+ report.coverage.summary.unrecognizedFieldCount
266
+ )
267
+ console.log(
268
+ 'Unrecognized field occurrences: ' +
269
+ report.coverage.summary.unrecognizedFieldOccurrenceCount
270
+ )
271
+ printTopFieldGaps(report.coverage.topUnrecognizedFields)
272
+ }
273
+
274
+ for (const file of report.files) {
275
+ console.log(file.status.toUpperCase() + ' ' + file.relativePath)
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Prints the most frequent unrecognized source fields.
281
+ * @param {object[]} fields Top field-gap rows.
282
+ * @returns {void}
283
+ */
284
+ function printTopFieldGaps(fields) {
285
+ const rows = (fields || []).slice(0, TEXT_FIELD_GAP_LIMIT)
286
+ if (!rows.length) {
287
+ return
288
+ }
289
+
290
+ console.log('Top unrecognized fields:')
291
+ for (const row of rows) {
292
+ console.log(
293
+ ' RECORD ' +
294
+ row.recordType +
295
+ ' ' +
296
+ row.recordName +
297
+ ' ' +
298
+ row.fieldName +
299
+ ': ' +
300
+ row.count +
301
+ ' in ' +
302
+ row.fileCount +
303
+ ' file(s)'
304
+ )
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Counts parsed files by one public property.
310
+ * @param {object[]} files Parsed file rows.
311
+ * @param {string} property Property to count.
312
+ * @returns {object[]}
313
+ */
314
+ function countByProperty(files, property) {
315
+ const counts = new Map()
316
+
317
+ for (const file of files) {
318
+ const value = String(file[property] || '')
319
+ if (!value) {
320
+ continue
321
+ }
322
+
323
+ counts.set(value, (counts.get(value) || 0) + 1)
324
+ }
325
+
326
+ return [...counts.entries()]
327
+ .sort(([left], [right]) => left.localeCompare(right))
328
+ .map(([value, count]) => ({ [property]: value, count }))
329
+ }
330
+
331
+ /**
332
+ * Aggregates schematic record-type counts across parsed files.
333
+ * @param {object[]} files Parsed file rows.
334
+ * @returns {object[]}
335
+ */
336
+ function aggregateRecordTypes(files) {
337
+ const rowsByRecordType = new Map()
338
+
339
+ for (const file of files) {
340
+ const seenInFile = new Set()
341
+
342
+ for (const recordType of file.coverage?.recordTypes || []) {
343
+ const key = String(recordType.recordType)
344
+ if (!rowsByRecordType.has(key)) {
345
+ rowsByRecordType.set(key, {
346
+ recordType: recordType.recordType,
347
+ name: recordType.name,
348
+ family: recordType.family,
349
+ supported: recordType.supported,
350
+ count: 0,
351
+ fileCount: 0
352
+ })
353
+ }
354
+
355
+ const row = rowsByRecordType.get(key)
356
+ row.count += recordType.count || 0
357
+ if (!seenInFile.has(key)) {
358
+ row.fileCount += 1
359
+ seenInFile.add(key)
360
+ }
361
+ }
362
+ }
363
+
364
+ return [...rowsByRecordType.values()].sort(
365
+ (left, right) => left.recordType - right.recordType
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Aggregates field-level coverage gaps across parsed schematic corpus files.
371
+ * @param {object[]} files Parsed file rows.
372
+ * @returns {object[]}
373
+ */
374
+ function aggregateFieldGaps(files) {
375
+ const rowsByRecordType = new Map()
376
+
377
+ for (const file of files) {
378
+ for (const recordType of file.coverage?.fieldCoverage?.recordTypes ||
379
+ []) {
380
+ const row = ensureFieldGapRow(rowsByRecordType, recordType)
381
+ row.recordCount += recordType.recordCount || 0
382
+ row.files.add(file.relativePath)
383
+
384
+ for (const field of recordType.unrecognizedFields || []) {
385
+ const fieldRow = ensureFieldGapField(row, field.name)
386
+ fieldRow.count += field.count || 0
387
+ fieldRow.files.add(file.relativePath)
388
+ }
389
+ }
390
+ }
391
+
392
+ return [...rowsByRecordType.values()]
393
+ .map((row) => publicFieldGapRow(row))
394
+ .sort((left, right) => left.recordType - right.recordType)
395
+ }
396
+
397
+ /**
398
+ * Returns one mutable aggregate row for a record type.
399
+ * @param {Map<string, object>} rowsByRecordType Aggregate rows.
400
+ * @param {object} recordType Source coverage record-type row.
401
+ * @returns {object}
402
+ */
403
+ function ensureFieldGapRow(rowsByRecordType, recordType) {
404
+ const key = String(recordType.recordType)
405
+ if (!rowsByRecordType.has(key)) {
406
+ rowsByRecordType.set(key, {
407
+ recordType: recordType.recordType,
408
+ name: recordType.name,
409
+ family: recordType.family,
410
+ supported: recordType.supported,
411
+ recordCount: 0,
412
+ files: new Set(),
413
+ unrecognizedFields: new Map()
414
+ })
415
+ }
416
+
417
+ return rowsByRecordType.get(key)
418
+ }
419
+
420
+ /**
421
+ * Returns one mutable aggregate field row.
422
+ * @param {{ unrecognizedFields: Map<string, object> }} row Aggregate row.
423
+ * @param {string} name Field name.
424
+ * @returns {object}
425
+ */
426
+ function ensureFieldGapField(row, name) {
427
+ if (!row.unrecognizedFields.has(name)) {
428
+ row.unrecognizedFields.set(name, {
429
+ name,
430
+ count: 0,
431
+ files: new Set()
432
+ })
433
+ }
434
+
435
+ return row.unrecognizedFields.get(name)
436
+ }
437
+
438
+ /**
439
+ * Converts one mutable field-gap row to public JSON.
440
+ * @param {object} row Mutable aggregate row.
441
+ * @returns {object}
442
+ */
443
+ function publicFieldGapRow(row) {
444
+ const unrecognizedFields = [...row.unrecognizedFields.values()]
445
+ .map((field) => ({
446
+ name: field.name,
447
+ count: field.count,
448
+ fileCount: field.files.size
449
+ }))
450
+ .sort(
451
+ (left, right) =>
452
+ right.count - left.count ||
453
+ right.fileCount - left.fileCount ||
454
+ left.name.localeCompare(right.name)
455
+ )
456
+ const unrecognizedOccurrenceCount = unrecognizedFields.reduce(
457
+ (total, field) => total + field.count,
458
+ 0
459
+ )
460
+
461
+ return {
462
+ recordType: row.recordType,
463
+ name: row.name,
464
+ family: row.family,
465
+ supported: row.supported,
466
+ recordCount: row.recordCount,
467
+ fileCount: row.files.size,
468
+ unrecognizedFieldCount: unrecognizedFields.length,
469
+ unrecognizedOccurrenceCount,
470
+ unrecognizedFields
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Builds the highest-frequency unrecognized field rows.
476
+ * @param {object[]} fieldGaps Aggregate field-gap rows.
477
+ * @returns {object[]}
478
+ */
479
+ function buildTopUnrecognizedFields(fieldGaps) {
480
+ return (fieldGaps || [])
481
+ .flatMap((recordType) =>
482
+ recordType.unrecognizedFields.map((field) => ({
483
+ recordType: recordType.recordType,
484
+ recordName: recordType.name,
485
+ family: recordType.family,
486
+ supported: recordType.supported,
487
+ fieldName: field.name,
488
+ count: field.count,
489
+ fileCount: field.fileCount
490
+ }))
491
+ )
492
+ .sort(
493
+ (left, right) =>
494
+ right.count - left.count ||
495
+ right.fileCount - left.fileCount ||
496
+ left.recordType - right.recordType ||
497
+ left.fieldName.localeCompare(right.fieldName)
498
+ )
499
+ .slice(0, TOP_FIELD_GAP_LIMIT)
500
+ }
501
+
502
+ const args = process.argv.slice(2)
503
+
504
+ if (hasHelpFlag(args)) {
505
+ printCorpusHelp()
506
+ } else {
507
+ const rootDirectory = inputPathFromArgs(args)
508
+
509
+ if (!rootDirectory) {
510
+ printMissingDirectory()
511
+ } else {
512
+ const report = await buildCorpusReport(
513
+ rootDirectory,
514
+ wantsCoverage(args)
515
+ )
516
+
517
+ if (wantsJson(args)) {
518
+ printJson(report)
519
+ } else {
520
+ printTextReport(report)
521
+ }
522
+ }
523
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 André Fiedler
3
+ //
4
+ // SPDX-License-Identifier: GPL-3.0-or-later
5
+
6
+ import {
7
+ modelIdentity,
8
+ printCsv,
9
+ printJson,
10
+ runReadOnlyScript,
11
+ wantsJson
12
+ } from './cli-utils.mjs'
13
+
14
+ /**
15
+ * Normalizes BOM rows for report and CSV output.
16
+ * @param {object} model Parsed model.
17
+ * @returns {object[]}
18
+ */
19
+ function bomRows(model) {
20
+ return (model.bom || []).map((row) => ({
21
+ quantity: row.quantity || (row.designators || []).length || 0,
22
+ designators: (row.designators || []).join(' '),
23
+ pattern: row.pattern || '',
24
+ value: row.value || '',
25
+ source: row.source || ''
26
+ }))
27
+ }
28
+
29
+ await runReadOnlyScript({
30
+ scriptName: 'extract-bom',
31
+ summary: 'Extract grouped BOM rows from a parsed design.',
32
+ helpLines: ['Default output is CSV. Use --json for structured rows.'],
33
+ run(model, args) {
34
+ const rows = bomRows(model)
35
+ if (wantsJson(args)) {
36
+ printJson({
37
+ ...modelIdentity(model),
38
+ rows
39
+ })
40
+ return
41
+ }
42
+ printCsv(
43
+ ['quantity', 'designators', 'pattern', 'value', 'source'],
44
+ rows
45
+ )
46
+ }
47
+ })
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 André Fiedler
3
+ //
4
+ // SPDX-License-Identifier: GPL-3.0-or-later
5
+
6
+ import {
7
+ modelIdentity,
8
+ printCsv,
9
+ printJson,
10
+ runReadOnlyScript,
11
+ wantsJson
12
+ } from './cli-utils.mjs'
13
+
14
+ /**
15
+ * Returns normalized pick-and-place entries from a parsed model.
16
+ * @param {object} model Parsed model.
17
+ * @returns {object[]}
18
+ */
19
+ function pnpRows(model) {
20
+ const entries = model.pnp?.entries || model.pcb?.pickPlace?.entries || []
21
+ return entries.map((entry) => ({
22
+ designator: entry.designator || '',
23
+ pattern: entry.pattern || '',
24
+ layer: entry.layer || '',
25
+ x: entry.x ?? '',
26
+ y: entry.y ?? '',
27
+ rotation: entry.rotation ?? '',
28
+ positionSource: entry.positionSource || ''
29
+ }))
30
+ }
31
+
32
+ await runReadOnlyScript({
33
+ scriptName: 'generate-pnp',
34
+ summary: 'Generate a read-only pick-and-place CSV from a parsed PCB model.',
35
+ helpLines: ['Default output is CSV. Use --json for structured rows.'],
36
+ run(model, args) {
37
+ const rows = pnpRows(model)
38
+ if (wantsJson(args)) {
39
+ printJson({
40
+ ...modelIdentity(model),
41
+ units: model.pnp?.units || model.pcb?.pickPlace?.units || {},
42
+ rows
43
+ })
44
+ return
45
+ }
46
+ printCsv(
47
+ [
48
+ 'designator',
49
+ 'pattern',
50
+ 'layer',
51
+ 'x',
52
+ 'y',
53
+ 'rotation',
54
+ 'positionSource'
55
+ ],
56
+ rows
57
+ )
58
+ }
59
+ })
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2026 André Fiedler
3
+ //
4
+ // SPDX-License-Identifier: GPL-3.0-or-later
5
+
6
+ import {
7
+ ParserFieldCoverageReportBuilder,
8
+ RawDataPreservationReportBuilder
9
+ } from '../src/index.mjs'
10
+
11
+ import {
12
+ modelIdentity,
13
+ printJson,
14
+ runReadOnlyScript,
15
+ wantsJson
16
+ } from './cli-utils.mjs'
17
+
18
+ /**
19
+ * Builds a compact board inspection report.
20
+ * @param {object} model Parsed model.
21
+ * @returns {object}
22
+ */
23
+ function buildReport(model) {
24
+ return {
25
+ ...modelIdentity(model),
26
+ summary: model.summary || {},
27
+ diagnostics: model.diagnostics || [],
28
+ parserFieldCoverage: ParserFieldCoverageReportBuilder.build({
29
+ models: [model]
30
+ }),
31
+ rawDataPreservation: RawDataPreservationReportBuilder.build({
32
+ models: [model]
33
+ })
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Prints a text board inspection report.
39
+ * @param {object} report Board report.
40
+ * @returns {void}
41
+ */
42
+ function printTextReport(report) {
43
+ console.log(report.title)
44
+ console.log('Type: ' + report.fileType)
45
+ console.log('Kind: ' + report.kind)
46
+ console.log('Components: ' + (report.summary.componentCount || 0))
47
+ console.log('Nets: ' + (report.summary.netCount || 0))
48
+ console.log('Layers: ' + (report.summary.layerCount || 0))
49
+ console.log(
50
+ 'Raw records: ' +
51
+ report.rawDataPreservation.summary.rawRecordCount +
52
+ ' preserved'
53
+ )
54
+ console.log('Diagnostics: ' + report.diagnostics.length)
55
+ }
56
+
57
+ await runReadOnlyScript({
58
+ scriptName: 'inspect-board',
59
+ summary:
60
+ 'Inspect a parsed design or library and print a read-only summary.',
61
+ helpLines: ['Use --json for the full structured inspection report.'],
62
+ run(model, args) {
63
+ const report = buildReport(model)
64
+ if (wantsJson(args)) {
65
+ printJson(report)
66
+ return
67
+ }
68
+ printTextReport(report)
69
+ }
70
+ })