altium-toolkit 1.0.9 → 1.1.0

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 (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -14,6 +14,15 @@ export class PcbStatisticsBuilder {
14
14
  static build(pcb) {
15
15
  return {
16
16
  schema: 'altium-toolkit.pcb.statistics.a1',
17
+ units: {
18
+ coordinate: 'mil',
19
+ length: 'mil',
20
+ board: 'mil',
21
+ drill: 'mil',
22
+ thickness: 'mil',
23
+ copperWeight: 'oz',
24
+ angle: 'deg'
25
+ },
17
26
  board: PcbStatisticsBuilder.#boardStats(pcb?.boardOutline || {}),
18
27
  drills: PcbStatisticsBuilder.#drillStats(
19
28
  pcb?.pads || [],
@@ -12,6 +12,36 @@ export class PrintableTextDecoder {
12
12
  0x9c, 0x9e, 0x9f
13
13
  ])
14
14
 
15
+ static #WINDOWS_1252_CONTROL_CODE_POINTS = new Map([
16
+ [0x80, 0x20ac],
17
+ [0x82, 0x201a],
18
+ [0x83, 0x0192],
19
+ [0x84, 0x201e],
20
+ [0x85, 0x2026],
21
+ [0x86, 0x2020],
22
+ [0x87, 0x2021],
23
+ [0x88, 0x02c6],
24
+ [0x89, 0x2030],
25
+ [0x8a, 0x0160],
26
+ [0x8b, 0x2039],
27
+ [0x8c, 0x0152],
28
+ [0x8e, 0x017d],
29
+ [0x91, 0x2018],
30
+ [0x92, 0x2019],
31
+ [0x93, 0x201c],
32
+ [0x94, 0x201d],
33
+ [0x95, 0x2022],
34
+ [0x96, 0x2013],
35
+ [0x97, 0x2014],
36
+ [0x98, 0x02dc],
37
+ [0x99, 0x2122],
38
+ [0x9a, 0x0161],
39
+ [0x9b, 0x203a],
40
+ [0x9c, 0x0153],
41
+ [0x9e, 0x017e],
42
+ [0x9f, 0x0178]
43
+ ])
44
+
15
45
  /**
16
46
  * Returns printable ASCII-like runs from a binary buffer.
17
47
  * @param {ArrayBuffer} arrayBuffer
@@ -93,7 +123,7 @@ export class PrintableTextDecoder {
93
123
  preferredEncoding === 'cp1252'
94
124
  ) {
95
125
  return (
96
- PrintableTextDecoder.#tryDecode(bytes, 'windows-1252') ||
126
+ PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
97
127
  new TextDecoder('utf-8').decode(bytes)
98
128
  )
99
129
  }
@@ -104,10 +134,8 @@ export class PrintableTextDecoder {
104
134
  }
105
135
 
106
136
  if (PrintableTextDecoder.#hasWindows1252PreferredBytes(bytes)) {
107
- const windows1252 = PrintableTextDecoder.#tryDecode(
108
- bytes,
109
- 'windows-1252'
110
- )
137
+ const windows1252 =
138
+ PrintableTextDecoder.#tryDecodeWindows1252(bytes)
111
139
  if (windows1252 !== null) {
112
140
  return windows1252
113
141
  }
@@ -115,7 +143,7 @@ export class PrintableTextDecoder {
115
143
 
116
144
  return (
117
145
  PrintableTextDecoder.#tryDecode(bytes, 'gb18030') ||
118
- PrintableTextDecoder.#tryDecode(bytes, 'windows-1252') ||
146
+ PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
119
147
  new TextDecoder('utf-8').decode(bytes)
120
148
  )
121
149
  }
@@ -203,4 +231,40 @@ export class PrintableTextDecoder {
203
231
  return null
204
232
  }
205
233
  }
234
+
235
+ /**
236
+ * Tries a Windows-1252 decode and normalizes runtimes that expose C1 bytes
237
+ * as control characters instead of punctuation.
238
+ * @param {Uint8Array} bytes
239
+ * @returns {string | null}
240
+ */
241
+ static #tryDecodeWindows1252(bytes) {
242
+ const decoded = PrintableTextDecoder.#tryDecode(bytes, 'windows-1252')
243
+ if (decoded === null) return null
244
+
245
+ return PrintableTextDecoder.#normalizeWindows1252Controls(decoded)
246
+ }
247
+
248
+ /**
249
+ * Maps Windows-1252 control-range punctuation to stable Unicode code
250
+ * points across Node/ICU builds.
251
+ * @param {string} text
252
+ * @returns {string}
253
+ */
254
+ static #normalizeWindows1252Controls(text) {
255
+ let normalized = ''
256
+
257
+ for (const character of text) {
258
+ const codePoint = character.codePointAt(0)
259
+ const windows1252CodePoint =
260
+ PrintableTextDecoder.#WINDOWS_1252_CONTROL_CODE_POINTS.get(
261
+ codePoint
262
+ )
263
+ normalized += String.fromCodePoint(
264
+ windows1252CodePoint || codePoint
265
+ )
266
+ }
267
+
268
+ return normalized
269
+ }
206
270
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
6
  import { ProjectOutJobDigestBuilder } from './ProjectOutJobDigestBuilder.mjs'
