@packet-schema/core 0.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 (46) hide show
  1. package/dist/collect-refs.d.ts +4 -0
  2. package/dist/collect-refs.d.ts.map +1 -0
  3. package/dist/collect-refs.js +76 -0
  4. package/dist/collect-refs.js.map +1 -0
  5. package/dist/constraint.d.ts +66 -0
  6. package/dist/constraint.d.ts.map +1 -0
  7. package/dist/constraint.js +286 -0
  8. package/dist/constraint.js.map +1 -0
  9. package/dist/expr.d.ts +32 -0
  10. package/dist/expr.d.ts.map +1 -0
  11. package/dist/expr.js +190 -0
  12. package/dist/expr.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +19 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/layout.d.ts +7 -0
  18. package/dist/layout.d.ts.map +1 -0
  19. package/dist/layout.js +217 -0
  20. package/dist/layout.js.map +1 -0
  21. package/dist/normalize.d.ts +37 -0
  22. package/dist/normalize.d.ts.map +1 -0
  23. package/dist/normalize.js +857 -0
  24. package/dist/normalize.js.map +1 -0
  25. package/dist/types.d.ts +538 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +27 -0
  28. package/dist/types.js.map +1 -0
  29. package/dist/utils.d.ts +3 -0
  30. package/dist/utils.d.ts.map +1 -0
  31. package/dist/utils.js +4 -0
  32. package/dist/utils.js.map +1 -0
  33. package/dist/validate.d.ts +8 -0
  34. package/dist/validate.d.ts.map +1 -0
  35. package/dist/validate.js +1222 -0
  36. package/dist/validate.js.map +1 -0
  37. package/dist/values.d.ts +31 -0
  38. package/dist/values.d.ts.map +1 -0
  39. package/dist/values.js +73 -0
  40. package/dist/values.js.map +1 -0
  41. package/dist/yaml.d.ts +11 -0
  42. package/dist/yaml.d.ts.map +1 -0
  43. package/dist/yaml.js +145 -0
  44. package/dist/yaml.js.map +1 -0
  45. package/package.json +35 -0
  46. package/schemas/psdl-0.5.yaml +1223 -0
