@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,857 @@
1
+ // normalize.ts — walks the Container tree into a flat NormalizedField list.
2
+ //
3
+ // Implements the §10 processing model: seed phase (§10.2) then a forward,
4
+ // single-pass parse that injects context-dependent expression values
5
+ // (remaining/enclosingBits/wireSize/prevIter) into the env as it goes.
6
+ import { evalExprOr, exprContains, enclosingBitsEnvKey, prevIterEnvKey, remainingEnvKey, wireSizeEnvKey } from "./expr.js";
7
+ import { isField } from "./utils.js";
8
+ export function berLenEnvKey(fieldId) {
9
+ return `__berLen__${fieldId}`;
10
+ }
11
+ /**
12
+ * Decoder-injected wire byte length of a delimiter-terminated `bytes` field
13
+ * (§3/§10.7, D3). Keyed by the field's fully-qualified id under a dedicated
14
+ * namespace so it never collides with env[id] (the field's value slot) or any
15
+ * other injection key. With no injection (static preview) the length is unknown
16
+ * and the field lays out as 0 bytes.
17
+ */
18
+ export function bytesDelimLenEnvKey(qid) {
19
+ return `__bytesDelimLen__${qid}`;
20
+ }
21
+ /** True if a `bytes.n` is the delimiter form rather than an Expr (§3, D3). */
22
+ export function isBytesDelimited(n) {
23
+ return typeof n === "object" && n !== null && !Array.isArray(n) && "delimiter" in n &&
24
+ Array.isArray(n.delimiter);
25
+ }
26
+ /** Internal bit-accumulator mirror of wireSizeEnvKey (§4 sub-byte rounding). */
27
+ function wireSizeBitsEnvKey(target) {
28
+ return `__wireSizeBits__${target}`;
29
+ }
30
+ /** Decoder-injected wire bit-width of a varint field (distinct from its value). */
31
+ export function varintBitsEnvKey(fieldId) {
32
+ return `__varintBits__${fieldId}`;
33
+ }
34
+ /** Maximum recursive-def expansion depth (decoder is the real authority, §6). */
35
+ const MAX_REF_DEPTH = 64;
36
+ /**
37
+ * Static wire bit-width of a wire type. For decoder-determined widths
38
+ * (`varint`, delimiter-terminated `bytes`), the width is looked up in `env`
39
+ * under a key derived from `fieldId`. The CALLER must pass the same id used at
40
+ * injection: for fields expanded inside a `ref`/`repeat` that is the qualified
41
+ * id (`{ref.id}.{field.id}#N`), which the normalize walk threads through emit().
42
+ * Calling this helper directly with a bare id for a qualified field yields 0
43
+ * (unknown width) — resolve such fields via the full `normalize()` walk.
44
+ */
45
+ export function typeBits(type, env, fieldId) {
46
+ switch (type.kind) {
47
+ case "int":
48
+ case "enum":
49
+ return type.bits;
50
+ case "bits":
51
+ return type.n;
52
+ case "bytes":
53
+ // Delimiter-terminated bytes have a decoder-determined length
54
+ // injected under a qualified key; typeBits has only the bare id, so the
55
+ // qualified lookup happens in emit(). Without the qid the static layout is
56
+ // 0 bytes (unknown length, §3/§10.7, D3).
57
+ if (isBytesDelimited(type.n)) {
58
+ if (fieldId !== undefined) {
59
+ const v = env.get(bytesDelimLenEnvKey(fieldId));
60
+ if (v !== undefined)
61
+ return Math.max(0, Math.trunc(v)) * 8;
62
+ }
63
+ return 0;
64
+ }
65
+ return Math.max(0, Math.trunc(evalExprOr(type.n, env))) * 8;
66
+ case "varint":
67
+ // §3/§10: the decoder injects the varint's wire bit-width under a key
68
+ // distinct from the field's value slot (env[fieldId] holds the decoded
69
+ // value, not its width). With no injection the static layout yields 0.
70
+ if (fieldId !== undefined) {
71
+ const v = env.get(varintBitsEnvKey(fieldId));
72
+ if (v !== undefined)
73
+ return v;
74
+ }
75
+ return 0;
76
+ case "berLength":
77
+ if (fieldId !== undefined) {
78
+ const v = env.get(berLenEnvKey(fieldId));
79
+ if (v !== undefined)
80
+ return v;
81
+ }
82
+ return 8;
83
+ }
84
+ }
85
+ /* ------------------------------------------------------------------ *
86
+ * Seeding (§10.2): const wins over defaultValue; recursive.
87
+ * ------------------------------------------------------------------ */
88
+ function seedDefaults(containers, env, defs, depth = 0, activeRecursive = new Set()) {
89
+ if (depth > MAX_REF_DEPTH)
90
+ return;
91
+ for (const c of containers) {
92
+ if (isField(c)) {
93
+ const seed = c.const ?? c.defaultValue;
94
+ if (seed !== undefined && !env.has(c.id))
95
+ env.set(c.id, seed);
96
+ }
97
+ else if (c.kind === "group") {
98
+ seedDefaults(c.children, env, defs, depth, activeRecursive);
99
+ }
100
+ else if (c.kind === "bounded") {
101
+ seedDefaults(c.fields, env, defs, depth, activeRecursive);
102
+ }
103
+ else if (c.kind === "encrypted") {
104
+ seedDefaults(c.plaintext.fields, env, defs, depth, activeRecursive);
105
+ }
106
+ else if (c.kind === "optional") {
107
+ seedDefaults([c.container], env, defs, depth, activeRecursive);
108
+ }
109
+ else if (c.kind === "repeat") {
110
+ seedDefaults(c.element.fields, env, defs, depth, activeRecursive);
111
+ }
112
+ else if (c.kind === "switch") {
113
+ for (const arm of Object.values(c.cases))
114
+ seedDefaults(arm.fields, env, defs, depth, activeRecursive);
115
+ }
116
+ else if (c.kind === "ref") {
117
+ const def = defs[c.ref];
118
+ if (!def)
119
+ continue;
120
+ // §10.2 rule 3: a recursive `ref` (a ref re-entering a def already being
121
+ // seeded) is an unresolved boundary — seed only the directly-declared
122
+ // fields of the def body, not recursively-expanded instances.
123
+ if (activeRecursive.has(c.ref))
124
+ continue;
125
+ const nextActive = def.recursive ? new Set([...activeRecursive, c.ref]) : activeRecursive;
126
+ seedDefaults(def.fields, env, defs, depth + 1, nextActive);
127
+ }
128
+ // virtual / align seed nothing
129
+ }
130
+ }
131
+ /** Inject remaining/enclosingBits for the innermost budgeted scope (§4). */
132
+ function injectScopeBudget(state) {
133
+ let frame;
134
+ for (let i = state.scopeStack.length - 1; i >= 0; i--) {
135
+ const f = state.scopeStack[i];
136
+ if (f.budgetBits !== undefined) {
137
+ frame = f;
138
+ break;
139
+ }
140
+ }
141
+ if (frame === undefined) {
142
+ state.env.delete(remainingEnvKey());
143
+ state.env.delete(enclosingBitsEnvKey());
144
+ return undefined;
145
+ }
146
+ // §4 Sub-byte rounding: `remaining` counts whole bytes left in the scope.
147
+ // Compute it from the raw bit gap so it stays consistent when budgetBits is
148
+ // not a whole number of bytes (e.g. an encrypted scope with a sub-byte
149
+ // wireBits): `floor((budget - consumed) / 8)`. This avoids the prior
150
+ // floor(budget/8) - ceil(consumed/8) form, which double-penalised a partial
151
+ // trailing budget byte against a mid-byte cursor and under-reported by one.
152
+ const consumedBits = state.offset - frame.startOffset;
153
+ const remainingBytes = Math.max(0, Math.floor((frame.budgetBits - consumedBits) / 8));
154
+ state.env.set(remainingEnvKey(), remainingBytes);
155
+ state.env.set(enclosingBitsEnvKey(), frame.budgetBits);
156
+ return frame;
157
+ }
158
+ /**
159
+ * §4/§11.2: `remaining`/`enclosingBits` used in a top-level `body` expression
160
+ * when the decoder has not injected the total packet size is a runtime error.
161
+ * The validator already rejects these primitives inside an
162
+ * `encrypted.plaintext` without `wireBits`, and a `bounded` always carries a
163
+ * budget, so the only way to reach an expression referencing them with no
164
+ * budgeted scope frame at runtime is the top-level body with no `totalBits`
165
+ * injected. Distinguish that from "no scope provider at all" and raise, rather
166
+ * than silently resolving to 0.
167
+ */
168
+ function guardScopeBudget(state, frame, expr) {
169
+ if (frame !== undefined)
170
+ return;
171
+ if (!exprContains(expr, (e) => e.kind === "remaining" || e.kind === "enclosingBits"))
172
+ return;
173
+ throw new Error("normalize: 'remaining'/'enclosingBits' used at the top-level body but the decoder did not inject the total packet size (pass totalBits) (§4/§11.2).");
174
+ }
175
+ /**
176
+ * Evaluate an expression with scope budget freshly injected (§4, §10.3).
177
+ *
178
+ * `peek` (§10.6) is NOT injected here: normalize is a static layout pass with
179
+ * no underlying wire buffer, so it cannot read the stream. A `switch.on`,
180
+ * `optional.when`, or `repeat.count` driven by `peek` therefore requires the
181
+ * decoder to pre-seed the corresponding `peekEnvKey(offset, bits)` entry in the
182
+ * env before calling normalize; with no such injection `peek` evaluates to 0
183
+ * (§11.3 "peek reads past available data → 0"), selecting the disc=0 path. This
184
+ * is the documented decoder hand-off for the in-walk evaluation paths.
185
+ *
186
+ * The §10.6 scope-clamp and context-relative current-position semantics (a
187
+ * peek that would read past the innermost scope-providing container's remaining
188
+ * budget yields 0 even if bytes exist beyond the boundary; the current position
189
+ * is the first bit of the switch/optional, the first element for fixed
190
+ * `repeat.count`, or the first bit after the last byte of the just-completed
191
+ * iteration for `repeat.count.until`) CANNOT be enforced here — this package
192
+ * never sees the underlying buffer. The decoder MUST apply that clamp and
193
+ * context-relative origin when seeding each `peekEnvKey(offset, bits)`; the
194
+ * library cannot verify it.
195
+ */
196
+ function evalIn(state, expr) {
197
+ const frame = injectScopeBudget(state);
198
+ // §4/§11.2: top-level `remaining`/`enclosingBits` with no injected total size
199
+ // is a runtime error (not a silent 0).
200
+ guardScopeBudget(state, frame, expr);
201
+ // §11.2 scopes the mid-byte error specifically to sizing a `bytes` field; that
202
+ // guard lives in `emit`. `remaining` in a non-sizing slot (optional.when,
203
+ // switch.on, repeat.count, bounded.bytes) is spec-legal even mid-byte, so no
204
+ // blanket mid-byte throw here.
205
+ return evalExprOr(expr, state.env);
206
+ }
207
+ function repeatSuffix(state) {
208
+ return state.repeatIndexStack.length > 0 ? `#${state.repeatIndexStack.join("_")}` : "";
209
+ }
210
+ /** Fully-qualified id for a container/field (prefix + id + repeat suffix). */
211
+ function qualify(state, id) {
212
+ const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
213
+ return `${prefix}${id}${repeatSuffix(state)}`;
214
+ }
215
+ /**
216
+ * Record a container/field wire footprint for `wireSize` (§4). Stored under
217
+ * the fully-qualified id (so ref/group/repeat-iteration entries never collide),
218
+ * and additionally accumulated under the unqualified id while inside a repeat
219
+ * so `wireSize(target)` yields the aggregate across iterations.
220
+ *
221
+ * Footprints are tracked in BITS and only floored to a byte count at the point
222
+ * the byte-valued wireSize key is written, so adjacent sub-byte fields (e.g.
223
+ * two nibbles) are not each truncated to 0 bytes (§4 sub-byte rounding).
224
+ *
225
+ * NOTE on duplicate bare ids (§10.1): outside a repeat the unqualified key is
226
+ * overwritten on every occurrence, so when the same id appears more than once
227
+ * in document order (two sibling `ref`s to one def, or a field id reused across
228
+ * switch arms), `wireSize(id)` reflects the LAST occurrence walked before the
229
+ * consuming expression. The validator only approves a `wireSize(target)` whose
230
+ * `target` is already declared/closed and precedes the expression in document
231
+ * order (validate.ts §11.1), so a forward expression resolves the most recent
232
+ * preceding instance. Authors who need a specific instance must give that
233
+ * instance a unique id; a bare id shared by multiple instances resolves to the
234
+ * nearest preceding one, not a sum. (Inside a repeat the bare id deliberately
235
+ * accumulates the per-iteration aggregate, see below.)
236
+ */
237
+ function recordWireSize(state, id, bits) {
238
+ const qid = qualify(state, id);
239
+ state.env.set(wireSizeBitsEnvKey(qid), bits);
240
+ state.env.set(wireSizeEnvKey(qid), Math.floor(bits / 8));
241
+ if (state.repeatIndexStack.length > 0) {
242
+ const prevBits = state.env.get(wireSizeBitsEnvKey(id)) ?? 0;
243
+ const totalBits = prevBits + bits;
244
+ state.env.set(wireSizeBitsEnvKey(id), totalBits);
245
+ state.env.set(wireSizeEnvKey(id), Math.floor(totalBits / 8));
246
+ }
247
+ else {
248
+ state.env.set(wireSizeBitsEnvKey(id), bits);
249
+ state.env.set(wireSizeEnvKey(id), Math.floor(bits / 8));
250
+ }
251
+ }
252
+ /**
253
+ * Apply walk-context attribution shared by every emitted NormalizedField:
254
+ * switch-arm key, repeat index, and enclosing-group identity/provenance (§5,
255
+ * §5.4). Used by emit() and by the wire-view encrypted blob so both paths
256
+ * agree on attribution.
257
+ */
258
+ function applyWalkContext(state, nf) {
259
+ // §5: every field within a selected switch arm carries the arm key, whether
260
+ // a direct child or nested inside a group/optional/bounded/repeat/ref.
261
+ if (state.switchCase !== undefined)
262
+ nf.switchCase = state.switchCase;
263
+ if (state.repeatIndexStack.length > 0)
264
+ nf.repeatIndex = state.repeatIndexStack[state.repeatIndexStack.length - 1];
265
+ if (state.groupStack.length > 0) {
266
+ const top = state.groupStack[state.groupStack.length - 1];
267
+ const indexTag = state.repeatIndexStack.length > 0 ? state.repeatIndexStack.join("_") : null;
268
+ nf.groupId = indexTag !== null ? `${top.id}#${indexTag}` : top.id;
269
+ nf.groupName = top.name;
270
+ // §5.4: groupMeta is the meta of the NEAREST enclosing group that defines
271
+ // one (innermost wins; an outer group's meta is the fallback), so
272
+ // nested-group provenance still reaches every leaf when only the outer
273
+ // group carries meta.
274
+ for (let i = state.groupStack.length - 1; i >= 0; i--) {
275
+ const frame = state.groupStack[i];
276
+ if (frame.meta !== undefined) {
277
+ nf.groupMeta = frame.meta;
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ }
283
+ function emit(state, field, path) {
284
+ const frame = injectScopeBudget(state);
285
+ // §4/§11.2: sizing a `bytes` field from `remaining` while the cursor is
286
+ // mid-byte is a runtime error.
287
+ if (field.type.kind === "bytes" && !isBytesDelimited(field.type.n)) {
288
+ const nExpr = field.type.n;
289
+ // §4/§11.2: top-level `remaining`/`enclosingBits` with no injected total.
290
+ guardScopeBudget(state, frame, nExpr);
291
+ if (state.offset % 8 !== 0 && exprContains(nExpr, (e) => e.kind === "remaining"))
292
+ throw new Error(`normalize: 'remaining' sizes bytes field "${field.id}" while the cursor is mid-byte (offset ${state.offset} bits); insert an 'align' first (§11.2).`);
293
+ }
294
+ const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
295
+ const suffix = repeatSuffix(state);
296
+ const id = `${prefix}${field.id}${suffix}`;
297
+ // §3/§10.7 (D3): a delimiter-terminated `bytes` length is injected under the
298
+ // qualified id; without injection the static layout is 0 bytes (unknown).
299
+ const bits = (field.type.kind === "bytes" && isBytesDelimited(field.type.n))
300
+ ? Math.max(0, Math.trunc(state.env.get(bytesDelimLenEnvKey(id)) ?? 0)) * 8
301
+ : typeBits(field.type, state.env, field.id);
302
+ const nf = {
303
+ id,
304
+ name: field.name,
305
+ bits,
306
+ absoluteBitOffset: state.offset,
307
+ originalContainerPath: path,
308
+ ...(field.category !== undefined ? { category: field.category } : {}),
309
+ ...(field.doc !== undefined ? { doc: field.doc } : {}),
310
+ // §5.3/§5.4: value dictionary and RFC provenance ride through to the
311
+ // normalized/layout output so LSP and renderers can surface them.
312
+ ...(field.values !== undefined ? { values: field.values } : {}),
313
+ ...(field.meta !== undefined ? { meta: field.meta } : {}),
314
+ // §12 (D4): mask-addressed subfields ride through for LSP/codegen value decode.
315
+ ...(field.subfields !== undefined ? { subfields: field.subfields } : {}),
316
+ // §8: checksum binding rides through so codegen/LSP can read the algorithm,
317
+ // covered fields, pseudo-header, and CRC parameters (width included).
318
+ ...(field.checksumAlgorithm !== undefined ? { checksumAlgorithm: field.checksumAlgorithm } : {}),
319
+ ...(field.checksumCovers !== undefined ? { checksumCovers: field.checksumCovers } : {}),
320
+ ...(field.checksumPseudoHeader !== undefined ? { checksumPseudoHeader: field.checksumPseudoHeader } : {}),
321
+ ...(field.checksumParams !== undefined ? { checksumParams: field.checksumParams } : {}),
322
+ };
323
+ applyWalkContext(state, nf);
324
+ if (state.encryptedStack.length > 0) {
325
+ const top = state.encryptedStack[state.encryptedStack.length - 1];
326
+ nf.encryptedParentId = top.parentId;
327
+ nf.encryptedContextNote = top.contextNote;
328
+ for (const frame of state.encryptedStack) {
329
+ if (frame.headerProtected.has(field.id)) {
330
+ nf.headerProtected = true;
331
+ break;
332
+ }
333
+ }
334
+ }
335
+ if (field.byteOrder)
336
+ nf.byteOrder = field.byteOrder;
337
+ state.out.push(nf);
338
+ state.env.set(id, state.env.get(id) ?? state.env.get(field.id) ?? field.const ?? field.defaultValue ?? 0);
339
+ state.offset += bits;
340
+ // Record wire footprint for wireSize (parse-direction; §4). Tracked in bits.
341
+ recordWireSize(state, field.id, bits);
342
+ }
343
+ function walkContainer(c, path, state) {
344
+ if (isField(c)) {
345
+ emit(state, c, path);
346
+ return;
347
+ }
348
+ switch (c.kind) {
349
+ case "group":
350
+ walkGroup(c, path, state);
351
+ return;
352
+ case "repeat":
353
+ walkRepeat(c, path, state);
354
+ return;
355
+ case "switch":
356
+ walkSwitch(c, path, state);
357
+ return;
358
+ case "encrypted":
359
+ walkEncrypted(c, path, state);
360
+ return;
361
+ case "bounded":
362
+ walkBounded(c, path, state);
363
+ return;
364
+ case "align":
365
+ walkAlign(c, state);
366
+ return;
367
+ case "virtual":
368
+ walkVirtual(c, path, state);
369
+ return;
370
+ case "optional": {
371
+ const test = evalIn(state, c.when);
372
+ if (test !== 0)
373
+ walkContainer(c.container, path, state);
374
+ return;
375
+ }
376
+ case "ref":
377
+ walkRef(c, path, state);
378
+ return;
379
+ }
380
+ }
381
+ function walkVirtual(v, path, state) {
382
+ const value = evalIn(state, v.expr);
383
+ // Expressions reference the bare id; also record the qualified id so virtuals
384
+ // inside a ref expansion / repeat iteration stay distinguishable.
385
+ state.env.set(v.id, value);
386
+ const qid = qualify(state, v.id);
387
+ if (qid !== v.id)
388
+ state.env.set(qid, value);
389
+ // Zero-width; recorded as a virtual normalized field for tooling. The
390
+ // authored name/doc ride through for LSP hover (§5). The walk path is
391
+ // recorded like every other emitted field so (a) an LSP can trace the
392
+ // virtual to its source container, and (b) a virtual inside a group does
393
+ // not split the group's consecutive run in the layout collapse (§5.4).
394
+ const nf = {
395
+ id: qid,
396
+ name: v.name ?? v.id,
397
+ bits: 0,
398
+ absoluteBitOffset: state.offset,
399
+ originalContainerPath: path,
400
+ virtual: true,
401
+ ...(v.doc !== undefined ? { doc: v.doc } : {}),
402
+ };
403
+ applyWalkContext(state, nf);
404
+ state.out.push(nf);
405
+ }
406
+ function walkAlign(a, state) {
407
+ // Round up to next whole byte first, then to the `to` boundary (§5).
408
+ const byteAligned = Math.ceil(state.offset / 8) * 8;
409
+ let target = byteAligned;
410
+ if (a.to > 0) {
411
+ const rem = byteAligned % a.to;
412
+ if (rem !== 0)
413
+ target = byteAligned + (a.to - rem);
414
+ }
415
+ let padBits = target - state.offset;
416
+ // §5: inside a `bounded` scope, padding exceeding the authored byte budget
417
+ // is a runtime error; at the top-level/decoder-injected end it caps to what
418
+ // remains (SCTP last chunk). NOTE: top-level align capping requires the
419
+ // decoder to inject `totalBits` (which pushes a budgeted top frame). Without
420
+ // an injected total there is no defined end-of-data, so the loop finds no
421
+ // budgeted frame and the align advances unbounded — consistent with
422
+ // `remaining` being undefined at the top level without injection (§4).
423
+ for (let i = state.scopeStack.length - 1; i >= 0; i--) {
424
+ const f = state.scopeStack[i];
425
+ if (f.budgetBits !== undefined) {
426
+ const avail = f.budgetBits - (state.offset - f.startOffset);
427
+ if (padBits > avail) {
428
+ if (f.kind === "bounded")
429
+ throw new Error(`normalize: align to ${a.to} requires ${padBits} padding bits but only ${Math.max(0, avail)} remain in the bounded scope (§5/§11.2).`);
430
+ padBits = Math.max(0, avail);
431
+ }
432
+ break;
433
+ }
434
+ }
435
+ state.offset += padBits;
436
+ }
437
+ function walkRef(r, path, state) {
438
+ if (state.refDepth >= MAX_REF_DEPTH)
439
+ return;
440
+ const def = state.defs[r.ref];
441
+ if (!def)
442
+ return;
443
+ const sub = `${path}/${r.id}`;
444
+ const prevPrefix = state.idPrefix;
445
+ state.idPrefix = state.idPrefix ? `${state.idPrefix}.${r.id}` : r.id;
446
+ state.refDepth++;
447
+ const startOffset = state.offset;
448
+ for (const child of def.fields)
449
+ walkContainer(child, sub, state);
450
+ // wireSize is keyed by the qualified id from the parent scope, so restore
451
+ // the prefix before recording (the ref id lives in the enclosing scope).
452
+ state.idPrefix = prevPrefix;
453
+ recordWireSize(state, r.id, state.offset - startOffset);
454
+ state.refDepth--;
455
+ }
456
+ function walkGroup(g, path, state) {
457
+ const sub = `${path}/${g.id}`;
458
+ const prev = state.groupStack;
459
+ state.groupStack = [...prev, { id: g.id, name: g.name ?? g.id, ...(g.meta !== undefined ? { meta: g.meta } : {}) }];
460
+ const startOffset = state.offset;
461
+ for (const child of g.children)
462
+ walkContainer(child, sub, state);
463
+ recordWireSize(state, g.id, state.offset - startOffset);
464
+ state.groupStack = prev;
465
+ }
466
+ function walkBounded(b, path, state) {
467
+ const sub = `${path}/${b.id}`;
468
+ const budgetBits = Math.max(0, Math.trunc(evalIn(state, b.bytes))) * 8;
469
+ const startOffset = state.offset;
470
+ state.scopeStack.push({ startOffset, budgetBits, kind: "bounded" });
471
+ for (const child of b.fields)
472
+ walkContainer(child, sub, state);
473
+ state.scopeStack.pop();
474
+ // §5: over-consuming an authored `bounded` budget is a runtime error (mirrors
475
+ // the over-budget `align` rule); under-consumption advances to the scope end.
476
+ const consumed = state.offset - startOffset;
477
+ if (consumed > budgetBits)
478
+ throw new Error(`normalize: bounded scope "${b.id}" over-consumed: children used ${consumed} bits but the budget is ${budgetBits} bits (§5/§11.2).`);
479
+ if (consumed < budgetBits)
480
+ state.offset = startOffset + budgetBits;
481
+ recordWireSize(state, b.id, state.offset - startOffset);
482
+ }
483
+ function walkRepeat(r, path, state) {
484
+ const sub = `${path}/${r.id}`;
485
+ const count = resolveRepeatCount(r, state);
486
+ const startOffset = state.offset;
487
+ const prevStack = state.repeatIndexStack;
488
+ const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
489
+ // Element fields whose value participates in prevIter injection (§10.4).
490
+ // Collect ALL leaf field ids reachable in the element — including fields
491
+ // nested inside a group/switch/ref/optional — not just the direct children,
492
+ // so a `repeat.until`/`prevIter` referencing a grouped field still resolves
493
+ // (§10.4). Each descriptor carries the ref-prefix path it is emitted under
494
+ // (groups/switch/optional/bounded do not extend the id prefix; only `ref`
495
+ // does), so the injection probe matches the actually-emitted id.
496
+ const elementFields = collectElementFields(r.element.fields, state.defs);
497
+ // Snapshot prevIter keys so the repeat does not leak stale previous-iteration
498
+ // values into sibling/enclosing containers (§4, §10.4).
499
+ const savedPrevIter = new Map();
500
+ for (const child of elementFields)
501
+ savedPrevIter.set(prevIterEnvKey(child.id), state.env.get(prevIterEnvKey(child.id)));
502
+ // Reset per-target wireSize accumulators so a fresh aggregate is built (§4).
503
+ // Clear ALL container ids reachable in the element — not just the direct
504
+ // fields — so a nested group/switch/ref inside the element does not leak its
505
+ // aggregate from a prior sibling repeat that shares the same id.
506
+ const aggregateIds = collectAggregateIds(r.element.fields);
507
+ for (const aid of aggregateIds) {
508
+ state.env.delete(wireSizeEnvKey(aid));
509
+ state.env.delete(wireSizeBitsEnvKey(aid));
510
+ }
511
+ // §4/§10.2: first iteration sees each field's seeded value (const ?? default).
512
+ for (const child of elementFields) {
513
+ const seed = child.const ?? child.defaultValue;
514
+ if (seed !== undefined)
515
+ state.env.set(prevIterEnvKey(child.id), seed);
516
+ else
517
+ state.env.delete(prevIterEnvKey(child.id));
518
+ }
519
+ for (let i = 0; i < count; i++) {
520
+ state.repeatIndexStack = [...prevStack, i];
521
+ const innerPath = `${sub}[${i}]`;
522
+ // Inject prevIter values from the just-completed iteration (§10.4). The
523
+ // emitted id is fully qualified (prefix + id + repeat suffix), so probe the
524
+ // same form — otherwise prevIter never resolves inside a ref expansion.
525
+ if (i > 0) {
526
+ const prevSuffix = `#${[...prevStack, i - 1].join("_")}`;
527
+ for (const child of elementFields) {
528
+ const childPrefix = child.prefixPath ? `${child.prefixPath}.` : "";
529
+ const prevId = `${prefix}${childPrefix}${child.id}${prevSuffix}`;
530
+ const v = state.env.get(prevId);
531
+ if (v !== undefined)
532
+ state.env.set(prevIterEnvKey(child.id), v);
533
+ }
534
+ }
535
+ for (const child of r.element.fields)
536
+ walkContainer(child, innerPath, state);
537
+ }
538
+ state.repeatIndexStack = prevStack;
539
+ // Restore prevIter keys so later expressions do not read leftover values.
540
+ for (const [key, value] of savedPrevIter) {
541
+ if (value === undefined)
542
+ state.env.delete(key);
543
+ else
544
+ state.env.set(key, value);
545
+ }
546
+ // §10.7: populate env[repeat.id] with the completed iteration count so a
547
+ // `ref` to the repeat id resolves uniformly for fixed/until/eos forms.
548
+ state.env.set(qualify(state, r.id), count);
549
+ state.env.set(r.id, count);
550
+ recordWireSize(state, r.id, state.offset - startOffset);
551
+ }
552
+ /**
553
+ * Collect every leaf field reachable in a repeat element — recursing into
554
+ * group/switch/ref/optional/bounded containers — so prevIter seeding and
555
+ * injection cover nested fields, not only direct children (§10.4). Only `ref`
556
+ * extends the emitted id prefix; other containers leave it unchanged.
557
+ */
558
+ function collectElementFields(fields, defs, prefixPath = "", out = [], seenRefs = new Set()) {
559
+ for (const c of fields) {
560
+ if (isField(c)) {
561
+ out.push({ id: c.id, prefixPath, ...(c.const !== undefined ? { const: c.const } : {}), ...(c.defaultValue !== undefined ? { defaultValue: c.defaultValue } : {}) });
562
+ continue;
563
+ }
564
+ switch (c.kind) {
565
+ case "group":
566
+ collectElementFields(c.children, defs, prefixPath, out, seenRefs);
567
+ break;
568
+ case "bounded":
569
+ collectElementFields(c.fields, defs, prefixPath, out, seenRefs);
570
+ break;
571
+ case "optional":
572
+ collectElementFields([c.container], defs, prefixPath, out, seenRefs);
573
+ break;
574
+ case "repeat": /* inner repeat fields belong to that repeat's iterations */ break;
575
+ case "encrypted":
576
+ collectElementFields(c.plaintext.fields, defs, prefixPath, out, seenRefs);
577
+ break;
578
+ case "switch":
579
+ for (const arm of Object.values(c.cases))
580
+ collectElementFields(arm.fields, defs, prefixPath, out, seenRefs);
581
+ break;
582
+ case "ref": {
583
+ if (seenRefs.has(c.ref))
584
+ break; // guard against recursive defs
585
+ const def = defs[c.ref];
586
+ if (!def)
587
+ break;
588
+ const nextPrefix = prefixPath ? `${prefixPath}.${c.id}` : c.id;
589
+ collectElementFields(def.fields, defs, nextPrefix, out, new Set([...seenRefs, c.ref]));
590
+ break;
591
+ }
592
+ // align/virtual contribute no prevIter source
593
+ }
594
+ }
595
+ return out;
596
+ }
597
+ function collectAggregateIds(fields, out = []) {
598
+ for (const c of fields) {
599
+ if (isField(c)) {
600
+ out.push(c.id);
601
+ continue;
602
+ }
603
+ switch (c.kind) {
604
+ case "group":
605
+ out.push(c.id);
606
+ collectAggregateIds(c.children, out);
607
+ break;
608
+ case "bounded":
609
+ out.push(c.id);
610
+ collectAggregateIds(c.fields, out);
611
+ break;
612
+ case "switch":
613
+ out.push(c.id);
614
+ for (const arm of Object.values(c.cases))
615
+ collectAggregateIds(arm.fields, out);
616
+ break;
617
+ case "repeat":
618
+ out.push(c.id);
619
+ collectAggregateIds(c.element.fields, out);
620
+ break;
621
+ case "encrypted":
622
+ out.push(c.id);
623
+ collectAggregateIds(c.plaintext.fields, out);
624
+ break;
625
+ case "ref":
626
+ out.push(c.id);
627
+ break;
628
+ case "optional":
629
+ collectAggregateIds([c.container], out);
630
+ break;
631
+ // align/virtual record no wireSize footprint
632
+ }
633
+ }
634
+ return out;
635
+ }
636
+ function resolveRepeatCount(r, state) {
637
+ // §10.7: the decoder injects the completed iteration count at the repeat's
638
+ // id. When the repeat lives inside a ref expansion or an outer repeat, each
639
+ // runtime instance has a distinct qualified id (prefix + id + repeat suffix),
640
+ // so prefer the per-instance qualified key before falling back to the bare
641
+ // id — otherwise sibling/iteration instances all read iteration 0's count.
642
+ const injectedCount = () => {
643
+ const q = state.env.get(qualify(state, r.id));
644
+ if (q !== undefined)
645
+ return q;
646
+ return state.env.get(r.id);
647
+ };
648
+ if (r.count === "eos") {
649
+ // §10.7/§11.3: the decoder MUST inject the completed iteration count at the
650
+ // repeat id (qualified per instance); with no injection (static layout
651
+ // preview) the normalize phase yields zero iterations.
652
+ const injected = injectedCount();
653
+ if (injected !== undefined)
654
+ return Math.max(0, Math.trunc(injected));
655
+ return 0;
656
+ }
657
+ if (typeof r.count === "object" && "until" in r.count) {
658
+ // The `until` termination depends on per-iteration field values the decoder
659
+ // observes while streaming; like `eos`, the count is supplied at the repeat
660
+ // id (qualified per instance). With no injection this yields zero iterations.
661
+ return Math.max(0, Math.trunc(injectedCount() ?? 0));
662
+ }
663
+ return Math.max(0, Math.trunc(evalIn(state, r.count)));
664
+ }
665
+ function walkSwitch(s, path, state) {
666
+ const sub = `${path}/${s.id}`;
667
+ const disc = Math.trunc(evalIn(state, s.on));
668
+ const arm = selectArm(s.cases, disc);
669
+ if (!arm)
670
+ return;
671
+ const startOffset = state.offset;
672
+ // Thread the arm key through the whole arm subtree so nested fields (inside a
673
+ // group/optional/bounded/repeat/ref) keep their case attribution (§5).
674
+ const prevSwitchCase = state.switchCase;
675
+ state.switchCase = arm.key;
676
+ try {
677
+ for (const child of arm.struct.fields)
678
+ walkContainer(child, sub, state);
679
+ }
680
+ finally {
681
+ if (prevSwitchCase === undefined)
682
+ delete state.switchCase;
683
+ else
684
+ state.switchCase = prevSwitchCase;
685
+ }
686
+ recordWireSize(state, s.id, state.offset - startOffset);
687
+ }
688
+ /** Match in order: exact → list → range → "_" (§5). */
689
+ export function selectArm(cases, disc) {
690
+ const exact = cases[String(disc)];
691
+ if (exact)
692
+ return { key: String(disc), struct: exact };
693
+ // list keys "a,b,c"
694
+ for (const [key, struct] of Object.entries(cases)) {
695
+ if (key.includes(",")) {
696
+ const vals = key.split(",").map((s) => s.trim());
697
+ if (vals.includes(String(disc)))
698
+ return { key, struct };
699
+ }
700
+ }
701
+ // range keys "lo-hi"
702
+ for (const [key, struct] of Object.entries(cases)) {
703
+ // Match the validator's canonical range grammar exactly (no leading
704
+ // zeros), so selectArm and the validator agree on which keys are ranges.
705
+ const m = /^(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(key);
706
+ if (m) {
707
+ const lo = Number(m[1]);
708
+ const hi = Number(m[2]);
709
+ // A reversed range (lo > hi) matches nothing and is rejected by the
710
+ // validator (§5); guard here so it never silently masks the `_` arm.
711
+ if (lo > hi)
712
+ throw new Error(`selectArm: invalid reversed range key "${key}" (lo > hi).`);
713
+ if (disc >= lo && disc <= hi)
714
+ return { key, struct };
715
+ }
716
+ }
717
+ if (cases["_"])
718
+ return { key: "_", struct: cases["_"] };
719
+ return undefined;
720
+ }
721
+ /**
722
+ * §5 (D6): tag plaintext-external header-protected fields. A headerProtected id
723
+ * that names a field declared earlier in the same body (not a plaintext field)
724
+ * is tagged on the already-emitted NormalizedField. Matching is by exact
725
+ * emitted id (a top-level/direct header field emits with id === its bare id),
726
+ * so a ref-expanded leaf with the same tail is NOT over-matched. Plaintext-
727
+ * internal ids are tagged separately during the plaintext walk (emit, via the
728
+ * encryptedStack frame). Same set in both views for fields emitted as leaves.
729
+ */
730
+ function tagExternalHeaderProtected(e, state) {
731
+ if (!e.headerProtected || e.headerProtected.length === 0)
732
+ return;
733
+ const plaintextIds = new Set();
734
+ for (const c of e.plaintext.fields)
735
+ if (isField(c))
736
+ plaintextIds.add(c.id);
737
+ for (const hp of e.headerProtected) {
738
+ if (plaintextIds.has(hp))
739
+ continue; // plaintext-internal: handled in emit()
740
+ for (const nf of state.out) {
741
+ if (nf.id === hp) {
742
+ nf.headerProtected = true;
743
+ break;
744
+ }
745
+ }
746
+ }
747
+ }
748
+ function walkEncrypted(e, path, state) {
749
+ const sub = `${path}/${e.id}`;
750
+ tagExternalHeaderProtected(e, state);
751
+ if (state.viewMode === "wire") {
752
+ const bits = e.wireBits !== undefined
753
+ ? Math.max(0, Math.trunc(evalIn(state, e.wireBits)))
754
+ : sumPlaintextBits(e, state);
755
+ const nf = {
756
+ // Same qualification as emit(): ref prefix + repeat suffix, so repeat
757
+ // iterations / sibling ref expansions never collide on the blob id (§5).
758
+ id: qualify(state, e.id),
759
+ name: e.name ?? e.id,
760
+ bits,
761
+ absoluteBitOffset: state.offset,
762
+ originalContainerPath: sub,
763
+ ...(e.category !== undefined ? { category: e.category } : {}),
764
+ ...(e.doc !== undefined ? { doc: e.doc } : {}),
765
+ // §5.4: the encrypted region's RFC provenance rides on the wire-view
766
+ // blob, same as field.meta in emit().
767
+ ...(e.meta !== undefined ? { meta: e.meta } : {}),
768
+ encrypted: true,
769
+ ...(e.contextNote !== undefined ? { encryptedContextNote: e.contextNote } : {}),
770
+ };
771
+ // Switch-arm / repeat / group attribution, identical to emit() (§5, §5.4).
772
+ applyWalkContext(state, nf);
773
+ if (state.encryptedStack.length > 0)
774
+ nf.encryptedParentId = state.encryptedStack[state.encryptedStack.length - 1].parentId;
775
+ state.out.push(nf);
776
+ state.offset += bits;
777
+ // §4: record the encrypted container's wire footprint so a later
778
+ // `wireSize(e.id)` resolves to its byte size rather than 0.
779
+ recordWireSize(state, e.id, bits);
780
+ return;
781
+ }
782
+ const budgetBits = e.wireBits !== undefined
783
+ ? Math.max(0, Math.trunc(evalIn(state, e.wireBits)))
784
+ : undefined;
785
+ const startOffset = state.offset;
786
+ const frame = {
787
+ parentId: e.id,
788
+ contextNote: e.contextNote ?? "",
789
+ headerProtected: new Set(e.headerProtected ?? []),
790
+ };
791
+ state.encryptedStack.push(frame);
792
+ state.scopeStack.push({ startOffset, kind: "encrypted", ...(budgetBits !== undefined ? { budgetBits } : {}) });
793
+ try {
794
+ for (const child of e.plaintext.fields)
795
+ walkContainer(child, sub, state);
796
+ }
797
+ finally {
798
+ state.scopeStack.pop();
799
+ state.encryptedStack.pop();
800
+ }
801
+ // §5: when wireBits gives the ciphertext footprint, the plaintext children's
802
+ // bit sum need not match it (AEAD ciphertext+tag differs from the plaintext
803
+ // layout). Mirror walkBounded: over-consuming the wireBits budget is a runtime
804
+ // error, and under-consumption snaps the cursor to the budget end so every
805
+ // following field gets the correct absoluteBitOffset.
806
+ if (budgetBits !== undefined) {
807
+ const consumed = state.offset - startOffset;
808
+ if (consumed > budgetBits)
809
+ throw new Error(`normalize: encrypted scope "${e.id}" over-consumed: plaintext used ${consumed} bits but wireBits is ${budgetBits} bits (§5/§11.2).`);
810
+ if (consumed < budgetBits)
811
+ state.offset = startOffset + budgetBits;
812
+ }
813
+ // §4: record the encrypted container's wire footprint for wireSize.
814
+ recordWireSize(state, e.id, state.offset - startOffset);
815
+ }
816
+ function sumPlaintextBits(e, parent) {
817
+ // Use an isolated env copy: this is a throwaway size probe laid out at
818
+ // offset 0 with an empty id prefix, so emit/walkVirtual writes (field
819
+ // values, wireSize keys, virtual values) must NOT leak back into the live
820
+ // parent env, where a later sibling could read the polluted footprint.
821
+ const tmpEnv = new Map(parent.env);
822
+ const tmp = {
823
+ out: [], env: tmpEnv, offset: 0, viewMode: "wire", defs: parent.defs,
824
+ encryptedStack: [], scopeStack: [], groupStack: [], repeatIndexStack: [],
825
+ idPrefix: "", refDepth: parent.refDepth,
826
+ };
827
+ for (const child of e.plaintext.fields)
828
+ walkContainer(child, e.plaintext.id, tmp);
829
+ return tmp.offset;
830
+ }
831
+ export function normalize(packet, env = new Map(), opts = {}) {
832
+ const localEnv = new Map(env);
833
+ const defs = packet.defs ?? {};
834
+ seedDefaults(packet.body, localEnv, defs);
835
+ const state = {
836
+ out: [],
837
+ env: localEnv,
838
+ offset: 0,
839
+ viewMode: opts.viewMode ?? "wire",
840
+ defs,
841
+ encryptedStack: [],
842
+ scopeStack: [{ startOffset: 0, kind: "top", ...(opts.totalBits !== undefined ? { budgetBits: opts.totalBits } : {}) }],
843
+ groupStack: [],
844
+ repeatIndexStack: [],
845
+ idPrefix: "",
846
+ refDepth: 0,
847
+ };
848
+ for (const c of packet.body)
849
+ walkContainer(c, packet.name, state);
850
+ return { fields: state.out, totalBits: state.offset };
851
+ }
852
+ export function initialEnv(packet) {
853
+ const env = new Map();
854
+ seedDefaults(packet.body, env, packet.defs ?? {});
855
+ return env;
856
+ }
857
+ //# sourceMappingURL=normalize.js.map