@vessel-dsp/core 0.6.3 → 0.6.5

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 (182) hide show
  1. package/README.md +11 -3
  2. package/dist/editor/commands.d.ts +13 -13
  3. package/dist/editor/commands.d.ts.map +1 -1
  4. package/dist/editor/commands.js +44 -29
  5. package/dist/editor/commands.js.map +1 -1
  6. package/dist/editor/factory.d.ts +1 -1
  7. package/dist/editor/factory.d.ts.map +1 -1
  8. package/dist/editor/factory.js +51 -51
  9. package/dist/editor/factory.js.map +1 -1
  10. package/dist/editor/history.d.ts +7 -7
  11. package/dist/editor/history.d.ts.map +1 -1
  12. package/dist/editor/history.js +20 -12
  13. package/dist/editor/history.js.map +1 -1
  14. package/dist/editor/index.d.ts +8 -8
  15. package/dist/editor/index.d.ts.map +1 -1
  16. package/dist/editor/index.js +4 -4
  17. package/dist/editor/index.js.map +1 -1
  18. package/dist/editor/layout.d.ts +1 -1
  19. package/dist/editor/layout.d.ts.map +1 -1
  20. package/dist/editor/layout.js +11 -6
  21. package/dist/editor/layout.js.map +1 -1
  22. package/dist/formats/circuit-json/serializer.d.ts +15 -15
  23. package/dist/formats/circuit-json/serializer.d.ts.map +1 -1
  24. package/dist/formats/circuit-json/serializer.js +486 -394
  25. package/dist/formats/circuit-json/serializer.js.map +1 -1
  26. package/dist/formats/document.d.ts +6 -6
  27. package/dist/formats/document.d.ts.map +1 -1
  28. package/dist/formats/document.js +112 -92
  29. package/dist/formats/document.js.map +1 -1
  30. package/dist/formats/interchange/parser.d.ts +1 -1
  31. package/dist/formats/interchange/parser.d.ts.map +1 -1
  32. package/dist/formats/interchange/parser.js +483 -286
  33. package/dist/formats/interchange/parser.js.map +1 -1
  34. package/dist/formats/interchange/serializer.d.ts +1 -1
  35. package/dist/formats/interchange/serializer.d.ts.map +1 -1
  36. package/dist/formats/interchange/serializer.js +59 -28
  37. package/dist/formats/interchange/serializer.js.map +1 -1
  38. package/dist/formats/ltspice/catalog.d.ts +1 -1
  39. package/dist/formats/ltspice/catalog.d.ts.map +1 -1
  40. package/dist/formats/ltspice/catalog.js +150 -48
  41. package/dist/formats/ltspice/catalog.js.map +1 -1
  42. package/dist/formats/ltspice/encoding.js +12 -40
  43. package/dist/formats/ltspice/encoding.js.map +1 -1
  44. package/dist/formats/ltspice/parser.d.ts +1 -1
  45. package/dist/formats/ltspice/parser.d.ts.map +1 -1
  46. package/dist/formats/ltspice/parser.js +122 -75
  47. package/dist/formats/ltspice/parser.js.map +1 -1
  48. package/dist/formats/ltspice/serializer.d.ts +1 -1
  49. package/dist/formats/ltspice/serializer.d.ts.map +1 -1
  50. package/dist/formats/ltspice/serializer.js +69 -47
  51. package/dist/formats/ltspice/serializer.js.map +1 -1
  52. package/dist/formats/schx/catalog.d.ts +1 -1
  53. package/dist/formats/schx/catalog.d.ts.map +1 -1
  54. package/dist/formats/schx/catalog.js +499 -254
  55. package/dist/formats/schx/catalog.js.map +1 -1
  56. package/dist/formats/schx/parser.d.ts +1 -1
  57. package/dist/formats/schx/parser.d.ts.map +1 -1
  58. package/dist/formats/schx/parser.js +40 -38
  59. package/dist/formats/schx/parser.js.map +1 -1
  60. package/dist/formats/schx/runtime-descriptors.d.ts +1 -1
  61. package/dist/formats/schx/runtime-descriptors.d.ts.map +1 -1
  62. package/dist/formats/schx/runtime-descriptors.js +239 -201
  63. package/dist/formats/schx/runtime-descriptors.js.map +1 -1
  64. package/dist/formats/schx/serializer.d.ts +2 -2
  65. package/dist/formats/schx/serializer.d.ts.map +1 -1
  66. package/dist/formats/schx/serializer.js +106 -106
  67. package/dist/formats/schx/serializer.js.map +1 -1
  68. package/dist/formats/schx/transforms.d.ts +1 -1
  69. package/dist/formats/schx/transforms.d.ts.map +1 -1
  70. package/dist/formats/schx/transforms.js +16 -8
  71. package/dist/formats/schx/transforms.js.map +1 -1
  72. package/dist/formats/spice/parser.d.ts +1 -1
  73. package/dist/formats/spice/parser.d.ts.map +1 -1
  74. package/dist/formats/spice/parser.js +105 -56
  75. package/dist/formats/spice/parser.js.map +1 -1
  76. package/dist/formats/spice/serializer.d.ts +1 -1
  77. package/dist/formats/spice/serializer.js +14 -12
  78. package/dist/formats/spice/serializer.js.map +1 -1
  79. package/dist/index.d.ts +47 -46
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +32 -31
  82. package/dist/index.js.map +1 -1
  83. package/dist/model/connectivity.d.ts +1 -1
  84. package/dist/model/connectivity.d.ts.map +1 -1
  85. package/dist/model/connectivity.js +22 -7
  86. package/dist/model/connectivity.js.map +1 -1
  87. package/dist/model/netlist.d.ts +3 -3
  88. package/dist/model/netlist.d.ts.map +1 -1
  89. package/dist/model/netlist.js +117 -100
  90. package/dist/model/netlist.js.map +1 -1
  91. package/dist/model/properties.d.ts +1 -1
  92. package/dist/model/properties.d.ts.map +1 -1
  93. package/dist/model/properties.js +16 -16
  94. package/dist/model/properties.js.map +1 -1
  95. package/dist/model/quantity.d.ts +1 -1
  96. package/dist/model/quantity.d.ts.map +1 -1
  97. package/dist/model/quantity.js +35 -35
  98. package/dist/model/quantity.js.map +1 -1
  99. package/dist/model/types.d.ts +72 -37
  100. package/dist/model/types.d.ts.map +1 -1
  101. package/dist/model/types.js +1 -1
  102. package/dist/model/types.js.map +1 -1
  103. package/dist/model/validation.d.ts +5 -5
  104. package/dist/model/validation.d.ts.map +1 -1
  105. package/dist/model/validation.js +763 -315
  106. package/dist/model/validation.js.map +1 -1
  107. package/dist/model/wires.d.ts +1 -1
  108. package/dist/model/wires.d.ts.map +1 -1
  109. package/dist/model/wires.js +4 -1
  110. package/dist/model/wires.js.map +1 -1
  111. package/dist/panel/extract.d.ts +2 -2
  112. package/dist/panel/extract.d.ts.map +1 -1
  113. package/dist/panel/extract.js +376 -216
  114. package/dist/panel/extract.js.map +1 -1
  115. package/dist/panel/index.d.ts +7 -5
  116. package/dist/panel/index.d.ts.map +1 -1
  117. package/dist/panel/index.js +5 -4
  118. package/dist/panel/index.js.map +1 -1
  119. package/dist/panel/knobs.d.ts +4 -4
  120. package/dist/panel/knobs.d.ts.map +1 -1
  121. package/dist/panel/knobs.js +1 -1
  122. package/dist/panel/knobs.js.map +1 -1
  123. package/dist/panel/placement.d.ts +27 -0
  124. package/dist/panel/placement.d.ts.map +1 -0
  125. package/dist/panel/placement.js +91 -0
  126. package/dist/panel/placement.js.map +1 -0
  127. package/dist/panel/protocol.d.ts +1 -1
  128. package/dist/panel/protocol.d.ts.map +1 -1
  129. package/dist/panel/protocol.js +32 -23
  130. package/dist/panel/protocol.js.map +1 -1
  131. package/dist/panel/types.d.ts +26 -18
  132. package/dist/panel/types.d.ts.map +1 -1
  133. package/dist/panel/types.js.map +1 -1
  134. package/dist/preview/bounds.d.ts +1 -1
  135. package/dist/preview/bounds.d.ts.map +1 -1
  136. package/dist/preview/bounds.js +3 -3
  137. package/dist/preview/bounds.js.map +1 -1
  138. package/dist/preview/box-layout.d.ts +2 -2
  139. package/dist/preview/box-layout.js.map +1 -1
  140. package/dist/preview/colors.d.ts +1 -1
  141. package/dist/preview/colors.js +35 -35
  142. package/dist/preview/colors.js.map +1 -1
  143. package/dist/preview/hanging.d.ts +1 -1
  144. package/dist/preview/hanging.d.ts.map +1 -1
  145. package/dist/preview/hanging.js +4 -1
  146. package/dist/preview/hanging.js.map +1 -1
  147. package/dist/preview/junctions.d.ts +1 -1
  148. package/dist/preview/junctions.d.ts.map +1 -1
  149. package/dist/preview/junctions.js.map +1 -1
  150. package/dist/preview/label-layout.d.ts.map +1 -1
  151. package/dist/preview/label-layout.js +4 -4
  152. package/dist/preview/label-layout.js.map +1 -1
  153. package/dist/preview/ports.d.ts +1 -1
  154. package/dist/preview/ports.d.ts.map +1 -1
  155. package/dist/preview/ports.js +2 -1
  156. package/dist/preview/ports.js.map +1 -1
  157. package/dist/preview/renderable-wires.d.ts +1 -1
  158. package/dist/preview/renderable-wires.d.ts.map +1 -1
  159. package/dist/preview/renderable-wires.js +3 -1
  160. package/dist/preview/renderable-wires.js.map +1 -1
  161. package/dist/preview/routing.d.ts +1 -1
  162. package/dist/preview/routing.js +1 -1
  163. package/dist/preview/routing.js.map +1 -1
  164. package/dist/preview/snap.d.ts +1 -1
  165. package/dist/preview/snap.d.ts.map +1 -1
  166. package/dist/preview/snap.js +11 -3
  167. package/dist/preview/snap.js.map +1 -1
  168. package/dist/preview/symbols/svg-content.d.ts.map +1 -1
  169. package/dist/preview/symbols/svg-content.js +200 -50
  170. package/dist/preview/symbols/svg-content.js.map +1 -1
  171. package/dist/preview/symbols.d.ts +2 -2
  172. package/dist/preview/symbols.d.ts.map +1 -1
  173. package/dist/preview/symbols.js +100 -97
  174. package/dist/preview/symbols.js.map +1 -1
  175. package/dist/preview/wire-chains.d.ts +1 -1
  176. package/dist/preview/wire-chains.d.ts.map +1 -1
  177. package/dist/preview/wire-chains.js.map +1 -1
  178. package/dist/profiles.d.ts +600 -0
  179. package/dist/profiles.d.ts.map +1 -0
  180. package/dist/profiles.js +118 -0
  181. package/dist/profiles.js.map +1 -0
  182. package/package.json +54 -54
