lemmascript 0.5.18 → 0.5.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.
@@ -4,9 +4,13 @@
4
4
  * Consumes resolved types and classifications.
5
5
  * No type lookups, no string parsing, no re-inference.
6
6
  */
7
+ import { tyEqual } from "./typedir.js";
7
8
  import { anyExprInStmts, pWild, pCtor, patternBinders, patternBinds, patternCtor } from "./ir.js";
8
9
  import { parseTsType } from "./types.js";
9
10
  import { freshName } from "./names.js";
11
+ import { builtinSpec } from "./builtins.js";
12
+ import { isFalsyCapableTy } from "./condition-facts.js";
13
+ import { declOf, declOfKind, declOfTy, unionDeclOfTy, declWithVariant, tyBaseName } from "./typedecls.js";
10
14
  // ── Generic IR walkers ──────────────────────────────────────
11
15
  /**
12
16
  * Map over all sub-expressions in an Expr. `f` is called on each node;
@@ -21,6 +25,7 @@ function mapExpr(e, f) {
21
25
  switch (e.kind) {
22
26
  case "var":
23
27
  case "num":
28
+ case "bigint":
24
29
  case "bool":
25
30
  case "str":
26
31
  case "emptyMap":
@@ -43,7 +48,7 @@ function mapExpr(e, f) {
43
48
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
44
49
  case "if": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
45
50
  case "match": {
46
- const scr = typeof e.scrutinee === "string" ? e.scrutinee : r(e.scrutinee);
51
+ const scr = r(e.scrutinee);
47
52
  return { ...e, scrutinee: scr, arms: e.arms.map(a => ({ ...a, body: r(a.body) })) };
48
53
  }
49
54
  case "forall": return { ...e, body: r(e.body) };
@@ -66,7 +71,7 @@ function mapStmt(s, f) {
66
71
  case "continue": return s;
67
72
  case "if": return { ...s, cond: r(s.cond), then: s.then.map(t => mapStmt(t, f)), else: s.else.map(t => mapStmt(t, f)) };
68
73
  case "match": {
69
- const scr = typeof s.scrutinee === "string" ? s.scrutinee : r(s.scrutinee);
74
+ const scr = r(s.scrutinee);
70
75
  return { ...s, scrutinee: scr, arms: s.arms.map(a => ({ ...a, body: a.body.map(t => mapStmt(t, f)) })) };
71
76
  }
72
77
  case "while": return { ...s, cond: r(s.cond), invariants: s.invariants.map(r), body: s.body.map(t => mapStmt(t, f)) };
@@ -81,7 +86,10 @@ function mapStmt(s, f) {
81
86
  * rest of the block), `match` arm patterns, `forall`/`exists`, and `for-in`
82
87
  * indices. Capture-avoiding: a nested scope that reintroduces `from` keeps its
83
88
  * own binding untouched. `mapExpr` doesn't descend into lambda bodies, so this
84
- * walks them by hand. */
89
+ * walks them by hand — statement binders are tracked only at that body's top
90
+ * level, which is enough for the sole caller (dafny-emit's
91
+ * `comprehensionBinder`). TODO: generalize if more callers need this. */
92
+ const varE = (name) => ({ kind: "var", name });
85
93
  export function renameFreeVar(e, from, to) {
86
94
  const f = (x) => {
87
95
  if (x.kind === "var")
@@ -93,9 +101,7 @@ export function renameFreeVar(e, from, to) {
93
101
  if ((x.kind === "forall" || x.kind === "exists") && x.var === from)
94
102
  return x;
95
103
  if (x.kind === "match") {
96
- const scr = typeof x.scrutinee === "string"
97
- ? (x.scrutinee === from ? to : x.scrutinee) : mapExpr(x.scrutinee, f);
98
- return { ...x, scrutinee: scr, arms: x.arms.map(a => patternBinds(a.pattern, from) ? a : { ...a, body: mapExpr(a.body, f) }) };
104
+ return { ...x, scrutinee: mapExpr(x.scrutinee, f), arms: x.arms.map(a => patternBinds(a.pattern, from) ? a : { ...a, body: mapExpr(a.body, f) }) };
99
105
  }
100
106
  if (x.kind === "lambda") {
101
107
  if (x.params.some(p => p.name === from))
@@ -128,6 +134,7 @@ function mapTExpr(e, f) {
128
134
  switch (e.kind) {
129
135
  case "var":
130
136
  case "num":
137
+ case "bigint":
131
138
  case "str":
132
139
  case "bool":
133
140
  case "havoc": return e;
@@ -193,8 +200,33 @@ let _typeDecls = [];
193
200
  * in a higher-order position resolves to the monadic method, so it must be
194
201
  * redirected to the pure mirror. Set once per module transform. */
195
202
  let _pureDefNames = new Set();
196
- /** Array methods that take a function argument. */
197
- const HOF_METHODS = new Set(["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex", "reduce"]);
203
+ /** Discriminated unions whose tag is read as a *value* (`x.kind` compared
204
+ * to another union's tag, passed as an argument, …). Each gets a generated
205
+ * `<Union>_<disc>` discriminator function beside its datatype, returning
206
+ * the source tag strings. Narrowing consumes discriminant *checks*, so this
207
+ * fires only for surviving reads. Populated during body transforms; drained
208
+ * into the types file. */
209
+ let _neededKindHelpers = new Map();
210
+ function kindHelperName(decl) {
211
+ return freshName(`${decl.name}_${decl.discriminant}`);
212
+ }
213
+ function kindHelperDecl(decl) {
214
+ return {
215
+ kind: "def",
216
+ name: kindHelperName(decl),
217
+ typeParams: [],
218
+ params: [{ name: "t", type: { kind: "user", name: decl.name } }],
219
+ returnType: { kind: "string" },
220
+ requires: [], ensures: [], decreases: null,
221
+ body: {
222
+ kind: "match", scrutinee: varE("t"),
223
+ arms: decl.variants.map(v => ({
224
+ pattern: { kind: "ctor", ctor: v.name, binders: v.fields.map((_, i) => `_${i}`) },
225
+ body: { kind: "str", value: v.name },
226
+ })),
227
+ },
228
+ };
229
+ }
198
230
  /** Prefix match-bound field names to avoid capturing user variables.
199
231
  * When prefix is given (the scrutinee name), include it to avoid
200
232
  * collisions in nested matches on different variables. `freshName` closes
@@ -202,7 +234,10 @@ const HOF_METHODS = new Set(["map", "filter", "every", "some", "find", "findLast
202
234
  * arm body would still be captured, so prime on any module-wide collision.
203
235
  * Deterministic, so the pattern binder and its body substitutions agree. */
204
236
  function matchBinder(fieldName, prefix) {
205
- return freshName(prefix ? `_${prefix}_${fieldName}` : `_${fieldName}`);
237
+ const safePrefix = prefix === "\\result"
238
+ ? "result"
239
+ : prefix?.replace(/[^A-Za-z0-9_]/g, "_");
240
+ return freshName(safePrefix ? `_${safePrefix}_${fieldName}` : `_${fieldName}`);
206
241
  }
207
242
  /** Build a match arm pattern like `.VariantName _v_field1 _v_field2` from variant info. */
208
243
  function buildMatchPattern(variantName, fields, scopePrefix) {
@@ -214,17 +249,16 @@ function isIntegral(ty) { return ty.kind === "int" || ty.kind === "nat"; }
214
249
  function isArray(ty) { return ty.kind === "array"; }
215
250
  function isUser(ty) { return ty.kind === "user"; }
216
251
  function isRecordType(ty) {
217
- if (ty.kind !== "user")
218
- return false;
219
- const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
220
- return _typeDecls.find(d => d.name === base)?.kind === "record";
252
+ return declOfTy(_typeDecls, ty)?.kind === "record";
221
253
  }
222
254
  /** Truthiness test for a *lowered* value of source type `ty`, used by `||`
223
- * falsiness lowering. Mirrors narrow.ts's `canBeFalsy`: only int/nat/string/bool
224
- * values can be falsy in JS (`0`, `""`, `false`); every other value (array, user
225
- * type, …) is always truthy. Returns null for the always-truthy types so callers
226
- * can unwrap directly instead of emitting a redundant guard. */
255
+ * falsiness lowering. Which types need one is `isFalsyCapableTy` (shared with
256
+ * condition-facts' falsy gate); this adds the per-type test. Returns null for
257
+ * the always-truthy types so callers can unwrap directly instead of emitting
258
+ * a redundant guard. */
227
259
  function valueTruthyCond(value, ty) {
260
+ if (!isFalsyCapableTy(ty))
261
+ return null;
228
262
  switch (ty.kind) {
229
263
  case "int":
230
264
  case "nat":
@@ -327,20 +361,33 @@ function flattenLambdaBody(stmts) {
327
361
  * field, index, record, forall, or exists sub-expressions.
328
362
  */
329
363
  /** JS truthiness coercion for `if`/`while`/`?:` conditions.
330
- * Dafny requires bool; coerce number→`≠0`, string→non-empty, array→`true`
364
+ * Dafny requires bool; coerce number→`!== 0`, string→non-empty, array→`true`
331
365
  * (every array, even `[]`, is truthy in JS).
332
- * Optional conds are handled separately by narrow.ts (rewritten to someMatch). */
333
- function coerceCondToBool(cond, ty) {
334
- if (ty.kind === "bool")
335
- return cond;
336
- if (ty.kind === "int" || ty.kind === "nat")
337
- return { kind: "binop", op: "≠", left: cond, right: { kind: "num", value: 0 } };
338
- if (ty.kind === "string")
339
- return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "length" }, right: { kind: "num", value: 0 } };
366
+ *
367
+ * Rewrites the typed tree, before lowering, and distributes over `&&`/`||`:
368
+ * each operand of a logical connective is itself in condition position, and the
369
+ * operands need not share a type — `i >= 0 && carry`, with `carry` an int, is a
370
+ * bool conjoined with a number. A conjunction takes its type from its right
371
+ * operand (resolve), so coercing the whole expression by its type would emit
372
+ * `((i >= 0) && carry) != 0`, which is not well-typed.
373
+ *
374
+ * Optional conds never arrive here — narrow.ts rewrites them to someMatch. */
375
+ function asCondition(e) {
376
+ const bool = { kind: "bool" };
377
+ if (e.ty.kind === "bool")
378
+ return e;
379
+ if (e.kind === "binop" && (e.op === "&&" || e.op === "||"))
380
+ return { ...e, left: asCondition(e.left), right: asCondition(e.right), ty: bool };
381
+ if (e.ty.kind === "int" || e.ty.kind === "nat")
382
+ return { kind: "binop", op: "!==", left: e, right: { kind: "num", value: 0, ty: e.ty }, ty: bool };
383
+ if (e.ty.kind === "string")
384
+ return { kind: "binop", op: ">",
385
+ left: { kind: "field", obj: e, field: "length", ty: { kind: "nat" } },
386
+ right: { kind: "num", value: 0, ty: { kind: "nat" } }, ty: bool };
340
387
  // Arrays, objects, maps, sets, tuples are always truthy in JS (even `[]`/`{}`).
341
- if (["array", "user", "map", "set", "tuple"].includes(ty.kind))
342
- return { kind: "bool", value: true };
343
- return cond;
388
+ if (["array", "user", "map", "set", "tuple"].includes(e.ty.kind))
389
+ return { kind: "bool", value: true, ty: bool };
390
+ return e;
344
391
  }
345
392
  /** Wrap an expression in Some/None for optional-typed conditionals.
346
393
  * If the raw TExpr is `undefined`, emit `.none`; otherwise wrap in `Some`. */
@@ -350,7 +397,7 @@ function wrapOptionalBranch(expr, raw) {
350
397
  // like the scrutinee of an outer match. Dafny treats `Option.Some` and bare
351
398
  // `Some` equivalently — the qualification is harmless there.
352
399
  if (raw.kind === "var" && raw.name === "undefined")
353
- return { kind: "constructor", name: "none", type: "Option" };
400
+ return { kind: "constructor", name: "none", type: "Option", args: [] };
354
401
  if (raw.ty.kind === "optional")
355
402
  return expr; // already Option<T>, don't double-wrap
356
403
  return { kind: "constructor", name: "some", type: "Option", args: [expr] };
@@ -419,12 +466,16 @@ function lowerExpr(e, binds) {
419
466
  switch (e.kind) {
420
467
  case "var": return { kind: "var", name: e.name };
421
468
  case "num": return { kind: "num", value: e.value };
469
+ case "bigint": return { kind: "bigint", value: e.value };
422
470
  case "bool": return { kind: "bool", value: e.value };
423
471
  case "str":
424
472
  if (e.ty.kind === "user")
425
- return { kind: "constructor", name: e.value, type: e.ty.name };
473
+ return { kind: "constructor", name: e.value, type: e.ty.name, args: [] };
426
474
  return { kind: "str", value: e.value };
427
475
  case "unop":
476
+ // Only `num` folds. A `bigint` payload is a string, so negating it here
477
+ // would coerce through `Number` and round: `-9007199254740993n` stays a
478
+ // structural `unop("-", bigint(...))` and is negated by the emitter.
428
479
  if (e.op === "-" && e.expr.kind === "num")
429
480
  return { kind: "num", value: -e.expr.value };
430
481
  // String truthiness: !str → str == ""
@@ -469,7 +520,7 @@ function lowerExpr(e, binds) {
469
520
  kind: "binop",
470
521
  op: e.op === "===" ? "=" : "≠",
471
522
  left: transformExpr(e.left.obj),
472
- right: { kind: "constructor", name: e.right.value, type: objTy },
523
+ right: { kind: "constructor", name: e.right.value, type: objTy, args: [] },
473
524
  };
474
525
  }
475
526
  // String literal comparison — constructor if user type, string literal if string.
@@ -479,7 +530,7 @@ function lowerExpr(e, binds) {
479
530
  const left = lowerExpr(e.left, binds);
480
531
  const leftTy = e.left.ty.kind === "user" ? e.left.ty.name : undefined;
481
532
  const right = isUser(e.left.ty)
482
- ? { kind: "constructor", name: e.right.value, type: leftTy }
533
+ ? { kind: "constructor", name: e.right.value, type: leftTy, args: [] }
483
534
  : { kind: "str", value: e.right.value };
484
535
  return { kind: "binop", op: e.op === "===" ? "=" : "≠", left, right };
485
536
  }
@@ -504,7 +555,7 @@ function lowerExpr(e, binds) {
504
555
  // non-optional string-literal rule above.
505
556
  const innerTy = optSide.ty.kind === "optional" ? optSide.ty.inner : optSide.ty;
506
557
  const valExpr = valSide.kind === "str" && innerTy.kind === "user"
507
- ? { kind: "constructor", name: valSide.value, type: innerTy.name }
558
+ ? { kind: "constructor", name: valSide.value, type: innerTy.name, args: [] }
508
559
  : lowerExpr(valSide, binds);
509
560
  const cmpOp = BOOL_OP_MAP[e.op] ?? e.op;
510
561
  const noneVal = e.op === "!==" ? true : false;
@@ -543,7 +594,7 @@ function lowerExpr(e, binds) {
543
594
  // || on optional → match Some/None with default. JS `||` tests falsiness of
544
595
  // the *unwrapped* value, so when the inner type can be falsy the Some arm must
545
596
  // re-test (`Some(0) || 1 === 1`); array/user inners are always truthy and
546
- // unwrap directly. Mirrors narrow.ts's canBeFalsy gate.
597
+ // unwrap directly. Same gate as condition-facts' `canBeFalsy`.
547
598
  if (e.op === "||" && e.left.ty.kind === "optional") {
548
599
  const optExpr = lowerExpr(e.left, binds);
549
600
  const defaultExpr = lowerExpr(e.right, binds);
@@ -694,21 +745,41 @@ function lowerExpr(e, binds) {
694
745
  // as bare truthiness checks. Emit as the Dafny discriminator predicate for the
695
746
  // 'true' variant: result.ok → result.true_?
696
747
  if (e.isDiscriminant && e.obj.ty.kind === "user") {
697
- const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
698
- const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
748
+ const decl = unionDeclOfTy(_typeDecls, e.obj.ty);
699
749
  if (decl?.variants?.some(v => v.name === "true")) {
700
750
  return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
701
751
  }
752
+ // Surviving string-discriminant read (`x.kind` used as a value —
753
+ // compared against another union's tag, passed as an argument, …):
754
+ // lower to the generated per-union discriminator function, which
755
+ // returns the source tag strings. Narrowing consumes discriminant
756
+ // *checks*; this catches reads that survive as values.
757
+ if (decl?.variants && decl.discriminant && decl.discriminant !== "__isArray__") {
758
+ _neededKindHelpers.set(decl.name, decl);
759
+ return { kind: "app", fn: kindHelperName(decl), args: [lowerExpr(e.obj, binds)] };
760
+ }
702
761
  }
703
762
  // Union destructor: `x.field` where x is a discriminated union and `field`
704
763
  // is a data field of one of its variants. Dafny reads the destructor
705
764
  // directly; Lean has no field projection on a multi-ctor inductive, so tag
706
765
  // the node with the union's base name and let the Lean emitter `match`.
707
766
  if (e.obj.ty.kind === "user") {
708
- const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
709
- const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
710
- if (decl?.variants?.some(v => v.fields.some(f => f.name === e.field))) {
711
- return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName, datatypeField: true };
767
+ const baseName = tyBaseName(e.obj.ty.name);
768
+ const decl = declOfKind(_typeDecls, baseName, "discriminated-union");
769
+ const owners = decl?.variants?.filter(v => v.fields.some(f => f.name === e.field)) ?? [];
770
+ if (owners.length > 0) {
771
+ // Shared field name with differing declared types: those destructors
772
+ // are renamed per-constructor, and the read's own resolved type
773
+ // identifies the owning variant (the types differ exactly when the
774
+ // rename happens). Pin it so emitters use the renamed destructor.
775
+ const ownerTy = (v) => v.fields.find(f => f.name === e.field)?.type;
776
+ const differ = owners.some(v => {
777
+ const a = ownerTy(v), b = ownerTy(owners[0]);
778
+ return a && b && !tyEqual(a, b);
779
+ });
780
+ const matching = differ ? owners.filter(v => { const t = ownerTy(v); return t && tyEqual(t, e.ty); }) : [];
781
+ const ctor = e.ofVariant ?? (matching.length === 1 ? matching[0].name : undefined);
782
+ return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName, ctor, datatypeField: true };
712
783
  }
713
784
  }
714
785
  return { kind: "field", obj: transformExpr(e.obj), field: e.field, datatypeField: isRecordType(e.obj.ty) };
@@ -737,17 +808,22 @@ function lowerExpr(e, binds) {
737
808
  e.fn.field === "isArray" && e.args.length === 1) {
738
809
  const arg = e.args[0];
739
810
  if (arg.ty.kind === "user") {
740
- const baseName = arg.ty.name.includes("<") ? arg.ty.name.slice(0, arg.ty.name.indexOf("<")) : arg.ty.name;
741
- const decl = _typeDecls.find(d => d.name === baseName);
811
+ const decl = declOfTy(_typeDecls, arg.ty);
742
812
  if (decl?.kind === "discriminated-union" && decl.discriminant === "__isArray__") {
743
813
  return {
744
814
  kind: "binop", op: "=",
745
815
  left: lowerExpr(arg, binds),
746
- right: { kind: "constructor", name: "ArrayBranch", type: arg.ty.name },
816
+ right: { kind: "constructor", name: "ArrayBranch", type: arg.ty.name, args: [] },
747
817
  };
748
818
  }
749
819
  }
750
820
  }
821
+ // String.fromCharCode(n) → preamble function (inverse of charCodeAt,
822
+ // which lowers to `(s[i] as int)`).
823
+ if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "String" &&
824
+ e.fn.field === "fromCharCode" && e.args.length === 1) {
825
+ return { kind: "app", fn: "StringFromCharCode", args: [lowerExpr(e.args[0], binds)] };
826
+ }
751
827
  // Math.abs/min/max → preamble functions
752
828
  if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "Math") {
753
829
  if (e.fn.field === "abs" && e.args.length === 1)
@@ -787,7 +863,10 @@ function lowerExpr(e, binds) {
787
863
  if (e.fn.kind === "field") {
788
864
  const recv = lowerExpr(e.fn.obj, binds);
789
865
  let method = e.fn.field;
790
- const isHOF = e.fn.obj.ty.kind === "array" && HOF_METHODS.has(method);
866
+ const spec = e.builtinId !== undefined ? builtinSpec(e.builtinId) : null;
867
+ // Lambda-taking array builtins (registry `hof`, comparator excluded —
868
+ // mirrors the historical HOF_METHODS set).
869
+ const isHOF = spec?.hof !== undefined && spec.hof.shape !== "comparator";
791
870
  const args = e.args.map((a, i) => {
792
871
  const lowered = lowerExpr(a, binds);
793
872
  // Lean: a pure fn passed to a HOF by name resolves to the monadic
@@ -796,9 +875,9 @@ function lowerExpr(e, binds) {
796
875
  lowered.kind === "var" && _pureDefNames.has(lowered.name)) {
797
876
  return { kind: "var", name: `Pure.${lowered.name}` };
798
877
  }
799
- // Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1).
800
- const isArrIdxArg = e.fn.kind === "field" && e.fn.obj.ty.kind === "array" &&
801
- ((e.fn.field === "with" && i === 0) || ((e.fn.field === "includes" || e.fn.field === "indexOf") && i === 1));
878
+ // Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1)
879
+ // registry `intArgPositions`.
880
+ const isArrIdxArg = spec?.intArgPositions !== undefined && spec.intArgPositions.includes(i);
802
881
  if (isArrIdxArg && !isNat(a.ty))
803
882
  return { kind: "toNat", expr: lowered };
804
883
  return lowered;
@@ -846,8 +925,8 @@ function lowerExpr(e, binds) {
846
925
  if (e.ty.kind === "user" && !e.spread) {
847
926
  const tyName = e.ty.name;
848
927
  // Match base type name (strip generic args: "Result<Model, Err>" → "Result")
849
- const baseName = tyName.includes("<") ? tyName.slice(0, tyName.indexOf("<")) : tyName;
850
- const decl = _typeDecls.find(d => d.name === baseName && (d.kind === "discriminated-union" || d.kind === "string-union"));
928
+ const baseName = tyBaseName(tyName);
929
+ const decl = declOfKind(_typeDecls, baseName, "discriminated-union", "string-union");
851
930
  if (decl && decl.discriminant) {
852
931
  const discField = e.fields.find(f => f.name === decl.discriminant);
853
932
  if (discField && (discField.value.kind === "str" || discField.value.kind === "bool")) {
@@ -855,8 +934,11 @@ function lowerExpr(e, binds) {
855
934
  const variant = decl.variants?.find(v => v.name === variantName);
856
935
  if (variant) {
857
936
  const nonDiscFields = e.fields.filter(f => f.name !== decl.discriminant);
858
- if (nonDiscFields.length === 0) {
859
- return { kind: "constructor", name: variantName, type: tyName };
937
+ // Bare-constructor shortcut only when the variant truly has no
938
+ // fields a variant with only optional fields still needs its
939
+ // None-filled argument list (`int_(None)`, not `int_`).
940
+ if (variant.fields.length === 0) {
941
+ return { kind: "constructor", name: variantName, type: tyName, args: [] };
860
942
  }
861
943
  // Constructor with args: match variant field order. Emit a bare `app`
862
944
  // (Dafny renders `variantName(args)`, a valid unqualified constructor
@@ -877,9 +959,9 @@ function lowerExpr(e, binds) {
877
959
  if (e.spread) {
878
960
  const spreadTy = e.spread.ty.kind === "optional" ? e.spread.ty.inner : e.spread.ty;
879
961
  const structName = spreadTy.kind === "user" ? spreadTy.name : undefined;
880
- const structDecl = structName ? _typeDecls.find(d => d.name === structName && d.kind === "record") : undefined;
962
+ const structDecl = structName ? declOfKind(_typeDecls, structName, "record") : undefined;
881
963
  // Also check discriminated-union variants for field types
882
- const unionDecl = structName ? _typeDecls.find(d => d.name === structName && d.kind === "discriminated-union") : undefined;
964
+ const unionDecl = structName ? declOfKind(_typeDecls, structName, "discriminated-union") : undefined;
883
965
  const loweredFields = e.fields.map(f => {
884
966
  // Propagate declared field type onto value if it has unknown type
885
967
  let fieldValue = f.value;
@@ -932,10 +1014,8 @@ function lowerExpr(e, binds) {
932
1014
  // Carry the resolved record type so the emitter can pick the right
933
1015
  // constructor when two datatypes share a field-name set (Event vs
934
1016
  // SparseEvent) — structural matching alone would take the first-declared.
935
- const recName = e.ty.kind === "user"
936
- ? (e.ty.name.includes("<") ? e.ty.name.slice(0, e.ty.name.indexOf("<")) : e.ty.name)
937
- : undefined;
938
- const ctor = recName && _typeDecls.find(d => d.name === recName && d.kind === "record") ? recName : undefined;
1017
+ const recName = e.ty.kind === "user" ? tyBaseName(e.ty.name) : undefined;
1018
+ const ctor = recName && declOfKind(_typeDecls, recName, "record") ? recName : undefined;
939
1019
  return { kind: "record", spread: null, ctor, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
940
1020
  }
941
1021
  case "arrayLiteral":
@@ -974,7 +1054,7 @@ function lowerExpr(e, binds) {
974
1054
  // JS truthiness coercion (string/array/int → ... > 0). Matches SPEC §3.1
975
1055
  // negation forms (`!s` → `s == ""`). Optional conds are already
976
1056
  // rewritten to someMatch by narrow.ts.
977
- const cond = coerceCondToBool(lowerExpr(e.cond, binds), e.cond.ty);
1057
+ const cond = lowerExpr(asCondition(e.cond), binds);
978
1058
  let thenExpr = lowerExpr(e.then, binds);
979
1059
  let elseExpr = lowerExpr(e.else, binds);
980
1060
  if (e.ty.kind === "optional") {
@@ -985,10 +1065,10 @@ function lowerExpr(e, binds) {
985
1065
  }
986
1066
  case "optChain":
987
1067
  // Narrow should have rewritten optChain to someMatch.
988
- throw new Error(`optChain reached transform — narrow should have rewritten it`);
1068
+ throw new Error(`optChain reached transform — narrow should have rewritten it: ${JSON.stringify(e).slice(0, 400)}`);
989
1069
  case "nullish":
990
1070
  // Narrow should have rewritten nullish to someMatch.
991
- throw new Error(`nullish reached transform — narrow should have rewritten it`);
1071
+ throw new Error(`nullish reached transform — narrow should have rewritten it: ${JSON.stringify(e).slice(0, 300)}`);
992
1072
  case "havoc":
993
1073
  // Dafny's * only works in var/assign positions — lift to own declaration
994
1074
  if (binds) {
@@ -1009,7 +1089,7 @@ function lowerExpr(e, binds) {
1009
1089
  // Bare-var shortcut, but route \result through lowerExpr so the
1010
1090
  // lemma-side replaceVar pass can substitute it with the function call.
1011
1091
  scrutinee = path.fields.length === 0 && path.rootVar !== "\\result"
1012
- ? path.rootVar
1092
+ ? varE(path.rootVar)
1013
1093
  : lowerExpr(e.scrutinee, binds);
1014
1094
  }
1015
1095
  else {
@@ -1040,7 +1120,7 @@ function lowerExpr(e, binds) {
1040
1120
  // Path scrutinees (e.g. `m.content`) get a synthesized hint derived
1041
1121
  // from the last field/var name so the binder reads naturally.
1042
1122
  const scrutinee = lowerExpr(e.scrutinee, binds);
1043
- const decl = _typeDecls.find(d => d.name === e.typeName);
1123
+ const decl = declOf(_typeDecls, e.typeName);
1044
1124
  const isSynthArrayUnion = decl?.discriminant === "__isArray__";
1045
1125
  const varName = e.scrutinee.kind === "var" ? e.scrutinee.name : undefined;
1046
1126
  const pathHint = varName ?? scrutineeHint(e.scrutinee);
@@ -1050,7 +1130,7 @@ function lowerExpr(e, binds) {
1050
1130
  const fields = variant?.fields ?? [];
1051
1131
  let body = lowerExpr(c.body, binds);
1052
1132
  if (varName && fields.length > 0) {
1053
- body = replaceFieldAccess(body, varName, fields);
1133
+ body = replaceFieldAccess(body, varName, fields, c.variant, tyBaseName(e.typeName));
1054
1134
  if (isSynthArrayUnion && fields.length === 1) {
1055
1135
  body = replaceVarInExpr(body, varName, matchBinder(fields[0].name, varName));
1056
1136
  }
@@ -1071,7 +1151,7 @@ function lowerExpr(e, binds) {
1071
1151
  body = wrapOptionalBranch(body, e.fallthrough);
1072
1152
  arms.push({ pattern: pWild(), body });
1073
1153
  }
1074
- return { kind: "match", scrutinee: varName ?? scrutinee, arms };
1154
+ return { kind: "match", scrutinee: varName !== undefined ? varE(varName) : scrutinee, arms };
1075
1155
  }
1076
1156
  }
1077
1157
  }
@@ -1100,7 +1180,7 @@ function ensuresToMatch(e, typeDecls) {
1100
1180
  if (obj.kind !== "var" || obj.ty.kind !== "user")
1101
1181
  return null;
1102
1182
  const typeName = obj.ty.name;
1103
- const decl = typeDecls.find(d => d.name === typeName && d.kind === "discriminated-union");
1183
+ const decl = declOfKind(typeDecls, typeName, "discriminated-union");
1104
1184
  if (!decl)
1105
1185
  return null;
1106
1186
  const variantName = e.left.right.value;
@@ -1111,18 +1191,26 @@ function ensuresToMatch(e, typeDecls) {
1111
1191
  const pattern = buildMatchPattern(variantName, fields, obj.name);
1112
1192
  let rhs = transformExpr(e.right);
1113
1193
  rhs = replaceFieldAccess(rhs, obj.name, fields);
1114
- return { kind: "match", scrutinee: obj.name, arms: [{ pattern, body: rhs }, { pattern: pWild(), body: { kind: "bool", value: true } }] };
1194
+ return { kind: "match", scrutinee: varE(obj.name), arms: [{ pattern, body: rhs }, { pattern: pWild(), body: { kind: "bool", value: true } }] };
1115
1195
  }
1116
- function replaceFieldAccess(e, varName, fields) {
1196
+ function replaceFieldAccess(e, varName, fields, ctorName, ctorOf) {
1117
1197
  return mapExpr(e, x => {
1118
1198
  if (x.kind === "field" && x.obj.kind === "var" && x.obj.name === varName) {
1119
1199
  const f = fields.find(f => f.name === x.field);
1120
1200
  if (f)
1121
1201
  return { kind: "var", name: matchBinder(f.name, varName) };
1122
1202
  }
1203
+ // Datatype update of the scrutinee (`{ ...vn, f: v }`): the arm knows the
1204
+ // variant, so stamp it — emitters need it for per-constructor destructor
1205
+ // names. Recurse manually (returning a node stops mapExpr's own descent).
1206
+ if (ctorName && x.kind === "record" && !x.ctor && x.spread &&
1207
+ x.spread.kind === "var" && x.spread.name === varName) {
1208
+ return { ...x, ctor: ctorName, ctorOf,
1209
+ fields: x.fields.map(f => ({ ...f, value: replaceFieldAccess(f.value, varName, fields, ctorName, ctorOf) })) };
1210
+ }
1123
1211
  // If this let shadows the matched variable, stop replacing in the body
1124
1212
  if (x.kind === "let" && x.name === varName)
1125
- return { ...x, value: replaceFieldAccess(x.value, varName, fields) };
1213
+ return { ...x, value: replaceFieldAccess(x.value, varName, fields, ctorName, ctorOf) };
1126
1214
  return null;
1127
1215
  });
1128
1216
  }
@@ -1176,11 +1264,12 @@ function scrutineeHint(e) {
1176
1264
  // `if (X) continue; rest` → `if (!X) { rest }` at the top of a loop body.
1177
1265
  // Dafny's lowered while-loops have the index increment at the bottom, so a
1178
1266
  // `continue` would skip it and loop forever; rewriting to if/else lets the
1179
- // loop fall through normally.
1267
+ // loop fall through normally. The operand is already-lowered IR, where
1268
+ // negation is spelled `¬` (lowerExpr rewrites `!`).
1180
1269
  function negateExpr(e) {
1181
- if (e.kind === "unop" && e.op === "!")
1270
+ if (e.kind === "unop" && e.op === "¬")
1182
1271
  return e.expr;
1183
- return { kind: "unop", op: "!", expr: e };
1272
+ return { kind: "unop", op: "¬", expr: e };
1184
1273
  }
1185
1274
  /** Build the two pieces of an `arr.pop()` lowering on a named array variable:
1186
1275
  * - `optValue` is `(if |arr|>0 then Some(arr[|arr|-1]) else None)` (the popped element)
@@ -1300,14 +1389,16 @@ function matchToIfChains(stmts) {
1300
1389
  const arms = s.arms.map(a => ({ ...a, body: matchToIfChains(a.body) }));
1301
1390
  const ctorArms = arms.filter(a => a.pattern.kind !== "wild");
1302
1391
  const firstCtor = ctorArms[0] ? patternCtor(ctorArms[0].pattern) : undefined;
1303
- const decl = firstCtor
1304
- ? _typeDecls.find(d => (d.kind === "discriminated-union" || d.kind === "string-union") &&
1305
- ((d.variants?.some(v => v.name === firstCtor)) || (d.values?.includes(firstCtor))))
1306
- : undefined;
1392
+ const decl = firstCtor ? declWithVariant(_typeDecls, firstCtor) : undefined;
1307
1393
  if (!decl)
1308
1394
  return [{ ...s, arms }]; // not a user union (e.g. Option) — leave as match
1309
- const scrutExpr = typeof s.scrutinee === "string" ? { kind: "var", name: s.scrutinee } : s.scrutinee;
1395
+ const scrutExpr = s.scrutinee;
1310
1396
  const defaultArm = arms.find(a => a.pattern.kind === "wild");
1397
+ const declaredCtors = decl.kind === "discriminated-union"
1398
+ ? decl.variants?.map(v => v.name)
1399
+ : decl.kind === "string-union" ? decl.values : undefined;
1400
+ const coveredCtors = new Set(ctorArms.map(a => patternCtor(a.pattern)).filter((c) => !!c));
1401
+ const exhaustiveWithoutDefault = !defaultArm && !!declaredCtors && declaredCtors.every(c => coveredCtors.has(c));
1311
1402
  let elseBranch = defaultArm ? defaultArm.body : [];
1312
1403
  for (let k = ctorArms.length - 1; k >= 0; k--) {
1313
1404
  const armBody = ctorArms[k].body;
@@ -1325,7 +1416,7 @@ function matchToIfChains(stmts) {
1325
1416
  { pattern: pCtor(ctor), body: { kind: "bool", value: true } },
1326
1417
  { pattern: pWild(), body: { kind: "bool", value: false } }
1327
1418
  ] }
1328
- : { kind: "binop", op: "=", left: scrutExpr, right: { kind: "constructor", name: ctor, type: decl.name } };
1419
+ : { kind: "binop", op: "=", left: scrutExpr, right: { kind: "constructor", name: ctor, type: decl.name, args: [] } };
1329
1420
  // Bind only the constructor-field binders the body actually uses, pinning the
1330
1421
  // owning ctor so the destructor doesn't guess (variants share field names).
1331
1422
  const lets = [];
@@ -1337,6 +1428,13 @@ function matchToIfChains(stmts) {
1337
1428
  value: { kind: "field", obj: scrutExpr, field: f.name, fromUnion: decl.name, ctor },
1338
1429
  });
1339
1430
  });
1431
+ // An exhaustive source match has no fallthrough. Use its final arm as
1432
+ // the unconditional else branch; emitting `else pure ()` would force a
1433
+ // Unit result even when every arm returns the method's result type.
1434
+ if (exhaustiveWithoutDefault && k === ctorArms.length - 1) {
1435
+ elseBranch = [...lets, ...armBody];
1436
+ continue;
1437
+ }
1340
1438
  elseBranch = [{ kind: "if", cond, then: [...lets, ...armBody], else: elseBranch }];
1341
1439
  }
1342
1440
  return elseBranch;
@@ -1381,6 +1479,34 @@ function requireDoneWithForBreaks(stmts, fnName) {
1381
1479
  }
1382
1480
  }
1383
1481
  }
1482
+ /** The forin emission places the index increment at the loop-body end, so a
1483
+ * surviving `continue` would skip it. Insert the increment immediately
1484
+ * before every same-scope continue (nested loops own their continues),
1485
+ * mirroring the C-style-for desugar's discipline. */
1486
+ function insertIncrementBeforeContinue(stmts, idxName) {
1487
+ const incr = { kind: "assign", target: idxName,
1488
+ value: { kind: "binop", op: "+", left: { kind: "var", name: idxName }, right: { kind: "num", value: 1 } } };
1489
+ const walk = (ss) => {
1490
+ const out = [];
1491
+ for (const s of ss) {
1492
+ if (s.kind === "continue") {
1493
+ out.push(incr, s);
1494
+ continue;
1495
+ }
1496
+ if (s.kind === "if") {
1497
+ out.push({ ...s, then: walk(s.then), else: walk(s.else) });
1498
+ continue;
1499
+ }
1500
+ if (s.kind === "match") {
1501
+ out.push({ ...s, arms: s.arms.map(a => ({ ...a, body: walk(a.body) })) });
1502
+ continue;
1503
+ }
1504
+ out.push(s);
1505
+ }
1506
+ return out;
1507
+ };
1508
+ return walk(stmts);
1509
+ }
1384
1510
  function eliminateTopLevelContinue(stmts) {
1385
1511
  const out = [];
1386
1512
  for (let i = 0; i < stmts.length; i++) {
@@ -1489,7 +1615,7 @@ function transformStmts(stmts, typeDecls) {
1489
1615
  const arrSize = { kind: "field", obj: seq, field: "size" };
1490
1616
  // Auto-add bound invariant: idx ≤ bound (always true for range loops)
1491
1617
  const boundInv = { kind: "binop", op: "≤", left: idxVar, right: arrSize };
1492
- const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1618
+ const bodyStmts = insertIncrementBeforeContinue(eliminateTopLevelContinue(transformStmts(s.body, typeDecls)), idxName);
1493
1619
  result.push({
1494
1620
  kind: "forin", idx: idxName, bound: arrSize,
1495
1621
  invariants: [boundInv, ...s.invariants.map(transformExpr)],
@@ -1662,13 +1788,13 @@ function transformStmt(s, typeDecls) {
1662
1788
  }
1663
1789
  case "if": {
1664
1790
  // Lift from condition only (Lean rule: don't lift from branches).
1665
- const { binds, expr: cond } = liftMethodCalls(s.cond);
1666
- return [...binds, { kind: "if", cond: coerceCondToBool(cond, s.cond.ty), then: transformStmts(s.then, typeDecls), else: transformStmts(s.else, typeDecls) }];
1791
+ const { binds, expr: cond } = liftMethodCalls(asCondition(s.cond));
1792
+ return [...binds, { kind: "if", cond, then: transformStmts(s.then, typeDecls), else: transformStmts(s.else, typeDecls) }];
1667
1793
  }
1668
1794
  case "while":
1669
1795
  return [{
1670
1796
  kind: "while",
1671
- cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty),
1797
+ cond: transformExpr(asCondition(s.cond)),
1672
1798
  invariants: s.invariants.map(transformExpr),
1673
1799
  decreasing: s.decreases ? transformExpr(s.decreases) : null,
1674
1800
  doneWith: s.doneWith ? transformExpr(s.doneWith) : null,
@@ -1692,7 +1818,7 @@ function transformStmt(s, typeDecls) {
1692
1818
  const replaced = replacePathInTStmts(s.someBody, path, s.binder, s.binderTy);
1693
1819
  const someBody = transformStmts(replaced, typeDecls);
1694
1820
  const noneBody = transformStmts(s.noneBody, typeDecls);
1695
- const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
1821
+ const scrutinee = path.fields.length === 0 ? varE(path.rootVar) : transformExpr(s.scrutinee);
1696
1822
  return [{
1697
1823
  kind: "match", scrutinee,
1698
1824
  arms: [
@@ -1716,13 +1842,13 @@ function mapStmtExprs(s, r) {
1716
1842
  * and delegates body transformation to the caller-provided function.
1717
1843
  * Returns null if any body transformation returns null (pure path abort). */
1718
1844
  function buildMatchArms(cases, varName, typeName, typeDecls, transformBody) {
1719
- const decl = typeName ? typeDecls.find(d => d.name === typeName) : undefined;
1845
+ const decl = typeName ? declOf(typeDecls, typeName) : undefined;
1720
1846
  const arms = [];
1721
1847
  for (const c of cases) {
1722
1848
  const variant = decl?.variants?.find(v => v.name === c.name);
1723
1849
  const fields = variant?.fields ?? [];
1724
1850
  const pattern = buildMatchPattern(c.name, fields, varName);
1725
- const body = transformBody(c.body, varName, fields);
1851
+ const body = transformBody(c.body, varName, fields, c.name);
1726
1852
  if (body === null)
1727
1853
  return null;
1728
1854
  arms.push({ pattern, body });
@@ -1730,7 +1856,7 @@ function buildMatchArms(cases, varName, typeName, typeDecls, transformBody) {
1730
1856
  return arms;
1731
1857
  }
1732
1858
  function emitMatchStmt(scrutinee, typeName, cases, fallthrough, typeDecls) {
1733
- const decl = typeDecls.find(d => d.name === typeName);
1859
+ const decl = declOf(typeDecls, typeName);
1734
1860
  // Synth array-unions (discriminant "__isArray__") have single-field variants
1735
1861
  // ArrayBranch(arr) / NonArrayBranch(val). The matched arm refers to the
1736
1862
  // scrutinee by its bare name/path (`content`, `m.content`), not `.arr`, so
@@ -1784,7 +1910,7 @@ function emitMatchStmt(scrutinee, typeName, cases, fallthrough, typeDecls) {
1784
1910
  arms.push({ pattern: pWild(), body: transformStmts(fallthrough, typeDecls) });
1785
1911
  }
1786
1912
  }
1787
- return { kind: "match", scrutinee: isPath ? transformExpr(scrutinee) : prefix, arms };
1913
+ return { kind: "match", scrutinee: isPath ? transformExpr(scrutinee) : varE(prefix), arms };
1788
1914
  }
1789
1915
  /** Replace bare `var(oldName)` references → `var(newName)` with the given type.
1790
1916
  * Used by emitMatchStmt for synth array-unions where the variant has a single
@@ -1799,7 +1925,7 @@ function replaceVarInTStmts(stmts, oldName, newName, newTy) {
1799
1925
  }
1800
1926
  /** If the chain has matched all variants but one, return that remaining variant. */
1801
1927
  function remainingVariant(typeName, cases, typeDecls) {
1802
- const decl = typeDecls.find(d => d.name === typeName);
1928
+ const decl = declOf(typeDecls, typeName);
1803
1929
  if (!decl?.variants)
1804
1930
  return null;
1805
1931
  const matched = new Set(cases.map(c => c.variant));
@@ -1818,10 +1944,7 @@ function remainingVariant(typeName, cases, typeDecls) {
1818
1944
  function enumFieldSwitch(s, typeDecls) {
1819
1945
  if (!s.discriminant)
1820
1946
  return null;
1821
- const objBase = s.expr.ty.kind === "user"
1822
- ? (s.expr.ty.name.includes("<") ? s.expr.ty.name.slice(0, s.expr.ty.name.indexOf("<")) : s.expr.ty.name)
1823
- : undefined;
1824
- const objDecl = objBase ? typeDecls.find(d => d.name === objBase) : undefined;
1947
+ const objDecl = declOfTy(typeDecls, s.expr.ty);
1825
1948
  if (objDecl?.kind === "discriminated-union" && objDecl.discriminant === s.discriminant)
1826
1949
  return null;
1827
1950
  const fieldTy = objDecl?.kind === "record" ? objDecl.fields?.find(f => f.name === s.discriminant)?.type : undefined;
@@ -1830,15 +1953,36 @@ function enumFieldSwitch(s, typeDecls) {
1830
1953
  enumTyName: fieldTy?.kind === "user" ? fieldTy.name : undefined,
1831
1954
  };
1832
1955
  }
1956
+ /** Stamp variant ctor info onto datatype updates of the match scrutinee in
1957
+ * lowered arm bodies (`{ ...vn, f: v }`) — the statement-path twin of
1958
+ * `replaceFieldAccess`'s stamping. Emitters need the pin to use
1959
+ * per-constructor destructor names for collision-renamed fields. */
1960
+ function stampScrutineeUpdates(body, varName, ctorName, ctorOf) {
1961
+ const stamp = (x) => {
1962
+ if (x.kind === "record" && !x.ctor && x.spread &&
1963
+ x.spread.kind === "var" && x.spread.name === varName) {
1964
+ return { ...x, ctor: ctorName, ctorOf,
1965
+ fields: x.fields.map(f => ({ ...f, value: mapExpr(f.value, stamp) })) };
1966
+ }
1967
+ return null;
1968
+ };
1969
+ return body.map(st => mapStmt(st, stamp));
1970
+ }
1833
1971
  function emitSwitchStmt(s, typeDecls) {
1834
1972
  const cases = s.cases.map(c => ({ name: c.label, body: c.body }));
1835
1973
  const ef = enumFieldSwitch(s, typeDecls);
1974
+ const baseName = s.expr.ty.kind === "user" ? tyBaseName(s.expr.ty.name) : undefined;
1836
1975
  const arms = ef
1837
1976
  ? buildMatchArms(cases, undefined, ef.enumTyName, typeDecls, (body) => transformStmts(body, typeDecls))
1838
- : buildMatchArms(cases, s.expr.kind === "var" ? s.expr.name : "?", s.expr.ty.kind === "user" ? s.expr.ty.name : undefined, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1977
+ : buildMatchArms(cases, s.expr.kind === "var" ? s.expr.name : "?", s.expr.ty.kind === "user" ? s.expr.ty.name : undefined, typeDecls, (body, vn, fields, ctorName) => {
1978
+ let out = transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls);
1979
+ if (ctorName && vn && baseName)
1980
+ out = stampScrutineeUpdates(out, vn, ctorName, baseName);
1981
+ return out;
1982
+ });
1839
1983
  if (s.defaultBody.length > 0)
1840
1984
  arms.push({ pattern: pWild(), body: transformStmts(s.defaultBody, typeDecls) });
1841
- return { kind: "match", scrutinee: ef ? ef.scrutinee : (s.expr.kind === "var" ? s.expr.name : "?"), arms };
1985
+ return { kind: "match", scrutinee: ef ? ef.scrutinee : varE(s.expr.kind === "var" ? s.expr.name : "?"), arms };
1842
1986
  }
1843
1987
  /** Replace obj.field → replacement var in typed IR.
1844
1988
  * Uses the TExpr's resolved type when available, falling back to `fallbackTy`. */
@@ -1857,14 +2001,67 @@ function replaceFieldsInTStmts(stmts, objName, replacements) {
1857
2001
  }));
1858
2002
  }
1859
2003
  /** Replace all variant fields of obj → match binder vars in typed IR.
1860
- * Thin wrapper around replaceFieldsInTStmts for discriminant match/switch. */
2004
+ * Before replacing field reads, realize TypeScript's structural argument
2005
+ * conversion for calls such as `helper(outcome)` inside a narrowed union arm.
2006
+ * The source value is still the enclosing union in typed IR, while Dafny and
2007
+ * Lean expect the helper's nominal record. Rebuild that record solely from
2008
+ * the fields bound by this arm's match pattern. */
1861
2009
  function replaceFieldAccessInTStmts(stmts, varName, fields) {
1862
- return replaceFieldsInTStmts(stmts, varName, fields.map(f => ({
2010
+ const projected = projectStructuralCallArgsInTStmts(stmts, varName, fields);
2011
+ return replaceFieldsInTStmts(projected, varName, fields.map(f => ({
1863
2012
  fieldName: f.name,
1864
2013
  newName: matchBinder(f.name, varName),
1865
2014
  fallbackTy: f.type ?? parseTsType(f.tsType),
1866
2015
  })));
1867
2016
  }
2017
+ /** Project a narrowed union scrutinee into a named structural record expected
2018
+ * by a same-module/extern call. Resolution stamps named calls with paramTys;
2019
+ * this pass fires only when every target record field has an identically typed
2020
+ * match-bound source field. That deliberately avoids inventing a broad cast:
2021
+ * it is the nominal-backend witness for the structural call TS already accepts. */
2022
+ function projectStructuralCallArgsInTStmts(stmts, varName, fields) {
2023
+ const sourceFields = fields.map(f => ({
2024
+ ...f,
2025
+ resolvedTy: f.type ?? parseTsType(f.tsType),
2026
+ }));
2027
+ return stmts.map(s => mapTStmt(s, e => {
2028
+ if (e.kind !== "call" || !e.paramTys)
2029
+ return null;
2030
+ const paramTys = e.paramTys;
2031
+ let changed = false;
2032
+ const args = e.args.map((arg, i) => {
2033
+ if (arg.kind !== "var" || arg.name !== varName || i >= paramTys.length)
2034
+ return arg;
2035
+ const targetTy = paramTys[i];
2036
+ const targetDecl = declOfTy(_typeDecls, targetTy);
2037
+ if (targetTy.kind !== "user" || targetDecl?.kind !== "record" || !targetDecl.fields)
2038
+ return arg;
2039
+ const matched = [];
2040
+ for (const targetField of targetDecl.fields) {
2041
+ const sourceField = sourceFields.find(f => f.name === targetField.name);
2042
+ const targetFieldTy = targetField.type ?? parseTsType(targetField.tsType);
2043
+ if (!sourceField || !tyEqual(sourceField.resolvedTy, targetFieldTy))
2044
+ return arg;
2045
+ matched.push({ targetField, sourceField });
2046
+ }
2047
+ changed = true;
2048
+ return {
2049
+ kind: "record",
2050
+ spread: null,
2051
+ fields: matched.map(m => ({
2052
+ name: m.targetField.name,
2053
+ value: {
2054
+ kind: "var",
2055
+ name: matchBinder(m.sourceField.name, varName),
2056
+ ty: m.sourceField.resolvedTy,
2057
+ },
2058
+ })),
2059
+ ty: targetTy,
2060
+ };
2061
+ });
2062
+ return changed ? { ...e, args } : null;
2063
+ }));
2064
+ }
1868
2065
  /** Replace obj.field → replacement var in typed IR expressions (before lowering).
1869
2066
  * Mirrors replaceFieldsInTStmts but operates on a single TExpr tree. */
1870
2067
  function replaceFieldInTExpr(expr, objName, replacements) {
@@ -1952,7 +2149,7 @@ function transformPureBody(stmts, typeDecls) {
1952
2149
  const elseExpr = transformPureBody(elseStmts, typeDecls);
1953
2150
  if (!elseExpr)
1954
2151
  return null;
1955
- return { kind: "if", cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty), then: thenExpr, else: elseExpr };
2152
+ return { kind: "if", cond: transformExpr(asCondition(s.cond)), then: thenExpr, else: elseExpr };
1956
2153
  }
1957
2154
  case "switch": return transformPureSwitch(s, typeDecls);
1958
2155
  case "someMatch": {
@@ -1965,7 +2162,7 @@ function transformPureBody(stmts, typeDecls) {
1965
2162
  const noneExpr = transformPureBody([...s.noneBody, ...rest], typeDecls);
1966
2163
  if (!noneExpr)
1967
2164
  return null;
1968
- const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
2165
+ const scrutinee = path.fields.length === 0 ? varE(path.rootVar) : transformExpr(s.scrutinee);
1969
2166
  return {
1970
2167
  kind: "match", scrutinee,
1971
2168
  arms: [
@@ -1997,16 +2194,17 @@ function transformPureSwitch(s, typeDecls) {
1997
2194
  return { kind: "match", scrutinee: ef.scrutinee, arms };
1998
2195
  }
1999
2196
  const typeName = s.expr.ty.kind === "user" ? s.expr.ty.name : "";
2000
- if (!typeDecls.find(d => d.name === typeName))
2197
+ if (!declOf(typeDecls, typeName))
2001
2198
  return null;
2002
2199
  const varName = s.expr.kind === "var" ? s.expr.name : undefined;
2003
2200
  const cases = s.cases.map(c => ({ name: c.label, body: c.body }));
2004
- const arms = buildMatchArms(cases, varName, typeName, typeDecls, (body, vn, fields) => {
2005
- let result = transformPureBody(body, typeDecls);
2201
+ const arms = buildMatchArms(cases, varName, typeName, typeDecls, (body, vn, fields, ctorName) => {
2202
+ const projected = vn ? projectStructuralCallArgsInTStmts(body, vn, fields) : body;
2203
+ let result = transformPureBody(projected, typeDecls);
2006
2204
  if (!result)
2007
2205
  return null;
2008
2206
  if (fields.length > 0 && vn)
2009
- result = replaceFieldAccess(result, vn, fields);
2207
+ result = replaceFieldAccess(result, vn, fields, ctorName, tyBaseName(typeName));
2010
2208
  return result;
2011
2209
  });
2012
2210
  if (!arms)
@@ -2019,21 +2217,22 @@ function transformPureSwitch(s, typeDecls) {
2019
2217
  }
2020
2218
  if (s.expr.kind !== "var")
2021
2219
  return null;
2022
- return { kind: "match", scrutinee: s.expr.name, arms };
2220
+ return { kind: "match", scrutinee: varE(s.expr.name), arms };
2023
2221
  }
2024
2222
  function transformPureMatch(chain, typeDecls) {
2025
2223
  const cases = chain.cases.map(c => ({ name: c.variant, body: c.body }));
2026
- const decl = typeDecls.find(d => d.name === chain.typeName);
2224
+ const decl = declOf(typeDecls, chain.typeName);
2027
2225
  // Synth array-unions have single-field variants and user code refers to the
2028
2226
  // scrutinee by its bare name, not field-accessed. See emitMatchStmt for
2029
2227
  // the statement-level counterpart of this substitution.
2030
2228
  const isSynthArrayUnion = decl?.discriminant === "__isArray__";
2031
- const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => {
2032
- let result = transformPureBody(body, typeDecls);
2229
+ const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields, ctorName) => {
2230
+ const projected = vn ? projectStructuralCallArgsInTStmts(body, vn, fields) : body;
2231
+ let result = transformPureBody(projected, typeDecls);
2033
2232
  if (!result)
2034
2233
  return null;
2035
2234
  if (fields.length > 0 && vn)
2036
- result = replaceFieldAccess(result, vn, fields);
2235
+ result = replaceFieldAccess(result, vn, fields, ctorName, tyBaseName(chain.typeName));
2037
2236
  if (isSynthArrayUnion && fields.length === 1 && vn) {
2038
2237
  result = replaceVarInExpr(result, vn, matchBinder(fields[0].name, vn));
2039
2238
  }
@@ -2049,11 +2248,12 @@ function transformPureMatch(chain, typeDecls) {
2049
2248
  const remaining = remainingVariant(chain.typeName, chain.cases, typeDecls);
2050
2249
  if (remaining) {
2051
2250
  // Exactly one variant left — destructure for variant-specific field access.
2052
- let body = transformPureBody(chain.fallthrough, typeDecls);
2251
+ const projected = projectStructuralCallArgsInTStmts(chain.fallthrough, chain.varName, remaining.fields);
2252
+ let body = transformPureBody(projected, typeDecls);
2053
2253
  if (!body)
2054
2254
  return null;
2055
2255
  if (remaining.fields.length > 0)
2056
- body = replaceFieldAccess(body, chain.varName, remaining.fields);
2256
+ body = replaceFieldAccess(body, chain.varName, remaining.fields, remaining.name, tyBaseName(chain.typeName));
2057
2257
  if (isSynthArrayUnion && remaining.fields.length === 1) {
2058
2258
  body = replaceVarInExpr(body, chain.varName, matchBinder(remaining.fields[0].name, chain.varName));
2059
2259
  }
@@ -2066,7 +2266,7 @@ function transformPureMatch(chain, typeDecls) {
2066
2266
  arms.push({ pattern: pWild(), body });
2067
2267
  }
2068
2268
  }
2069
- return { kind: "match", scrutinee: chain.varName, arms };
2269
+ return { kind: "match", scrutinee: varE(chain.varName), arms };
2070
2270
  }
2071
2271
  // ── Generate type declarations ───────────────────────────────
2072
2272
  function transformTypeDecl(d) {
@@ -2203,6 +2403,7 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2203
2403
  _forofCounters.clear();
2204
2404
  _liftCounter = 0;
2205
2405
  _typeDecls = mod.typeDecls;
2406
+ _neededKindHelpers = new Map();
2206
2407
  _pureDefNames = new Set(mod.functions.filter(f => f.isPure).map(f => f.name));
2207
2408
  const typeDecls = mod.typeDecls.map(transformTypeDecl);
2208
2409
  // Module-level constants
@@ -2272,27 +2473,6 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2272
2473
  ensures: ext.ensures.map(e => replaceVar(transformExpr(e), "\\result", fnCall)),
2273
2474
  };
2274
2475
  });
2275
- // Types file
2276
- const typesImports = ["LemmaScript"];
2277
- let typesFile = null;
2278
- const pureNamespace = pureDefs.length > 0
2279
- ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
2280
- : [];
2281
- if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0) {
2282
- // Declaration order differs by backend. Dafny allows forward references, so
2283
- // externs go first to be in scope everywhere. Lean requires definition-before-use:
2284
- // an extern's signature may reference a declared type (e.g. `estimateTokens(m: AgentMessage)`),
2285
- // so types must precede externs, which in turn precede the pure mirrors that may call them.
2286
- const decls = _opts.backend === "lean"
2287
- ? [...typeDecls, ...externDecls, ...pureNamespace]
2288
- : [...externDecls, ...typeDecls, ...pureNamespace];
2289
- typesFile = {
2290
- comment: " Generated by lsc — Lean types and pure function mirrors.",
2291
- imports: typesImports,
2292
- options: [],
2293
- decls,
2294
- };
2295
- }
2296
2476
  // Def file: Velvet methods
2297
2477
  // Pure functions get a thin wrapper that calls Pure.fnName
2298
2478
  // def-by-method functions also skip their method wrappers
@@ -2369,6 +2549,106 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2369
2549
  methods: classMethods,
2370
2550
  };
2371
2551
  });
2552
+ // Types file — assembled after all body transforms, so needs discovered
2553
+ // there (discriminator kind-helpers) are included.
2554
+ const kindHelpers = [..._neededKindHelpers.values()].map(kindHelperDecl);
2555
+ // ── Imported / undeclared user types: opaque by default ─────────────
2556
+ // A module may reference types it imports (ir.ts uses typedir's `Ty`).
2557
+ // Standalone compilation has no declaration for them; synthesize an
2558
+ // opaque type — the value passes through, uninspectable, which is the
2559
+ // only sound use of an undeclared type (same doctrine as _synthOpaque).
2560
+ // Any attempted inspection still fails loudly: an opaque type has no
2561
+ // constructors and no operations. Signature-level coverage (type-decl
2562
+ // fields, params, returns, class fields, externs, consts); a body-level
2563
+ // reference to an undeclared type still errors in the backend.
2564
+ const referenced = new Set();
2565
+ const collectTy = (ty) => {
2566
+ switch (ty.kind) {
2567
+ case "user":
2568
+ referenced.add(tyBaseName(ty.name));
2569
+ return;
2570
+ case "array":
2571
+ case "set":
2572
+ collectTy(ty.elem);
2573
+ return;
2574
+ case "optional":
2575
+ collectTy(ty.inner);
2576
+ return;
2577
+ case "map":
2578
+ collectTy(ty.key);
2579
+ collectTy(ty.value);
2580
+ return;
2581
+ case "tuple":
2582
+ ty.elems.forEach(collectTy);
2583
+ return;
2584
+ case "fn":
2585
+ ty.params.forEach(collectTy);
2586
+ collectTy(ty.result);
2587
+ return;
2588
+ default: return;
2589
+ }
2590
+ };
2591
+ const knownTypeNames = new Set(typeDecls.map(d => d.name));
2592
+ const allTypeParams = new Set();
2593
+ // Exclude type params from the *source* decls — the transformed IR drops
2594
+ // them for aliases (`type Step<S, A> = …`), and a generic alias's params
2595
+ // must not be mistaken for imported types. Params may carry a `//@ type`
2596
+ // decoration ("S(==)"); references collect as the bare name, so strip it.
2597
+ const addTp = (tp) => { allTypeParams.add(tp.replace(/\(.*$/, "").trim()); };
2598
+ for (const d of mod.typeDecls)
2599
+ d.typeParams?.forEach(addTp);
2600
+ for (const d of typeDecls) {
2601
+ if (d.kind === "inductive")
2602
+ d.constructors.forEach(c => c.fields.forEach(f => collectTy(f.type)));
2603
+ else if (d.kind === "structure")
2604
+ d.fields.forEach(f => collectTy(f.type));
2605
+ else if (d.kind === "type-alias")
2606
+ collectTy(d.target);
2607
+ }
2608
+ for (const fn of mod.functions) {
2609
+ fn.typeParams.forEach(addTp);
2610
+ fn.params.forEach(p => collectTy(p.ty));
2611
+ collectTy(fn.returnTy);
2612
+ }
2613
+ for (const cls of mod.classes ?? []) {
2614
+ cls.fields.forEach(f => collectTy(f.ty));
2615
+ for (const m of cls.methods) {
2616
+ m.typeParams.forEach(addTp);
2617
+ m.params.forEach(p => collectTy(p.ty));
2618
+ collectTy(m.returnTy);
2619
+ }
2620
+ }
2621
+ for (const ext of mod.externs ?? []) {
2622
+ ext.typeParams.forEach(addTp);
2623
+ ext.params.forEach(p => collectTy(p.ty));
2624
+ collectTy(ext.returnTy);
2625
+ }
2626
+ for (const c of mod.constants ?? [])
2627
+ collectTy(c.ty);
2628
+ const opaqueImports = [...referenced]
2629
+ .filter(n => !knownTypeNames.has(n) && !allTypeParams.has(n))
2630
+ .map(n => ({ kind: "opaque-type", name: n }));
2631
+ const typesImports = ["LemmaScript"];
2632
+ let typesFile = null;
2633
+ const pureNamespace = pureDefs.length > 0
2634
+ ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
2635
+ : [];
2636
+ if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0 || opaqueImports.length > 0) {
2637
+ // Declaration order differs by backend. Dafny allows forward references, so
2638
+ // externs go first to be in scope everywhere. Lean requires definition-before-use:
2639
+ // an extern's signature may reference a declared type (e.g. `estimateTokens(m: AgentMessage)`),
2640
+ // so types must precede externs, which in turn precede the pure mirrors that may call them.
2641
+ // Kind-helpers sit right after the datatypes they discriminate.
2642
+ const decls = _opts.backend === "lean"
2643
+ ? [...opaqueImports, ...typeDecls, ...kindHelpers, ...externDecls, ...pureNamespace]
2644
+ : [...externDecls, ...opaqueImports, ...typeDecls, ...kindHelpers, ...pureNamespace];
2645
+ typesFile = {
2646
+ comment: " Generated by lsc — Lean types and pure function mirrors.",
2647
+ imports: typesImports,
2648
+ options: [],
2649
+ decls,
2650
+ };
2651
+ }
2372
2652
  const defImport = specImport ?? (typesFile ? `«${moduleBase}.types»` : null);
2373
2653
  const defBaseImports = defImport ? [defImport] : ["LemmaScript"];
2374
2654
  const defFile = {