7
+ import { ProjectDocumentGraphBuilder } from './ProjectDocumentGraphBuilder.mjs'
7
8
 
8
9
  /**
9
10
  * Parses Altium PrjPcb INI-style project files into a normalized project
@@ -58,6 +59,11 @@ export class PrjPcbModelParser {
58
59
  documents,
59
60
  outputGroups
60
61
  })
62
+ const documentGraph = ProjectDocumentGraphBuilder.build({
63
+ documents,
64
+ documentGroups,
65
+ outputGroups
66
+ })
61
67
  const summary = PrjPcbModelParser.#buildSummary(
62
68
  fileName,
63
69
  documents,
@@ -87,6 +93,7 @@ export class PrjPcbModelParser {
87
93
  configurations,
88
94
  outputGroups,
89
95
  outJobDigest,
96
+ documentGraph,
90
97
  classGeneration,
91
98
  sections: PrjPcbModelParser.#serializeSections(sections)
92
99
  },
@@ -229,6 +236,9 @@ export class PrjPcbModelParser {
229
236
  integratedLibraries: documents.filter(
230
237
  (document) => document.kind === 'integrated-library'
231
238
  ),
239
+ harnessFiles: documents.filter(
240
+ (document) => document.kind === 'harness'
241
+ ),
232
242
  outJobs: documents.filter(
233
243
  (document) => document.kind === 'output-job'
234
244
  ),
@@ -699,7 +709,18 @@ export class PrjPcbModelParser {
699
709
  PrjPcbModelParser.#stringField(fields, 'OutputType' + index) ||
700
710
  ''
701
711
  if (!type) continue
702
- rows.push({
712
+ const targetPath =
713
+ PrjPcbModelParser.#stringField(
714
+ fields,
715
+ 'OutputTargetPath' + index
716
+ ) ||
717
+ PrjPcbModelParser.#stringField(fields, 'OutputPath' + index) ||
718
+ ''
719
+ const configRows = PrjPcbModelParser.#extractOutputConfigRows(
720
+ fields,
721
+ index
722
+ )
723
+ const row = {
703
724
  index,
704
725
  type,
705
726
  name:
@@ -721,7 +742,50 @@ export class PrjPcbModelParser {
721
742
  fields,
722
743
  'OutputDefault' + index
723
744
  )
724
- })
745
+ }
746
+ if (targetPath) row.targetPath = targetPath
747
+ if (configRows.length) row.configRows = configRows
748
+ rows.push(row)
749
+ }
750
+
751
+ return rows
752
+ }
753
+
754
+ /**
755
+ * Extracts output configuration rows associated with one output index.
756
+ * @param {Record<string, string | string[]>} fields Output group fields.
757
+ * @param {number} outputIndex Output row index.
758
+ * @returns {{ key: string, record: string, fields: Record<string, string> }[]}
759
+ */
760
+ static #extractOutputConfigRows(fields, outputIndex) {
761
+ const rows = []
762
+ const patterns = [
763
+ new RegExp('^Configuration' + outputIndex + '_Item\\d+$', 'i'),
764
+ new RegExp(
765
+ '^OutputConfiguration(?:Parameter)?' +
766
+ outputIndex +
767
+ '(?:_Item\\d+)?$',
768
+ 'i'
769
+ )
770
+ ]
771
+
772
+ for (const key of Object.keys(fields || {}).sort((left, right) =>
773
+ left.localeCompare(right, undefined, { numeric: true })
774
+ )) {
775
+ if (!patterns.some((pattern) => pattern.test(key))) continue
776
+ const values = Array.isArray(fields[key])
777
+ ? fields[key]
778
+ : [fields[key]]
779
+ for (const value of values) {
780
+ const parsed = PrjPcbModelParser.#parsePipeFields(value)
781
+ const record = parsed.Record || ''
782
+ delete parsed.Record
783
+ rows.push({
784
+ key,
785
+ record,
786
+ fields: parsed
787
+ })
788
+ }
725
789
  }
