altium-toolkit 1.1.22 → 1.1.24
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 +134 -23
- package/docs/model-format.md +174 -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 +511 -1
- 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 +2 -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/src/core/BinaryReader.mjs +213 -2
- package/src/core/altium/AltiumParser.mjs +352 -14
- package/src/core/altium/AltiumUnits.mjs +205 -0
- package/src/core/altium/AsciiRecordParser.mjs +9 -0
- 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/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/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/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/SchematicOwnershipGraphParser.mjs +102 -3
- package/src/core/altium/SchematicPinParser.mjs +12 -45
- package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
- 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 +64 -25
- 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/UnsupportedFeatureReportBuilder.mjs +380 -0
- package/src/parser.mjs +35 -1
- package/src/renderers.mjs +1 -0
- package/src/ui/SchematicShapeRenderer.mjs +49 -6
- package/src/ui/SchematicSvgRenderer.mjs +37 -8
- package/src/ui/SchematicTypography.mjs +4 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds read-only inventories for pipe/backtick parameter record strings.
|
|
7
|
+
*/
|
|
8
|
+
export class ParameterRecordInventoryBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.parameter-record-inventory.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a parameter record inventory report.
|
|
13
|
+
* @param {{ records?: (object | string)[] } | (object | string)[]} [input]
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(input = {}) {
|
|
17
|
+
const sourceRecords = Array.isArray(input) ? input : input.records || []
|
|
18
|
+
const records = sourceRecords.map((record, index) =>
|
|
19
|
+
ParameterRecordInventoryBuilder.scanRecord(
|
|
20
|
+
ParameterRecordInventoryBuilder.#raw(record),
|
|
21
|
+
{
|
|
22
|
+
sourceStream:
|
|
23
|
+
typeof record === 'object' ? record.sourceStream : '',
|
|
24
|
+
recordIndex:
|
|
25
|
+
typeof record === 'object'
|
|
26
|
+
? (record.recordIndex ?? index)
|
|
27
|
+
: index
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
schema: ParameterRecordInventoryBuilder.SCHEMA,
|
|
34
|
+
summary: ParameterRecordInventoryBuilder.#summary(records),
|
|
35
|
+
records
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scans one raw record string.
|
|
41
|
+
* @param {string} raw Raw parameter record text.
|
|
42
|
+
* @param {{ sourceStream?: string, recordIndex?: number }} [metadata]
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
static scanRecord(raw, metadata = {}) {
|
|
46
|
+
const fields = []
|
|
47
|
+
const keyCounts = new Map()
|
|
48
|
+
let emptyEntryCount = 0
|
|
49
|
+
|
|
50
|
+
for (const segment of ParameterRecordInventoryBuilder.#segments(raw)) {
|
|
51
|
+
const text = segment.text.trim()
|
|
52
|
+
if (!text) {
|
|
53
|
+
emptyEntryCount += 1
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const separatorIndex = text.indexOf('=')
|
|
58
|
+
if (separatorIndex <= 0) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rawKey = text.slice(0, separatorIndex).trim()
|
|
63
|
+
const isUtf8 = rawKey.startsWith('%UTF8%')
|
|
64
|
+
const key = rawKey.replace(/^%UTF8%/u, '')
|
|
65
|
+
if (!key) continue
|
|
66
|
+
|
|
67
|
+
const value = text.slice(separatorIndex + 1).trim()
|
|
68
|
+
const occurrence = (keyCounts.get(key) || 0) + 1
|
|
69
|
+
keyCounts.set(key, occurrence)
|
|
70
|
+
fields.push({
|
|
71
|
+
rawKey,
|
|
72
|
+
key,
|
|
73
|
+
value,
|
|
74
|
+
delimiter: segment.delimiter,
|
|
75
|
+
level: segment.level,
|
|
76
|
+
occurrence,
|
|
77
|
+
isUtf8,
|
|
78
|
+
typedValue: ParameterRecordInventoryBuilder.#typedValue(value)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const duplicateFields =
|
|
83
|
+
ParameterRecordInventoryBuilder.#duplicateFields(fields)
|
|
84
|
+
|
|
85
|
+
return ParameterRecordInventoryBuilder.#stripUndefined({
|
|
86
|
+
sourceStream: metadata.sourceStream || undefined,
|
|
87
|
+
recordIndex: metadata.recordIndex,
|
|
88
|
+
fieldCount: fields.length,
|
|
89
|
+
emptyEntryCount,
|
|
90
|
+
duplicateFields,
|
|
91
|
+
fields
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extracts raw record text from supported inputs.
|
|
97
|
+
* @param {object | string} record Source record.
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
static #raw(record) {
|
|
101
|
+
if (typeof record === 'string') return record
|
|
102
|
+
return String(record?.raw || record?.text || '')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Splits a parameter string into delimiter-aware segments.
|
|
107
|
+
* @param {string} raw Raw parameter text.
|
|
108
|
+
* @returns {{ delimiter: string, level: number, text: string }[]}
|
|
109
|
+
*/
|
|
110
|
+
static #segments(raw) {
|
|
111
|
+
const value = String(raw || '')
|
|
112
|
+
const segments = []
|
|
113
|
+
let delimiter = ''
|
|
114
|
+
let text = ''
|
|
115
|
+
|
|
116
|
+
for (const character of value) {
|
|
117
|
+
if (character === '|' || character === '`') {
|
|
118
|
+
if (delimiter || text) {
|
|
119
|
+
segments.push({
|
|
120
|
+
delimiter,
|
|
121
|
+
level: delimiter === '`' ? 1 : 0,
|
|
122
|
+
text
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
delimiter = character
|
|
126
|
+
text = ''
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
text += character
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (delimiter || text) {
|
|
134
|
+
segments.push({
|
|
135
|
+
delimiter,
|
|
136
|
+
level: delimiter === '`' ? 1 : 0,
|
|
137
|
+
text
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return segments
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Infers a typed value for common parameter scalar encodings.
|
|
146
|
+
* @param {string} value Field value.
|
|
147
|
+
* @returns {{ kind: 'boolean' | 'integer' | 'number', value: boolean | number } | null}
|
|
148
|
+
*/
|
|
149
|
+
static #typedValue(value) {
|
|
150
|
+
const text = String(value || '').trim()
|
|
151
|
+
if (/^(T|TRUE)$/iu.test(text)) {
|
|
152
|
+
return { kind: 'boolean', value: true }
|
|
153
|
+
}
|
|
154
|
+
if (/^(F|FALSE)$/iu.test(text)) {
|
|
155
|
+
return { kind: 'boolean', value: false }
|
|
156
|
+
}
|
|
157
|
+
if (/^[+-]?\d+$/u.test(text)) {
|
|
158
|
+
return { kind: 'integer', value: Number(text) }
|
|
159
|
+
}
|
|
160
|
+
if (/^[+-]?(?:\d+\.\d*|\.\d+|\d+E[+-]?\d+)$/iu.test(text)) {
|
|
161
|
+
const parsed = Number(text)
|
|
162
|
+
return Number.isFinite(parsed)
|
|
163
|
+
? { kind: 'number', value: parsed }
|
|
164
|
+
: null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Builds duplicate field rows.
|
|
172
|
+
* @param {object[]} fields Scanned fields.
|
|
173
|
+
* @returns {object[]}
|
|
174
|
+
*/
|
|
175
|
+
static #duplicateFields(fields) {
|
|
176
|
+
const byKey = new Map()
|
|
177
|
+
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
byKey.set(field.key, [...(byKey.get(field.key) || []), field])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return [...byKey.entries()]
|
|
183
|
+
.filter(([, rows]) => rows.length > 1)
|
|
184
|
+
.map(([key, rows]) => ({
|
|
185
|
+
key,
|
|
186
|
+
count: rows.length,
|
|
187
|
+
firstValue: rows[0].value,
|
|
188
|
+
lastValue: rows.at(-1).value
|
|
189
|
+
}))
|
|
190
|
+
.sort((left, right) => left.key.localeCompare(right.key))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Summarizes scanned records.
|
|
195
|
+
* @param {object[]} records Scanned records.
|
|
196
|
+
* @returns {object}
|
|
197
|
+
*/
|
|
198
|
+
static #summary(records) {
|
|
199
|
+
const duplicateFieldCount = records.reduce(
|
|
200
|
+
(total, record) => total + record.duplicateFields.length,
|
|
201
|
+
0
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
recordCount: records.length,
|
|
206
|
+
fieldCount: ParameterRecordInventoryBuilder.#sum(
|
|
207
|
+
records,
|
|
208
|
+
'fieldCount'
|
|
209
|
+
),
|
|
210
|
+
duplicateFieldCount,
|
|
211
|
+
duplicateOccurrenceCount: records.reduce(
|
|
212
|
+
(total, record) =>
|
|
213
|
+
total +
|
|
214
|
+
record.duplicateFields.reduce(
|
|
215
|
+
(count, field) => count + field.count - 1,
|
|
216
|
+
0
|
|
217
|
+
),
|
|
218
|
+
0
|
|
219
|
+
),
|
|
220
|
+
utf8FieldCount: ParameterRecordInventoryBuilder.#fieldCount(
|
|
221
|
+
records,
|
|
222
|
+
(field) => field.isUtf8
|
|
223
|
+
),
|
|
224
|
+
nestedFieldCount: ParameterRecordInventoryBuilder.#fieldCount(
|
|
225
|
+
records,
|
|
226
|
+
(field) => field.level > 0
|
|
227
|
+
),
|
|
228
|
+
emptyEntryCount: ParameterRecordInventoryBuilder.#sum(
|
|
229
|
+
records,
|
|
230
|
+
'emptyEntryCount'
|
|
231
|
+
),
|
|
232
|
+
typedFieldCount: ParameterRecordInventoryBuilder.#fieldCount(
|
|
233
|
+
records,
|
|
234
|
+
(field) => field.typedValue
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sums one numeric record field.
|
|
241
|
+
* @param {object[]} records Scanned records.
|
|
242
|
+
* @param {string} key Numeric key.
|
|
243
|
+
* @returns {number}
|
|
244
|
+
*/
|
|
245
|
+
static #sum(records, key) {
|
|
246
|
+
return records.reduce((total, record) => total + Number(record[key]), 0)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Counts fields matching a predicate.
|
|
251
|
+
* @param {object[]} records Scanned records.
|
|
252
|
+
* @param {(field: object) => boolean} predicate Match predicate.
|
|
253
|
+
* @returns {number}
|
|
254
|
+
*/
|
|
255
|
+
static #fieldCount(records, predicate) {
|
|
256
|
+
return records.reduce(
|
|
257
|
+
(total, record) =>
|
|
258
|
+
total +
|
|
259
|
+
record.fields.filter((field) => predicate(field)).length,
|
|
260
|
+
0
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Removes undefined properties from one row.
|
|
266
|
+
* @param {object} row Source row.
|
|
267
|
+
* @returns {object}
|
|
268
|
+
*/
|
|
269
|
+
static #stripUndefined(row) {
|
|
270
|
+
return Object.fromEntries(
|
|
271
|
+
Object.entries(row).filter(([, value]) => value !== undefined)
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -28,6 +28,9 @@ export class ParserCompatibilityFuzzer {
|
|
|
28
28
|
caseCount: cases.length,
|
|
29
29
|
failureCount: cases.filter((entry) => entry.status === 'fail')
|
|
30
30
|
.length,
|
|
31
|
+
handledErrorCount: cases.filter(
|
|
32
|
+
(entry) => entry.status === 'handled-error'
|
|
33
|
+
).length,
|
|
31
34
|
diagnosticCount: cases.reduce(
|
|
32
35
|
(total, entry) =>
|
|
33
36
|
total + Number(entry.diagnosticCount || 0),
|
|
@@ -40,7 +43,7 @@ export class ParserCompatibilityFuzzer {
|
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* Lists deterministic compatibility cases.
|
|
43
|
-
* @returns {{ key: string, parse: () => object }[]}
|
|
46
|
+
* @returns {{ key: string, parse: () => object, expectedError?: boolean }[]}
|
|
44
47
|
*/
|
|
45
48
|
static #cases() {
|
|
46
49
|
return [
|
|
@@ -107,18 +110,93 @@ export class ParserCompatibilityFuzzer {
|
|
|
107
110
|
'fuzz.PCBDwf',
|
|
108
111
|
new Uint8Array([0, 1, 2, 3]).buffer
|
|
109
112
|
)
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: 'empty-schdoc',
|
|
116
|
+
parse: () =>
|
|
117
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
118
|
+
'empty.SchDoc',
|
|
119
|
+
new ArrayBuffer(0)
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'random-pcbdoc',
|
|
124
|
+
parse: () =>
|
|
125
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
126
|
+
'random.PcbDoc',
|
|
127
|
+
ParserCompatibilityFuzzer.#byteBuffer([
|
|
128
|
+
0, 17, 65, 127, 128, 255, 42, 3
|
|
129
|
+
])
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: 'random-pcblib',
|
|
134
|
+
parse: () =>
|
|
135
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
136
|
+
'random.PcbLib',
|
|
137
|
+
ParserCompatibilityFuzzer.#byteBuffer([
|
|
138
|
+
9, 8, 7, 6, 5, 4, 3, 2
|
|
139
|
+
])
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: 'random-intlib',
|
|
144
|
+
expectedError: true,
|
|
145
|
+
parse: () =>
|
|
146
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
147
|
+
'random.IntLib',
|
|
148
|
+
ParserCompatibilityFuzzer.#byteBuffer([
|
|
149
|
+
1, 35, 69, 103, 137, 171, 205, 239
|
|
150
|
+
])
|
|
151
|
+
)
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: 'wrong-reader-schdoc-as-intlib',
|
|
155
|
+
expectedError: true,
|
|
156
|
+
parse: () =>
|
|
157
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
158
|
+
'wrong-reader.IntLib',
|
|
159
|
+
ParserCompatibilityFuzzer.#encodeText(
|
|
160
|
+
'|HEADER=Schematic Document|' +
|
|
161
|
+
'|RECORD=31|CustomX=120|CustomY=80|'
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
key: 'unknown-extension-fallback',
|
|
167
|
+
parse: () =>
|
|
168
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
169
|
+
'unknown.bin',
|
|
170
|
+
ParserCompatibilityFuzzer.#encodeText(
|
|
171
|
+
'|RECORD=1|NET=FALLBACK|'
|
|
172
|
+
)
|
|
173
|
+
)
|
|
110
174
|
}
|
|
111
175
|
]
|
|
112
176
|
}
|
|
113
177
|
|
|
114
178
|
/**
|
|
115
179
|
* Executes one compatibility case.
|
|
116
|
-
* @param {{ key: string, parse: () => object }} entry Case descriptor.
|
|
180
|
+
* @param {{ key: string, parse: () => object, expectedError?: boolean }} entry Case descriptor.
|
|
117
181
|
* @returns {object}
|
|
118
182
|
*/
|
|
119
183
|
static #runCase(entry) {
|
|
120
184
|
try {
|
|
121
185
|
const model = entry.parse()
|
|
186
|
+
if (entry.expectedError) {
|
|
187
|
+
return {
|
|
188
|
+
key: entry.key,
|
|
189
|
+
status: 'fail',
|
|
190
|
+
expectedError: true,
|
|
191
|
+
diagnosticCount: 1,
|
|
192
|
+
error: {
|
|
193
|
+
name: 'ExpectedError',
|
|
194
|
+
message:
|
|
195
|
+
'Parser case was expected to fail in a controlled way.'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
122
200
|
return {
|
|
123
201
|
key: entry.key,
|
|
124
202
|
status: 'pass',
|
|
@@ -130,6 +208,19 @@ export class ParserCompatibilityFuzzer {
|
|
|
130
208
|
)
|
|
131
209
|
}
|
|
132
210
|
} catch (error) {
|
|
211
|
+
if (entry.expectedError) {
|
|
212
|
+
return {
|
|
213
|
+
key: entry.key,
|
|
214
|
+
status: 'handled-error',
|
|
215
|
+
expectedError: true,
|
|
216
|
+
diagnosticCount: 1,
|
|
217
|
+
error: {
|
|
218
|
+
name: error?.name || 'Error',
|
|
219
|
+
message: error?.message || String(error)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
133
224
|
return {
|
|
134
225
|
key: entry.key,
|
|
135
226
|
status: 'fail',
|
|
@@ -168,6 +259,19 @@ export class ParserCompatibilityFuzzer {
|
|
|
168
259
|
)
|
|
169
260
|
}
|
|
170
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Builds an ArrayBuffer from stable synthetic bytes.
|
|
264
|
+
* @param {number[]} values Byte values.
|
|
265
|
+
* @returns {ArrayBuffer}
|
|
266
|
+
*/
|
|
267
|
+
static #byteBuffer(values) {
|
|
268
|
+
const bytes = new Uint8Array(values)
|
|
269
|
+
return bytes.buffer.slice(
|
|
270
|
+
bytes.byteOffset,
|
|
271
|
+
bytes.byteOffset + bytes.length
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
171
275
|
/**
|
|
172
276
|
* Builds a schematic payload with one Windows-1252 punctuation byte.
|
|
173
277
|
* @returns {ArrayBuffer}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
const DIAGNOSTIC_METADATA_KEYS = Object.freeze([
|
|
6
|
+
'source',
|
|
7
|
+
'sourceStorage',
|
|
8
|
+
'sourceStream',
|
|
9
|
+
'fileName',
|
|
10
|
+
'recordIndex',
|
|
11
|
+
'recordType',
|
|
12
|
+
'fieldName',
|
|
13
|
+
'contextKey',
|
|
14
|
+
'errorKind'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes parser diagnostics into one reusable, machine-readable envelope.
|
|
19
|
+
*/
|
|
20
|
+
export class ParserDiagnosticNormalizer {
|
|
21
|
+
static SCHEMA = 'altium-toolkit.parser-diagnostics.a1'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalizes one diagnostic entry.
|
|
25
|
+
* @param {object | string | Error} diagnostic Diagnostic entry.
|
|
26
|
+
* @param {object} [defaults] Default metadata applied when absent.
|
|
27
|
+
* @returns {object}
|
|
28
|
+
*/
|
|
29
|
+
static normalize(diagnostic, defaults = {}) {
|
|
30
|
+
const source = ParserDiagnosticNormalizer.#diagnosticObject(diagnostic)
|
|
31
|
+
const message = ParserDiagnosticNormalizer.#message(source, defaults)
|
|
32
|
+
const normalized = {
|
|
33
|
+
code: ParserDiagnosticNormalizer.#code(source, defaults, message),
|
|
34
|
+
severity: ParserDiagnosticNormalizer.#severity(source, defaults),
|
|
35
|
+
message
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const key of DIAGNOSTIC_METADATA_KEYS) {
|
|
39
|
+
const value =
|
|
40
|
+
source[key] === undefined ? defaults[key] : source[key]
|
|
41
|
+
if (value === undefined || value === null || value === '') {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
normalized[key] =
|
|
46
|
+
key === 'recordIndex'
|
|
47
|
+
? ParserDiagnosticNormalizer.#recordIndex(value)
|
|
48
|
+
: value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const [key, value] of Object.entries(source)) {
|
|
52
|
+
if (
|
|
53
|
+
key === 'code' ||
|
|
54
|
+
key === 'severity' ||
|
|
55
|
+
key === 'message' ||
|
|
56
|
+
DIAGNOSTIC_METADATA_KEYS.includes(key) ||
|
|
57
|
+
value === undefined
|
|
58
|
+
) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
normalized[key] = value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return normalized
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes a diagnostic list.
|
|
70
|
+
* @param {(object | string | Error)[]} diagnostics Diagnostic entries.
|
|
71
|
+
* @param {object} [defaults] Default metadata applied when absent.
|
|
72
|
+
* @returns {object[]}
|
|
73
|
+
*/
|
|
74
|
+
static normalizeMany(diagnostics, defaults = {}) {
|
|
75
|
+
return (diagnostics || []).map((diagnostic) =>
|
|
76
|
+
ParserDiagnosticNormalizer.normalize(diagnostic, defaults)
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a structured diagnostic report.
|
|
82
|
+
* @param {{ diagnostics?: (object | string | Error)[], defaults?: object } | (object | string | Error)[]} [input]
|
|
83
|
+
* @returns {object}
|
|
84
|
+
*/
|
|
85
|
+
static buildReport(input = {}) {
|
|
86
|
+
const diagnostics = Array.isArray(input)
|
|
87
|
+
? input
|
|
88
|
+
: input.diagnostics || []
|
|
89
|
+
const defaults = Array.isArray(input) ? {} : input.defaults || {}
|
|
90
|
+
const normalized = ParserDiagnosticNormalizer.normalizeMany(
|
|
91
|
+
diagnostics,
|
|
92
|
+
defaults
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
schema: ParserDiagnosticNormalizer.SCHEMA,
|
|
97
|
+
summary: ParserDiagnosticNormalizer.summarize(normalized),
|
|
98
|
+
diagnostics: normalized
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Summarizes normalized diagnostics by severity.
|
|
104
|
+
* @param {object[]} diagnostics Normalized diagnostics.
|
|
105
|
+
* @returns {{ diagnosticCount: number, infoCount: number, warningCount: number, errorCount: number }}
|
|
106
|
+
*/
|
|
107
|
+
static summarize(diagnostics) {
|
|
108
|
+
const rows = diagnostics || []
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
diagnosticCount: rows.length,
|
|
112
|
+
infoCount: rows.filter(
|
|
113
|
+
(diagnostic) => diagnostic.severity === 'info'
|
|
114
|
+
).length,
|
|
115
|
+
warningCount: rows.filter(
|
|
116
|
+
(diagnostic) => diagnostic.severity === 'warning'
|
|
117
|
+
).length,
|
|
118
|
+
errorCount: rows.filter(
|
|
119
|
+
(diagnostic) => diagnostic.severity === 'error'
|
|
120
|
+
).length
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Converts supported diagnostic inputs into plain objects.
|
|
126
|
+
* @param {object | string | Error} diagnostic Diagnostic entry.
|
|
127
|
+
* @returns {object}
|
|
128
|
+
*/
|
|
129
|
+
static #diagnosticObject(diagnostic) {
|
|
130
|
+
if (diagnostic instanceof Error) {
|
|
131
|
+
const source = {
|
|
132
|
+
severity: 'error',
|
|
133
|
+
message: diagnostic.message
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [key, value] of Object.entries(diagnostic)) {
|
|
137
|
+
if (key === 'name' || key === 'stack' || key === 'cause') {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
source[key] = value
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return source
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof diagnostic === 'string') {
|
|
148
|
+
return { message: diagnostic }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return diagnostic && typeof diagnostic === 'object' ? diagnostic : {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolves one diagnostic message.
|
|
156
|
+
* @param {object} diagnostic Diagnostic object.
|
|
157
|
+
* @param {object} defaults Default metadata.
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
static #message(diagnostic, defaults) {
|
|
161
|
+
return String(
|
|
162
|
+
diagnostic.message || defaults.message || 'Parser diagnostic'
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolves one stable diagnostic code.
|
|
168
|
+
* @param {object} diagnostic Diagnostic object.
|
|
169
|
+
* @param {object} defaults Default metadata.
|
|
170
|
+
* @param {string} message Normalized message.
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
static #code(diagnostic, defaults, message) {
|
|
174
|
+
const explicitCode = String(
|
|
175
|
+
diagnostic.code || defaults.code || ''
|
|
176
|
+
).trim()
|
|
177
|
+
if (explicitCode) return explicitCode
|
|
178
|
+
|
|
179
|
+
const slug = message
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9]+/gu, '.')
|
|
182
|
+
.replace(/^\.+|\.+$/gu, '')
|
|
183
|
+
.slice(0, 80)
|
|
184
|
+
|
|
185
|
+
return 'parser.' + (slug || 'diagnostic')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolves one normalized severity.
|
|
190
|
+
* @param {object} diagnostic Diagnostic object.
|
|
191
|
+
* @param {object} defaults Default metadata.
|
|
192
|
+
* @returns {'info' | 'warning' | 'error'}
|
|
193
|
+
*/
|
|
194
|
+
static #severity(diagnostic, defaults) {
|
|
195
|
+
const value = String(diagnostic.severity || defaults.severity || 'info')
|
|
196
|
+
.trim()
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
|
|
199
|
+
if (value === 'warn' || value === 'warning') return 'warning'
|
|
200
|
+
if (value === 'error' || value === 'fatal') return 'error'
|
|
201
|
+
return 'info'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Normalizes a record index while preserving nonnumeric identifiers.
|
|
206
|
+
* @param {unknown} value Record index value.
|
|
207
|
+
* @returns {number | unknown}
|
|
208
|
+
*/
|
|
209
|
+
static #recordIndex(value) {
|
|
210
|
+
const numeric = Number(value)
|
|
211
|
+
return Number.isInteger(numeric) ? numeric : value
|
|
212
|
+
}
|
|
213
|
+
}
|