@scriptc/compiler 0.0.18 → 0.0.20

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.
@@ -24,15 +24,17 @@
24
24
  * pattern) is allowed with a pinned order: depth-first source order of
25
25
  * the constituent declarations (A's arms in A's own declaration order,
26
26
  * then B's), an arm name appearing in several constituents keeping its
27
- * FIRST occurrence. A type alias of a named type is transparent: the
28
- * table derives from the aliased declaration, the alias adds no entry
29
- * and no reordering.
27
+ * FIRST occurrence. Type aliases are transparent where the wire has no
28
+ * alias identity: a reference alias tables the target declaration, while
29
+ * a scalar alias projects as its underlying bool/number/string/bytes
30
+ * TypeRef. Neither alias adds a table entry or reorders the target.
30
31
  *
31
32
  * Integer slots (ask 4): the profile's `sidecar.integer_slots` declares
32
33
  * specific number slots i64/u64 by slot path; the projection spells each
33
- * declared slot's TypeRef/descriptor `i64` (the frozen format-1
34
- * vocabulary — u64 is the stricter compile-time obligation over the same
35
- * wire spelling), refuses paths that resolve to no plain number slot,
34
+ * declared slot's TypeRef/descriptor `i64`, nested under `optional` when
35
+ * the TypeScript slot is optional (the frozen format-1 vocabulary — u64 is
36
+ * the stricter compile-time obligation over the same wire spelling), and
37
+ * refuses paths that resolve to no bare or optional number slot,
36
38
  * and emits `integer_slots` as the resolved-decision list, in profile
