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.
- package/README.md +34 -5
- package/docs/api.md +171 -23
- package/docs/model-format.md +192 -20
- package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
- package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
- package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
- package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
- package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
- package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
- package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
- package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
- package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
- package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
- package/docs/testing.md +7 -0
- package/examples/README.md +21 -0
- package/examples/cli-utils.mjs +148 -0
- package/examples/corpus-smoke.mjs +523 -0
- package/examples/extract-bom.mjs +47 -0
- package/examples/generate-pnp.mjs +59 -0
- package/examples/inspect-board.mjs +70 -0
- package/examples/inspect-schematic.mjs +406 -0
- package/examples/library-catalog.mjs +115 -0
- package/examples/net-report.mjs +61 -0
- package/examples/validate-library.mjs +59 -0
- package/package.json +1 -1
- package/spec/library-scope.md +5 -0
- package/src/core/BinaryReader.mjs +213 -2
- package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
- package/src/core/altium/AltiumParser.mjs +357 -16
- package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
- package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
- package/src/core/altium/AltiumUnits.mjs +205 -0
- package/src/core/altium/AsciiRecordParser.mjs +52 -11
- package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
- package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
- package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
- package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
- package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
- package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
- package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
- package/src/core/altium/NormalizedModelSchema.mjs +3 -31
- package/src/core/altium/ParameterCollection.mjs +431 -0
- package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
- package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
- package/src/core/altium/ParserErrors.mjs +90 -0
- package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
- package/src/core/altium/ParserUtils.mjs +24 -0
- package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
- package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
- package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
- package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
- package/src/core/altium/PcbLayerGroups.mjs +308 -0
- package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
- package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
- package/src/core/altium/PcbLibModelParser.mjs +20 -4
- package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
- package/src/core/altium/PcbModelParser.mjs +223 -4
- package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
- package/src/core/altium/PcbStreamExtractor.mjs +130 -6
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
- package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
- package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
- package/src/core/altium/SchLibModelParser.mjs +840 -0
- package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
- package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
- package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
- package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
- package/src/core/altium/SchematicHarnessParser.mjs +9 -3
- package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
- package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
- package/src/core/altium/SchematicPinParser.mjs +99 -65
- package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
- package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
- package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
- package/src/core/altium/SchematicSheetParser.mjs +8 -2
- package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
- package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
- package/src/core/altium/SchematicTextParser.mjs +28 -12
- package/src/core/altium/SchematicTextRunParser.mjs +81 -0
- package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
- package/src/core/altium/SourceBundleExporter.mjs +156 -0
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
- package/src/core/altium/SourceComponentClient.mjs +239 -0
- package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
- package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
- package/src/parser.mjs +43 -1
- package/src/renderers.mjs +1 -0
- package/src/styles/altium-renderers.css +6 -6
- package/src/ui/PcbArcUtils.mjs +19 -2
- package/src/ui/PcbScene3dBuilder.mjs +202 -20
- package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
- package/src/ui/SchematicColorResolver.mjs +185 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
- package/src/ui/SchematicLineColorResolver.mjs +88 -0
- package/src/ui/SchematicNoteRenderer.mjs +5 -1
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
- package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
- package/src/ui/SchematicShapeRenderer.mjs +86 -17
- package/src/ui/SchematicSvgRenderer.mjs +980 -58
- 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
|
+
})
|