@@ -0,0 +1,1222 @@
1
+ // PSDL semantic validator — walks the Container tree and enforces invariants
2
+ // (§11.1) that JSON Schema alone cannot express: id format, expression
3
+ // placement, ref-cycle detection, switch case key format, import uniqueness.
4
+ import { walkExpr } from "./expr.js";
5
+ import { isField } from "./utils.js";
6
+ import { BIN_OPS } from "./types.js";
7
+ const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
8
+ const DECIMAL_INT_RE = /^(0|[1-9][0-9]*)$/;
9
+ /** Ternary bit-pattern for ValueEntry.pattern (§5.3): 0, 1, or x/X. */
10
+ const PATTERN_RE = /^[01xX]+$/;
11
+ const SWITCH_KEY_RE = /^(_|(0|[1-9][0-9]*)|(0|[1-9][0-9]*)-(0|[1-9][0-9]*)|(0|[1-9][0-9]*)(,(0|[1-9][0-9]*))+)$/;
12
+ /** Hex-string form for wide checksum params (§8, D9): `^0x[0-9A-Fa-f]+$`. */
13
+ const HEX_PARAM_RE = /^0x[0-9A-Fa-f]+$/;
14
+ /** True if a `bytes.n` is the delimiter form, however malformed (§3, D3). */
15
+ function isBytesDelimitedShape(n) {
16
+ return typeof n === "object" && n !== null && !Array.isArray(n) && "delimiter" in n;
17
+ }
18
+ const NORM_LEVELS = new Set(["must", "should", "may"]);
19
+ /** The closed nine-token category set (§5.1, schema CategoryToken enum). */
20
+ const CATEGORY_TOKEN_SET = new Set([
21
+ "addressing", "identifier", "length", "type", "flags",
22
+ "reserved", "checksum", "variable", "payload-marker",
23
+ ]);
24
+ /**
25
+ * Named checksum algorithms that do NOT use the CRC parameter model (§8). Using
26
+ * `checksumParams` with one of these is a validation error (§11.1) because they
27
+ * have fixed internal parameters structurally incompatible with the CRC set.
28
+ */
29
+ const NON_CRC_CHECKSUM_ALGORITHMS = new Set(["internet", "adler32"]);
30
+ /** ValueEntry keys (schema ValueEntry, `additionalProperties: false`, §5.3). */
31
+ const VALUE_ENTRY_KEYS = new Set([
32
+ "value", "range", "pattern", "name", "label", "doc", "level", "meta",
33
+ ]);
34
+ /** Enum variant object keys (schema EnumVariant, `additionalProperties: false`, §3). */
35
+ const ENUM_VARIANT_KEYS = new Set(["label", "doc", "level", "meta"]);
36
+ /** Constraint keys (schema Constraint, `additionalProperties: false`, §9). */
37
+ const CONSTRAINT_KEYS = new Set(["lhs", "rhs", "doc", "level"]);
38
+ /* ------------------------------------------------------------------ *
39
+ * RFC provenance shape (§5.4)
40
+ * ------------------------------------------------------------------ */
41
+ /**
42
+ * A single `updates` entry (§5.4): a bare integer RFC number, or an object
43
+ * `{ rfc (required integer), section? (string) }` with no surplus keys. A bare
44
+ * number N means `{ rfc: N }` (no section); the object form names the section
45
+ * of that updating RFC.
46
+ */
47
+ function isValidUpdateRef(u) {
48
+ if (typeof u === "number")
49
+ return Number.isInteger(u);
50
+ if (typeof u !== "object" || u === null || Array.isArray(u))
51
+ return false;
52
+ const o = u;
53
+ for (const key of Object.keys(o)) {
54
+ if (key !== "rfc" && key !== "section")
55
+ return false;
56
+ }
57
+ if (!Number.isInteger(o.rfc))
58
+ return false;
59
+ if (o.section !== undefined && typeof o.section !== "string")
60
+ return false;
61
+ return true;
62
+ }
63
+ /** RfcRef shape (§5.4): a bare integer, or `{ defined, updates? }` with no surplus keys. */
64
+ function isValidRfcRef(v) {
65
+ if (typeof v === "number")
66
+ return Number.isInteger(v);
67
+ if (typeof v !== "object" || v === null || Array.isArray(v))
68
+ return false;
69
+ const o = v;
70
+ for (const key of Object.keys(o)) {
71
+ if (key !== "defined" && key !== "updates")
72
+ return false;
73
+ }
74
+ if (!Number.isInteger(o.defined))
75
+ return false;
76
+ if (o.updates !== undefined &&
77
+ (!Array.isArray(o.updates) || !o.updates.every((u) => isValidUpdateRef(u))))
78
+ return false;
79
+ return true;
80
+ }
81
+ /** FieldMeta keys (schema FieldMeta, `additionalProperties: false`). */
82
+ const FIELD_META_KEYS = new Set(["rfc", "section"]);
83
+ /** PacketMeta keys (schema PacketMeta additionally allows `aliases`, `tags`, `family`). */
84
+ const PACKET_META_KEYS = new Set(["rfc", "section", "aliases", "tags", "family"]);
85
+ /**
86
+ * Lightweight `meta` shape check (§5.4), mirroring the schema's FieldMeta /
87
+ * PacketMeta / RfcRef defs so the two validation layers agree on what a
88
+ * well-formed provenance annotation is (the schema rejects these shapes too):
89
+ * unknown keys are rejected (`additionalProperties: false`), `section` must be
90
+ * a string, `aliases` (packet meta only) must be an array of strings.
91
+ */
92
+ function validateMeta(meta, ctx, errors, allowedKeys = FIELD_META_KEYS) {
93
+ if (meta === undefined)
94
+ return;
95
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
96
+ errors.push({ message: `${ctx}: meta must be an object (§5.4).` });
97
+ return;
98
+ }
99
+ for (const key of Object.keys(meta)) {
100
+ if (!allowedKeys.has(key))
101
+ errors.push({ message: `${ctx}: meta has unknown key "${key}" (allowed: ${[...allowedKeys].join(", ")}) (§5.4).` });
102
+ }
103
+ const { rfc, section, aliases, tags, family } = meta;
104
+ if (rfc !== undefined && !isValidRfcRef(rfc))
105
+ errors.push({ message: `${ctx}: meta.rfc must be an integer or { defined, updates? } where each updates entry is an integer or { rfc, section? } (§5.4).` });
106
+ if (section !== undefined && typeof section !== "string")
107
+ errors.push({ message: `${ctx}: meta.section must be a string (e.g. "3.1") (§5.4).` });
108
+ if (allowedKeys.has("aliases") && aliases !== undefined &&
109
+ (!Array.isArray(aliases) || !aliases.every((a) => typeof a === "string")))
110
+ errors.push({ message: `${ctx}: meta.aliases must be an array of strings.` });
111
+ // §1.1: free-form catalog classification — the language checks only the shape
112
+ // (string[] / string); the vocabulary is governed by the catalog layer.
113
+ if (allowedKeys.has("tags") && tags !== undefined &&
114
+ (!Array.isArray(tags) || !tags.every((t) => typeof t === "string")))
115
+ errors.push({ message: `${ctx}: meta.tags must be an array of strings (§1.1).` });
116
+ if (allowedKeys.has("family") && family !== undefined && typeof family !== "string")
117
+ errors.push({ message: `${ctx}: meta.family must be a string (§1.1).` });
118
+ }
119
+ /* ------------------------------------------------------------------ *
120
+ * Structural expression well-formedness
121
+ * ------------------------------------------------------------------ */
122
+ export function isValidExpr(expr) {
123
+ if (typeof expr !== "object" || expr === null)
124
+ return false;
125
+ const e = expr;
126
+ switch (e.kind) {
127
+ case "lit":
128
+ return typeof expr.value === "number" &&
129
+ Number.isFinite(expr.value);
130
+ case "ref":
131
+ case "prevIter":
132
+ case "enclosingField":
133
+ return typeof expr.field === "string";
134
+ case "op": {
135
+ const o = expr;
136
+ return typeof o.op === "string" && BIN_OPS.includes(o.op) &&
137
+ isValidExpr(o.a) && isValidExpr(o.b);
138
+ }
139
+ case "cond": {
140
+ const c = expr;
141
+ return isValidExpr(c.test) && isValidExpr(c.t) && isValidExpr(c.f);
142
+ }
143
+ case "peek": {
144
+ const p = expr;
145
+ if (typeof p.bits !== "number" || !Number.isInteger(p.bits) || p.bits < 1 || p.bits > 64)
146
+ return false;
147
+ return p.offset === undefined || isValidExpr(p.offset);
148
+ }
149
+ case "lookup": {
150
+ const l = expr;
151
+ if (!isValidExpr(l.key))
152
+ return false;
153
+ if (typeof l.table !== "object" || l.table === null)
154
+ return false;
155
+ // §4/§11.1: every lookup key and value must be a non-negative decimal
156
+ // integer. Keys arrive as object-key strings; values must be integers.
157
+ for (const [k, v] of Object.entries(l.table)) {
158
+ if (!DECIMAL_INT_RE.test(k))
159
+ return false;
160
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0)
161
+ return false;
162
+ }
163
+ return true;
164
+ }
165
+ case "wireSize":
166
+ return typeof expr.target === "string";
167
+ case "remaining":
168
+ case "enclosingBits":
169
+ return true;
170
+ default:
171
+ return false;
172
+ }
173
+ }
174
+ const PEEK_ALLOWED = new Set([
175
+ "switch.on", "optional.when", "repeat.count", "repeat.until",
176
+ ]);
177
+ function validateExprPlacement(expr, slot, ctx, errors, pc,
178
+ /**
179
+ * §10.7 carve-out (passed ONLY from the two `repeat.count`/`repeat.until`
180
+ * call sites in validateRepeat): the set of referenceable ids contributed by
181
+ * the SAME repeat's element subtree. A leaf `ref` naming one of these resolves
182
+ * to the just-completed iteration's field value (§10.7) and is therefore
183
+ * exempt from the §10.1 forward-reference rule. The set is built from
184
+ * `element.fields` only, so it excludes the repeat container's own id and any
185
+ * non-element sibling — a self-ref to the repeat id or a forward ref to a
186
+ * later sibling still errors. Undefined at every other call site.
187
+ */
188
+ elementRefExempt) {
189
+ walkExpr(expr, (e) => {
190
+ if (e.kind === "peek" && !PEEK_ALLOWED.has(slot))
191
+ errors.push({ message: `${ctx}: peek may not appear in ${slot} (allowed: switch.on, optional.when, repeat.count/until).` });
192
+ if (e.kind === "enclosingField" && slot !== "constraint")
193
+ errors.push({ message: `${ctx}: enclosingField may only appear in constraints, not ${slot}.` });
194
+ if (e.kind === "prevIter" && slot !== "repeat.count" && slot !== "repeat.until")
195
+ errors.push({ message: `${ctx}: prevIter may only appear in repeat.count or repeat.until, not ${slot}.` });
196
+ if (pc === undefined)
197
+ return;
198
+ // §D11: leaf `ref`/`wireSize` existence check. Applies to body and
199
+ // constraint expressions (pc present). A target naming nothing declared
200
+ // anywhere in the document is a typo and a validation error.
201
+ if (pc.documentDeclaredIds !== undefined &&
202
+ (e.kind === "ref" || e.kind === "wireSize")) {
203
+ const id = e.kind === "ref" ? e.field : e.target;
204
+ const head = id.includes(".") ? id.slice(0, id.indexOf(".")) : id;
205
+ if (id.includes("#")) {
206
+ errors.push({ message: `${ctx}: '#'-qualified ids may not appear in expressions (repeat-indexed instances are not referenceable, §2/§10.4).` });
207
+ }
208
+ else if (id.includes(".") && pc.importPrefixes?.has(head)) {
209
+ // Import-qualified (e.g. addr.ipv4Addr.oct0): resolution is deferred to
210
+ // the import-resolving layer (§1.2); the core validator does not check it.
211
+ }
212
+ else if (!pc.documentDeclaredIds.has(id)) {
213
+ errors.push({ message: `${ctx}: ${e.kind === "ref" ? "ref" : "wireSize"} target "${id}" is not declared anywhere in the document (§11.1).` });
214
+ }
215
+ }
216
+ // §11.1: remaining/enclosingBits scope-provider placement. Constraints are
217
+ // evaluated over the fully-parsed env, not at a body position, so they are
218
+ // exempt from the scope-provider restriction.
219
+ if (slot !== "constraint") {
220
+ if (e.kind === "remaining" && !pc.remainingOk)
221
+ errors.push({ message: `${ctx}: 'remaining' used outside a scope-providing container with a defined byte budget (§11.1).` });
222
+ if (e.kind === "enclosingBits" && !pc.enclosingBitsOk)
223
+ errors.push({ message: `${ctx}: 'enclosingBits' used outside a scope-providing container that carries an injected bit budget (§11.1).` });
224
+ // §10.1/§11.1: forward-reference rules for wireSize and repeat-id ref.
225
+ if (e.kind === "wireSize") {
226
+ if (pc.openIds.has(e.target))
227
+ errors.push({ message: `${ctx}: wireSize target "${e.target}" is an enclosing/not-yet-closed container (§11.1).` });
228
+ else if (!pc.declaredIds.has(e.target))
229
+ errors.push({ message: `${ctx}: wireSize target "${e.target}" does not precede this expression in document order (§11.1).` });
230
+ }
231
+ // §10.1/§11.1: a leaf `ref` in a body slot may name only a field/container
232
+ // that PRECEDES it in document order. A ref to a repeat container id keeps
233
+ // its specific message; every other leaf ref (including a self-size ref, a
234
+ // ref to a later field, or a dotted ref-expanded target not yet closed) is
235
+ // caught by the general forward branch. The check is gated on
236
+ // `declaredExprIds` (document-order-so-far) and on the target EXISTING in
237
+ // `documentDeclaredIds` — a typo is already reported above as "not declared
238
+ // anywhere", so it must not also be reported here. Import-/`#`-qualified ids
239
+ // took their early-out branches above and are not re-flagged. Constraints
240
+ // are §10.1-exempt: `declaredExprIds` is undefined for them, so this branch
241
+ // is skipped.
242
+ if (e.kind === "ref" && pc.declaredExprIds !== undefined &&
243
+ pc.documentDeclaredIds !== undefined && !pc.declaredExprIds.has(e.field) &&
244
+ !e.field.includes("#") &&
245
+ !(e.field.includes(".") && pc.importPrefixes?.has(e.field.slice(0, e.field.indexOf("."))))) {
246
+ if (pc.repeatIds.has(e.field) && !pc.declaredIds.has(e.field)) {
247
+ errors.push({ message: `${ctx}: ref to repeat container id "${e.field}" precedes that repeat in document order (§11.1).` });
248
+ }
249
+ else if (pc.documentDeclaredIds.has(e.field) && !elementRefExempt?.has(e.field)) {
250
+ // §10.7 carve-out: a `repeat.count`/`repeat.count.until` ordinary ref
251
+ // to a field of the SAME repeat's element is NOT forward — it resolves
252
+ // to the just-completed iteration's value. `elementRefExempt` carries
253
+ // exactly those element-field ids (and only at those two call sites),
254
+ // so the repeat's own id and any later sibling still fall through to
255
+ // this error (excluded from the exempt set by construction).
256
+ errors.push({ message: `${ctx}: ref target "${e.field}" does not precede this expression in document order, or refers to its own container (a self-size ref); a body expression may reference only fields declared before it (§10.1/§11.1).` });
257
+ }
258
+ }
259
+ }
260
+ });
261
+ }
262
+ /* ------------------------------------------------------------------ *
263
+ * Types
264
+ * ------------------------------------------------------------------ */
265
+ function validateType(type, ctx, errors, pc) {
266
+ switch (type.kind) {
267
+ case "int":
268
+ if (!Number.isInteger(type.bits) || type.bits <= 0)
269
+ errors.push({ message: `${ctx}: int must have positive integer bits, got ${type.bits}.` });
270
+ return;
271
+ case "enum": {
272
+ if (!Number.isInteger(type.bits) || type.bits <= 0)
273
+ errors.push({ message: `${ctx}: enum must have positive integer bits, got ${type.bits}.` });
274
+ // `!Array.isArray`: a YAML list of labels is not a variants table — the
275
+ // schema's `variants: { type: object }` rejects arrays, so reject here
276
+ // too (typeof [] === "object" would otherwise let it through).
277
+ if (type.variants && typeof type.variants === "object" && !Array.isArray(type.variants)) {
278
+ for (const [k, v] of Object.entries(type.variants)) {
279
+ if (!DECIMAL_INT_RE.test(k))
280
+ errors.push({ message: `${ctx}: enum variant key "${k}" must be a non-negative decimal integer (§3).` });
281
+ if (typeof v === "object" && v !== null) {
282
+ const o = v;
283
+ for (const key of Object.keys(o)) {
284
+ // Mirrors the schema's EnumVariant `additionalProperties: false`
285
+ // so a typo'd annotation key cannot silently vanish (§3, §5.4).
286
+ if (!ENUM_VARIANT_KEYS.has(key))
287
+ errors.push({ message: `${ctx}: enum variant "${k}" has unknown key "${key}" (allowed: ${[...ENUM_VARIANT_KEYS].join(", ")}) (§3).` });
288
+ }
289
+ if (typeof o.label !== "string")
290
+ errors.push({ message: `${ctx}: enum variant "${k}" must have a string label (§3).` });
291
+ // Mirrors the schema's EnumVariant `doc` `type: string` (§3).
292
+ if (o.doc !== undefined && typeof o.doc !== "string")
293
+ errors.push({ message: `${ctx}: enum variant "${k}" doc must be a string (§3).` });
294
+ if (o.level !== undefined && !NORM_LEVELS.has(o.level))
295
+ errors.push({ message: `${ctx}: enum variant "${k}" has invalid level "${String(o.level)}" (must be must|should|may).` });
296
+ validateMeta(o.meta, `${ctx}: enum variant "${k}"`, errors);
297
+ }
298
+ else if (typeof v !== "string") {
299
+ errors.push({ message: `${ctx}: enum variant "${k}" must be a string label or an object with a label (§3).` });
300
+ }
301
+ }
302
+ }
303
+ else {
304
+ // Mirrors the schema's `required: [kind, bits, variants]` so the two
305
+ // validation layers agree (§3). An empty `{}` table is fine.
306
+ errors.push({ message: `${ctx}: enum must have a variants object (§3).` });
307
+ }
308
+ return;
309
+ }
310
+ case "bits":
311
+ if (!Number.isInteger(type.n) || type.n <= 0)
312
+ errors.push({ message: `${ctx}: bits must have positive integer n, got ${type.n}.` });
313
+ return;
314
+ case "bytes":
315
+ // §3 (D3): bytes.n is either an Expr or the delimiter form.
316
+ if (isBytesDelimitedShape(type.n)) {
317
+ const delim = type.n.delimiter;
318
+ // The delimiter form accepts only the `delimiter` key (mirrors schema
319
+ // BytesDelimited additionalProperties:false).
320
+ for (const key of Object.keys(type.n))
321
+ if (key !== "delimiter")
322
+ errors.push({ message: `${ctx}: bytes.n delimiter form accepts only the "delimiter" key (got "${key}") (§3).` });
323
+ if (!Array.isArray(delim) || delim.length < 1)
324
+ errors.push({ message: `${ctx}: bytes.n delimiter must be a non-empty array of byte integers (§3/§11.1).` });
325
+ else if (!delim.every((b) => Number.isInteger(b) && b >= 0 && b <= 255))
326
+ errors.push({ message: `${ctx}: bytes.n delimiter elements must be integers in 0–255 (§3/§11.1).` });
327
+ }
328
+ else if (!isValidExpr(type.n))
329
+ errors.push({ message: `${ctx}: bytes has a malformed length expression.` });
330
+ else
331
+ validateExprPlacement(type.n, "bytes.n", ctx, errors, pc);
332
+ return;
333
+ case "varint":
334
+ if (typeof type.encoding !== "string" || type.encoding.length === 0)
335
+ errors.push({ message: `${ctx}: varint encoding must be a non-empty string.` });
336
+ return;
337
+ case "berLength":
338
+ if (type.maxBytes !== undefined && (!Number.isInteger(type.maxBytes) || type.maxBytes < 1 || type.maxBytes > 5))
339
+ errors.push({ message: `${ctx}: berLength maxBytes must be 1–5, got ${type.maxBytes}.` });
340
+ return;
341
+ default:
342
+ errors.push({ message: `${ctx}: unknown type kind "${type.kind}".` });
343
+ }
344
+ }
345
+ /**
346
+ * §8/§11.1 (D9): validate `checksumParams` width and the polynomial/initValue/
347
+ * finalXOR numeric forms. A bare integer is allowed only when it is a safe
348
+ * integer (≤ 2^53−1); a wider value MUST use the `^0x[0-9A-Fa-f]+$` hex string
349
+ * so its full 64-bit precision survives. A non-int/bits checksum field (e.g. a
350
+ * `bytes` checksum) requires an explicit `width` because its declared bit width
351
+ * is not unique.
352
+ */
353
+ function validateChecksumParams(field, ctx, errors) {
354
+ const p = field.checksumParams;
355
+ for (const key of ["polynomial", "initValue", "finalXOR"]) {
356
+ const v = p[key];
357
+ if (v === undefined)
358
+ continue;
359
+ if (typeof v === "string") {
360
+ if (!HEX_PARAM_RE.test(v))
361
+ errors.push({ message: `${ctx}: checksumParams.${key} hex string "${v}" must match ^0x[0-9A-Fa-f]+$ (§8/§11.1).` });
362
+ }
363
+ else if (typeof v === "number") {
364
+ if (!Number.isInteger(v) || v < 0)
365
+ errors.push({ message: `${ctx}: checksumParams.${key} must be a non-negative integer or a 0x hex string (§8).` });
366
+ else if (!Number.isSafeInteger(v))
367
+ errors.push({ message: `${ctx}: checksumParams.${key} exceeds 2^53−1 and must be written as a ^0x[0-9A-Fa-f]+$ hex string to preserve precision (§8/§11.1).` });
368
+ }
369
+ else {
370
+ errors.push({ message: `${ctx}: checksumParams.${key} must be a non-negative integer or a 0x hex string (§8).` });
371
+ }
372
+ }
373
+ // width: 1–64 if present; required when the field type lacks a single
374
+ // declared bit width (bytes/varint/berLength).
375
+ const typeHasDeclaredWidth = field.type.kind === "int" || field.type.kind === "bits" || field.type.kind === "enum";
376
+ if (p.width !== undefined) {
377
+ if (!Number.isInteger(p.width) || p.width < 1 || p.width > 64)
378
+ errors.push({ message: `${ctx}: checksumParams.width must be an integer in 1–64, got ${String(p.width)} (§8).` });
379
+ }
380
+ else if (!typeHasDeclaredWidth) {
381
+ errors.push({ message: `${ctx}: checksumParams on a field whose type has no single declared bit width (e.g. bytes) requires an explicit width (§8/§11.1).` });
382
+ }
383
+ }
384
+ /** Decode a mask given as a non-negative integer or a 0x hex string to BigInt (§12, D4). */
385
+ function decodeMask(mask) {
386
+ if (typeof mask === "number") {
387
+ if (!Number.isInteger(mask) || mask < 0)
388
+ return undefined;
389
+ return BigInt(mask);
390
+ }
391
+ if (typeof mask === "string" && HEX_PARAM_RE.test(mask))
392
+ return BigInt(mask);
393
+ return undefined;
394
+ }
395
+ /** Subfield keys (schema Subfield, `additionalProperties: false`, §12). */
396
+ const SUBFIELD_KEYS = new Set([
397
+ "id", "name", "mask", "doc", "values", "level", "category", "meta",
398
+ ]);
399
+ /**
400
+ * §12/§11.1 (D4): validate `subfields`. Permitted only on `int` or a
401
+ * byte-aligned `bits` field (n a multiple of 8). Each `mask` must be a
402
+ * non-negative integer or a 0x hex string and fit within the parent's declared
403
+ * bit width (mask < 2^width), decoded at BigInt precision so masks above 53 bits
404
+ * are exact. Overlap / zero masks are a §11.4 lint, not a hard error.
405
+ */
406
+ function validateSubfields(field, ctx, errors) {
407
+ const subs = field.subfields;
408
+ if (!Array.isArray(subs)) {
409
+ errors.push({ message: `${ctx}: subfields must be an array (§12).` });
410
+ return;
411
+ }
412
+ const t = field.type;
413
+ const isInt = t.kind === "int";
414
+ const isByteAlignedBits = t.kind === "bits" && Number.isInteger(t.n) && t.n % 8 === 0;
415
+ if (!isInt && !isByteAlignedBits) {
416
+ errors.push({ message: `${ctx}: subfields are only allowed on an int field or a byte-aligned bits field (n a multiple of 8), §12/§11.1.` });
417
+ return;
418
+ }
419
+ const width = isInt ? t.bits : t.n;
420
+ const limit = 1n << BigInt(width);
421
+ subs.forEach((sf, i) => {
422
+ const tag = `${ctx}: subfields[${i}]`;
423
+ if (typeof sf !== "object" || sf === null) {
424
+ errors.push({ message: `${tag} must be an object (§12).` });
425
+ return;
426
+ }
427
+ for (const key of Object.keys(sf))
428
+ if (!SUBFIELD_KEYS.has(key))
429
+ errors.push({ message: `${tag} has unknown key "${key}" (allowed: ${[...SUBFIELD_KEYS].join(", ")}) (§12).` });
430
+ if (typeof sf.id !== "string" || !ID_RE.test(sf.id))
431
+ errors.push({ message: `${tag}: id "${String(sf.id)}" must match ${ID_RE} (§12).` });
432
+ if (typeof sf.name !== "string")
433
+ errors.push({ message: `${tag}: name must be a string (§12).` });
434
+ const m = decodeMask(sf.mask);
435
+ if (m === undefined)
436
+ errors.push({ message: `${tag}: mask must be a non-negative integer or a ^0x[0-9A-Fa-f]+$ hex string (§12).` });
437
+ else if (m >= limit)
438
+ errors.push({ message: `${tag}: mask ${String(sf.mask)} does not fit within the field's declared ${width}-bit width (§12/§11.1).` });
439
+ if (sf.level !== undefined && !NORM_LEVELS.has(sf.level))
440
+ errors.push({ message: `${tag}: invalid level "${String(sf.level)}" (must be must|should|may) (§12).` });
441
+ if (sf.category !== undefined && !CATEGORY_TOKEN_SET.has(sf.category))
442
+ errors.push({ message: `${tag}: category "${String(sf.category)}" is not one of the nine category tokens (§5.1).` });
443
+ if (sf.doc !== undefined && typeof sf.doc !== "string")
444
+ errors.push({ message: `${tag}: doc must be a string (§12).` });
445
+ validateMeta(sf.meta, tag, errors);
446
+ });
447
+ }
448
+ function validateField(field, ctx, w) {
449
+ if (typeof field.id !== "string" || !ID_RE.test(field.id))
450
+ w.errors.push({ message: `${ctx}: field id "${String(field.id)}" must match ${ID_RE}.` });
451
+ if (typeof field.name !== "string")
452
+ w.errors.push({ message: `${ctx}: field "${field.id}" is missing a name.` });
453
+ if (!field.type) {
454
+ w.errors.push({ message: `${ctx}: field "${field.id}" is missing a type.` });
455
+ return;
456
+ }
457
+ validateType(field.type, `${ctx}/${field.id}`, w.errors, w.pc);
458
+ validateMeta(field.meta, `${ctx}/${field.id}`, w.errors);
459
+ // §5.1/§11.1: `category` is a closed nine-token set. An unknown token (e.g. a
460
+ // typo'd "checsum" or a not-yet-standard token) is a validation error, mirroring
461
+ // the schema's CategoryToken enum so the two validation layers agree.
462
+ if (field.category !== undefined && !CATEGORY_TOKEN_SET.has(field.category))
463
+ w.errors.push({ message: `${ctx}/${field.id}: category "${String(field.category)}" is not one of the nine category tokens (§5.1).` });
464
+ // §8/§11.1: `checksumParams` may only refine a CRC parameter model. Using it
465
+ // with a named non-CRC algorithm (`internet`, `adler32`) is a validation error
466
+ // — these algorithms have fixed internal parameters incompatible with the CRC set.
467
+ if (field.checksumParams !== undefined &&
468
+ field.checksumAlgorithm !== undefined &&
469
+ NON_CRC_CHECKSUM_ALGORITHMS.has(field.checksumAlgorithm))
470
+ w.errors.push({ message: `${ctx}/${field.id}: checksumParams cannot be used with the non-CRC algorithm "${field.checksumAlgorithm}" (§8/§11.1).` });
471
+ if (field.checksumParams !== undefined)
472
+ validateChecksumParams(field, `${ctx}/${field.id}`, w.errors);
473
+ if (field.subfields !== undefined)
474
+ validateSubfields(field, `${ctx}/${field.id}`, w.errors);
475
+ if (field.byteOrder !== undefined && field.byteOrder !== "BE" && field.byteOrder !== "LE")
476
+ w.errors.push({ message: `${ctx}/${field.id}: byteOrder must be 'BE' or 'LE'.` });
477
+ if (field.computedFrom !== undefined) {
478
+ if (field.computedFrom.kind !== "wireSize")
479
+ w.errors.push({ message: `${ctx}/${field.id}: computedFrom must be a wireSize expression.` });
480
+ else
481
+ validateExprPlacement(field.computedFrom, "computedFrom", `${ctx}/${field.id}`, w.errors, w.pc);
482
+ }
483
+ if (field.values !== undefined) {
484
+ if (!Array.isArray(field.values)) {
485
+ w.errors.push({ message: `${ctx}/${field.id}: values must be an array (§5.3).` });
486
+ }
487
+ else {
488
+ field.values.forEach((ve, i) => {
489
+ const tag = `${ctx}/${field.id}: values[${i}]`;
490
+ // Guard against null/non-object entries (e.g. YAML `values: [~]`):
491
+ // the validator must report, not crash on, untrusted input.
492
+ if (typeof ve !== "object" || ve === null) {
493
+ w.errors.push({ message: `${tag} must be an object (§5.3).` });
494
+ return;
495
+ }
496
+ // Mirrors the schema's ValueEntry `additionalProperties: false` so a
497
+ // typo'd annotation key (e.g. "lable") cannot silently vanish (§5.3).
498
+ for (const key of Object.keys(ve)) {
499
+ if (!VALUE_ENTRY_KEYS.has(key))
500
+ w.errors.push({ message: `${tag} has unknown key "${key}" (allowed: ${[...VALUE_ENTRY_KEYS].join(", ")}) (§5.3).` });
501
+ }
502
+ const hasValue = ve.value !== undefined;
503
+ const hasRange = ve.range !== undefined;
504
+ const hasPattern = ve.pattern !== undefined;
505
+ const forms = (hasValue ? 1 : 0) + (hasRange ? 1 : 0) + (hasPattern ? 1 : 0);
506
+ if (forms !== 1)
507
+ w.errors.push({ message: `${tag} must set exactly one of 'value', 'range', or 'pattern' (§5.3).` });
508
+ // Values may be negative: signed int fields annotate negative codes (§5.3).
509
+ if (hasValue && !Number.isInteger(ve.value))
510
+ w.errors.push({ message: `${tag}: value must be an integer.` });
511
+ if (hasRange) {
512
+ const r = ve.range;
513
+ if (!Array.isArray(r) || r.length !== 2 ||
514
+ !Number.isInteger(r[0]) || !Number.isInteger(r[1]) ||
515
+ r[1] < r[0])
516
+ w.errors.push({ message: `${tag}: range must be [min, max] integers with min ≤ max.` });
517
+ }
518
+ if (hasPattern && (typeof ve.pattern !== "string" || !PATTERN_RE.test(ve.pattern)))
519
+ w.errors.push({ message: `${tag}: pattern must be a non-empty string of 0, 1, or x/X.` });
520
+ // Mirrors the schema's ValueEntry `name`/`label`/`doc` `type: string`
521
+ // so a YAML scalar that parses as a number (e.g. `name: 404`) is
522
+ // rejected by both validation layers (§5.3, §16.4).
523
+ if (ve.name !== undefined && typeof ve.name !== "string")
524
+ w.errors.push({ message: `${tag}: name must be a string (§5.3).` });
525
+ if (ve.label !== undefined && typeof ve.label !== "string")
526
+ w.errors.push({ message: `${tag}: label must be a string (§5.3).` });
527
+ if (ve.doc !== undefined && typeof ve.doc !== "string")
528
+ w.errors.push({ message: `${tag}: doc must be a string (§5.3).` });
529
+ if (ve.level !== undefined && !NORM_LEVELS.has(ve.level))
530
+ w.errors.push({ message: `${tag}: invalid level "${String(ve.level)}" (must be must|should|may).` });
531
+ validateMeta(ve.meta, tag, w.errors);
532
+ });
533
+ }
534
+ }
535
+ }
536
+ /** Run `body` with `id` marked open on the parse stack (§10.1 wireSize rule). */
537
+ function withOpen(w, id, body) {
538
+ if (w.pc === undefined || id === undefined) {
539
+ body();
540
+ return;
541
+ }
542
+ const added = !w.pc.openIds.has(id);
543
+ if (added)
544
+ w.pc.openIds.add(id);
545
+ try {
546
+ body();
547
+ }
548
+ finally {
549
+ if (added)
550
+ w.pc.openIds.delete(id);
551
+ }
552
+ }
553
+ function validateGroup(g, ctx, w) {
554
+ validateMeta(g.meta, `${ctx}/${g.id}`, w.errors);
555
+ if (!Array.isArray(g.children))
556
+ w.errors.push({ message: `${ctx}: group "${g.id}" must have a children array.` });
557
+ else
558
+ withOpen(w, g.id, () => {
559
+ for (const child of g.children)
560
+ validateContainerCtx(child, `${ctx}/${g.id}`, w);
561
+ });
562
+ }
563
+ function validateRepeat(r, ctx, w) {
564
+ const sub = `${ctx}/${r.id}`;
565
+ // §10.7: a `repeat.count`/`repeat.count.until` ordinary `ref` to a field of
566
+ // this repeat's element resolves to the just-completed iteration's value and
567
+ // is exempt from the §10.1 forward-reference rule. Build the exempt set from
568
+ // the element subtree's referenceable ids (bare + dotted, exactly as a
569
+ // within-element ref would name them). Walking `element.fields` — NOT the
570
+ // repeat container — means this set does NOT include the repeat's own id, so
571
+ // a self-ref to the repeat id (or a forward ref to a non-element sibling)
572
+ // still errors. The set is passed ONLY to the count/until placement calls.
573
+ const elemIds = new Set();
574
+ if (w.pc && r.element && Array.isArray(r.element.fields))
575
+ collectDeclaredIds(r.element.fields, w.defs, "", elemIds, new Set(), 0);
576
+ if (r.count === "eos") {
577
+ /* ok */
578
+ }
579
+ else if (typeof r.count === "object" && "until" in r.count) {
580
+ // Mirrors schema RepeatCount until-form additionalProperties:false: the
581
+ // count object accepts only the `until` key.
582
+ for (const key of Object.keys(r.count))
583
+ if (key !== "until")
584
+ w.errors.push({ message: `${sub}: repeat count until-object accepts only the "until" key (got "${key}").` });
585
+ if (!isValidExpr(r.count.until))
586
+ w.errors.push({ message: `${sub}: repeat until has a malformed expression.` });
587
+ else
588
+ validateExprPlacement(r.count.until, "repeat.until", sub, w.errors, w.pc, elemIds);
589
+ }
590
+ else if (!isValidExpr(r.count)) {
591
+ w.errors.push({ message: `${sub}: repeat count has a malformed expression.` });
592
+ }
593
+ else {
594
+ validateExprPlacement(r.count, "repeat.count", sub, w.errors, w.pc, elemIds);
595
+ }
596
+ if (!r.element || !Array.isArray(r.element.fields))
597
+ w.errors.push({ message: `${sub}: repeat is missing an element struct.` });
598
+ else
599
+ withOpen(w, r.id, () => validateStruct(r.element, sub, w));
600
+ }
601
+ function validateSwitch(s, ctx, w) {
602
+ const sub = `${ctx}/${s.id}`;
603
+ if (!isValidExpr(s.on))
604
+ w.errors.push({ message: `${sub}: switch has an invalid discriminator expression.` });
605
+ else
606
+ validateExprPlacement(s.on, "switch.on", sub, w.errors, w.pc);
607
+ if (!s.cases || typeof s.cases !== "object") {
608
+ w.errors.push({ message: `${sub}: switch is missing cases.` });
609
+ return;
610
+ }
611
+ withOpen(w, s.id, () => {
612
+ for (const [key, struct] of Object.entries(s.cases)) {
613
+ if (!SWITCH_KEY_RE.test(key)) {
614
+ w.errors.push({ message: `${sub}: invalid switch case key "${key}" (use decimal, "lo-hi", "a,b,c", or "_").` });
615
+ }
616
+ else {
617
+ const range = /^(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(key);
618
+ if (range && Number(range[1]) > Number(range[2]))
619
+ w.errors.push({ message: `${sub}: invalid range switch case key "${key}" (lo must be <= hi).` });
620
+ }
621
+ validateStruct(struct, `${sub}/${key}`, w);
622
+ }
623
+ });
624
+ }
625
+ function validateEncrypted(e, ctx, w) {
626
+ const sub = `${ctx}/${e.id}`;
627
+ validateMeta(e.meta, sub, w.errors);
628
+ if (!e.plaintext || !Array.isArray(e.plaintext.fields))
629
+ w.errors.push({ message: `${sub}: encrypted container must have a plaintext struct.` });
630
+ if (e.wireBits !== undefined) {
631
+ if (!isValidExpr(e.wireBits))
632
+ w.errors.push({ message: `${sub}: encrypted wireBits is malformed.` });
633
+ else
634
+ validateExprPlacement(e.wireBits, "wireBits", sub, w.errors, w.pc);
635
+ }
636
+ // §5/§11.1 (D6): every `headerProtected` id must resolve to either (a) a
637
+ // direct leaf field of this encrypted container's plaintext, or (b) a field
638
+ // declared earlier in the SAME body in document order (a plaintext-external
639
+ // header field a header-protection scheme reorders/masks, e.g. QUIC's first
640
+ // byte and packet number). The "earlier same-body" set is exactly the
641
+ // document-order declaredIds tracked so far (w.pc). This is the single
642
+ // normative resolution set shared with normalize (walkEncrypted).
643
+ if (Array.isArray(e.headerProtected) && e.plaintext && Array.isArray(e.plaintext.fields)) {
644
+ const resolvable = new Set();
645
+ for (const c of e.plaintext.fields)
646
+ if (isField(c) && typeof c.id === "string")
647
+ resolvable.add(c.id);
648
+ if (w.pc !== undefined)
649
+ for (const id of w.pc.declaredIds)
650
+ resolvable.add(id);
651
+ for (const hp of e.headerProtected) {
652
+ if (typeof hp !== "string" || !resolvable.has(hp))
653
+ w.errors.push({ message: `${sub}: headerProtected id "${String(hp)}" resolves to neither a plaintext field of this encrypted container nor a field declared earlier in the same body (§5/§11.1).` });
654
+ }
655
+ }
656
+ if (!e.plaintext)
657
+ return;
658
+ // §5/§11.1: an encrypted.plaintext with wireBits provides a bit budget; one
659
+ // without wireBits has no defined budget, so remaining/enclosingBits inside
660
+ // it are validation errors (regardless of any outer top-body budget).
661
+ const prevPc = w.pc;
662
+ if (prevPc !== undefined) {
663
+ const ok = e.wireBits !== undefined;
664
+ w.pc = { ...prevPc, remainingOk: ok, enclosingBitsOk: ok };
665
+ }
666
+ try {
667
+ withOpen(w, e.id, () => validateStruct(e.plaintext, sub, w));
668
+ }
669
+ finally {
670
+ if (prevPc !== undefined)
671
+ w.pc = prevPc;
672
+ }
673
+ }
674
+ function validateBounded(b, ctx, w) {
675
+ const sub = `${ctx}/${b.id}`;
676
+ validateMeta(b.meta, sub, w.errors);
677
+ if (!isValidExpr(b.bytes))
678
+ w.errors.push({ message: `${sub}: bounded bytes is malformed.` });
679
+ else
680
+ validateExprPlacement(b.bytes, "bounded.bytes", sub, w.errors, w.pc);
681
+ if (!Array.isArray(b.fields)) {
682
+ w.errors.push({ message: `${sub}: bounded must have a fields array.` });
683
+ return;
684
+ }
685
+ // A `bounded` scope carries a byte budget, so `remaining` is well-defined
686
+ // inside it (§4). `enclosingBits` resolves to the nearest bit-budget provider,
687
+ // which the enclosing context already tracks.
688
+ const prevPc = w.pc;
689
+ if (prevPc !== undefined)
690
+ w.pc = { ...prevPc, remainingOk: true };
691
+ try {
692
+ withOpen(w, b.id, () => {
693
+ for (const child of b.fields)
694
+ validateContainerCtx(child, sub, w);
695
+ });
696
+ }
697
+ finally {
698
+ if (prevPc !== undefined)
699
+ w.pc = prevPc;
700
+ }
701
+ }
702
+ function validateOptional(o, ctx, w) {
703
+ validateMeta(o.meta, ctx, w.errors);
704
+ if (!isValidExpr(o.when))
705
+ w.errors.push({ message: `${ctx}: optional has an invalid 'when' expression.` });
706
+ else
707
+ validateExprPlacement(o.when, "optional.when", ctx, w.errors, w.pc);
708
+ validateContainerCtx(o.container, ctx, w);
709
+ }
710
+ export function validateContainer(c, ctx) {
711
+ const w = { errors: [], defs: {}, inDef: false };
712
+ validateContainerCtx(c, ctx, w);
713
+ if (w.errors.length > 0)
714
+ throw new Error(w.errors[0].message);
715
+ }
716
+ /** The declarable id of a container, if any (used for document-order tracking). */
717
+ function containerId(c) {
718
+ if (isField(c))
719
+ return c.id;
720
+ switch (c.kind) {
721
+ case "group":
722
+ case "repeat":
723
+ case "switch":
724
+ case "encrypted":
725
+ case "bounded":
726
+ case "ref":
727
+ case "virtual":
728
+ return c.id;
729
+ case "optional":
730
+ case "align":
731
+ return c.id;
732
+ }
733
+ }
734
+ function validateContainerCtx(c, ctx, w) {
735
+ dispatchContainer(c, ctx, w);
736
+ // §10.1: register this container's id as declared/closed in document order so
737
+ // later siblings' wireSize/repeat-id references resolve, but not earlier ones.
738
+ // declaredExprIds additionally captures the richer subtree set (dotted +
739
+ // bare-tail + local-ref-expanded ids) for the §D11 leaf-ref forward check, in
740
+ // the SAME document-order position. Both registrations happen post-dispatch,
741
+ // so a container's own id is not yet present while its body expressions are
742
+ // validated (self-size ref correctly flagged).
743
+ if (w.pc) {
744
+ const id = containerId(c);
745
+ if (typeof id === "string")
746
+ w.pc.declaredIds.add(id);
747
+ if (w.pc.declaredExprIds !== undefined)
748
+ collectSubtreeDeclaredIds(c, w.defs, "", w.pc.declaredExprIds, new Set(), 0);
749
+ }
750
+ }
751
+ function dispatchContainer(c, ctx, w) {
752
+ if (isField(c)) {
753
+ validateField(c, ctx, w);
754
+ return;
755
+ }
756
+ switch (c.kind) {
757
+ case "group":
758
+ validateGroup(c, ctx, w);
759
+ return;
760
+ case "repeat":
761
+ validateRepeat(c, ctx, w);
762
+ return;
763
+ case "switch":
764
+ validateSwitch(c, ctx, w);
765
+ return;
766
+ case "encrypted":
767
+ validateEncrypted(c, ctx, w);
768
+ return;
769
+ case "bounded":
770
+ validateBounded(c, ctx, w);
771
+ return;
772
+ case "optional":
773
+ validateOptional(c, ctx, w);
774
+ return;
775
+ case "virtual":
776
+ if (typeof c.id !== "string" || !ID_RE.test(c.id))
777
+ w.errors.push({ message: `${ctx}: virtual id "${String(c.id)}" must match ${ID_RE}.` });
778
+ if (w.inDef)
779
+ w.errors.push({ message: `${ctx}: virtual field "${c.id}" is forbidden inside a defs struct.` });
780
+ if (!isValidExpr(c.expr))
781
+ w.errors.push({ message: `${ctx}: virtual "${c.id}" has a malformed expr.` });
782
+ else
783
+ validateExprPlacement(c.expr, "virtual.expr", ctx, w.errors, w.pc);
784
+ return;
785
+ case "align":
786
+ if (!Number.isInteger(c.to) || c.to < 1 || (c.to & (c.to - 1)) !== 0 || c.to % 8 !== 0)
787
+ w.errors.push({ message: `${ctx}: align 'to' must be a power of 2 and a multiple of 8, got ${c.to}.` });
788
+ if (c.fill !== undefined && (!Number.isInteger(c.fill) || c.fill < 0 || c.fill > 255))
789
+ w.errors.push({ message: `${ctx}: align 'fill' must be 0–255, got ${c.fill}.` });
790
+ return;
791
+ case "ref":
792
+ if (typeof c.ref !== "string" || c.ref.length === 0)
793
+ w.errors.push({ message: `${ctx}: ref container is missing 'ref'.` });
794
+ else if (!w.defs[c.ref] && !c.ref.includes("."))
795
+ w.errors.push({ message: `${ctx}: ref target "${c.ref}" not found in defs.` });
796
+ if (typeof c.id !== "string" || !ID_RE.test(c.id))
797
+ w.errors.push({ message: `${ctx}: ref container id "${String(c.id)}" must match ${ID_RE}.` });
798
+ return;
799
+ }
800
+ }
801
+ function validateStruct(s, ctx, w) {
802
+ validateMeta(s.meta, `${ctx}/${s.id ?? "?"}`, w.errors);
803
+ if (!Array.isArray(s.fields)) {
804
+ w.errors.push({ message: `${ctx}/${s.id ?? "?"}: struct must have a fields array.` });
805
+ return;
806
+ }
807
+ for (const child of s.fields)
808
+ validateContainerCtx(child, `${ctx}/${s.id}`, w);
809
+ }
810
+ /* ------------------------------------------------------------------ *
811
+ * Ref cycle detection (§6, §11.1)
812
+ * ------------------------------------------------------------------ */
813
+ function refTargets(fields, out) {
814
+ for (const c of fields) {
815
+ if (isField(c))
816
+ continue;
817
+ switch (c.kind) {
818
+ case "ref":
819
+ out.push(c.ref);
820
+ break;
821
+ case "group":
822
+ refTargets(c.children, out);
823
+ break;
824
+ case "bounded":
825
+ refTargets(c.fields, out);
826
+ break;
827
+ case "optional":
828
+ refTargets([c.container], out);
829
+ break;
830
+ case "repeat":
831
+ refTargets(c.element.fields, out);
832
+ break;
833
+ case "encrypted":
834
+ refTargets(c.plaintext.fields, out);
835
+ break;
836
+ case "switch":
837
+ for (const arm of Object.values(c.cases))
838
+ refTargets(arm.fields, out);
839
+ break;
840
+ }
841
+ }
842
+ }
843
+ /** All repeat container ids reachable in `fields` (§10.1 repeat-id ref rule). */
844
+ function collectRepeatIds(fields, out) {
845
+ for (const c of fields) {
846
+ if (isField(c))
847
+ continue;
848
+ switch (c.kind) {
849
+ case "repeat":
850
+ out.add(c.id);
851
+ collectRepeatIds(c.element.fields, out);
852
+ break;
853
+ case "group":
854
+ collectRepeatIds(c.children, out);
855
+ break;
856
+ case "bounded":
857
+ collectRepeatIds(c.fields, out);
858
+ break;
859
+ case "optional":
860
+ collectRepeatIds([c.container], out);
861
+ break;
862
+ case "encrypted":
863
+ collectRepeatIds(c.plaintext.fields, out);
864
+ break;
865
+ case "switch":
866
+ for (const arm of Object.values(c.cases))
867
+ collectRepeatIds(arm.fields, out);
868
+ break;
869
+ }
870
+ }
871
+ }
872
+ /**
873
+ * §D11: collect every id a body/constraint `ref`/`wireSize` may legally name.
874
+ * This includes: each authored container/field id (bare), each local-ref-
875
+ * expanded dotted id (`{ref.id}.{field.id}`, recursing through local defs with
876
+ * a one-level cap for recursive defs), and — to honour §6 nearest-preceding
877
+ * bare-id resolution — the bare tail segment of every expanded id (so a bare
878
+ * `oct0` referencing an expanded `src.oct0` resolves). Import-qualified ref
879
+ * targets are NOT expanded here; those are deferred to the import layer (§1.2)
880
+ * and detected via `importPrefixes` at the use site.
881
+ */
882
+ function collectDeclaredIds(containers, defs, prefix, out, seenRefs, depth) {
883
+ if (depth > 64)
884
+ return;
885
+ for (const c of containers)
886
+ collectSubtreeDeclaredIds(c, defs, prefix, out, seenRefs, depth);
887
+ }
888
+ /**
889
+ * §D11: emit every body/constraint-referenceable id contributed by a SINGLE
890
+ * container `c` and its subtree (full dotted id + bare tail segment, with local
891
+ * `ref` targets expanded once). Used both up-front to build `documentDeclaredIds`
892
+ * (via collectDeclaredIds) and incrementally — at each container's post-dispatch
893
+ * close point — to build the document-order `declaredExprIds` set the forward-
894
+ * reference check consults. Because the up-front and incremental builders share
895
+ * this helper, the two sets agree on exactly which ids exist; they differ only
896
+ * in WHEN an id appears (incrementally, an id appears only once its container has
897
+ * closed in document order), which is precisely the §10.1 forward-order signal.
898
+ */
899
+ function collectSubtreeDeclaredIds(c, defs, prefix, out, seenRefs, depth) {
900
+ if (depth > 64)
901
+ return;
902
+ const add = (id) => {
903
+ const full = prefix ? `${prefix}.${id}` : id;
904
+ out.add(full);
905
+ out.add(id); // bare tail segment (nearest-preceding bare-id resolution, §6)
906
+ };
907
+ if (isField(c)) {
908
+ if (typeof c.id === "string")
909
+ add(c.id);
910
+ return;
911
+ }
912
+ switch (c.kind) {
913
+ case "virtual":
914
+ if (typeof c.id === "string")
915
+ add(c.id);
916
+ break;
917
+ case "group":
918
+ add(c.id);
919
+ collectDeclaredIds(c.children, defs, prefix, out, seenRefs, depth);
920
+ break;
921
+ case "bounded":
922
+ add(c.id);
923
+ collectDeclaredIds(c.fields, defs, prefix, out, seenRefs, depth);
924
+ break;
925
+ case "optional":
926
+ if (typeof c.id === "string")
927
+ add(c.id);
928
+ collectDeclaredIds([c.container], defs, prefix, out, seenRefs, depth);
929
+ break;
930
+ case "encrypted":
931
+ add(c.id);
932
+ collectDeclaredIds(c.plaintext.fields, defs, prefix, out, seenRefs, depth);
933
+ break;
934
+ case "repeat":
935
+ add(c.id);
936
+ collectDeclaredIds(c.element.fields, defs, prefix ? `${prefix}.${c.id}` : c.id, out, seenRefs, depth);
937
+ break;
938
+ case "switch":
939
+ add(c.id);
940
+ for (const arm of Object.values(c.cases))
941
+ collectDeclaredIds(arm.fields, defs, prefix, out, seenRefs, depth);
942
+ break;
943
+ case "align":
944
+ if (typeof c.id === "string")
945
+ add(c.id);
946
+ break;
947
+ case "ref": {
948
+ add(c.id);
949
+ if (c.ref.includes("."))
950
+ break; // import-qualified: defer to import layer
951
+ if (seenRefs.has(c.ref))
952
+ break; // recursive def: expand once
953
+ const def = defs[c.ref];
954
+ if (!def)
955
+ break;
956
+ collectDeclaredIds(def.fields, defs, prefix ? `${prefix}.${c.id}` : c.id, out, new Set([...seenRefs, c.ref]), depth + 1);
957
+ break;
958
+ }
959
+ }
960
+ }
961
+ /** True if the two arm-paths can never be selected simultaneously (§5). */
962
+ function mutuallyExclusive(a, b) {
963
+ const n = Math.min(a.length, b.length);
964
+ for (let i = 0; i < n; i++) {
965
+ if (a[i].switchId === b[i].switchId) {
966
+ if (a[i].key !== b[i].key)
967
+ return true;
968
+ }
969
+ else {
970
+ // Diverged on different switches before any shared discriminating switch:
971
+ // they are not mutually exclusive through this position.
972
+ return false;
973
+ }
974
+ }
975
+ return false;
976
+ }
977
+ /**
978
+ * §2/§11.1: detect two declarations that would emit the SAME expanded id while
979
+ * both able to be present in the env at once. "Expanded id" here is the id of
980
+ * the emitted NormalizedField: the ref-prefix-joined id (group/optional/bounded/
981
+ * encrypted/align do NOT contribute to the prefix; only `ref` does). The `#N`
982
+ * repeat-index suffix is a runtime instance handle (§6) and is NOT part of this
983
+ * static id; a repeat container instead opens a distinct id namespace (its
984
+ * element-field ids cannot collide with non-repeat siblings), captured by
985
+ * threading the repeat id into the prefix path. Mutually-exclusive switch arms
986
+ * may legally reuse an id (§5); every other same-namespace duplicate — a flat
987
+ * sibling duplicate, a duplicated ref instantiation id, an in-arm duplicate, or
988
+ * an arm-vs-enclosing clash — is a validation error.
989
+ *
990
+ * SCOPE LIMITATION (mirrors detectRefCycles): only local-def `ref` targets are
991
+ * expanded. An import-qualified ref (a `ref` containing `.`) is not expanded
992
+ * here, so collisions inside imported defs are left to the import-resolving
993
+ * layer, which MUST re-run this check over the merged def tree.
994
+ */
995
+ function detectExpandedIdCollisions(packet, defs, errors) {
996
+ // eid -> records seen so far. A new record collides with an existing one iff
997
+ // they are NOT mutually exclusive.
998
+ const seen = new Map();
999
+ const register = (eid, armPath, ctx) => {
1000
+ const prior = seen.get(eid);
1001
+ if (prior === undefined) {
1002
+ seen.set(eid, [{ armPath, ctx }]);
1003
+ return;
1004
+ }
1005
+ for (const rec of prior) {
1006
+ if (!mutuallyExclusive(rec.armPath, armPath)) {
1007
+ errors.push({
1008
+ message: `expanded id "${eid}" is declared more than once where both declarations can be live at the same time (${rec.ctx} and ${ctx}); ids that share an expanded id must be in mutually-exclusive switch arms (§2/§11.1).`,
1009
+ });
1010
+ // Still record so further duplicates are reported against this one too.
1011
+ }
1012
+ }
1013
+ prior.push({ armPath, ctx });
1014
+ };
1015
+ // prefix is the ref/repeat namespace path (joined with "."); armPath tracks
1016
+ // switch-arm selection. seenRefs guards recursive defs (expand at most once).
1017
+ const walk = (containers, prefix, armPath, ctx, seenRefs, depth) => {
1018
+ if (depth > 64)
1019
+ return; // recursive-def boundary (§6/§10.7)
1020
+ for (const c of containers) {
1021
+ const eidOf = (id) => (prefix ? `${prefix}.${id}` : id);
1022
+ if (isField(c)) {
1023
+ if (typeof c.id === "string")
1024
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1025
+ continue;
1026
+ }
1027
+ switch (c.kind) {
1028
+ case "virtual":
1029
+ if (typeof c.id === "string")
1030
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1031
+ break;
1032
+ case "group":
1033
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1034
+ walk(c.children, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
1035
+ break;
1036
+ case "bounded":
1037
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1038
+ walk(c.fields, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
1039
+ break;
1040
+ case "optional":
1041
+ walk([c.container], prefix, armPath, ctx, seenRefs, depth);
1042
+ break;
1043
+ case "encrypted":
1044
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1045
+ walk(c.plaintext.fields, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
1046
+ break;
1047
+ case "repeat":
1048
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1049
+ // The repeat opens a distinct namespace: its element fields are keyed
1050
+ // by the repeat id so they cannot collide with non-repeat siblings,
1051
+ // and each iteration is distinguished by the runtime #N suffix.
1052
+ walk(c.element.fields, eidOf(c.id), armPath, `${ctx}/${c.id}`, seenRefs, depth);
1053
+ break;
1054
+ case "switch":
1055
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1056
+ for (const [key, arm] of Object.entries(c.cases)) {
1057
+ walk(arm.fields, prefix, [...armPath, { switchId: c.id, key }], `${ctx}/${c.id}[${key}]`, seenRefs, depth);
1058
+ }
1059
+ break;
1060
+ case "ref": {
1061
+ register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
1062
+ // Only expand local-def targets; import-qualified refs are deferred.
1063
+ if (c.ref.includes("."))
1064
+ break;
1065
+ if (seenRefs.has(c.ref))
1066
+ break; // recursive def: expand once
1067
+ const def = defs[c.ref];
1068
+ if (!def)
1069
+ break;
1070
+ walk(def.fields, eidOf(c.id), armPath, `${ctx}/${c.id}`, new Set([...seenRefs, c.ref]), depth + 1);
1071
+ break;
1072
+ }
1073
+ case "align":
1074
+ break;
1075
+ }
1076
+ }
1077
+ };
1078
+ walk(packet.body ?? [], "", [], packet.name, new Set(), 0);
1079
+ }
1080
+ /**
1081
+ * Detect circular references through non-recursive defs (§6/§11.1), direct or
1082
+ * indirect. SCOPE LIMITATION: this check only follows ref targets that resolve
1083
+ * to a local `def` (`defs[t]`). An import-qualified ref target (one containing a
1084
+ * `.`, accepted unconditionally in dispatchContainer because imports are not
1085
+ * resolved in this module) is NOT followed, so a cycle that passes through an
1086
+ * import boundary cannot be detected here. Import resolution is a tool-layer
1087
+ * concern; the tool layer that resolves imports MUST re-run cycle detection
1088
+ * over the merged def set to complete the §6 check for transitive import edges.
1089
+ */
1090
+ function detectRefCycles(defs, errors) {
1091
+ const visiting = new Set();
1092
+ const done = new Set();
1093
+ const dfs = (name, stack) => {
1094
+ if (done.has(name))
1095
+ return;
1096
+ const def = defs[name];
1097
+ if (!def)
1098
+ return;
1099
+ if (def.recursive)
1100
+ return; // recursive defs are allowed to cycle (§6)
1101
+ if (visiting.has(name)) {
1102
+ errors.push({ message: `defs: circular reference through non-recursive def "${name}" (${[...stack, name].join(" → ")}).` });
1103
+ return;
1104
+ }
1105
+ visiting.add(name);
1106
+ const targets = [];
1107
+ refTargets(def.fields, targets);
1108
+ for (const t of targets) {
1109
+ if (defs[t])
1110
+ dfs(t, [...stack, name]);
1111
+ }
1112
+ visiting.delete(name);
1113
+ done.add(name);
1114
+ };
1115
+ for (const name of Object.keys(defs))
1116
+ dfs(name, []);
1117
+ }
1118
+ export function validatePacket(packet) {
1119
+ const errors = [];
1120
+ if (typeof packet.name !== "string" || packet.name.length === 0)
1121
+ errors.push({ message: "Packet must have a non-empty name." });
1122
+ const rowBits = packet.rendererHints?.rowBits ?? packet.rowBits;
1123
+ if (rowBits !== undefined && (!Number.isInteger(rowBits) || rowBits <= 0))
1124
+ errors.push({ message: `rowBits must be a positive integer, got ${String(rowBits)}.` });
1125
+ if (!Array.isArray(packet.body))
1126
+ errors.push({ message: "Packet must have a body array." });
1127
+ if (packet.byteOrder !== undefined && packet.byteOrder !== "BE" && packet.byteOrder !== "LE")
1128
+ errors.push({ message: `byteOrder must be 'BE' or 'LE', got "${String(packet.byteOrder)}".` });
1129
+ if (packet.version !== undefined && !/^\d+\.\d+$/.test(packet.version))
1130
+ errors.push({ message: `version must be "MAJOR.MINOR", got "${packet.version}".` });
1131
+ validateMeta(packet.meta, "packet", errors, PACKET_META_KEYS);
1132
+ // Imports: unique `as` prefix, valid format.
1133
+ if (packet.imports) {
1134
+ const seen = new Set();
1135
+ for (const imp of packet.imports) {
1136
+ if (typeof imp.source !== "string" || imp.source.length === 0)
1137
+ errors.push({ message: `imports: entry is missing a source.` });
1138
+ if (typeof imp.as !== "string" || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(imp.as))
1139
+ errors.push({ message: `imports: 'as' prefix "${String(imp.as)}" must match [a-zA-Z][a-zA-Z0-9_]*.` });
1140
+ else if (seen.has(imp.as))
1141
+ errors.push({ message: `imports: duplicate 'as' prefix "${imp.as}".` });
1142
+ else
1143
+ seen.add(imp.as);
1144
+ }
1145
+ }
1146
+ const defs = packet.defs ?? {};
1147
+ detectRefCycles(defs, errors);
1148
+ // §2/§11.1: two declarations that emit the same expanded id while both can be
1149
+ // live at once (outside mutually-exclusive switch arms) are a validation error.
1150
+ detectExpandedIdCollisions(packet, defs, errors);
1151
+ // Body — single shared WalkCtx so document-order tracking (§10.1) spans the
1152
+ // whole body. The top-level body is a scope provider that carries both a byte
1153
+ // budget (`remaining`) and an injected bit budget (`enclosingBits`), §4.
1154
+ const repeatIds = new Set();
1155
+ collectRepeatIds(packet.body ?? [], repeatIds);
1156
+ // §D11: the set of ids a body/constraint ref/wireSize may name, and the
1157
+ // import `as` prefixes whose dotted refs are deferred to the import layer.
1158
+ const documentDeclaredIds = new Set();
1159
+ collectDeclaredIds(packet.body ?? [], defs, "", documentDeclaredIds, new Set(), 0);
1160
+ const importPrefixes = new Set((packet.imports ?? [])
1161
+ .map((imp) => imp.as)
1162
+ .filter((a) => typeof a === "string"));
1163
+ const pc = {
1164
+ remainingOk: true,
1165
+ enclosingBitsOk: true,
1166
+ declaredIds: new Set(),
1167
+ declaredExprIds: new Set(),
1168
+ openIds: new Set(),
1169
+ repeatIds,
1170
+ documentDeclaredIds,
1171
+ importPrefixes,
1172
+ };
1173
+ const bodyW = { errors, defs, inDef: false, pc };
1174
+ for (const c of packet.body ?? []) {
1175
+ validateContainerCtx(c, packet.name, bodyW);
1176
+ }
1177
+ // Defs bodies (virtual forbidden inside)
1178
+ for (const [name, def] of Object.entries(defs)) {
1179
+ const w = { errors, defs, inDef: true };
1180
+ // §5.4/§6: defs structs may carry meta; validate its shape like every
1181
+ // other meta-bearing level (mirrors the schema's NamedStruct).
1182
+ validateMeta(def.meta, `defs/${name}`, errors);
1183
+ if (!Array.isArray(def.fields)) {
1184
+ errors.push({ message: `defs/${name}: struct must have a fields array.` });
1185
+ continue;
1186
+ }
1187
+ for (const child of def.fields)
1188
+ validateContainerCtx(child, `defs/${name}`, w);
1189
+ }
1190
+ // Constraints
1191
+ for (const [i, con] of (packet.constraints ?? []).entries()) {
1192
+ // Mirrors the schema's Constraint `additionalProperties: false` so a
1193
+ // typo'd key (e.g. "leval" for "level") cannot silently demote a should
1194
+ // constraint to the default `must` (§9.1, §16.4).
1195
+ for (const key of Object.keys(con)) {
1196
+ if (!CONSTRAINT_KEYS.has(key))
1197
+ errors.push({ message: `constraints[${i}] has unknown key "${key}" (allowed: ${[...CONSTRAINT_KEYS].join(", ")}) (§9).` });
1198
+ }
1199
+ // §D11: constraints are exempt from forward-order (§10.1), but ref/wireSize
1200
+ // existence is still checked. Pass a pc that carries only the document id
1201
+ // set (the forward-order branches are skipped for slot==="constraint").
1202
+ const conPc = {
1203
+ remainingOk: true, enclosingBitsOk: true,
1204
+ declaredIds: new Set(), openIds: new Set(), repeatIds,
1205
+ documentDeclaredIds, importPrefixes,
1206
+ };
1207
+ if (!isValidExpr(con.lhs))
1208
+ errors.push({ message: `constraints[${i}]: malformed lhs.` });
1209
+ else
1210
+ validateExprPlacement(con.lhs, "constraint", `constraints[${i}]`, errors, conPc);
1211
+ if (!isValidExpr(con.rhs))
1212
+ errors.push({ message: `constraints[${i}]: malformed rhs.` });
1213
+ else
1214
+ validateExprPlacement(con.rhs, "constraint", `constraints[${i}]`, errors, conPc);
1215
+ if (con.doc !== undefined && typeof con.doc !== "string")
1216
+ errors.push({ message: `constraints[${i}]: doc must be a string (§9).` });
1217
+ if (con.level !== undefined && !NORM_LEVELS.has(con.level))
1218
+ errors.push({ message: `constraints[${i}]: invalid level "${String(con.level)}" (must be must|should|may).` });
1219
+ }
1220
+ return errors;
1221
+ }
1222
+ //# sourceMappingURL=validate.js.map