37
39
  * declaration order, each entry recording the DECLARED class ({i64, u64}
38
40
  * — the flattening is TypeRef-only). The list is an ATTESTATION (schema
@@ -137,6 +139,10 @@ function classify(decl, index) {
137
139
  if (s.k === "object") {
138
140
  return { c: "struct", storage: decl.form === "interface" ? "node" : "value", fields: s.fields, decl, index };
139
141
  }
142
+ if (decl.form === "alias" &&
143
+ (s.k === "bool" || s.k === "number" || s.k === "text" || s.k === "bytes")) {
144
+ return { c: "scalar", shape: s, decl, index };
145
+ }
140
146
  if (decl.form === "alias" && s.k === "ref")
141
147
  return { c: "alias", target: s.name, decl, index };
142
148
  if (decl.form === "alias" && s.k === "stringLit")
@@ -191,7 +197,10 @@ class Projector {
191
197
  table = new Map();
192
198
  inProgress = new Set();
193
199
  flatArms = new Map();
200
+ allFlatArms = new Map();
194
201
  flattening = new Set();
202
+ allFlattening = new Set();
203
+ irPatterning = new Set();
195
204
  synthCounter = 0;
196
205
  /** The profile's declared integer slots (ask 4), by slot path; entries
197
206
  * move to `intConsumed` as the projection spells them — a declared path
@@ -200,9 +209,11 @@ class Projector {
200
209
  intDeclared = new Map();
201
210
  intConsumed = new Map();
202
211
  /** The record-field slots' resolution facts for the inference: the
203
- * containing record's full projected field-name list plus the target
204
- * field (ir shapes intern structurally by field names). */
205
- intRecordFacts = [];
212
+ * containing record's complete projected IR shape plus the target field
213
+ * (IR shapes intern structurally by field names and field types). Pattern
214
+ * construction is deferred until the whole contract graph has projected,
215
+ * so an invalid later sibling still takes its ordinary SidecarError path. */
216
+ pendingIntRecordFacts = [];
206
217
  constructor(facts, config, entryLoc) {
207
218
  this.facts = facts;
208
219
  this.config = config;
@@ -216,20 +227,28 @@ class Projector {
216
227
  this.intDeclared.set(e.slot, e.cls);
217
228
  }
218
229
  /** Spell a projected slot i64 when the profile declared it (ask 4).
219
- * Only a PLAIN NUMBER slot can be integer-declared: optionals, slices,
220
- * and named types refuse — the declaration must match the wire shape
221
- * the schema freezes. The document spells i64 for both classes (the
222
- * frozen format-1 vocabulary has no u64; u64 is the stricter
223
- * compile-time obligation over the same wire spelling). */
230
+ * A NUMBER slot may be bare or optional: optional<number> composes the
231
+ * schema's two existing constructors as optional<i64>, and the proof
232
+ * applies only to its present numeric arm. Slices and named types still
233
+ * refuse — the declaration must match the wire shape the schema freezes.
234
+ * The document spells i64 for both classes (the frozen format-1
235
+ * vocabulary has no u64; u64 is the stricter compile-time obligation
236
+ * over the same wire spelling). */
224
237
  intify(ref, slotPath, loc) {
225
238
  const cls = this.intDeclared.get(slotPath);
226
239
  if (cls === undefined)
227
240
  return ref;
228
- if (ref.kind !== "f64") {
229
- throw new SidecarError(`the profile declares integer slot '${slotPath}' (${cls}), but that slot is not a plain number slot (it projects as '${ref.kind}')`, loc);
241
+ if (ref.kind === "f64") {
242
+ this.intConsumed.set(slotPath, cls);
243
+ return { kind: "i64" };
244
+ }
245
+ if (ref.kind === "optional" && ref.inner.kind === "f64") {
246
+ this.intConsumed.set(slotPath, cls);
247
+ return { kind: "optional", inner: { kind: "i64" } };
248
+ }
249
+ {
250
+ throw new SidecarError(`the profile declares integer slot '${slotPath}' (${cls}), but that slot is not a number or optional number slot (it projects as '${ref.kind}')`, loc);
230
251
  }
231
- this.intConsumed.set(slotPath, cls);
232
- return { kind: "i64" };
233
252
  }
234
253
  /** intify for a struct field (declared or synthesized), recording the
235
254
  * record-resolution fact the inference maps onto interned IR shapes. */
@@ -238,15 +257,123 @@ class Projector {
238
257
  const before = this.intConsumed.has(slotPath);
239
258
  const out = this.intify(ref, slotPath, loc);
240
259
  if (!before && this.intConsumed.has(slotPath)) {
241
- this.intRecordFacts.push({
242
- fieldNames: allFields,
260
+ this.pendingIntRecordFacts.push({
261
+ fields: allFields,
262
+ tagged: false,
243
263
  targetField: field,
244
264
  cls: this.intConsumed.get(slotPath),
245
265
  path: slotPath,
266
+ loc,
246
267
  });
247
268
  }
248
269
  return out;
249
270
  }
271
+ /** Canonical union constructor mirroring the frontend: nested unions
272
+ * flatten, structurally repeated arms collapse, and one surviving arm is
273
+ * just that arm. */
274
+ irUnionPattern(arms) {
275
+ const flat = [];
276
+ for (const arm of arms) {
277
+ if (arm.kind === "union")
278
+ flat.push(...arm.arms);
279
+ else
280
+ flat.push(arm);
281
+ }
282
+ const unique = new Map();
283
+ for (const arm of flat)
284
+ unique.set(JSON.stringify(arm), arm);
285
+ const canonical = [...unique.entries()]
286
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
287
+ .map(([, arm]) => arm);
288
+ if (canonical.length === 0)
289
+ throw new Error("sidecar pattern bug: empty union");
290
+ return canonical.length === 1 ? canonical[0] : { kind: "union", arms: canonical };
291
+ }
292
+ irFieldPattern(field) {
293
+ const inner = this.irTypePattern(field.shape, field.loc);
294
+ return field.optional
295
+ ? this.irUnionPattern([inner, { kind: "undefinedT" }])
296
+ : inner;
297
+ }
298
+ irRecordPattern(fields, tagged = false) {
299
+ const projected = fields.map((field) => ({
300
+ name: field.name,
301
+ type: this.irFieldPattern(field),
302
+ }));
303
+ if (tagged)
304
+ projected.push({ name: "kind", type: { kind: "string" } });
305
+ projected.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
306
+ return {
307
+ kind: "record",
308
+ fields: projected,
309
+ ...(tagged ? { kindMayBeOmitted: true } : {}),
310
+ };
311
+ }
312
+ /** Flatten a tagged union for IR identity, preserving every source
313
+ * constituent. This is deliberately separate from unionArms(): the
314
+ * sidecar's wire table is first-discriminant-name-wins, while the
315
+ * frontend's structural union interner retains later same-named arms
316
+ * whenever their payload shapes differ. irUnionPattern performs the
317
+ * frontend's structural deduplication after this walk. */
318
+ irTaggedArmPatterns(unionName, loc) {
319
+ return this.allUnionArms(unionName, loc).map((arm) => this.irRecordPattern(arm.fields, true));
320
+ }
321
+ /** Convert a sidecar-supported syntactic type into the exact structural
322
+ * IR pattern the frontend maps it to. Projection has already refused
323
+ * unsupported shapes before an integer fact is recorded, so the default
324
+ * cases are internal consistency checks. */
325
+ irTypePattern(shape, loc) {
326
+ switch (shape.k) {
327
+ case "number":
328
+ return { kind: "f64" };
329
+ case "text":
330
+ case "stringLit":
331
+ return { kind: "string" };
332
+ case "bool":
333
+ return { kind: "bool" };
334
+ case "bytes":
335
+ return { kind: "bytes", elem: "u8" };
336
+ case "absent":
337
+ return { kind: shape.unit === "null" ? "nullT" : "undefinedT" };
338
+ case "array":
339
+ return { kind: "array", elem: this.irTypePattern(shape.elem, loc) };
340
+ case "union":
341
+ return this.irUnionPattern(shape.parts.map((part) => this.irTypePattern(part, loc)));
342
+ case "object":
343
+ // A declared `{}` annotation is TypeScript's top non-nullish type,
344
+ // not the inferred shape of an empty object literal. The frontend
345
+ // therefore lowers it to dyn (types.ts's declared-empty rule).
346
+ return shape.fields.length === 0
347
+ ? { kind: "dyn" }
348
+ : this.irRecordPattern(shape.fields);
349
+ case "ref": {
350
+ const resolved = this.resolve(shape.name, loc);
351
+ if (resolved.c.c === "scalar")
352
+ return this.irTypePattern(resolved.c.shape, loc);
353
+ if (resolved.c.c === "enum")
354
+ return { kind: "string" };
355
+ if (resolved.c.c === "struct" && resolved.c.fields.length === 0) {
356
+ return { kind: "dyn" };
357
+ }
358
+ if (this.irPatterning.has(resolved.name)) {
359
+ throw new SidecarError(`the contract type graph is cyclic through '${resolved.name}' — recursive contract types cannot encode`, loc);
360
+ }
361
+ this.irPatterning.add(resolved.name);
362
+ try {
363
+ return resolved.c.c === "struct"
364
+ ? this.irRecordPattern(resolved.c.fields)
365
+ : this.irUnionPattern(this.irTaggedArmPatterns(resolved.name, loc));
366
+ }
367
+ finally {
368
+ this.irPatterning.delete(resolved.name);
369
+ }
370
+ }
371
+ case "void":
372
+ case "tuple":
373
+ case "unsupported":
374
+ throw new SidecarError(`an integer-slot record contains a shape outside the sidecar's structural vocabulary (${shape.k})`, loc);
375
+ }
376
+ }
250
377
  /** A declared slot path that the whole projection never spelled: the
251
378
  * path names nothing (or names a slot outside the schema's integer
252
379
  * grammar), and silently ignoring it would drop an ABI obligation. */
@@ -294,6 +421,17 @@ class Projector {
294
421
  cur = c.target;
295
422
  }
296
423
  }
424
+ /** Resolve only aliases whose names have no wire identity. Named
425
+ * records/enums/unions return null and retain their table projection. */
426
+ scalarShape(shape, loc) {
427
+ if (shape.k === "bool" || shape.k === "number" || shape.k === "text" || shape.k === "bytes") {
428
+ return shape;
429
+ }
430
+ if (shape.k !== "ref")
431
+ return null;
432
+ const resolved = this.resolve(shape.name, loc);
433
+ return resolved.c.c === "scalar" ? resolved.c.shape : null;
434
+ }
297
435
  /** A kind-tagged union's arms, its reference constituents flattened
298
436
  * DEPTH-FIRST IN SOURCE ORDER (the ask-3 composition rule, pinned by
299
437
  * conformance): an inline arm lands where it is spelled; a constituent
@@ -346,6 +484,113 @@ class Projector {
346
484
  this.flattening.delete(unionName);
347
485
  }
348
486
  }
487
+ /** Every source constituent of a composed tagged union, including later
488
+ * occurrences of an already-seen discriminant name. The wire table keeps
489
+ * first occurrence, but structural matching and integer obligations must
490
+ * retain all shapes the frontend can lower under that discriminant. */
491
+ allUnionArms(unionName, loc) {
492
+ const memo = this.allFlatArms.get(unionName);
493
+ if (memo !== undefined)
494
+ return memo;
495
+ if (this.allFlattening.has(unionName)) {
496
+ throw new SidecarError(`union composition is cyclic through '${unionName}' — a union cannot spread itself`, loc);
497
+ }
498
+ const c = this.lookup(unionName, loc);
499
+ if (c.c !== "tagged") {
500
+ throw new SidecarError(`'${unionName}' is not a kind-tagged union of object literals`, c.decl.loc);
501
+ }
502
+ this.allFlattening.add(unionName);
503
+ try {
504
+ const out = [];
505
+ for (const part of c.parts) {
506
+ if (part.p === "arm") {
507
+ out.push({ name: part.name, fields: part.fields, loc: part.loc });
508
+ continue;
509
+ }
510
+ const r = this.resolve(part.name, part.loc);
511
+ if (r.c.c !== "tagged") {
512
+ throw new SidecarError(`constituent '${part.name}' of union '${unionName}' is not a kind-tagged union — only kind-tagged unions compose by reference`, part.loc);
513
+ }
514
+ out.push(...this.allUnionArms(r.name, part.loc));
515
+ }
516
+ this.allFlatArms.set(unionName, out);
517
+ return out;
518
+ }
519
+ finally {
520
+ this.allFlattening.delete(unionName);
521
+ }
522
+ }
523
+ /** Record one integer obligation for every lowered structural arm carrying
524
+ * the wire-selected discriminant. A composed union can repeat an arm name
525
+ * with a different payload field name; the sidecar's scalar descriptor
526
+ * omits that source name, so each compatible record shape must prove the
527
+ * same boundary slot. Incompatible later payloads refuse rather than let a
528
+ * non-integer value ride an integer-attested wire arm. */
529
+ recordIntegerUnionArmFacts(unionName, armName, intifiedRef, cls, path, loc) {
530
+ const selectedOptional = intifiedRef.kind === "optional" && intifiedRef.inner.kind === "i64";
531
+ if (intifiedRef.kind !== "i64" && !selectedOptional) {
532
+ throw new Error(`sidecar pattern bug: integer arm '${path}' has ref '${intifiedRef.kind}'`);
533
+ }
534
+ for (const arm of this.allUnionArms(unionName, loc)) {
535
+ if (arm.name !== armName)
536
+ continue;
537
+ if (arm.fields.length !== 1) {
538
+ throw new SidecarError(`integer slot '${path}' (${cls}) selects a scalar ${selectedOptional ? "optional " : ""}number payload, but another composed '${armName}' arm has ${arm.fields.length === 0 ? "no payload" : `${arm.fields.length} payload fields`}`, arm.loc);
539
+ }
540
+ const field = arm.fields[0];
541
+ const ref = this.fieldRef(field, unionName);
542
+ const candidateOptional = ref.kind === "optional" && ref.inner.kind === "f64";
543
+ if (ref.kind !== "f64" && !candidateOptional) {
544
+ throw new SidecarError(`integer slot '${path}' (${cls}) selects a scalar ${selectedOptional ? "optional " : ""}number payload, but another composed '${armName}' arm projects as '${ref.kind}'`, arm.loc);
545
+ }
546
+ if (!selectedOptional && candidateOptional) {
547
+ throw new SidecarError(`integer slot '${path}' (${cls}) selects a required number payload, but another composed '${armName}' arm makes that payload optional`, arm.loc);
548
+ }
549
+ this.pendingIntRecordFacts.push({
550
+ fields: arm.fields,
551
+ tagged: true,
552
+ targetField: field.name,
553
+ cls,
554
+ path,
555
+ loc: arm.loc,
556
+ });
557
+ }
558
+ }
559
+ /** Record a number_bytes obligation for every composed occurrence of the
560
+ * wire-selected discriminant. Unlike a scalar descriptor, number_bytes
561
+ * exposes both source field names, so a later same-named arm must retain
562
+ * those names and the required number-then-bytes family. Text and bytes
563
+ * are the same sidecar bytes payload but distinct IR shapes; compatible
564
+ * occurrences therefore each need their own structural inference fact. */
565
+ recordIntegerNumberBytesArmFacts(unionName, armName, numberField, bytesField, cls, path, loc) {
566
+ for (const arm of this.allUnionArms(unionName, loc)) {
567
+ if (arm.name !== armName)
568
+ continue;
569
+ const [number, bytes] = arm.fields;
570
+ const numberShape = number === undefined ? null : this.scalarShape(number.shape, number.loc);
571
+ const bytesShape = bytes === undefined ? null : this.scalarShape(bytes.shape, bytes.loc);
572
+ const compatible = arm.fields.length === 2 &&
573
+ number !== undefined &&
574
+ bytes !== undefined &&
575
+ !number.optional &&
576
+ !bytes.optional &&
577
+ number.name === numberField &&
578
+ bytes.name === bytesField &&
579
+ numberShape?.k === "number" &&
580
+ (bytesShape?.k === "text" || bytesShape?.k === "bytes");
581
+ if (!compatible) {
582
+ throw new SidecarError(`integer slot '${path}' (${cls}) selects number_bytes payload fields '${numberField}' and '${bytesField}', but another composed '${armName}' arm does not have the same required number-then-bytes fields`, arm.loc);
583
+ }
584
+ this.pendingIntRecordFacts.push({
585
+ fields: arm.fields,
586
+ tagged: true,
587
+ targetField: numberField,
588
+ cls,
589
+ path,
590
+ loc: arm.loc,
591
+ });
592
+ }
593
+ }
349
594
  /** Project a syntactic field to a TypeRef, tabling every named type it
350
595
  * references. `container`/`member` seed synthesized names. */
351
596
  fieldRef(field, container) {
@@ -380,9 +625,12 @@ class Projector {
380
625
  throw new SidecarError(`'${container}.${member}' is an inline union — declare it as a named kind-tagged union (or a string-literal-union enum) and reference it by name`, loc);
381
626
  }
382
627
  case "ref": {
383
- // Aliases are transparent: the table entry (and its order) is the
384
- // ALIASED declaration's, under the aliased declaration's name.
628
+ // Scalar aliases dissolve to their underlying TypeRef. Named-type
629
+ // aliases remain transparent to the aliased table declaration.
385
630
  const r = this.resolve(shape.name, loc);
631
+ if (r.c.c === "scalar") {
632
+ return this.shapeRef(r.c.shape, container, member, loc);
633
+ }
386
634
  if (r.c.c === "struct") {
387
635
  this.tableNamed(r.name, loc);
388
636
  return { kind: r.c.storage, name: r.name };
@@ -430,6 +678,11 @@ class Projector {
430
678
  this.tableNamed(this.resolve(name, loc).name, loc);
431
679
  return;
432
680
  }
681
+ if (c.c === "scalar") {
682
+ // Scalar aliases dissolve at their reference site and never have a
683
+ // type-table entry of their own.
684
+ return;
685
+ }
433
686
  this.inProgress.add(name);
434
687
  try {
435
688
  if (c.c === "enum") {
@@ -453,7 +706,7 @@ class Projector {
453
706
  seen.add(f.name);
454
707
  entry.fields.push({
455
708
  name: f.name,
456
- type: this.intifyStructField(this.fieldRef(f, name), name, f.name, c.fields.map((x) => x.name), f.loc),
709
+ type: this.intifyStructField(this.fieldRef(f, name), name, f.name, c.fields, f.loc),
457
710
  });
458
711
  }
459
712
  this.table.set(name, { kind: "struct", entry, anchor: c.index, sub: -1 });
@@ -469,14 +722,7 @@ class Projector {
469
722
  const before = this.intConsumed.has(slotPath);
470
723
  payload = this.intify(payload, slotPath, arm.loc);
471
724
  if (!before && this.intConsumed.has(slotPath)) {
472
- // The one intifiable arm shape is a single number payload
473
- // field; its IR record carries the 'kind' discriminant too.
474
- this.intRecordFacts.push({
475
- fieldNames: [...arm.fields.map((f) => f.name), "kind"],
476
- targetField: arm.fields[0].name,
477
- cls: this.intConsumed.get(slotPath),
478
- path: slotPath,
479
- });
725
+ this.recordIntegerUnionArmFacts(name, arm.name, payload, this.intConsumed.get(slotPath), slotPath, arm.loc);
480
726
  }
481
727
  entry.arms.push({ name: arm.name, payload });
482
728
  }
@@ -518,7 +764,7 @@ class Projector {
518
764
  seen.add(f.name);
519
765
  entry.fields.push({
520
766
  name: f.name,
521
- type: this.intifyStructField(this.fieldRef(f, name), name, f.name, fields.map((x) => x.name), f.loc),
767
+ type: this.intifyStructField(this.fieldRef(f, name), name, f.name, fields, f.loc),
522
768
  });
523
769
  }
524
770
  return name;
@@ -530,43 +776,35 @@ class Projector {
530
776
  return { kind: "void" };
531
777
  if (fields.length === 2) {
532
778
  const [first, second] = [fields[0], fields[1]];
779
+ const firstShape = this.scalarShape(first.shape, first.loc);
780
+ const secondShape = this.scalarShape(second.shape, second.loc);
533
781
  // Family 4 covers exactly the number-first two-field record; a
534
782
  // bytes-first spelling takes the record family instead
535
783
  // (declaration order is semantic everywhere — the clarified rule).
536
784
  if (!first.optional &&
537
785
  !second.optional &&
538
- first.shape.k === "number" &&
539
- (second.shape.k === "text" || second.shape.k === "bytes")) {
786
+ firstShape?.k === "number" &&
787
+ (secondShape?.k === "text" || secondShape?.k === "bytes")) {
540
788
  // The number half is an ask-4 declarable slot:
541
789
  // `<msg>.<arm>.<numberField>` (the schema's number_bytes path).
542
790
  const slotPath = `${msgName}.${arm.name}.${first.name}`;
543
791
  const numRef = this.intify({ kind: "f64" }, slotPath, first.loc);
544
792
  if (numRef.kind === "i64") {
545
- this.intRecordFacts.push({
546
- fieldNames: [first.name, second.name, "kind"],
547
- targetField: first.name,
548
- cls: this.intConsumed.get(slotPath),
549
- path: slotPath,
550
- });
793
+ this.recordIntegerNumberBytesArmFacts(msgName, arm.name, first.name, second.name, this.intConsumed.get(slotPath), slotPath, first.loc);
551
794
  }
552
795
  return { kind: "number_bytes", number_field: first.name, number_class: numRef.kind, bytes_field: second.name };
553
796
  }
554
797
  }
555
798
  if (fields.length === 1 && !fields[0].optional) {
556
799
  let ref = this.fieldRef(fields[0], msgName);
557
- if (ref.kind === "f64") {
558
- // A plain number payload is an ask-4 declarable slot: `<msg>.<arm>`.
559
- const slotPath = `${msgName}.${arm.name}`;
560
- const before = this.intConsumed.has(slotPath);
561
- ref = this.intify(ref, slotPath, arm.loc);
562
- if (!before && this.intConsumed.has(slotPath)) {
563
- this.intRecordFacts.push({
564
- fieldNames: [fields[0].name, "kind"],
565
- targetField: fields[0].name,
566
- cls: this.intConsumed.get(slotPath),
567
- path: slotPath,
568
- });
569
- }
800
+ // A bare or optional number payload is an ask-4 declarable slot:
801
+ // `<msg>.<arm>`. Calling intify for every one-field family also makes
802
+ // a declaration targeting a non-number refuse at the precise arm.
803
+ const slotPath = `${msgName}.${arm.name}`;
804
+ const before = this.intConsumed.has(slotPath);
805
+ ref = this.intify(ref, slotPath, arm.loc);
806
+ if (!before && this.intConsumed.has(slotPath)) {
807
+ this.recordIntegerUnionArmFacts(msgName, arm.name, ref, this.intConsumed.get(slotPath), slotPath, arm.loc);
570
808
  }
571
809
  switch (ref.kind) {
572
810
  case "bytes":
@@ -591,6 +829,14 @@ class Projector {
591
829
  // payload fields — tables a synthesized record (family 5).
592
830
  return { kind: "record", name: this.tableSynthesized(msgName, arm.name, fields, arm.loc) };
593
831
  }
832
+ /** Materialize structural join patterns only after normal sidecar
833
+ * projection has validated every field reachable from these facts. */
834
+ finishedIntRecordFacts() {
835
+ return this.pendingIntRecordFacts.map(({ fields, tagged, ...fact }) => ({
836
+ shape: this.irRecordPattern(fields, tagged),
837
+ ...fact,
838
+ }));
839
+ }
594
840
  /** The finished type table, each array in declaration order (synthesized
595
841
  * entries anchor at their containing declaration). */
596
842
  finishedTable() {
@@ -760,6 +1006,7 @@ export function buildSidecar(input) {
760
1006
  // Every declared integer slot must have been spelled by now — the
761
1007
  // whole contract (model, msg, helpers, channels) is projected.
762
1008
  projector.checkIntConsumed();
1009
+ const intRecordFacts = projector.finishedIntRecordFacts();
763
1010
  const doc = {
764
1011
  format: SIDECAR_FORMAT,
765
1012
  wire_version: config.wireVersion,
@@ -818,7 +1065,7 @@ export function buildSidecar(input) {
818
1065
  ok: true,
819
1066
  doc,
820
1067
  json: JSON.stringify(doc, null, 2) + "\n",
821
- integerSlotFacts: { helpers: helperIntFacts, records: projector.intRecordFacts },
1068
+ integerSlotFacts: { helpers: helperIntFacts, records: intRecordFacts },
822
1069
  };
823
1070
  }
824
1071
  catch (e) {