@@ -1,93 +1,279 @@
1
- import { propertyQuantityValue, propertyStringValue } from './properties.js';
2
- import { extractPanel } from '../panel/extract.js';
3
- const MODEL_ALIASES = ['Model', 'Type', 'partNumber', 'PartNumber'];
1
+ import { propertyQuantityValue, propertyStringValue } from "./properties.js";
2
+ import { extractPanel } from "../panel/extract.js";
3
+ const MODEL_ALIASES = ["Model", "Type", "partNumber", "PartNumber"];
4
4
  // Short source-type names (last dotted segment) that represent an "ideal" component variant —
5
5
  // no model name is required because the component is a mathematical abstraction.
6
- const IDEAL_SOURCE_TYPES = new Set(['IdealOpAmp']);
6
+ const IDEAL_SOURCE_TYPES = new Set(["IdealOpAmp"]);
7
7
  // Per-kind property names that, if present, satisfy the "needs a model" requirement.
8
8
  // LiveSPICE stores tube Koren parameters and opamp small-signal parameters inline; when those
9
9
  // are present, the parameters ARE the model definition and no separate model name is needed.
10
10
  const INLINE_MODEL_PARAMETERS = {
11
- opamp: ['Rin', 'Rout', 'Aol', 'GBP', 'SupplyVoltage'],
12
- triode: ['Mu', 'K', 'Kp', 'Kvb', 'Ex', 'Kg'],
13
- pentode: ['Mu', 'K', 'Kp', 'Kvb', 'Ex', 'Kg', 'Kg1', 'Kg2'],
11
+ opamp: ["Rin", "Rout", "Aol", "GBP", "SupplyVoltage"],
12
+ triode: ["Mu", "K", "Kp", "Kvb", "Ex", "Kg"],
13
+ pentode: ["Mu", "K", "Kp", "Kvb", "Ex", "Kg", "Kg1", "Kg2"],
14
14
  };
15
15
  const RUNTIME_DESCRIPTOR_CONTROL_PROPERTIES = [
16
- 'TimeControl',
17
- 'FeedbackControl',
18
- 'MixControl',
19
- 'LevelControl',
20
- 'ToneControl',
21
- 'ModRateControl',
22
- 'ModDepthControl',
23
- 'ModeControl',
24
- 'TempoTapControl',
25
- 'TapTempoControl',
26
- 'TempoControl',
27
- 'DirectOutputJack',
28
- 'DirectOutJack',
29
- 'DirectOutputControl',
30
- 'DirectOutControl',
16
+ "TimeControl",
17
+ "FeedbackControl",
18
+ "MixControl",
19
+ "LevelControl",
20
+ "ToneControl",
21
+ "ModRateControl",
22
+ "ModDepthControl",
23
+ "ModeControl",
24
+ "TempoTapControl",
25
+ "TapTempoControl",
26
+ "TempoControl",
27
+ "DirectOutputJack",
28
+ "DirectOutJack",
29
+ "DirectOutputControl",
30
+ "DirectOutControl",
31
31
  ];
32
32
  const KIND_RULES = {
33
- resistor: [{
34
- kind: 'quantity', name: 'R', required: true, unit: 'Ω',
35
- min: 1e-9, max: 1e9, aliases: ['Resistance', 'resistance', 'r'],
36
- }],
37
- 'variable-resistor': [{
38
- kind: 'quantity', name: 'R', required: true, unit: 'Ω',
39
- min: 1e-9, max: 1e9, aliases: ['Resistance', 'resistance', 'r'],
40
- }],
33
+ resistor: [
34
+ {
35
+ kind: "quantity",
36
+ name: "R",
37
+ required: true,
38
+ unit: "Ω",
39
+ min: 1e-9,
40
+ max: 1e9,
41
+ aliases: ["Resistance", "resistance", "r"],
42
+ },
43
+ ],
44
+ "variable-resistor": [
45
+ {
46
+ kind: "quantity",
47
+ name: "R",
48
+ required: true,
49
+ unit: "Ω",
50
+ min: 1e-9,
51
+ max: 1e9,
52
+ aliases: ["Resistance", "resistance", "r"],
53
+ },
54
+ ],
41
55
  potentiometer: [
42
56
  {
43
- kind: 'quantity', name: 'R', required: true, unit: 'Ω',
44
- min: 1e-9, max: 1e9, aliases: ['Resistance', 'totalResistance'],
57
+ kind: "quantity",
58
+ name: "R",
59
+ required: true,
60
+ unit: "Ω",
61
+ min: 1e-9,
62
+ max: 1e9,
63
+ aliases: ["Resistance", "totalResistance"],
64
+ },
65
+ { kind: "string", name: "taper", required: false, aliases: ["Taper"] },
66
+ ],
67
+ capacitor: [
68
+ {
69
+ kind: "quantity",
70
+ name: "C",
71
+ required: true,
72
+ unit: "F",
73
+ min: 1e-15,
74
+ max: 1,
75
+ aliases: ["Capacitance", "capacitance", "c"],
76
+ },
77
+ ],
78
+ inductor: [
79
+ {
80
+ kind: "quantity",
81
+ name: "L",
82
+ required: true,
83
+ unit: "H",
84
+ min: 1e-12,
85
+ max: 100,
86
+ aliases: ["Inductance", "inductance", "l"],
87
+ },
88
+ ],
89
+ "voltage-source": [
90
+ {
91
+ kind: "quantity",
92
+ name: "V",
93
+ required: true,
94
+ unit: "V",
95
+ aliases: ["Voltage", "voltage", "v"],
96
+ },
97
+ ],
98
+ "current-source": [
99
+ {
100
+ kind: "quantity",
101
+ name: "I",
102
+ required: true,
103
+ unit: "A",
104
+ aliases: ["Current", "current", "i"],
105
+ },
106
+ ],
107
+ battery: [
108
+ {
109
+ kind: "quantity",
110
+ name: "V",
111
+ required: true,
112
+ unit: "V",
113
+ aliases: ["Voltage", "voltage", "v"],
114
+ },
115
+ ],
116
+ rail: [
117
+ {
118
+ kind: "quantity",
119
+ name: "V",
120
+ required: true,
121
+ unit: "V",
122
+ aliases: ["Voltage", "voltage", "v"],
123
+ },
124
+ ],
125
+ diode: [
126
+ {
127
+ kind: "string",
128
+ name: "model",
129
+ required: true,
130
+ aliases: [...MODEL_ALIASES],
131
+ },
132
+ ],
133
+ led: [
134
+ {
135
+ kind: "string",
136
+ name: "model",
137
+ required: true,
138
+ aliases: [...MODEL_ALIASES],
139
+ },
140
+ ],
141
+ bjt: [
142
+ {
143
+ kind: "string",
144
+ name: "model",
145
+ required: true,
146
+ aliases: [...MODEL_ALIASES],
147
+ },
148
+ ],
149
+ jfet: [
150
+ {
151
+ kind: "string",
152
+ name: "model",
153
+ required: true,
154
+ aliases: [...MODEL_ALIASES],
155
+ },
156
+ ],
157
+ mosfet: [
158
+ {
159
+ kind: "string",
160
+ name: "model",
161
+ required: true,
162
+ aliases: [...MODEL_ALIASES],
163
+ },
164
+ ],
165
+ opamp: [
166
+ {
167
+ kind: "string",
168
+ name: "model",
169
+ required: true,
170
+ aliases: [...MODEL_ALIASES],
171
+ },
172
+ ],
173
+ triode: [
174
+ {
175
+ kind: "string",
176
+ name: "model",
177
+ required: true,
178
+ aliases: [...MODEL_ALIASES],
179
+ },
180
+ ],
181
+ pentode: [
182
+ {
183
+ kind: "string",
184
+ name: "model",
185
+ required: true,
186
+ aliases: [...MODEL_ALIASES],
187
+ },
188
+ ],
189
+ "tube-diode": [
190
+ {
191
+ kind: "string",
192
+ name: "model",
193
+ required: true,
194
+ aliases: [...MODEL_ALIASES],
195
+ },
196
+ ],
197
+ optocoupler: [
198
+ {
199
+ kind: "string",
200
+ name: "model",
201
+ required: true,
202
+ aliases: [...MODEL_ALIASES],
203
+ },
204
+ ],
205
+ transformer: [
206
+ {
207
+ kind: "string",
208
+ name: "model",
209
+ required: false,
210
+ aliases: [...MODEL_ALIASES],
211
+ },
212
+ ],
213
+ ota: [
214
+ {
215
+ kind: "string",
216
+ name: "model",
217
+ required: true,
218
+ aliases: [...MODEL_ALIASES],
219
+ },
220
+ ],
221
+ bbd: [
222
+ {
223
+ kind: "string",
224
+ name: "model",
225
+ required: true,
226
+ aliases: [...MODEL_ALIASES],
227
+ },
228
+ ],
229
+ "delay-ic": [
230
+ {
231
+ kind: "string",
232
+ name: "model",
233
+ required: true,
234
+ aliases: [...MODEL_ALIASES],
235
+ },
236
+ ],
237
+ "power-amp": [
238
+ {
239
+ kind: "string",
240
+ name: "model",
241
+ required: true,
242
+ aliases: [...MODEL_ALIASES],
243
+ },
244
+ ],
245
+ regulator: [
246
+ {
247
+ kind: "string",
248
+ name: "model",
249
+ required: true,
250
+ aliases: [...MODEL_ALIASES],
251
+ },
252
+ ],
253
+ "analog-switch": [
254
+ {
255
+ kind: "string",
256
+ name: "model",
257
+ required: true,
258
+ aliases: [...MODEL_ALIASES],
259
+ },
260
+ ],
261
+ flipflop: [
262
+ {
263
+ kind: "string",
264
+ name: "model",
265
+ required: true,
266
+ aliases: [...MODEL_ALIASES],
267
+ },
268
+ ],
269
+ ic: [
270
+ {
271
+ kind: "string",
272
+ name: "model",
273
+ required: true,
274
+ aliases: [...MODEL_ALIASES],
45
275
  },
46
- { kind: 'string', name: 'taper', required: false, aliases: ['Taper'] },
47
276
  ],
48
- capacitor: [{
49
- kind: 'quantity', name: 'C', required: true, unit: 'F',
50
- min: 1e-15, max: 1, aliases: ['Capacitance', 'capacitance', 'c'],
51
- }],
52
- inductor: [{
53
- kind: 'quantity', name: 'L', required: true, unit: 'H',
54
- min: 1e-12, max: 100, aliases: ['Inductance', 'inductance', 'l'],
55
- }],
56
- 'voltage-source': [{
57
- kind: 'quantity', name: 'V', required: true, unit: 'V',
58
- aliases: ['Voltage', 'voltage', 'v'],
59
- }],
60
- 'current-source': [{
61
- kind: 'quantity', name: 'I', required: true, unit: 'A',
62
- aliases: ['Current', 'current', 'i'],
63
- }],
64
- battery: [{
65
- kind: 'quantity', name: 'V', required: true, unit: 'V',
66
- aliases: ['Voltage', 'voltage', 'v'],
67
- }],
68
- rail: [{
69
- kind: 'quantity', name: 'V', required: true, unit: 'V',
70
- aliases: ['Voltage', 'voltage', 'v'],
71
- }],
72
- diode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
73
- led: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
74
- bjt: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
75
- jfet: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
76
- mosfet: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
77
- opamp: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
78
- triode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
79
- pentode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
80
- 'tube-diode': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
81
- optocoupler: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
82
- transformer: [{ kind: 'string', name: 'model', required: false, aliases: [...MODEL_ALIASES] }],
83
- ota: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
84
- bbd: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
85
- 'delay-ic': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
86
- 'power-amp': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
87
- regulator: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
88
- 'analog-switch': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
89
- flipflop: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
90
- ic: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
91
277
  };
