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.
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,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
+ }