726
790
 
727
791
  return rows
@@ -986,6 +1050,9 @@ export class PrjPcbModelParser {
986
1050
  return 'pcb-library'
987
1051
  case '.intlib':
988
1052
  return 'integrated-library'
1053
+ case '.harness':
1054
+ case '.harnessdoc':
1055
+ return 'harness'
989
1056
  case '.outjob':
990
1057
  return 'output-job'
991
1058
  default:
@@ -0,0 +1,386 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { ParserUtils } from './ParserUtils.mjs'
7
+
8
+ /**
9
+ * Parses Altium script-project files into a read-only project-script digest.
10
+ */
11
+ export class PrjScrModelParser {
12
+ /**
13
+ * Parses one script-project ArrayBuffer.
14
+ * @param {string} fileName Source file name.
15
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
16
+ * @param {{ existingPaths?: string[] }} options Parser options.
17
+ * @returns {object}
18
+ */
19
+ static parse(fileName, arrayBuffer, options = {}) {
20
+ return PrjScrModelParser.parseText(
21
+ fileName,
22
+ PrjScrModelParser.#decodeText(arrayBuffer),
23
+ options
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Parses one script-project text payload.
29
+ * @param {string} fileName Source file name.
30
+ * @param {string} text Source text.
31
+ * @param {{ existingPaths?: string[] }} options Parser options.
32
+ * @returns {object}
33
+ */
34
+ static parseText(fileName, text, options = {}) {
35
+ const sections = PrjScrModelParser.#parseIniSections(text)
36
+ const design = PrjScrModelParser.#sectionFields(
37
+ PrjScrModelParser.#findSection(sections, 'Design')
38
+ )
39
+ const existingPaths = new Set(
40
+ (options.existingPaths || []).map((path) =>
41
+ PrjScrModelParser.#normalizePath(path)
42
+ )
43
+ )
44
+ const documents = PrjScrModelParser.#extractDocuments(
45
+ sections,
46
+ existingPaths,
47
+ options
48
+ )
49
+ const scripts = documents
50
+ .filter((document) => document.kind === 'script')
51
+ .map((document) => PrjScrModelParser.#publicScript(document))
52
+ const diagnostics = PrjScrModelParser.#diagnostics(documents)
53
+
54
+ return NormalizedModelSchema.attach({
55
+ kind: 'project-script',
56
+ fileType: 'PrjScr',
57
+ fileName,
58
+ summary: {
59
+ title: ParserUtils.stripExtension(fileName),
60
+ documentCount: documents.length,
61
+ scriptCount: scripts.length,
62
+ missingPathCount: scripts.filter(
63
+ (script) => script.exists === false
64
+ ).length,
65
+ diagnosticCount: diagnostics.length
66
+ },
67
+ diagnostics,
68
+ projectScript: {
69
+ name: ParserUtils.stripExtension(fileName),
70
+ design,
71
+ documents,
72
+ scripts,
73
+ sections: PrjScrModelParser.#serializeSections(sections)
74
+ },
75
+ bom: []
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Decodes text with common project-file encodings.
81
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
82
+ * @returns {string}
83
+ */
84
+ static #decodeText(arrayBuffer) {
85
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
86
+ for (const encoding of ['utf-8', 'windows-1252']) {
87
+ try {
88
+ return new TextDecoder(encoding, { fatal: true })
89
+ .decode(bytes)
90
+ .replace(/^\uFEFF/u, '')
91
+ } catch {
92
+ // Try the next legacy-compatible project encoding.
93
+ }
94
+ }
95
+
96
+ return new TextDecoder('windows-1252')
97
+ .decode(bytes)
98
+ .replace(/^\uFEFF/u, '')
99
+ }
100
+
101
+ /**
102
+ * Parses INI sections while preserving option order.
103
+ * @param {string} text Source text.
104
+ * @returns {{ name: string, index: number, entries: object[] }[]}
105
+ */
106
+ static #parseIniSections(text) {
107
+ const sections = []
108
+ let current = null
109
+ const lines = String(text || '')
110
+ .replace(/\r\n?/gu, '\n')
111
+ .split('\n')
112
+
113
+ for (const [lineIndex, rawLine] of lines.entries()) {
114
+ const trimmed = rawLine.trim()
115
+ if (
116
+ !trimmed ||
117
+ trimmed.startsWith(';') ||
118
+ trimmed.startsWith('#')
119
+ ) {
120
+ continue
121
+ }
122
+
123
+ const sectionMatch = /^\[([^\]]+)\]$/u.exec(trimmed)
124
+ if (sectionMatch) {
125
+ current = {
126
+ name: sectionMatch[1].trim(),
127
+ index: sections.length,
128
+ entries: []
129
+ }
130
+ sections.push(current)
131
+ continue
132
+ }
133
+
134
+ if (!current) continue
135
+ const separatorIndex = rawLine.indexOf('=')
136
+ if (separatorIndex < 0) continue
137
+
138
+ current.entries.push({
139
+ key: rawLine.slice(0, separatorIndex).trim(),
140
+ value: rawLine.slice(separatorIndex + 1).trim(),
141
+ line: lineIndex + 1
142
+ })
143
+ }
144
+
145
+ return sections
146
+ }
147
+
148
+ /**
149
+ * Extracts numbered document entries.
150
+ * @param {object[]} sections Parsed sections.
151
+ * @param {Set<string>} existingPaths Normalized existing paths.
152
+ * @param {{ existingPaths?: string[] }} options Parser options.
153
+ * @returns {object[]}
154
+ */
155
+ static #extractDocuments(sections, existingPaths, options) {
156
+ return PrjScrModelParser.#numberedSections(sections, 'Document').map(
157
+ ({ section, number }) => {
158
+ const fields = PrjScrModelParser.#sectionFields(section)
159
+ const path = String(fields.DocumentPath || '')
160
+ const normalizedPath = PrjScrModelParser.#normalizePath(path)
161
+ const base = {
162
+ index: number,
163
+ section: section.name,
164
+ path,
165
+ normalizedPath,
166
+ fileName: PrjScrModelParser.#basename(path),
167
+ extension: PrjScrModelParser.#extension(path),
168
+ kind:
169
+ PrjScrModelParser.#extension(path).toLowerCase() ===
170
+ '.pas'
171
+ ? 'script'
172
+ : 'unsupported',
173
+ ...(options.existingPaths
174
+ ? { exists: existingPaths.has(normalizedPath) }
175
+ : {}),
176
+ annotationEnabled: PrjScrModelParser.#optionalBoolean(
177
+ fields.AnnotationEnabled
178
+ ),
179
+ classGeneration: PrjScrModelParser.#classGeneration(fields),
180
+ updatePolicies: PrjScrModelParser.#updatePolicies(fields),
181
+ options: fields
182
+ }
183
+
184
+ return PrjScrModelParser.#stripEmpty(base)
185
+ }
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Builds class-generation option metadata.
191
+ * @param {Record<string, string>} fields Document fields.
192
+ * @returns {object | undefined}
193
+ */
194
+ static #classGeneration(fields) {
195
+ return PrjScrModelParser.#stripEmpty({
196
+ classGenCcAutoEnabled: PrjScrModelParser.#optionalBoolean(
197
+ fields.ClassGenCCAutoEnabled
198
+ ),
199
+ classGenCcAutoRoomEnabled: PrjScrModelParser.#optionalBoolean(
200
+ fields.ClassGenCCAutoRoomEnabled
201
+ ),
202
+ classGenNcAutoScope: fields.ClassGenNCAutoScope
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Builds update-policy metadata.
208
+ * @param {Record<string, string>} fields Document fields.
209
+ * @returns {object | undefined}
210
+ */
211
+ static #updatePolicies(fields) {
212
+ return PrjScrModelParser.#stripEmpty({
213
+ doLibraryUpdate: PrjScrModelParser.#optionalBoolean(
214
+ fields.DoLibraryUpdate
215
+ ),
216
+ doDatabaseUpdate: PrjScrModelParser.#optionalBoolean(
217
+ fields.DoDatabaseUpdate
218
+ )
219
+ })
220
+ }
221
+
222
+ /**
223
+ * Builds structured parser diagnostics.
224
+ * @param {object[]} documents Parsed documents.
225
+ * @returns {object[]}
226
+ */
227
+ static #diagnostics(documents) {
228
+ return [
229
+ ...documents
230
+ .filter(
231
+ (document) =>
232
+ document.kind === 'script' && document.exists === false
233
+ )
234
+ .map((document) => ({
235
+ code: 'project-script.missing-document-path',
236
+ severity: 'warning',
237
+ message: 'Script project document path was not found.',
238
+ path: document.path,
239
+ normalizedPath: document.normalizedPath
240
+ })),
241
+ ...documents
242
+ .filter((document) => document.kind === 'unsupported')
243
+ .map((document) => ({
244
+ code: 'project-script.unsupported-document-kind',
245
+ severity: 'warning',
246
+ message:
247
+ 'Script project document is not a supported script file.',
248
+ path: document.path,
249
+ normalizedPath: document.normalizedPath
250
+ }))
251
+ ]
252
+ }
253
+
254
+ /**
255
+ * Builds the public script convenience row.
256
+ * @param {object} document Parsed document row.
257
+ * @returns {object}
258
+ */
259
+ static #publicScript(document) {
260
+ const { kind: _kind, ...script } = document
261
+ return script
262
+ }
263
+
264
+ /**
265
+ * Finds numbered sections with a common prefix.
266
+ * @param {object[]} sections Parsed sections.
267
+ * @param {string} prefix Section prefix.
268
+ * @returns {{ section: object, number: number }[]}
269
+ */
270
+ static #numberedSections(sections, prefix) {
271
+ const pattern = new RegExp('^' + prefix + '(\\d+)$', 'iu')
272
+
273
+ return (sections || [])
274
+ .map((section) => ({
275
+ section,
276
+ match: pattern.exec(section.name)
277
+ }))
278
+ .filter(({ match }) => match)
279
+ .map(({ section, match }) => ({
280
+ section,
281
+ number: Number.parseInt(match[1], 10)
282
+ }))
283
+ .sort((left, right) => left.number - right.number)
284
+ }
285
+
286
+ /**
287
+ * Finds one section by case-insensitive name.
288
+ * @param {object[]} sections Parsed sections.
289
+ * @param {string} name Section name.
290
+ * @returns {object | undefined}
291
+ */
292
+ static #findSection(sections, name) {
293
+ const normalized = String(name || '').toLowerCase()
294
+ return (sections || []).find(
295
+ (section) => section.name.toLowerCase() === normalized
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Converts one section to a key-value map.
301
+ * @param {{ entries?: { key: string, value: string }[] } | undefined} section Parsed section.
302
+ * @returns {Record<string, string>}
303
+ */
304
+ static #sectionFields(section) {
305
+ return Object.fromEntries(
306
+ (section?.entries || []).map((entry) => [entry.key, entry.value])
307
+ )
308
+ }
309
+
310
+ /**
311
+ * Serializes preserved sections.
312
+ * @param {object[]} sections Parsed sections.
313
+ * @returns {object[]}
314
+ */
315
+ static #serializeSections(sections) {
316
+ return (sections || []).map((section) => ({
317
+ name: section.name,
318
+ entries: (section.entries || []).map((entry) => ({
319
+ key: entry.key,
320
+ value: entry.value
321
+ }))
322
+ }))
323
+ }
324
+
325
+ /**
326
+ * Parses optional boolean option values.
327
+ * @param {string | undefined} value Raw value.
328
+ * @returns {boolean | undefined}
329
+ */
330
+ static #optionalBoolean(value) {
331
+ const normalized = String(value ?? '')
332
+ .trim()
333
+ .toLowerCase()
334
+ if (!normalized) return undefined
335
+ return ['1', 'true', 't', 'yes'].includes(normalized)
336
+ }
337
+
338
+ /**
339
+ * Normalizes project-relative paths.
340
+ * @param {string} path Source path.
341
+ * @returns {string}
342
+ */
343
+ static #normalizePath(path) {
344
+ return String(path || '').replace(/\\/gu, '/')
345
+ }
346
+
347
+ /**
348
+ * Returns a basename from a project path.
349
+ * @param {string} path Source path.
350
+ * @returns {string}
351
+ */
352
+ static #basename(path) {
353
+ return PrjScrModelParser.#normalizePath(path).split('/').pop() || ''
354
+ }
355
+
356
+ /**
357
+ * Returns a lower-level extension token.
358
+ * @param {string} path Source path.
359
+ * @returns {string}
360
+ */
361
+ static #extension(path) {
362
+ const name = PrjScrModelParser.#basename(path)
363
+ const dotIndex = name.lastIndexOf('.')
364
+ return dotIndex >= 0 ? name.slice(dotIndex) : ''
365
+ }
366
+
367
+ /**
368
+ * Removes undefined and empty object fields.
369
+ * @param {Record<string, unknown>} value Source object.
370
+ * @returns {object}
371
+ */
372
+ static #stripEmpty(value) {
373
+ return Object.fromEntries(
374
+ Object.entries(value || {}).filter(([, entryValue]) => {
375
+ if (
376
+ entryValue &&
377
+ typeof entryValue === 'object' &&
378
+ !Array.isArray(entryValue)
379
+ ) {
380
+ return Object.keys(entryValue).length > 0
381
+ }
382
+ return entryValue !== undefined && entryValue !== ''
383
+ })
384
+ )
385
+ }
386
+ }