92
278
  export function getRulesForKind(kind) {
93
279
  return KIND_RULES[kind] ?? [];
@@ -102,8 +288,8 @@ export function validateComponent(component, rules = getRulesForKind(component.k
102
288
  }
103
289
  continue;
104
290
  }
105
- if (rule.kind === 'string') {
106
- if (typeof value !== 'string' || value.trim().length === 0) {
291
+ if (rule.kind === "string") {
292
+ if (typeof value !== "string" || value.trim().length === 0) {
107
293
  if (rule.required && !isRequirementWaived(component, rule)) {
108
294
  issues.push(missingPropertyIssue(component, rule));
109
295
  }
@@ -112,22 +298,25 @@ export function validateComponent(component, rules = getRulesForKind(component.k
112
298
  }
113
299
  const quantity = coerceQuantity(value);
114
300
  if (quantity === null) {
115
- if (typeof value === 'string' && isRawQuantityExpression(value)) {
301
+ if (typeof value === "string" && isRawQuantityExpression(value)) {
116
302
  continue;
117
303
  }
118
304
  issues.push({
119
- code: 'value-unparseable',
120
- severity: 'error',
305
+ code: "value-unparseable",
306
+ severity: "error",
121
307
  message: `${component.id}: property "${rule.name}" could not be parsed as a quantity`,
122
308
  componentId: component.id,
123
309
  property: rule.name,
124
310
  });
125
311
  continue;
126
312
  }
127
- if (rule.unit !== undefined && rule.unit.length > 0 && quantity.unit.length > 0 && quantity.unit !== rule.unit) {
313
+ if (rule.unit !== undefined &&
314
+ rule.unit.length > 0 &&
315
+ quantity.unit.length > 0 &&
316
+ quantity.unit !== rule.unit) {
128
317
  issues.push({
129
- code: 'unit-mismatch',
130
- severity: 'warning',
318
+ code: "unit-mismatch",
319
+ severity: "warning",
131
320
  message: `${component.id}: property "${rule.name}" has unit "${quantity.unit}" but expected "${rule.unit}"`,
132
321
  componentId: component.id,
133
322
  property: rule.name,
@@ -135,8 +324,8 @@ export function validateComponent(component, rules = getRulesForKind(component.k
135
324
  }
136
325
  if (rule.min !== undefined && quantity.value < rule.min) {
137
326
  issues.push({
138
- code: 'value-out-of-range',
139
- severity: 'error',
327
+ code: "value-out-of-range",
328
+ severity: "error",
140
329
  message: `${component.id}: property "${rule.name}" value ${quantity.value} is below minimum ${rule.min}`,
141
330
  componentId: component.id,
142
331
  property: rule.name,
@@ -144,8 +333,8 @@ export function validateComponent(component, rules = getRulesForKind(component.k
144
333
  }
145
334
  if (rule.max !== undefined && quantity.value > rule.max) {
146
335
  issues.push({
147
- code: 'value-out-of-range',
148
- severity: 'error',
336
+ code: "value-out-of-range",
337
+ severity: "error",
149
338
  message: `${component.id}: property "${rule.name}" value ${quantity.value} is above maximum ${rule.max}`,
150
339
  componentId: component.id,
151
340
  property: rule.name,
@@ -160,18 +349,18 @@ export function validateDocument(doc) {
160
349
  for (const component of doc.components) {
161
350
  if (seen.has(component.id)) {
162
351
  issues.push({
163
- code: 'duplicate-id',
164
- severity: 'error',
352
+ code: "duplicate-id",
353
+ severity: "error",
165
354
  message: `Duplicate component id "${component.id}"`,
166
355
  componentId: component.id,
167
356
  });
168
357
  }
169
358
  seen.add(component.id);
170
- if (component.kind === 'unsupported') {
359
+ if (component.kind === "unsupported") {
171
360
  issues.push({
172
- code: 'unsupported-component',
173
- severity: 'warning',
174
- message: `${component.id}: unsupported source type ${component.sourceTypeName ?? 'unknown'}`,
361
+ code: "unsupported-component",
362
+ severity: "warning",
363
+ message: `${component.id}: unsupported source type ${component.sourceTypeName ?? "unknown"}`,
175
364
  componentId: component.id,
176
365
  });
177
366
  continue;
@@ -187,8 +376,8 @@ export function validateDocument(doc) {
187
376
  const [a, b] = wire.endpoints;
188
377
  if (a.x === b.x && a.y === b.y) {
189
378
  issues.push({
190
- code: 'degenerate-wire',
191
- severity: 'warning',
379
+ code: "degenerate-wire",
380
+ severity: "warning",
192
381
  message: `Wire "${wire.id}" has identical endpoints`,
193
382
  wireId: wire.id,
194
383
  });
@@ -200,23 +389,27 @@ export function validateDocument(doc) {
200
389
  for (const issue of validatePanel(doc, seen, new Set(doc.deviceInterface?.controls.map((control) => control.id) ?? []))) {
201
390
  issues.push(issue);
202
391
  }
392
+ for (const issue of validateMountGroups(doc)) {
393
+ issues.push(issue);
394
+ }
203
395
  for (const issue of validateV3BuildMetadata(doc, seen)) {
204
396
  issues.push(issue);
205
397
  }
206
398
  return issues;
207
399
  }
208
400
  export function hasErrors(issues) {
209
- return issues.some((issue) => issue.severity === 'error');
401
+ return issues.some((issue) => issue.severity === "error");
210
402
  }
211
403
  function isRequirementWaived(component, rule) {
212
404
  if (isInterfaceOnlyComponent(component)) {
213
405
  return true;
214
406
  }
215
407
  // Only the "model" string requirement has a waiver path today.
216
- if (rule.kind !== 'string' || rule.name !== 'model') {
408
+ if (rule.kind !== "string" || rule.name !== "model") {
217
409
  return false;
218
410
  }
219
- if (component.kind === 'ic' && component.properties.RuntimeDescriptor === 'true') {
411
+ if (component.kind === "ic" &&
412
+ component.properties.RuntimeDescriptor === "true") {
220
413
  return true;
221
414
  }
222
415
  const shortType = shortSourceType(component.sourceTypeName);
@@ -231,54 +424,60 @@ function isInterfaceOnlyComponent(component) {
231
424
  if (interfaceOnly === true) {
232
425
  return true;
233
426
  }
234
- if (typeof interfaceOnly === 'string' && normalizeToken(interfaceOnly) === 'true') {
427
+ if (typeof interfaceOnly === "string" &&
428
+ normalizeToken(interfaceOnly) === "true") {
235
429
  return true;
236
430
  }
237
431
  const support = component.properties.Support;
238
- return typeof support === 'string' && normalizeToken(support) === 'view-only';
432
+ return typeof support === "string" && normalizeToken(support) === "view-only";
239
433
  }
240
434
  function validateSemanticMetadata(component) {
241
435
  const issues = [];
242
- if (component.kind === 'jack') {
436
+ if (component.kind === "jack") {
243
437
  issues.push(...validateJackSemanticMetadata(component));
244
438
  }
245
- if (component.kind === 'ic' && component.properties.RuntimeDescriptor === 'true') {
439
+ if (component.kind === "ic" &&
440
+ component.properties.RuntimeDescriptor === "true") {
246
441
  issues.push(...validateRuntimeDescriptorMetadata(component));
247
442
  }
248
443
  return issues;
249
444
  }
250
445
  function validateJackSemanticMetadata(component) {
251
446
  const issues = [];
252
- for (const property of ['Role', 'ControlRole']) {
447
+ for (const property of ["Role", "ControlRole"]) {
253
448
  const value = propertyString(component, property);
254
- if (value !== null && value.trim().length > 0 && !isRecognizedJackRole(value)) {
449
+ if (value !== null &&
450
+ value.trim().length > 0 &&
451
+ !isRecognizedJackRole(value)) {
255
452
  issues.push({
256
- code: 'invalid-jack-role',
257
- severity: 'warning',
453
+ code: "invalid-jack-role",
454
+ severity: "warning",
258
455
  message: `${component.id}: jack ${property} "${value}" is not a recognized panel role`,
259
456
  componentId: component.id,
260
457
  property,
261
458
  });
262
459
  }
263
460
  }
264
- const interfaceName = propertyString(component, 'Interface');
265
- if (interfaceName !== null && interfaceName.trim().length > 0 && !isRecognizedJackInterface(interfaceName)) {
461
+ const interfaceName = propertyString(component, "Interface");
462
+ if (interfaceName !== null &&
463
+ interfaceName.trim().length > 0 &&
464
+ !isRecognizedJackInterface(interfaceName)) {
266
465
  issues.push({
267
- code: 'invalid-jack-interface',
268
- severity: 'warning',
466
+ code: "invalid-jack-interface",
467
+ severity: "warning",
269
468
  message: `${component.id}: jack Interface "${interfaceName}" is not a recognized panel interface`,
270
469
  componentId: component.id,
271
- property: 'Interface',
470
+ property: "Interface",
272
471
  });
273
472
  }
274
- const audioRole = propertyString(component, 'AudioRole');
473
+ const audioRole = propertyString(component, "AudioRole");
275
474
  if (audioRole !== null && !isValidJackAudioRole(audioRole)) {
276
475
  issues.push({
277
- code: 'invalid-jack-audio-role',
278
- severity: 'warning',
476
+ code: "invalid-jack-audio-role",
477
+ severity: "warning",
279
478
  message: `${component.id}: jack AudioRole "${audioRole}" must be a lower-kebab source subtype slug`,
280
479
  componentId: component.id,
281
- property: 'AudioRole',
480
+ property: "AudioRole",
282
481
  });
283
482
  }
284
483
  return issues;
@@ -289,23 +488,25 @@ function validateRuntimeDescriptorMetadata(component) {
289
488
  const value = propertyString(component, property);
290
489
  if (value !== null && value.trim().length === 0) {
291
490
  issues.push({
292
- code: 'descriptor-control-empty',
293
- severity: 'warning',
491
+ code: "descriptor-control-empty",
492
+ severity: "warning",
294
493
  message: `${component.id}: runtime descriptor property "${property}" must not be empty`,
295
494
  componentId: component.id,
296
495
  property,
297
496
  });
298
497
  }
299
498
  }
300
- const labels = parseStringList(propertyStringAny(component, ['ModeLabels', 'ModeOptions']));
301
- const stepCount = parsePositiveInteger(propertyStringAny(component, ['ModeStepCount', 'ModeSteps', 'ModeCount']));
302
- if (labels.length > 0 && stepCount !== undefined && labels.length !== stepCount) {
499
+ const labels = parseStringList(propertyStringAny(component, ["ModeLabels", "ModeOptions"]));
500
+ const stepCount = parsePositiveInteger(propertyStringAny(component, ["ModeStepCount", "ModeSteps", "ModeCount"]));
501
+ if (labels.length > 0 &&
502
+ stepCount !== undefined &&
503
+ labels.length !== stepCount) {
303
504
  issues.push({
304
- code: 'descriptor-mode-label-mismatch',
305
- severity: 'warning',
505
+ code: "descriptor-mode-label-mismatch",
506
+ severity: "warning",
306
507
  message: `${component.id}: ModeLabels has ${labels.length} labels but ModeStepCount is ${stepCount}`,
307
508
  componentId: component.id,
308
- property: 'ModeLabels',
509
+ property: "ModeLabels",
309
510
  });
310
511
  }
311
512
  return issues;
@@ -314,11 +515,11 @@ function shortSourceType(sourceTypeName) {
314
515
  if (sourceTypeName === null) {
315
516
  return null;
316
517
  }
317
- const head = sourceTypeName.split(',')[0]?.trim() ?? '';
518
+ const head = sourceTypeName.split(",")[0]?.trim() ?? "";
318
519
  if (head.length === 0) {
319
520
  return null;
320
521
  }
321
- const lastDot = head.lastIndexOf('.');
522
+ const lastDot = head.lastIndexOf(".");
322
523
  return lastDot >= 0 ? head.slice(lastDot + 1) : head;
323
524
  }
324
525
  function findProperty(component, rule) {
@@ -351,58 +552,58 @@ function isRawQuantityExpression(value) {
351
552
  if (trimmed.length === 0) {
352
553
  return false;
353
554
  }
354
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
555
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
355
556
  return true;
356
557
  }
357
- return /^(AC|DC)\b/i.test(trimmed) ||
358
- /^(SINE|PULSE|PWL|EXP|SFFM|AM|WAVEFILE)\s*\(/i.test(trimmed);
558
+ return (/^(AC|DC)\b/i.test(trimmed) ||
559
+ /^(SINE|PULSE|PWL|EXP|SFFM|AM|WAVEFILE)\s*\(/i.test(trimmed));
359
560
  }
360
561
  function isRecognizedJackRole(value) {
361
562
  const normalized = normalizeToken(value);
362
563
  return [
363
- 'input',
364
- 'audio-input',
365
- 'in',
366
- 'direct-output',
367
- 'direct-out',
368
- 'dry-output',
369
- 'dry-out',
370
- 'output',
371
- 'audio-output',
372
- 'out',
373
- 'send',
374
- 'return',
375
- 'expression',
376
- 'exp',
377
- 'expression-pedal',
378
- 'tempo-tap',
379
- 'tap-tempo',
380
- 'tempo-in',
381
- 'tap',
382
- 'tempo',
383
- 'external-control',
384
- 'external-control-input',
385
- 'control-input',
386
- 'remote',
387
- 'footswitch',
388
- 'trigger',
389
- 'reset',
564
+ "input",
565
+ "audio-input",
566
+ "in",
567
+ "direct-output",
568
+ "direct-out",
569
+ "dry-output",
570
+ "dry-out",
571
+ "output",
572
+ "audio-output",
573
+ "out",
574
+ "send",
575
+ "return",
576
+ "expression",
577
+ "exp",
578
+ "expression-pedal",
579
+ "tempo-tap",
580
+ "tap-tempo",
581
+ "tempo-in",
582
+ "tap",
583
+ "tempo",
584
+ "external-control",
585
+ "external-control-input",
586
+ "control-input",
587
+ "remote",
588
+ "footswitch",
589
+ "trigger",
590
+ "reset",
390
591
  ].includes(normalized);
391
592
  }
392
593
  function isRecognizedJackInterface(value) {
393
594
  const normalized = normalizeToken(value);
394
- return isRecognizedJackRole(value) ||
595
+ return (isRecognizedJackRole(value) ||
395
596
  [
396
- 'audio',
397
- 'audio-port',
398
- 'control',
399
- 'control-port',
400
- 'power',
401
- 'power-port',
402
- 'dc-power',
403
- 'dc-power-input',
404
- 'tap-tempo-input',
405
- ].includes(normalized);
597
+ "audio",
598
+ "audio-port",
599
+ "control",
600
+ "control-port",
601
+ "power",
602
+ "power-port",
603
+ "dc-power",
604
+ "dc-power-input",
605
+ "tap-tempo-input",
606
+ ].includes(normalized));
406
607
  }
407
608
  function isValidJackAudioRole(value) {
408
609
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
@@ -428,51 +629,56 @@ function parsePositiveInteger(value) {
428
629
  return Number.isInteger(count) && count > 0 ? count : undefined;
429
630
  }
430
631
  function normalizeToken(value) {
431
- return value.trim().toLowerCase().replace(/[\s_]+/g, '-');
632
+ return value
633
+ .trim()
634
+ .toLowerCase()
635
+ .replace(/[\s_]+/g, "-");
432
636
  }
433
637
  function validateDeviceInterface(doc, componentIds) {
434
638
  const issues = [];
435
639
  const groupIds = new Set(doc.controlGroups?.map((group) => group.id) ?? []);
436
640
  const contextIds = new Set(doc.controlContexts?.map((context) => context.id) ?? []);
641
+ const declaredControlIds = new Set(doc.deviceInterface?.controls.map((control) => control.id) ?? []);
437
642
  const semanticControlIds = new Set();
438
643
  const externalInterfaceIds = new Set(doc.controlInterfaces?.map((controlInterface) => controlInterface.id) ?? []);
439
644
  const componentsById = new Map(doc.components.map((component) => [component.id, component]));
440
645
  const resolvedPanelElements = resolvePanelElements(doc);
441
646
  for (const group of doc.controlGroups ?? []) {
442
- issues.push(...validateOpenToken(group.role, group.id, 'role'));
647
+ issues.push(...validateOpenToken(group.role, group.id, "role"));
443
648
  for (const contextId of group.contextIds ?? []) {
444
649
  if (!contextIds.has(contextId)) {
445
650
  issues.push({
446
- code: 'control-group-context-unresolved',
447
- severity: 'warning',
651
+ code: "control-group-context-unresolved",
652
+ severity: "warning",
448
653
  message: `Control group "${group.id}" references missing context "${contextId}"`,
449
654
  componentId: group.id,
450
- property: 'contextIds',
655
+ property: "contextIds",
451
656
  });
452
657
  }
453
658
  }
659
+ issues.push(...validateControlGroupMembers(group, declaredControlIds, contextIds));
454
660
  }
455
661
  for (const context of doc.controlContexts ?? []) {
456
- issues.push(...validateOpenToken(context.role, context.id, 'role'));
662
+ issues.push(...validateOpenToken(context.role, context.id, "role"));
457
663
  }
458
664
  for (const control of doc.deviceInterface?.controls ?? []) {
459
665
  if (semanticControlIds.has(control.id)) {
460
666
  issues.push({
461
- code: 'duplicate-device-interface-control-id',
462
- severity: 'error',
667
+ code: "duplicate-device-interface-control-id",
668
+ severity: "error",
463
669
  message: `Duplicate device interface control id "${control.id}"`,
464
670
  componentId: control.id,
465
671
  });
466
672
  }
467
673
  semanticControlIds.add(control.id);
468
- issues.push(...validateOpenToken(control.role, control.id, 'role'));
674
+ issues.push(...validateOpenToken(control.role, control.id, "role"));
469
675
  if (control.groupId !== undefined && !groupIds.has(control.groupId)) {
470
676
  issues.push({
471
- code: 'device-interface-group-unresolved',
472
- severity: 'warning',
677
+ code: "device-interface-group-unresolved",
678
+ severity: "warning",
473
679
  message: `Device interface control "${control.id}" references missing group "${control.groupId}"`,
474
680
  componentId: control.id,
475
- property: 'groupId',
681
+ property: "groupId",
476
682
  });
477
683
  }
478
684
  issues.push(...validateApplicability(control, contextIds));
@@ -480,37 +686,129 @@ function validateDeviceInterface(doc, componentIds) {
480
686
  issues.push(...validateDeviceInterfaceBinding(control, control.binding, componentIds, externalInterfaceIds, componentsById, resolvedPanelElements));
481
687
  }
482
688
  }
483
- issues.push(...validateDuplicateDeviceInterfaceRoles(doc.deviceInterface?.controls ?? []));
689
+ issues.push(...validateDuplicateDeviceInterfaceRoles(doc.deviceInterface?.controls ?? [], doc.controlGroups ?? []));
690
+ return issues;
691
+ }
692
+ function validateControlGroupMembers(group, controlIds, contextIds) {
693
+ const issues = [];
694
+ const orderOwners = new Map();
695
+ for (const member of group.members ?? []) {
696
+ if (!controlIds.has(member.controlId)) {
697
+ issues.push({
698
+ code: "control-group-member-unresolved",
699
+ severity: "warning",
700
+ message: `Control group "${group.id}" references missing member control "${member.controlId}"`,
701
+ componentId: group.id,
702
+ property: "members.controlId",
703
+ });
704
+ }
705
+ if (member.order !== undefined) {
706
+ const existingControlId = orderOwners.get(member.order);
707
+ if (existingControlId !== undefined) {
708
+ issues.push({
709
+ code: "control-group-member-order-duplicate",
710
+ severity: "warning",
711
+ message: `Control group "${group.id}" assigns order ${member.order} to both "${existingControlId}" and "${member.controlId}"`,
712
+ componentId: group.id,
713
+ property: "members.order",
714
+ });
715
+ }
716
+ orderOwners.set(member.order, member.controlId);
717
+ }
718
+ issues.push(...validateControlGroupMemberApplicability(group.id, member, contextIds));
719
+ }
720
+ return issues;
721
+ }
722
+ function validateControlGroupMemberApplicability(groupId, member, contextIds) {
723
+ const issues = [];
724
+ if (member.appliesWhen === undefined) {
725
+ return issues;
726
+ }
727
+ issues.push(...validateGroupMemberContextList(groupId, member, "members.appliesWhen.allOf", member.appliesWhen.allOf, contextIds));
728
+ issues.push(...validateGroupMemberContextList(groupId, member, "members.appliesWhen.anyOf", member.appliesWhen.anyOf, contextIds));
729
+ if (member.appliesWhen.allOf !== undefined &&
730
+ member.appliesWhen.allOf.length === 0 &&
731
+ member.appliesWhen.anyOf === undefined) {
732
+ issues.push(emptyGroupMemberApplicabilityIssue(groupId, member.controlId, "members.appliesWhen.allOf"));
733
+ }
734
+ if (member.appliesWhen.anyOf !== undefined &&
735
+ member.appliesWhen.anyOf.length === 0 &&
736
+ member.appliesWhen.allOf === undefined) {
737
+ issues.push(emptyGroupMemberApplicabilityIssue(groupId, member.controlId, "members.appliesWhen.anyOf"));
738
+ }
739
+ return issues;
740
+ }
741
+ function validateGroupMemberContextList(groupId, member, property, values, contextIds) {
742
+ if (values === undefined) {
743
+ return [];
744
+ }
745
+ const issues = [];
746
+ const seen = new Set();
747
+ if (values.length === 0) {
748
+ issues.push(emptyGroupMemberApplicabilityIssue(groupId, member.controlId, property));
749
+ }
750
+ for (const contextId of values) {
751
+ if (seen.has(contextId)) {
752
+ issues.push({
753
+ code: "control-group-member-context-unresolved",
754
+ severity: "warning",
755
+ message: `Control group "${groupId}" member "${member.controlId}" repeats context "${contextId}" in ${property}`,
756
+ componentId: groupId,
757
+ property,
758
+ });
759
+ }
760
+ seen.add(contextId);
761
+ if (!contextIds.has(contextId)) {
762
+ issues.push({
763
+ code: "control-group-member-context-unresolved",
764
+ severity: "warning",
765
+ message: `Control group "${groupId}" member "${member.controlId}" references missing context "${contextId}"`,
766
+ componentId: groupId,
767
+ property,
768
+ });
769
+ }
770
+ }
484
771
  return issues;
485
772
  }
773
+ function emptyGroupMemberApplicabilityIssue(groupId, controlId, property) {
774
+ return {
775
+ code: "control-group-member-context-unresolved",
776
+ severity: "warning",
777
+ message: `Control group "${groupId}" member "${controlId}" has empty ${property}; omit the predicate instead`,
778
+ componentId: groupId,
779
+ property,
780
+ };
781
+ }
486
782
  function validateOpenToken(value, componentId, property) {
487
783
  if (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) {
488
784
  return [];
489
785
  }
490
- return [{
491
- code: 'invalid-device-interface-token',
492
- severity: 'warning',
786
+ return [
787
+ {
788
+ code: "invalid-device-interface-token",
789
+ severity: "warning",
493
790
  message: `${componentId}: ${property} "${value}" must be a lower-kebab token`,
494
791
  componentId,
495
792
  property,
496
- }];
793
+ },
794
+ ];
497
795
  }
498
796
  function validateApplicability(control, contextIds) {
499
797
  const issues = [];
500
798
  if (control.appliesWhen === undefined) {
501
799
  return issues;
502
800
  }
503
- issues.push(...validateContextList(control.id, 'appliesWhen.allOf', control.appliesWhen.allOf, contextIds));
504
- issues.push(...validateContextList(control.id, 'appliesWhen.anyOf', control.appliesWhen.anyOf, contextIds));
505
- if (control.appliesWhen.allOf !== undefined
506
- && control.appliesWhen.allOf.length === 0
507
- && control.appliesWhen.anyOf === undefined) {
508
- issues.push(emptyApplicabilityIssue(control.id, 'appliesWhen.allOf'));
801
+ issues.push(...validateContextList(control.id, "appliesWhen.allOf", control.appliesWhen.allOf, contextIds));
802
+ issues.push(...validateContextList(control.id, "appliesWhen.anyOf", control.appliesWhen.anyOf, contextIds));
803
+ if (control.appliesWhen.allOf !== undefined &&
804
+ control.appliesWhen.allOf.length === 0 &&
805
+ control.appliesWhen.anyOf === undefined) {
806
+ issues.push(emptyApplicabilityIssue(control.id, "appliesWhen.allOf"));
509
807
  }
510
- if (control.appliesWhen.anyOf !== undefined
511
- && control.appliesWhen.anyOf.length === 0
512
- && control.appliesWhen.allOf === undefined) {
513
- issues.push(emptyApplicabilityIssue(control.id, 'appliesWhen.anyOf'));
808
+ if (control.appliesWhen.anyOf !== undefined &&
809
+ control.appliesWhen.anyOf.length === 0 &&
810
+ control.appliesWhen.allOf === undefined) {
811
+ issues.push(emptyApplicabilityIssue(control.id, "appliesWhen.anyOf"));
514
812
  }
515
813
  return issues;
516
814
  }
@@ -526,8 +824,8 @@ function validateContextList(controlId, property, values, contextIds) {
526
824
  for (const contextId of values) {
527
825
  if (seen.has(contextId)) {
528
826
  issues.push({
529
- code: 'device-interface-context-unresolved',
530
- severity: 'warning',
827
+ code: "device-interface-context-unresolved",
828
+ severity: "warning",
531
829
  message: `Device interface control "${controlId}" repeats context "${contextId}" in ${property}`,
532
830
  componentId: controlId,
533
831
  property,
@@ -536,8 +834,8 @@ function validateContextList(controlId, property, values, contextIds) {
536
834
  seen.add(contextId);
537
835
  if (!contextIds.has(contextId)) {
538
836
  issues.push({
539
- code: 'device-interface-context-unresolved',
540
- severity: 'warning',
837
+ code: "device-interface-context-unresolved",
838
+ severity: "warning",
541
839
  message: `Device interface control "${controlId}" references missing context "${contextId}"`,
542
840
  componentId: controlId,
543
841
  property,
@@ -548,8 +846,8 @@ function validateContextList(controlId, property, values, contextIds) {
548
846
  }
549
847
  function emptyApplicabilityIssue(controlId, property) {
550
848
  return {
551
- code: 'device-interface-context-unresolved',
552
- severity: 'warning',
849
+ code: "device-interface-context-unresolved",
850
+ severity: "warning",
553
851
  message: `Device interface control "${controlId}" has empty ${property}; omit the predicate instead`,
554
852
  componentId: controlId,
555
853
  property,
@@ -557,79 +855,204 @@ function emptyApplicabilityIssue(controlId, property) {
557
855
  }
558
856
  function validateDeviceInterfaceBinding(control, binding, componentIds, externalInterfaceIds, componentsById, resolvedPanelElements) {
559
857
  const issues = [];
560
- if (binding.externalInterfaceId !== undefined && !externalInterfaceIds.has(binding.externalInterfaceId)) {
858
+ if (binding.externalInterfaceId !== undefined &&
859
+ !externalInterfaceIds.has(binding.externalInterfaceId)) {
561
860
  issues.push({
562
- code: 'device-interface-binding-unresolved',
563
- severity: 'warning',
861
+ code: "device-interface-binding-unresolved",
862
+ severity: "warning",
564
863
  message: `Device interface control "${control.id}" references missing external interface "${binding.externalInterfaceId}"`,
565
864
  componentId: control.id,
566
- property: 'binding.externalInterfaceId',
865
+ property: "binding.externalInterfaceId",
567
866
  });
568
867
  }
569
868
  if (!componentIds.has(binding.componentId)) {
570
869
  issues.push({
571
- code: 'device-interface-binding-unresolved',
572
- severity: 'warning',
870
+ code: "device-interface-binding-unresolved",
871
+ severity: "warning",
573
872
  message: `Device interface control "${control.id}" references missing component "${binding.componentId}"`,
574
873
  componentId: control.id,
575
- property: 'binding.componentId',
874
+ property: "binding.componentId",
576
875
  });
577
876
  return issues;
578
877
  }
579
- if (binding.controlId !== undefined
580
- && !resolvedPanelElements.some((resolved) => resolved.componentId === binding.componentId && resolved.id === binding.controlId)) {
878
+ if (binding.controlId !== undefined &&
879
+ !resolvedPanelElements.some((resolved) => resolved.componentId === binding.componentId &&
880
+ resolved.id === binding.controlId)) {
581
881
  issues.push({
582
- code: 'device-interface-binding-unresolved',
583
- severity: 'warning',
882
+ code: "device-interface-binding-unresolved",
883
+ severity: "warning",
584
884
  message: `Device interface control "${control.id}" references missing control "${binding.controlId}"`,
585
885
  componentId: control.id,
586
- property: 'binding.controlId',
886
+ property: "binding.controlId",
587
887
  });
588
888
  }
589
889
  const component = componentsById.get(binding.componentId);
590
- if (binding.property !== undefined && component?.properties[binding.property] === undefined) {
890
+ if (binding.property !== undefined &&
891
+ component?.properties[binding.property] === undefined) {
591
892
  issues.push({
592
- code: 'device-interface-binding-unresolved',
593
- severity: 'warning',
893
+ code: "device-interface-binding-unresolved",
894
+ severity: "warning",
594
895
  message: `Device interface control "${control.id}" references missing property "${binding.property}"`,
595
896
  componentId: control.id,
596
- property: 'binding.property',
897
+ property: "binding.property",
597
898
  });
598
899
  }
599
900
  return issues;
600
901
  }
601
- function validateDuplicateDeviceInterfaceRoles(controls) {
902
+ function validateDuplicateDeviceInterfaceRoles(controls, groups) {
602
903
  const issues = [];
904
+ const layoutsByControlId = deviceInterfaceRoleLayoutsByControlId(groups);
603
905
  const seen = new Map();
604
906
  for (const control of controls) {
605
- const key = `${control.groupId ?? ''}:${control.role}`;
606
- const existing = seen.get(key);
607
- if (existing !== undefined && existing.order === undefined && control.order === undefined) {
608
- if (deviceInterfaceBindingSignature(existing.binding) === deviceInterfaceBindingSignature(control.binding)) {
609
- issues.push({
610
- code: 'device-interface-duplicate-role',
611
- severity: 'warning',
612
- message: `Device interface controls "${existing.id}" and "${control.id}" share role "${control.role}" without order or distinct binding`,
613
- componentId: control.id,
614
- property: 'role',
615
- });
907
+ const layouts = layoutsByControlId.get(control.id) ?? [
908
+ { groupId: control.groupId ?? "", order: control.order },
909
+ ];
910
+ for (const layout of layouts) {
911
+ const key = `${layout.groupId}:${control.role}`;
912
+ const existing = seen.get(key);
913
+ if (existing !== undefined &&
914
+ existing.order === undefined &&
915
+ layout.order === undefined) {
916
+ if (deviceInterfaceBindingSignature(existing.control.binding) ===
917
+ deviceInterfaceBindingSignature(control.binding)) {
918
+ issues.push({
919
+ code: "device-interface-duplicate-role",
920
+ severity: "warning",
921
+ message: `Device interface controls "${existing.control.id}" and "${control.id}" share role "${control.role}" without order or distinct binding`,
922
+ componentId: control.id,
923
+ property: "role",
924
+ });
925
+ }
616
926
  }
927
+ seen.set(key, {
928
+ control,
929
+ ...(layout.order === undefined ? {} : { order: layout.order }),
930
+ });
617
931
  }
618
- seen.set(key, control);
619
932
  }
620
933
  return issues;
621
934
  }
935
+ function deviceInterfaceRoleLayoutsByControlId(groups) {
936
+ const layoutsByControlId = new Map();
937
+ for (const group of groups) {
938
+ for (const member of group.members ?? []) {
939
+ const layouts = layoutsByControlId.get(member.controlId) ?? [];
940
+ layouts.push({
941
+ groupId: group.id,
942
+ ...(member.order === undefined ? {} : { order: member.order }),
943
+ });
944
+ layoutsByControlId.set(member.controlId, layouts);
945
+ }
946
+ }
947
+ return layoutsByControlId;
948
+ }
622
949
  function deviceInterfaceBindingSignature(binding) {
623
950
  if (binding === undefined) {
624
- return '';
951
+ return "";
625
952
  }
626
953
  return [
627
954
  binding.componentId,
628
- binding.controlId ?? '',
629
- binding.controlName ?? '',
630
- binding.property ?? '',
631
- binding.externalInterfaceId ?? '',
632
- ].join(':');
955
+ binding.controlId ?? "",
956
+ binding.controlName ?? "",
957
+ binding.property ?? "",
958
+ binding.externalInterfaceId ?? "",
959
+ ].join(":");
960
+ }
961
+ /**
962
+ * Structural (catalog-free) validation of multi-surface part mounts, where
963
+ * several placement elements share one physical part in one hole (e.g. a
964
+ * concentric pot). Catches orphan bindings and inconsistent mount groups.
965
+ * Surface-existence and completeness against the part catalog are validated
966
+ * downstream in the stompbox build layer, which owns the part profiles.
967
+ */
968
+ function validateMountGroups(doc) {
969
+ if (doc.panel === undefined) {
970
+ return [];
971
+ }
972
+ const issues = [];
973
+ const groups = new Map();
974
+ for (const face of doc.panel.faces) {
975
+ for (const element of face.elements) {
976
+ const physical = element.physical;
977
+ if (physical === undefined) {
978
+ continue;
979
+ }
980
+ const componentId = element.bind.componentId;
981
+ if (physical.mountId === undefined) {
982
+ if (physical.surface !== undefined) {
983
+ issues.push({
984
+ code: "panel-mount-orphan",
985
+ severity: "warning",
986
+ message: `Panel element for "${componentId}" sets physical.surface "${physical.surface}" without a mountId`,
987
+ componentId,
988
+ });
989
+ }
990
+ continue;
991
+ }
992
+ if (physical.surface === undefined) {
993
+ issues.push({
994
+ code: "panel-mount-orphan",
995
+ severity: "warning",
996
+ message: `Panel element for "${componentId}" joins mount "${physical.mountId}" without a surface`,
997
+ componentId,
998
+ });
999
+ }
1000
+ if (physical.partProfileId === undefined) {
1001
+ issues.push({
1002
+ code: "panel-mount-orphan",
1003
+ severity: "warning",
1004
+ message: `Panel element for "${componentId}" joins mount "${physical.mountId}" without a partProfileId`,
1005
+ componentId,
1006
+ });
1007
+ }
1008
+ const members = groups.get(physical.mountId) ?? [];
1009
+ members.push({ componentId, physical });
1010
+ groups.set(physical.mountId, members);
1011
+ }
1012
+ }
1013
+ for (const [mountId, members] of groups) {
1014
+ const anchorId = members[0]?.componentId;
1015
+ const surfaces = new Set();
1016
+ const partIds = new Set();
1017
+ const centers = new Set();
1018
+ for (const member of members) {
1019
+ const { surface, partProfileId, centerMm } = member.physical;
1020
+ if (surface !== undefined) {
1021
+ if (surfaces.has(surface)) {
1022
+ issues.push({
1023
+ code: "panel-mount-inconsistent",
1024
+ severity: "warning",
1025
+ message: `Mount "${mountId}" has duplicate surface "${surface}"`,
1026
+ componentId: member.componentId,
1027
+ });
1028
+ }
1029
+ surfaces.add(surface);
1030
+ }
1031
+ if (partProfileId !== undefined) {
1032
+ partIds.add(partProfileId);
1033
+ }
1034
+ if (centerMm !== undefined) {
1035
+ centers.add(`${centerMm.x},${centerMm.y}`);
1036
+ }
1037
+ }
1038
+ if (partIds.size > 1) {
1039
+ issues.push({
1040
+ code: "panel-mount-inconsistent",
1041
+ severity: "warning",
1042
+ message: `Mount "${mountId}" mixes part profiles: ${[...partIds].join(", ")}`,
1043
+ ...(anchorId === undefined ? {} : { componentId: anchorId }),
1044
+ });
1045
+ }
1046
+ if (centers.size > 1) {
1047
+ issues.push({
1048
+ code: "panel-mount-inconsistent",
1049
+ severity: "warning",
1050
+ message: `Mount "${mountId}" members are not at one shared centerMm`,
1051
+ ...(anchorId === undefined ? {} : { componentId: anchorId }),
1052
+ });
1053
+ }
1054
+ }
1055
+ return issues;
633
1056
  }
634
1057
  function validatePanel(doc, componentIds, semanticControlIds) {
635
1058
  if (doc.panel === undefined) {
@@ -640,19 +1063,20 @@ function validatePanel(doc, componentIds, semanticControlIds) {
640
1063
  for (const face of doc.panel.faces) {
641
1064
  for (const element of face.elements) {
642
1065
  const componentId = element.bind.componentId;
643
- if (element.interfaceControlId !== undefined && !semanticControlIds.has(element.interfaceControlId)) {
1066
+ if (element.interfaceControlId !== undefined &&
1067
+ !semanticControlIds.has(element.interfaceControlId)) {
644
1068
  issues.push({
645
- code: 'panel-interface-control-unresolved',
646
- severity: 'warning',
1069
+ code: "panel-interface-control-unresolved",
1070
+ severity: "warning",
647
1071
  message: `Panel element on face "${face.id}" references missing interface control "${element.interfaceControlId}"`,
648
1072
  componentId: element.interfaceControlId,
649
- property: 'interfaceControlId',
1073
+ property: "interfaceControlId",
650
1074
  });
651
1075
  }
652
1076
  if (!componentIds.has(componentId)) {
653
1077
  issues.push({
654
- code: 'panel-binding-unresolved',
655
- severity: 'warning',
1078
+ code: "panel-binding-unresolved",
1079
+ severity: "warning",
656
1080
  message: `Panel element on face "${face.id}" references missing component "${componentId}"`,
657
1081
  componentId,
658
1082
  });
@@ -661,18 +1085,19 @@ function validatePanel(doc, componentIds, semanticControlIds) {
661
1085
  const resolved = resolvePanelElement(resolvedElements, element);
662
1086
  if (element.bind.controlId !== undefined && resolved === undefined) {
663
1087
  issues.push({
664
- code: 'panel-control-unresolved',
665
- severity: 'warning',
1088
+ code: "panel-control-unresolved",
1089
+ severity: "warning",
666
1090
  message: `Panel element on face "${face.id}" references missing control "${element.bind.controlId}" on component "${componentId}"`,
667
1091
  componentId,
668
1092
  property: element.bind.controlId,
669
1093
  });
670
1094
  continue;
671
1095
  }
672
- if (resolved !== undefined && !panelKindsCompatible(element.kind, resolved.kind)) {
1096
+ if (resolved !== undefined &&
1097
+ !panelKindsCompatible(element.kind, resolved.kind)) {
673
1098
  issues.push({
674
- code: 'panel-kind-mismatch',
675
- severity: 'warning',
1099
+ code: "panel-kind-mismatch",
1100
+ severity: "warning",
676
1101
  message: `Panel element on face "${face.id}" binds component "${componentId}" as ${element.kind} but resolved kind is ${resolved.kind}`,
677
1102
  componentId,
678
1103
  });
@@ -688,7 +1113,8 @@ function panelKindsCompatible(declared, resolved) {
688
1113
  if (declared === resolved) {
689
1114
  return true;
690
1115
  }
691
- return resolved === 'switch' && (declared === 'selector' || declared === 'footswitch');
1116
+ return (resolved === "switch" &&
1117
+ (declared === "selector" || declared === "footswitch"));
692
1118
  }
693
1119
  function resolvePanelElements(doc) {
694
1120
  const panel = extractPanel(doc);
@@ -697,47 +1123,51 @@ function resolvePanelElements(doc) {
697
1123
  resolved.push({
698
1124
  id: knob.id,
699
1125
  componentId: componentIdFromPanelElementId(knob.id),
700
- kind: knob.id.endsWith(':mode') && knob.controlMode === 'stepped' ? 'switch' : 'knob',
1126
+ kind: knob.id.endsWith(":mode") && knob.controlMode === "stepped"
1127
+ ? "switch"
1128
+ : "knob",
701
1129
  });
702
1130
  }
703
1131
  for (const slider of panel.sliders ?? []) {
704
1132
  resolved.push({
705
1133
  id: slider.id,
706
1134
  componentId: componentIdFromPanelElementId(slider.id),
707
- kind: 'slider',
1135
+ kind: "slider",
708
1136
  });
709
1137
  }
710
1138
  for (const switchControl of panel.switches) {
711
1139
  resolved.push({
712
1140
  id: switchControl.id,
713
1141
  componentId: componentIdFromPanelElementId(switchControl.id),
714
- kind: 'switch',
1142
+ kind: "switch",
715
1143
  });
716
1144
  }
717
1145
  for (const led of panel.leds) {
718
1146
  resolved.push({
719
1147
  id: led.id,
720
1148
  componentId: componentIdFromPanelElementId(led.id),
721
- kind: 'led',
1149
+ kind: "led",
722
1150
  });
723
1151
  }
724
1152
  for (const jack of panel.jacks) {
725
1153
  resolved.push({
726
1154
  id: jack.id,
727
1155
  componentId: jack.sourceComponentId ?? componentIdFromPanelElementId(jack.id),
728
- kind: 'jack',
1156
+ kind: "jack",
729
1157
  });
730
1158
  }
731
1159
  return resolved;
732
1160
  }
733
1161
  function resolvePanelElement(resolvedElements, element) {
734
1162
  if (element.bind.controlId !== undefined) {
735
- return resolvedElements.find((resolved) => resolved.componentId === element.bind.componentId && resolved.id === element.bind.controlId);
1163
+ return resolvedElements.find((resolved) => resolved.componentId === element.bind.componentId &&
1164
+ resolved.id === element.bind.controlId);
736
1165
  }
737
- return resolvedElements.find((resolved) => resolved.componentId === element.bind.componentId && resolved.id === element.bind.componentId);
1166
+ return resolvedElements.find((resolved) => resolved.componentId === element.bind.componentId &&
1167
+ resolved.id === element.bind.componentId);
738
1168
  }
739
1169
  function componentIdFromPanelElementId(id) {
740
- const separator = id.indexOf(':');
1170
+ const separator = id.indexOf(":");
741
1171
  return separator <= 0 ? id : id.slice(0, separator);
742
1172
  }
743
1173
  function validatePanelCellCollisions(face) {
@@ -753,8 +1183,8 @@ function validatePanelCellCollisions(face) {
753
1183
  const key = `${row}:${column}`;
754
1184
  if (occupied.has(key)) {
755
1185
  issues.push({
756
- code: 'panel-cell-collision',
757
- severity: 'warning',
1186
+ code: "panel-cell-collision",
1187
+ severity: "warning",
758
1188
  message: `Panel face "${face.id}" has overlapping elements at row ${row}, column ${column}`,
759
1189
  componentId: element.bind.componentId,
760
1190
  });
@@ -786,21 +1216,21 @@ function validateV3BuildMetadata(doc, componentIds) {
786
1216
  ]));
787
1217
  const selectedBoardId = doc.build?.selectedBoardId;
788
1218
  if (selectedBoardId !== undefined && !boardsById.has(selectedBoardId)) {
789
- issues.push(unresolvedIssue('build-board-unresolved', 'error', `Build selectedBoardId references missing board "${selectedBoardId}"`, selectedBoardId, 'selectedBoardId'));
1219
+ issues.push(unresolvedIssue("build-board-unresolved", "error", `Build selectedBoardId references missing board "${selectedBoardId}"`, selectedBoardId, "selectedBoardId"));
790
1220
  }
791
1221
  for (const boardId of doc.build?.alternateBoardIds ?? []) {
792
1222
  if (!boardsById.has(boardId)) {
793
- issues.push(unresolvedIssue('build-board-unresolved', 'warning', `Build alternateBoardIds references missing board "${boardId}"`, boardId, 'alternateBoardIds'));
1223
+ issues.push(unresolvedIssue("build-board-unresolved", "warning", `Build alternateBoardIds references missing board "${boardId}"`, boardId, "alternateBoardIds"));
794
1224
  }
795
1225
  }
796
- const preferredBoardId = dataString(doc.mechanical?.internalBoard, 'preferredBoardId');
1226
+ const preferredBoardId = dataString(doc.mechanical?.internalBoard, "preferredBoardId");
797
1227
  if (preferredBoardId !== undefined && !boardsById.has(preferredBoardId)) {
798
- issues.push(unresolvedIssue('build-board-unresolved', 'warning', `Mechanical internalBoard.preferredBoardId references missing board "${preferredBoardId}"`, preferredBoardId, 'mechanical.internalBoard.preferredBoardId'));
1228
+ issues.push(unresolvedIssue("build-board-unresolved", "warning", `Mechanical internalBoard.preferredBoardId references missing board "${preferredBoardId}"`, preferredBoardId, "mechanical.internalBoard.preferredBoardId"));
799
1229
  }
800
1230
  const harnessesById = new Map(doc.offBoardWiring?.harnesses.map((harness) => [harness.id, harness]) ?? []);
801
1231
  for (const harnessId of doc.build?.selectedOffBoardWiringHarnessIds ?? []) {
802
1232
  if (!harnessesById.has(harnessId)) {
803
- issues.push(unresolvedIssue('build-harness-unresolved', 'error', `Build selectedOffBoardWiringHarnessIds references missing harness "${harnessId}"`, harnessId, 'selectedOffBoardWiringHarnessIds'));
1233
+ issues.push(unresolvedIssue("build-harness-unresolved", "error", `Build selectedOffBoardWiringHarnessIds references missing harness "${harnessId}"`, harnessId, "selectedOffBoardWiringHarnessIds"));
804
1234
  }
805
1235
  }
806
1236
  for (const item of doc.bom?.items ?? []) {
@@ -817,7 +1247,8 @@ function validateV3BuildMetadata(doc, componentIds) {
817
1247
  if (doc.offBoardWiring !== undefined) {
818
1248
  issues.push(...validateOffBoardWiring(doc, componentsById, panelElementIds, boardTerminalsByBoardId, boardNetsByBoardId));
819
1249
  }
820
- if (doc.build?.completeness === 'complete-selected-build' && selectedBoardId !== undefined) {
1250
+ if (doc.build?.completeness === "complete-selected-build" &&
1251
+ selectedBoardId !== undefined) {
821
1252
  const selectedBoard = boardsById.get(selectedBoardId);
822
1253
  if (selectedBoard !== undefined) {
823
1254
  issues.push(...validateCompleteSelectedBoardRoutes(selectedBoard));
@@ -826,7 +1257,7 @@ function validateV3BuildMetadata(doc, componentIds) {
826
1257
  return issues;
827
1258
  }
828
1259
  function hasV3BuildMetadata(doc) {
829
- return doc.mechanical !== undefined ||
1260
+ return (doc.mechanical !== undefined ||
830
1261
  doc.build !== undefined ||
831
1262
  doc.bom !== undefined ||
832
1263
  doc.partProfiles !== undefined ||
@@ -834,71 +1265,78 @@ function hasV3BuildMetadata(doc) {
834
1265
  doc.offBoardWiring !== undefined ||
835
1266
  doc.boards !== undefined ||
836
1267
  doc.panel?.faces.some((face) => face.geometry !== undefined ||
837
- face.elements.some((element) => element.id !== undefined || element.physical !== undefined)) === true;
1268
+ face.elements.some((element) => element.id !== undefined || element.physical !== undefined)) === true);
838
1269
  }
839
1270
  function validateBomRef(ref, componentIds, controlIds, panelElementIds, boardsById, itemId) {
840
- if (ref.kind === 'component' && (ref.componentId === undefined || !componentIds.has(ref.componentId))) {
841
- return unresolvedIssue('bom-ref-unresolved', 'warning', `BOM item "${itemId}" references missing component "${ref.componentId ?? ''}"`, itemId, 'refs.componentId');
1271
+ if (ref.kind === "component" &&
1272
+ (ref.componentId === undefined || !componentIds.has(ref.componentId))) {
1273
+ return unresolvedIssue("bom-ref-unresolved", "warning", `BOM item "${itemId}" references missing component "${ref.componentId ?? ""}"`, itemId, "refs.componentId");
842
1274
  }
843
- if (ref.kind === 'device-interface-control' &&
1275
+ if (ref.kind === "device-interface-control" &&
844
1276
  (ref.controlId === undefined || !controlIds.has(ref.controlId))) {
845
- return unresolvedIssue('bom-ref-unresolved', 'warning', `BOM item "${itemId}" references missing device interface control "${ref.controlId ?? ''}"`, itemId, 'refs.controlId');
1277
+ return unresolvedIssue("bom-ref-unresolved", "warning", `BOM item "${itemId}" references missing device interface control "${ref.controlId ?? ""}"`, itemId, "refs.controlId");
846
1278
  }
847
- if (ref.kind === 'panel-element' && (ref.panelElementId === undefined || !panelElementIds.has(ref.panelElementId))) {
848
- return unresolvedIssue('bom-ref-unresolved', 'warning', `BOM item "${itemId}" references missing panel element "${ref.panelElementId ?? ''}"`, itemId, 'refs.panelElementId');
1279
+ if (ref.kind === "panel-element" &&
1280
+ (ref.panelElementId === undefined ||
1281
+ !panelElementIds.has(ref.panelElementId))) {
1282
+ return unresolvedIssue("bom-ref-unresolved", "warning", `BOM item "${itemId}" references missing panel element "${ref.panelElementId ?? ""}"`, itemId, "refs.panelElementId");
849
1283
  }
850
- if (ref.kind === 'board' && (ref.boardId === undefined || !boardsById.has(ref.boardId))) {
851
- return unresolvedIssue('bom-ref-unresolved', 'warning', `BOM item "${itemId}" references missing board "${ref.boardId ?? ''}"`, itemId, 'refs.boardId');
1284
+ if (ref.kind === "board" &&
1285
+ (ref.boardId === undefined || !boardsById.has(ref.boardId))) {
1286
+ return unresolvedIssue("bom-ref-unresolved", "warning", `BOM item "${itemId}" references missing board "${ref.boardId ?? ""}"`, itemId, "refs.boardId");
852
1287
  }
853
1288
  return undefined;
854
1289
  }
855
1290
  function validateBoardRealization(board, componentsById, boardNetsByBoardId) {
856
1291
  const issues = [];
857
- if (board.sourceCircuit !== undefined && !isDigestShapedSourceHash(board.sourceCircuit.hash)) {
1292
+ if (board.sourceCircuit !== undefined &&
1293
+ !isDigestShapedSourceHash(board.sourceCircuit.hash)) {
858
1294
  issues.push({
859
- code: 'board-source-hash-invalid',
860
- severity: 'error',
1295
+ code: "board-source-hash-invalid",
1296
+ severity: "error",
861
1297
  message: `Board "${board.id}" sourceCircuit.hash must be sha256:<64 hex chars>`,
862
1298
  componentId: board.id,
863
- property: 'sourceCircuit.hash',
1299
+ property: "sourceCircuit.hash",
864
1300
  });
865
1301
  }
866
1302
  for (const terminal of board.edgeTerminals) {
867
- if (terminal.terminalRef !== undefined && !componentTerminalExists(componentsById, terminal.terminalRef)) {
868
- issues.push(unresolvedIssue('board-terminal-unresolved', 'warning', `Board "${board.id}" edge terminal "${terminal.id}" references missing component terminal`, board.id, terminal.id));
1303
+ if (terminal.terminalRef !== undefined &&
1304
+ !componentTerminalExists(componentsById, terminal.terminalRef)) {
1305
+ issues.push(unresolvedIssue("board-terminal-unresolved", "warning", `Board "${board.id}" edge terminal "${terminal.id}" references missing component terminal`, board.id, terminal.id));
869
1306
  }
870
1307
  }
871
1308
  for (const placement of board.footprintPlacements) {
872
1309
  if (!componentsById.has(placement.componentId)) {
873
- issues.push(unresolvedIssue('board-terminal-unresolved', 'warning', `Board "${board.id}" places missing component "${placement.componentId}"`, board.id, placement.componentId));
1310
+ issues.push(unresolvedIssue("board-terminal-unresolved", "warning", `Board "${board.id}" places missing component "${placement.componentId}"`, board.id, placement.componentId));
874
1311
  continue;
875
1312
  }
876
1313
  for (const pad of placement.pads) {
877
1314
  if (pad.terminalName !== undefined &&
878
1315
  !componentHasTerminal(componentsById, placement.componentId, pad.terminalName)) {
879
- issues.push(unresolvedIssue('board-terminal-unresolved', 'warning', `Board "${board.id}" pad "${pad.padId}" references missing terminal "${pad.terminalName}"`, board.id, pad.padId));
1316
+ issues.push(unresolvedIssue("board-terminal-unresolved", "warning", `Board "${board.id}" pad "${pad.padId}" references missing terminal "${pad.terminalName}"`, board.id, pad.padId));
880
1317
  }
881
1318
  }
882
1319
  }
883
1320
  for (const net of board.netlist?.nets ?? []) {
884
1321
  for (const member of net.members) {
885
1322
  if (!componentTerminalExists(componentsById, member)) {
886
- issues.push(unresolvedIssue('board-terminal-unresolved', 'warning', `Board "${board.id}" net "${net.id}" references missing component terminal`, board.id, net.id));
1323
+ issues.push(unresolvedIssue("board-terminal-unresolved", "warning", `Board "${board.id}" net "${net.id}" references missing component terminal`, board.id, net.id));
887
1324
  }
888
1325
  }
889
1326
  }
890
1327
  for (const route of board.routes) {
891
1328
  if (route.zones !== undefined || route.drills !== undefined) {
892
1329
  issues.push({
893
- code: 'board-route-feature-invalid',
894
- severity: 'error',
1330
+ code: "board-route-feature-invalid",
1331
+ severity: "error",
895
1332
  message: `Board "${board.id}" route "${route.id}" contains board-level zones or drills`,
896
1333
  componentId: board.id,
897
1334
  property: route.id,
898
1335
  });
899
1336
  }
900
- if (isBoardNetlistRef(route.netRef) && !boardNetRefExists(route.netRef, board.id, boardNetsByBoardId)) {
901
- issues.push(unresolvedIssue('offboard-signal-unresolved', 'warning', `Board "${board.id}" route "${route.id}" references missing board net "${route.netRef.netId}"`, board.id, route.id));
1337
+ if (isBoardNetlistRef(route.netRef) &&
1338
+ !boardNetRefExists(route.netRef, board.id, boardNetsByBoardId)) {
1339
+ issues.push(unresolvedIssue("offboard-signal-unresolved", "warning", `Board "${board.id}" route "${route.id}" references missing board net "${route.netRef.netId}"`, board.id, route.id));
902
1340
  }
903
1341
  }
904
1342
  return issues;
@@ -918,10 +1356,10 @@ function validateOffBoardWiring(doc, componentsById, panelElementIds, boardTermi
918
1356
  }
919
1357
  for (const connection of harness.connections) {
920
1358
  if (!localEndpointIds.has(connection.fromEndpointId)) {
921
- issues.push(unresolvedIssue('offboard-endpoint-unresolved', 'error', `Harness "${harness.id}" connection "${connection.id}" references missing endpoint "${connection.fromEndpointId}"`, harness.id, connection.id));
1359
+ issues.push(unresolvedIssue("offboard-endpoint-unresolved", "error", `Harness "${harness.id}" connection "${connection.id}" references missing endpoint "${connection.fromEndpointId}"`, harness.id, connection.id));
922
1360
  }
923
1361
  if (!localEndpointIds.has(connection.toEndpointId)) {
924
- issues.push(unresolvedIssue('offboard-endpoint-unresolved', 'error', `Harness "${harness.id}" connection "${connection.id}" references missing endpoint "${connection.toEndpointId}"`, harness.id, connection.id));
1362
+ issues.push(unresolvedIssue("offboard-endpoint-unresolved", "error", `Harness "${harness.id}" connection "${connection.id}" references missing endpoint "${connection.toEndpointId}"`, harness.id, connection.id));
925
1363
  }
926
1364
  if (connection.signalRef !== undefined) {
927
1365
  const issue = validateOffBoardSignalRef(connection.signalRef, componentsById, boardNetsByBoardId, harness.id);
@@ -937,33 +1375,38 @@ function validateOffBoardWiring(doc, componentsById, panelElementIds, boardTermi
937
1375
  continue;
938
1376
  }
939
1377
  for (const connection of harness.connections) {
940
- if (!endpointIds.has(connection.fromEndpointId) || !endpointIds.has(connection.toEndpointId)) {
941
- issues.push(unresolvedIssue('offboard-endpoint-unresolved', 'error', `Selected harness "${harnessId}" contains an unresolved connection endpoint`, harnessId, connection.id));
1378
+ if (!endpointIds.has(connection.fromEndpointId) ||
1379
+ !endpointIds.has(connection.toEndpointId)) {
1380
+ issues.push(unresolvedIssue("offboard-endpoint-unresolved", "error", `Selected harness "${harnessId}" contains an unresolved connection endpoint`, harnessId, connection.id));
942
1381
  }
943
1382
  }
944
1383
  }
945
1384
  return issues;
946
1385
  }
947
1386
  function validateOffBoardEndpoint(endpoint, componentsById, panelElementIds, boardTerminalsByBoardId) {
948
- if (endpoint.kind === 'board-terminal') {
949
- const terminalIds = endpoint.boardId === undefined ? undefined : boardTerminalsByBoardId.get(endpoint.boardId);
950
- if (terminalIds === undefined || endpoint.terminalId === undefined || !terminalIds.has(endpoint.terminalId)) {
951
- return unresolvedIssue('offboard-endpoint-unresolved', 'error', `Off-board endpoint "${endpoint.id}" references missing board terminal`, endpoint.id, 'terminalId');
1387
+ if (endpoint.kind === "board-terminal") {
1388
+ const terminalIds = endpoint.boardId === undefined
1389
+ ? undefined
1390
+ : boardTerminalsByBoardId.get(endpoint.boardId);
1391
+ if (terminalIds === undefined ||
1392
+ endpoint.terminalId === undefined ||
1393
+ !terminalIds.has(endpoint.terminalId)) {
1394
+ return unresolvedIssue("offboard-endpoint-unresolved", "error", `Off-board endpoint "${endpoint.id}" references missing board terminal`, endpoint.id, "terminalId");
952
1395
  }
953
1396
  return undefined;
954
1397
  }
955
- if (endpoint.kind === 'panel-component-terminal' ||
956
- endpoint.kind === 'power-terminal' ||
957
- endpoint.kind === 'footswitch-terminal') {
1398
+ if (endpoint.kind === "panel-component-terminal" ||
1399
+ endpoint.kind === "power-terminal" ||
1400
+ endpoint.kind === "footswitch-terminal") {
958
1401
  if (endpoint.componentId === undefined ||
959
1402
  endpoint.terminalName === undefined ||
960
1403
  !componentHasTerminal(componentsById, endpoint.componentId, endpoint.terminalName)) {
961
- return unresolvedIssue('offboard-endpoint-unresolved', 'error', `Off-board endpoint "${endpoint.id}" references missing component terminal`, endpoint.id, 'componentId');
1404
+ return unresolvedIssue("offboard-endpoint-unresolved", "error", `Off-board endpoint "${endpoint.id}" references missing component terminal`, endpoint.id, "componentId");
962
1405
  }
963
1406
  if (endpoint.panelElementId !== undefined &&
964
- endpoint.kind !== 'power-terminal' &&
1407
+ endpoint.kind !== "power-terminal" &&
965
1408
  !panelElementIds.has(endpoint.panelElementId)) {
966
- return unresolvedIssue('offboard-endpoint-unresolved', 'warning', `Off-board endpoint "${endpoint.id}" references missing panel element "${endpoint.panelElementId}"`, endpoint.id, 'panelElementId');
1409
+ return unresolvedIssue("offboard-endpoint-unresolved", "warning", `Off-board endpoint "${endpoint.id}" references missing panel element "${endpoint.panelElementId}"`, endpoint.id, "panelElementId");
967
1410
  }
968
1411
  }
969
1412
  return undefined;
@@ -971,16 +1414,18 @@ function validateOffBoardEndpoint(endpoint, componentsById, panelElementIds, boa
971
1414
  function validateOffBoardSignalRef(signalRef, componentsById, boardNetsByBoardId, harnessId) {
972
1415
  if (isBoardNetlistRef(signalRef)) {
973
1416
  if (!boardNetRefExists(signalRef, signalRef.boardId, boardNetsByBoardId)) {
974
- return unresolvedIssue('offboard-signal-unresolved', 'error', `Harness "${harnessId}" references missing board net "${signalRef.netId}"`, harnessId, 'signalRef');
1417
+ return unresolvedIssue("offboard-signal-unresolved", "error", `Harness "${harnessId}" references missing board net "${signalRef.netId}"`, harnessId, "signalRef");
975
1418
  }
976
1419
  return undefined;
977
1420
  }
978
- const member = dataObject(signalRef, 'member');
979
- const componentId = dataString(member, 'componentId');
980
- const terminalName = dataString(member, 'terminalName');
981
- if (dataString(signalRef, 'source') === 'canonical-circuit' && componentId !== undefined && terminalName !== undefined) {
1421
+ const member = dataObject(signalRef, "member");
1422
+ const componentId = dataString(member, "componentId");
1423
+ const terminalName = dataString(member, "terminalName");
1424
+ if (dataString(signalRef, "source") === "canonical-circuit" &&
1425
+ componentId !== undefined &&
1426
+ terminalName !== undefined) {
982
1427
  if (!componentHasTerminal(componentsById, componentId, terminalName)) {
983
- return unresolvedIssue('offboard-signal-unresolved', 'error', `Harness "${harnessId}" references missing canonical component terminal`, harnessId, 'signalRef');
1428
+ return unresolvedIssue("offboard-signal-unresolved", "error", `Harness "${harnessId}" references missing canonical component terminal`, harnessId, "signalRef");
984
1429
  }
985
1430
  }
986
1431
  return undefined;
@@ -989,7 +1434,7 @@ function validateCompleteSelectedBoardRoutes(board) {
989
1434
  const issues = [];
990
1435
  const routedNetIds = new Set(board.routes
991
1436
  .filter((route) => isRouteForBoardNet(route, board.id))
992
- .map((route) => dataString(route.netRef, 'netId'))
1437
+ .map((route) => dataString(route.netRef, "netId"))
993
1438
  .filter((netId) => netId !== undefined));
994
1439
  for (const net of board.netlist?.nets ?? []) {
995
1440
  if (isSingleTerminalEdgeNet(net)) {
@@ -997,8 +1442,8 @@ function validateCompleteSelectedBoardRoutes(board) {
997
1442
  }
998
1443
  if (!routedNetIds.has(net.id)) {
999
1444
  issues.push({
1000
- code: 'board-net-unrouted',
1001
- severity: 'error',
1445
+ code: "board-net-unrouted",
1446
+ severity: "error",
1002
1447
  message: `Selected board "${board.id}" net "${net.id}" has multiple members but no route`,
1003
1448
  componentId: board.id,
1004
1449
  property: net.id,
@@ -1017,7 +1462,8 @@ function isRouteForBoardNet(route, boardId) {
1017
1462
  return route.netRef.boardId === undefined || route.netRef.boardId === boardId;
1018
1463
  }
1019
1464
  function isBoardNetlistRef(value) {
1020
- return dataString(value, 'source') === 'board-netlist' && dataString(value, 'netId') !== undefined;
1465
+ return (dataString(value, "source") === "board-netlist" &&
1466
+ dataString(value, "netId") !== undefined);
1021
1467
  }
1022
1468
  function boardNetRefExists(ref, fallbackBoardId, boardNetsByBoardId) {
1023
1469
  const boardId = ref.boardId ?? fallbackBoardId;
@@ -1045,21 +1491,23 @@ function componentTerminalExists(componentsById, ref) {
1045
1491
  return componentHasTerminal(componentsById, ref.componentId, ref.terminalName);
1046
1492
  }
1047
1493
  function componentHasTerminal(componentsById, componentId, terminalName) {
1048
- return componentsById.get(componentId)?.terminals.some((terminal) => terminal.name === terminalName) === true;
1494
+ return (componentsById
1495
+ .get(componentId)
1496
+ ?.terminals.some((terminal) => terminal.name === terminalName) === true);
1049
1497
  }
1050
1498
  function isDigestShapedSourceHash(hash) {
1051
1499
  return /^sha256:[0-9a-f]{64}$/i.test(hash);
1052
1500
  }
1053
1501
  function dataString(object, key) {
1054
1502
  const value = object?.[key];
1055
- return typeof value === 'string' ? value : undefined;
1503
+ return typeof value === "string" ? value : undefined;
1056
1504
  }
1057
1505
  function dataObject(object, key) {
1058
1506
  const value = object?.[key];
1059
1507
  return isBuildDataObject(value) ? value : undefined;
1060
1508
  }
1061
1509
  function isBuildDataObject(value) {
1062
- return typeof value === 'object' && value !== null && !Array.isArray(value);
1510
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1063
1511
  }
1064
1512
  function unresolvedIssue(code, severity, message, componentId, property) {
1065
1513
  return {
@@ -1072,8 +1520,8 @@ function unresolvedIssue(code, severity, message, componentId, property) {
1072
1520
  }
1073
1521
  function missingPropertyIssue(component, rule) {
1074
1522
  return {
1075
- code: rule.kind === 'string' ? 'model-required' : 'value-required',
1076
- severity: 'error',
1523
+ code: rule.kind === "string" ? "model-required" : "value-required",
1524
+ severity: "error",
1077
1525
  message: `${component.id} (${component.kind}): missing required property "${rule.name}"`,
1078
1526
  componentId: component.id,
1079
1527
  property: rule.name,