lemmascript 0.5.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -87,6 +87,7 @@ export const BUILTINS = {
87
87
  "string.trimStart": { ret: STRING, pure: true },
88
88
  "string.toLowerCase": { ret: STRING, pure: true },
89
89
  "string.toUpperCase": { ret: STRING, pure: true },
90
+ "string.repeat": { ret: STRING, pure: true },
90
91
  "string.slice": { ret: STRING, pure: true },
91
92
  "string.substring": { ret: STRING, pure: true },
92
93
  "string.split": { pure: true,
@@ -80,6 +80,24 @@ const DAFNY_KEYWORDS = new Set([
80
80
  // by methodHeader and reset per decl. `\result` in an ensures must use the
81
81
  // *same* name, so escapeName routes it here.
82
82
  let _resultName = "res";
83
+ // User-type names appearing as the type of a havoc anywhere in the module —
84
+ // populated per file by emitDafnyFile, read by the opaque-type case.
85
+ const _havocedTypeNames = new Set();
86
+ /** Collect the user-type names of every havoc in a decl tree. */
87
+ function collectHavocedTypeNames(v, out) {
88
+ if (Array.isArray(v)) {
89
+ for (const x of v)
90
+ collectHavocedTypeNames(x, out);
91
+ return;
92
+ }
93
+ if (v === null || typeof v !== "object")
94
+ return;
95
+ const n = v;
96
+ if (n.kind === "havoc" && n.type?.kind === "user")
97
+ out.add(n.type.name);
98
+ for (const x of Object.values(v))
99
+ collectHavocedTypeNames(x, out);
100
+ }
83
101
  // ── Dafny name allocation ──────────────────────────────────
84
102
  //
85
103
  // freshName (names.ts) freshens in the *raw TS* namespace — but that is not the
@@ -145,6 +163,13 @@ function escapeName(name) {
145
163
  return user;
146
164
  return escapeGeneratedName(name);
147
165
  }
166
+ function isEmittedUserName(name) {
167
+ for (const emitted of _userDafnyNames.values()) {
168
+ if (emitted === name)
169
+ return true;
170
+ }
171
+ return false;
172
+ }
148
173
  /** Allocate a toolchain-generated name (an ANF temp, a comprehension binder, a
149
174
  * companion `_ensures` lemma). Escapes to a base, then freshens in the Dafny
150
175
  * namespace so it can't collapse onto an escaped user name. Bypasses the user
@@ -447,6 +472,10 @@ function emitExpr(e) {
447
472
  return `(|${obj}| >= |${args[0]}| && ${obj}[..|${args[0]}|] == ${args[0]})`;
448
473
  if (e.method === "charCodeAt")
449
474
  return `(${obj}[${args[0]}] as int)`;
475
+ if (e.method === "repeat") {
476
+ needPreamble("StringRepeat");
477
+ return `StringRepeat(${obj}, ${args[0]})`;
478
+ }
450
479
  }
451
480
  // Map methods
452
481
  if (ty === "map") {
@@ -515,7 +544,7 @@ function emitExpr(e) {
515
544
  // Discriminant check: x == .Ctor → x.Ctor?
516
545
  const op = mapOp(e.op);
517
546
  if ((op === "==" || op === "!=") && e.right.kind === "constructor") {
518
- const ctorName = escapeName(e.right.name.replace(/^\./, ""));
547
+ const ctorName = dafnyCtorName(e.right.name.replace(/^\./, ""));
519
548
  const pred = `${emitExpr(e.left)}.${ctorName}?`;
520
549
  return op === "!=" ? `(!${pred})` : pred;
521
550
  }
@@ -582,6 +611,8 @@ function emitExpr(e) {
582
611
  needPreamble("CeilReal");
583
612
  if (e.fn === "FloorReal")
584
613
  needPreamble("FloorReal");
614
+ if (e.fn === "StringFromCharCode")
615
+ needPreamble("StringFromCharCode");
585
616
  if (e.fn === "NatToString")
586
617
  needPreamble("NatToString");
587
618
  if (e.fn === "IntToString") {
@@ -611,7 +642,12 @@ function emitExpr(e) {
611
642
  // tags come from source strings ("spec-pure") that escapeName leaves alone.
612
643
  if (e.ctorOf) {
613
644
  const ctor = dafnyCtorName(e.fn);
614
- return _ambiguousCtors.has(e.fn)
645
+ // A source local may have the same name as a discriminated-union
646
+ // variant (`const error = ...; return { kind: "error", error }`).
647
+ // The bare `error(error)` is then parsed as a call through the local.
648
+ // Qualify whenever the emitted constructor spelling is already claimed
649
+ // in the user namespace, as well as when two datatypes share it.
650
+ return _ambiguousCtors.has(e.fn) || isEmittedUserName(ctor)
615
651
  ? `${e.ctorOf}.${ctor}(${args.join(", ")})`
616
652
  : `${ctor}(${args.join(", ")})`;
617
653
  }
@@ -875,7 +911,10 @@ function emitDecl(d) {
875
911
  case "opaque-type": {
876
912
  // Abstract type — no definition. `(==)` so it can sit inside datatypes
877
913
  // that derive structural equality. Never constructed or destructured.
878
- return `type ${escapeName(d.name)}(==)`;
914
+ // `0` (auto-init) only when a havoc of this type needs a witness to
915
+ // satisfy definite assignment — `var x: T := *` requires it.
916
+ const autoInit = _havocedTypeNames.has(d.name) ? ", 0" : "";
917
+ return `type ${escapeName(d.name)}(==${autoInit})`;
879
918
  }
880
919
  case "def": {
881
920
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
@@ -1269,6 +1308,27 @@ const STRING_TO_UPPER = `function StringToUpper(s: string): string
1269
1308
  var upper := if 'a' <= c <= 'z' then (c - 'a' + 'A') as char else c;
1270
1309
  [upper] + StringToUpper(s[1..])
1271
1310
  }`;
1311
+ // `String.fromCharCode(n)` — the inverse of `s.charCodeAt(i)`'s `(s[i] as int)`.
1312
+ // Dafny's `char` is a Unicode scalar value, so the argument must miss the
1313
+ // surrogate range; that is the `requires`, discharged at each call site. The two
1314
+ // `ensures` give callers the round-trip law without unfolding the body.
1315
+ const STRING_FROM_CHAR_CODE = `function StringFromCharCode(n: int): string
1316
+ requires 0 <= n < 0xD800 || 0xE000 <= n < 0x110000
1317
+ ensures |StringFromCharCode(n)| == 1
1318
+ ensures (StringFromCharCode(n)[0] as int) == n
1319
+ {
1320
+ [n as char]
1321
+ }`;
1322
+ // `s.repeat(n)` — n copies of s, concatenated. The per-index ensures is stated
1323
+ // for the single-character receiver (the common case: padding with one digit).
1324
+ const STRING_REPEAT = `function StringRepeat(s: string, n: int): string
1325
+ requires n >= 0
1326
+ ensures |StringRepeat(s, n)| == |s| * n
1327
+ ensures |s| == 1 ==> forall i :: 0 <= i < n ==> StringRepeat(s, n)[i] == s[0]
1328
+ decreases n
1329
+ {
1330
+ if n == 0 then "" else s + StringRepeat(s, n - 1)
1331
+ }`;
1272
1332
  const MATH_MIN = `function MathMin(a: int, b: int): int { if a <= b then a else b }`;
1273
1333
  const MATH_MAX = `function MathMax(a: int, b: int): int { if a >= b then a else b }`;
1274
1334
  const MAX_OF_SEQ = `function MaxOfSeq(s: seq<int>): int
@@ -1386,6 +1446,8 @@ const PREAMBLE_CODE = [
1386
1446
  ["StringTrim", STRING_TRIM],
1387
1447
  ["StringToLower", STRING_TO_LOWER],
1388
1448
  ["StringToUpper", STRING_TO_UPPER],
1449
+ ["StringFromCharCode", STRING_FROM_CHAR_CODE],
1450
+ ["StringRepeat", STRING_REPEAT],
1389
1451
  ["NatToString", NAT_TO_STRING],
1390
1452
  ["IntToString", INT_TO_STRING],
1391
1453
  ["MathAbs", MATH_ABS],
@@ -1512,6 +1574,8 @@ export function emitDafnyFile(file, tsFileName, opts) {
1512
1574
  resetDafnyNameCache();
1513
1575
  buildRecordCtorMap(file.decls);
1514
1576
  _neededPreambles.clear();
1577
+ _havocedTypeNames.clear();
1578
+ collectHavocedTypeNames(file.decls, _havocedTypeNames);
1515
1579
  // Track successfully emitted pure defs — method wrappers are only
1516
1580
  // skipped when the corresponding pure def was actually emitted.
1517
1581
  const emittedPureDefs = new Set();
@@ -11,6 +11,25 @@ import { setUserNames, freshName } from "./names.js";
11
11
  // ── Expression extraction ────────────────────────────────────
12
12
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
13
13
  let _havocKey = null;
14
+ /** A leading `//@ havoc`, `//@ havoc : Type`, or `//@ havoc <key>` directive. */
15
+ function havocDirective(s) {
16
+ const m = s.getLeadingCommentRanges()
17
+ .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+)|(?:\s+(\S+)))?$/))
18
+ .find(m => m !== null);
19
+ return m ? { type: m[1]?.trim() ?? null, key: m[2] ?? null } : null;
20
+ }
21
+ /** Run `fn` with `key` as the subexpression havoc key, restoring the outer one. */
22
+ function withHavocKey(key, fn) {
23
+ const saved = _havocKey;
24
+ if (key)
25
+ _havocKey = key;
26
+ try {
27
+ return fn();
28
+ }
29
+ finally {
30
+ _havocKey = saved;
31
+ }
32
+ }
14
33
  /** Auto-detected cross-file calls. Populated by `extractExpr` whenever it sees
15
34
  * a call `Obj.method(...)` or `foo(...)` whose ts-morph symbol resolves to a
16
35
  * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`.
@@ -326,14 +345,18 @@ function _eraseGenerics(tsType) {
326
345
  return tsType;
327
346
  }
328
347
  function extractExpr(node) {
329
- // Havoc key matching: replace matching calls with havoc expression
330
- if (_havocKey && Node.isCallExpression(node)) {
348
+ // Havoc key matching: replace matching calls or new expressions with havoc expression
349
+ if (_havocKey && (Node.isCallExpression(node) || Node.isNewExpression(node))) {
331
350
  const fnExpr = node.getExpression();
332
351
  const name = Node.isPropertyAccessExpression(fnExpr) ? fnExpr.getName()
333
352
  : Node.isIdentifier(fnExpr) ? fnExpr.getText()
334
353
  : null;
335
354
  if (name === _havocKey) {
336
- return { kind: "havoc", tsType: typeToString(node.getType()) };
355
+ // Prefer the contextual type: the abstracted value stands in where the
356
+ // surrounding code expects it, and a subclass (`new SdkError` into an
357
+ // `Error` field) has no subtyping relation once both are opaque.
358
+ const ty = node.getContextualType() ?? node.getType();
359
+ return { kind: "havoc", tsType: typeToString(ty) };
337
360
  }
338
361
  }
339
362
  // Numeric literal
@@ -1242,12 +1265,10 @@ function extractStmts(stmts) {
1242
1265
  continue;
1243
1266
  }
1244
1267
  if (Node.isVariableStatement(s)) {
1245
- const havocMatch = s.getLeadingCommentRanges()
1246
- .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+)|(?:\s+(\S+)))?$/))
1247
- .find(m => m !== null);
1248
- const havocType = havocMatch?.[1]?.trim() ?? null; // //@ havoc : Type
1249
- const havocKey = havocMatch?.[2] ?? null; // //@ havoc key
1250
- const isHavoc = !!havocMatch;
1268
+ const havoc = havocDirective(s);
1269
+ const havocType = havoc?.type ?? null; // //@ havoc : Type
1270
+ const havocKey = havoc?.key ?? null; // //@ havoc key
1271
+ const isHavoc = !!havoc;
1251
1272
  for (const d of s.getDeclarations()) {
1252
1273
  // Havoc on destructuring: emit each named binding as a separate havoced variable
1253
1274
  const nameNode = d.getNameNode();
@@ -1688,7 +1709,11 @@ function extractStmts(stmts) {
1688
1709
  // functions this would emit the wrong shape, but lsc has no current
1689
1710
  // examples of explicit bare return in void functions; revisit if one
1690
1711
  // appears.
1691
- result.push({ kind: "return", value: expr ? extractExpr(expr) : { kind: "var", name: "undefined" }, line });
1712
+ // `//@ havoc <key>` on a return abstracts the matching calls or new
1713
+ // expressions inside the returned expression — there is no variable to
1714
+ // hang a whole-value havoc on, so only the key form applies here.
1715
+ const value = withHavocKey(havocDirective(s)?.key ?? null, () => expr ? extractExpr(expr) : { kind: "var", name: "undefined" });
1716
+ result.push({ kind: "return", value, line });
1692
1717
  continue;
1693
1718
  }
1694
1719
  if (Node.isBreakStatement(s)) {
@@ -1704,14 +1729,12 @@ function extractStmts(stmts) {
1704
1729
  // //@ havoc before `x = e` — discard the RHS, assign a nondeterministic
1705
1730
  // value of x's type. Only applies to plain `=` with an identifier LHS;
1706
1731
  // compound assigns, `arr[i] = v`, and `x++` fall through to desugaring.
1707
- const havocMatch = s.getLeadingCommentRanges()
1708
- .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+))?$/))
1709
- .find(m => m !== null);
1710
- if (havocMatch && Node.isBinaryExpression(expr)
1732
+ const havoc = havocDirective(s);
1733
+ if (havoc && !havoc.key && Node.isBinaryExpression(expr)
1711
1734
  && expr.getOperatorToken().getText() === "="
1712
1735
  && Node.isIdentifier(expr.getLeft())) {
1713
1736
  const target = expr.getLeft().getText();
1714
- const tsType = havocMatch[1]?.trim() ?? _eraseGenerics(typeToString(expr.getLeft().getType()));
1737
+ const tsType = havoc.type ?? _eraseGenerics(typeToString(expr.getLeft().getType()));
1715
1738
  result.push({ kind: "assign", target, value: { kind: "havoc", tsType }, line });
1716
1739
  continue;
1717
1740
  }
@@ -1901,6 +1924,12 @@ function extractFunctionInner(fn, parentAnnotations) {
1901
1924
  return "void"; // Promise<void>
1902
1925
  }
1903
1926
  const node = fn.getReturnTypeNode();
1927
+ // A type predicate (`x is T` / `asserts x is T`) is a `boolean` at
1928
+ // runtime; the narrowing it performs is a TS-only refinement with no
1929
+ // counterpart in the model. Without this, `getText()` yields "x is T"
1930
+ // and the type mapper reads the subject name as an opaque type.
1931
+ if (node && node.getKind() === SyntaxKind.TypePredicate)
1932
+ return "boolean";
1904
1933
  if (node && Node.isUnionTypeNode(node))
1905
1934
  return _eraseGenerics(_tsTypeFromUnionNode(node));
1906
1935
  if (node)
@@ -2461,6 +2490,28 @@ export function extractModule(sourceFile) {
2461
2490
  }
2462
2491
  }
2463
2492
  }
2493
+ // A constant's initializer can reference other constants
2494
+ // (`const ZERO_NINE = ZERO + nthDigit(-1)`). Close over those initializers
2495
+ // before filtering — mirroring the transitive type filter below — or a
2496
+ // constant reachable only from another constant is dropped and the backend
2497
+ // sees an undefined name.
2498
+ // Snapshot first: what the closure adds are VALUE references, and a value
2499
+ // and a type can share a name (`const Action = Schema.Literals(…)` next to
2500
+ // `type Action = Schema.Schema.Type<typeof Action>`). Keeping the constant
2501
+ // alive must not also drag in the unrelated type alias, so the type filter
2502
+ // below runs off the pre-closure set.
2503
+ const typeReferencedNames = new Set(referencedNames);
2504
+ for (let grew = true; grew;) {
2505
+ grew = false;
2506
+ for (const c of constants) {
2507
+ if (!referencedNames.has(c.name))
2508
+ continue;
2509
+ const before = referencedNames.size;
2510
+ collectNamesExpr(c.value);
2511
+ if (referencedNames.size !== before)
2512
+ grew = true;
2513
+ }
2514
+ }
2464
2515
  constants.splice(0, constants.length, ...constants.filter(c => referencedNames.has(c.name)));
2465
2516
  // Filter types to only those referenced by verified functions (transitive)
2466
2517
  const neededTypes = new Set();
@@ -2479,7 +2530,7 @@ export function extractModule(sourceFile) {
2479
2530
  for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
2480
2531
  markType(m[1]);
2481
2532
  }
2482
- for (const name of referencedNames)
2533
+ for (const name of typeReferencedNames)
2483
2534
  markType(name);
2484
2535
  // Signature types also mark their base after stripping array/optional
2485
2536
  // WRAPPERS (`Out[]`/`Msg | undefined` → `Out`/`Msg`), so a function returning
@@ -385,7 +385,7 @@ function emitExpr(e, parentPrec) {
385
385
  const ctor = rhs.type ? _unionCtors.get(rhs.type)?.find(c => c.name === rhs.name) : undefined;
386
386
  if (ctor && ctor.fields.length > 0) {
387
387
  const [yes, no] = e.op === "=" ? ["true", "false"] : ["false", "true"];
388
- return `(match ${emitExpr(e.left)} with | .${escapeName(rhs.name)} .. => ${yes} | _ => ${no})`;
388
+ return `(match ${emitExpr(e.left)} with | .${leanCtorName(rhs.name)} .. => ${yes} | _ => ${no})`;
389
389
  }
390
390
  }
391
391
  // `k in m` (map/set membership) → `m.contains k` in Lean. Dafny has
@@ -471,7 +471,7 @@ function emitExpr(e, parentPrec) {
471
471
  const idx = owner.fields.findIndex(f => f.name === e.field);
472
472
  const pats = owner.fields.map((_, i) => (i === idx ? "_v" : "_")).join(" ");
473
473
  const fty = tyToLean(owner.fields[idx].type);
474
- return `(match ${emitExpr(e.obj)} with | .${escapeName(owner.name)} ${pats} => _v | _ => (default : ${fty}))`;
474
+ return `(match ${emitExpr(e.obj)} with | .${leanCtorName(owner.name)} ${pats} => _v | _ => (default : ${fty}))`;
475
475
  }
476
476
  }
477
477
  const obj = emitExpr(e.obj);
@@ -55,10 +55,36 @@ function resolveTsType(tsType, overrides, varName) {
55
55
  }
56
56
  return parseTsType(tsType);
57
57
  }
58
- /** If expr is a string literal and targetTy is a user type, coerce the literal's type. */
58
+ /** Infer a conditional's result type after its branches have been resolved or
59
+ * contextually coerced. A void branch is the source-level null/undefined arm. */
60
+ function conditionalResultTy(thenTy, elseTy) {
61
+ let ty = thenTy.kind !== "unknown" ? thenTy : elseTy;
62
+ if (thenTy.kind === "void" && elseTy.kind !== "void" && elseTy.kind !== "unknown") {
63
+ ty = { kind: "optional", inner: elseTy };
64
+ }
65
+ else if (elseTy.kind === "void" && thenTy.kind !== "void" && thenTy.kind !== "unknown") {
66
+ ty = { kind: "optional", inner: thenTy };
67
+ }
68
+ else if (thenTy.kind === "optional" && elseTy.kind !== "optional" && elseTy.kind !== "unknown") {
69
+ ty = thenTy;
70
+ }
71
+ else if (elseTy.kind === "optional" && thenTy.kind !== "optional" && thenTy.kind !== "unknown") {
72
+ ty = elseTy;
73
+ }
74
+ return ty;
75
+ }
76
+ /** Contextually type string literals as constructors. Ternary branches inherit
77
+ * the ternary's target, and an optional target contributes its payload type —
78
+ * the caller adds the Some wrapper only after the payload has been coerced. */
59
79
  function coerceStr(expr, targetTy) {
60
- if (expr.kind === "str" && targetTy.kind === "user")
61
- return { ...expr, ty: targetTy };
80
+ const payloadTy = targetTy.kind === "optional" ? targetTy.inner : targetTy;
81
+ if (expr.kind === "str" && payloadTy.kind === "user")
82
+ return { ...expr, ty: payloadTy };
83
+ if (expr.kind === "conditional") {
84
+ const then_ = coerceStr(expr.then, payloadTy);
85
+ const else_ = coerceStr(expr.else, payloadTy);
86
+ return { ...expr, then: then_, else: else_, ty: conditionalResultTy(then_.ty, else_.ty) };
87
+ }
62
88
  return expr;
63
89
  }
64
90
  // ── Helpers ──────────────────────────────────────────────────
@@ -84,6 +110,7 @@ function findSynthArrayUnion(name, typeDecls) {
84
110
  * Returns `value` unchanged if no coercion applies (types already match,
85
111
  * source is unknown, or no rule matches). */
86
112
  function coerceToTargetTy(value, targetTy, typeDecls) {
113
+ value = coerceStr(value, targetTy);
87
114
  if (value.ty.kind === "unknown" || value.ty.kind === "void")
88
115
  return value;
89
116
  if (targetTy.kind === "optional" && value.ty.kind !== "optional") {
@@ -328,6 +355,17 @@ function isUnmodeledTy(ty, typeDecls) {
328
355
  function isStringUnionTy(ty, typeDecls) {
329
356
  return declOfTy(typeDecls, ty)?.kind === "string-union";
330
357
  }
358
+ /** Whether ts-morph widened a string-union initializer to the declared string
359
+ * shape. Inferred optional locals need the same rescue as bare locals:
360
+ * `Option<string>` from TS must not erase an `Option<Color>` initializer. */
361
+ function isWidenedStringUnionTy(declTy, initTy, typeDecls) {
362
+ if (declTy.kind === "string" && isStringUnionTy(initTy, typeDecls))
363
+ return true;
364
+ if (declTy.kind === "optional" && initTy.kind === "optional") {
365
+ return isWidenedStringUnionTy(declTy.inner, initTy.inner, typeDecls);
366
+ }
367
+ return false;
368
+ }
331
369
  /** Infer quantifier variable type from usage in body.
332
370
  * If the variable is used as a map/set key (e.g. map.has(k), map.get(k)),
333
371
  * return the collection's key type. Otherwise return null (default to int). */
@@ -399,6 +437,8 @@ function classifyCall(fn, ctx) {
399
437
  return "pure";
400
438
  if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray")
401
439
  return "pure";
440
+ if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "String" && fn.field === "fromCharCode")
441
+ return "pure";
402
442
  if (fn.kind === "var" && (ctx.inSpec || ctx.inLambda) && ctx.pureFns.has(fn.name))
403
443
  return "spec-pure";
404
444
  // Bare-name `//@ extern` declarations are emitted as `function {:axiom}` —
@@ -515,7 +555,7 @@ function isDefinedCheckRawLambda(raw) {
515
555
  const isUndef = (x) => x.kind === "var" && x.name === "undefined";
516
556
  return (isParam(body.left) && isUndef(body.right)) || (isParam(body.right) && isUndef(body.left));
517
557
  }
518
- /** Coerce call arguments: string literals user types, non-optional → Some, pad missing optional args. */
558
+ /** Coerce call arguments to their declared parameter slots and pad missing optional args. */
519
559
  function coerceCallArgs(args, fn, ctx) {
520
560
  if (fn.kind !== "var" || !ctx.fnParams.has(fn.name))
521
561
  return args;
@@ -523,11 +563,7 @@ function coerceCallArgs(args, fn, ctx) {
523
563
  args = args.map((a, i) => {
524
564
  if (i >= paramTys.length)
525
565
  return a;
526
- a = coerceStr(a, paramTys[i]);
527
- if (a.ty.kind !== "optional" && paramTys[i].kind === "optional") {
528
- return wrapSome(a, paramTys[i]);
529
- }
530
- return a;
566
+ return coerceToTargetTy(a, paramTys[i], ctx.typeDecls);
531
567
  });
532
568
  // Pad missing optional args with None
533
569
  for (let i = args.length; i < paramTys.length; i++) {
@@ -546,6 +582,11 @@ function inferMethodReturnTy(fn, args, ctx) {
546
582
  if (fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray") {
547
583
  return { kind: "bool" };
548
584
  }
585
+ // `String.fromCharCode(n)` is the inverse of `s.charCodeAt(i)`: an int code
586
+ // point in, a one-character string out.
587
+ if (fn.obj.kind === "var" && fn.obj.name === "String" && fn.field === "fromCharCode") {
588
+ return { kind: "string" };
589
+ }
549
590
  // Math.* numeric builtins: abs/min/max preserve the operand's numeric type
550
591
  // (real if any operand is real); floor/ceil/round/trunc return an integer.
551
592
  if (fn.obj.kind === "var" && fn.obj.name === "Math") {
@@ -803,7 +844,7 @@ function resolveExpr(e, ctx) {
803
844
  if (ext) {
804
845
  const args = e.args.map(a => resolveExpr(a, ctx));
805
846
  const fn = { kind: "var", name: ext.flat, ty: { kind: "unknown" } };
806
- return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure" };
847
+ return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure", paramTys: ext.params };
807
848
  }
808
849
  }
809
850
  const fn = resolveExpr(e.fn, ctx);
@@ -819,7 +860,8 @@ function resolveExpr(e, ctx) {
819
860
  const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
820
861
  let args = coerceCallArgs(rawArgs.map((a, i) => {
821
862
  let aCtx = argCtx;
822
- if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
863
+ if (paramTypes && i < paramTypes.length &&
864
+ (paramTypes[i].kind === "user" || paramTypes[i].kind === "array" || paramTypes[i].kind === "optional")) {
823
865
  aCtx = { ...aCtx, returnTy: paramTypes[i] };
824
866
  }
825
867
  return resolveExpr(a, aCtx);
@@ -854,7 +896,8 @@ function resolveExpr(e, ctx) {
854
896
  }
855
897
  const builtinId = fn.kind === "field" ? recognizeBuiltin(fn.obj.ty, fn.field) : null;
856
898
  return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx),
857
- ...(builtinId ? { builtinId } : {}) };
899
+ ...(builtinId ? { builtinId } : {}),
900
+ ...(paramTypes ? { paramTys: paramTypes } : {}) };
858
901
  }
859
902
  case "index": {
860
903
  const obj = resolveExpr(e.obj, ctx);
@@ -935,7 +978,7 @@ function resolveExpr(e, ctx) {
935
978
  const inner = left.ty.kind === "optional" ? left.ty.inner : left.ty;
936
979
  // The default shares the result type, so coerce a string literal to a
937
980
  // string-union enum (e.g. `availableLevels[0] ?? "off"`).
938
- const right = coerceStr(resolveExpr(e.right, ctx), inner);
981
+ const right = coerceToTargetTy(resolveExpr(e.right, ctx), inner, ctx.typeDecls);
939
982
  // `??` is only total when its default is: with a nullable right operand
940
983
  // (rule-chain style `ruleA(e) ?? ruleB(e) ?? null`), the result stays
941
984
  // optional — otherwise the enclosing chain level loses its optionality
@@ -1047,7 +1090,6 @@ function resolveExpr(e, ctx) {
1047
1090
  let value = resolveExpr(f.value, valueCtx);
1048
1091
  if (fieldDecl) {
1049
1092
  const declTy = fieldDecl.type;
1050
- value = coerceStr(value, declTy);
1051
1093
  // Empty {} for map-typed fields → empty map (arrayLiteral with map type → emptyMap in transform)
1052
1094
  if (value.kind === "record" && value.fields.length === 0 && !value.spread && declTy.kind === "map") {
1053
1095
  value = { kind: "arrayLiteral", elems: [], ty: declTy };
@@ -1088,7 +1130,7 @@ function resolveExpr(e, ctx) {
1088
1130
  const elems = e.elems.map((el, i) => {
1089
1131
  const slot = slots[i];
1090
1132
  const r = resolveExpr(el, slot ? { ...ctx, returnTy: slot } : ctx);
1091
- return slot ? coerceStr(r, slot) : r;
1133
+ return slot ? coerceToTargetTy(r, slot, ctx.typeDecls) : r;
1092
1134
  });
1093
1135
  return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
1094
1136
  }
@@ -1105,7 +1147,7 @@ function resolveExpr(e, ctx) {
1105
1147
  const r = resolveExpr(el, elemCtx);
1106
1148
  // Coerce a bare string-literal element to a string-union enum (e.g.
1107
1149
  // `["off", …]: ModelThinkingLevel[]`), like return/arg positions.
1108
- return expectedElem ? coerceStr(r, expectedElem) : r;
1150
+ return expectedElem ? coerceToTargetTy(r, expectedElem, ctx.typeDecls) : r;
1109
1151
  });
1110
1152
  const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
1111
1153
  // No expected collection type: infer array vs tuple from the elements —
@@ -1173,21 +1215,7 @@ function resolveExpr(e, ctx) {
1173
1215
  let else_ = resolveExpr(e.else, elseCtx);
1174
1216
  then_ = coerceStr(then_, else_.ty);
1175
1217
  else_ = coerceStr(else_, then_.ty);
1176
- let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
1177
- if (then_.ty.kind === "void" && else_.ty.kind !== "void" && else_.ty.kind !== "unknown") {
1178
- ty = { kind: "optional", inner: else_.ty };
1179
- }
1180
- else if (else_.ty.kind === "void" && then_.ty.kind !== "void" && then_.ty.kind !== "unknown") {
1181
- ty = { kind: "optional", inner: then_.ty };
1182
- }
1183
- else if (then_.ty.kind === "optional" && else_.ty.kind !== "optional" && else_.ty.kind !== "unknown") {
1184
- // Asymmetric optional: one branch returns Option<T>, the other returns T.
1185
- // Widen to Option<T> so callers/return-coercion see the wider type.
1186
- ty = then_.ty;
1187
- }
1188
- else if (else_.ty.kind === "optional" && then_.ty.kind !== "optional" && then_.ty.kind !== "unknown") {
1189
- ty = else_.ty;
1190
- }
1218
+ const ty = conditionalResultTy(then_.ty, else_.ty);
1191
1219
  return { kind: "conditional", cond, then: then_, else: else_, ty };
1192
1220
  }
1193
1221
  case "emptyCollection": {
@@ -1284,7 +1312,16 @@ function resolveStmt(s, ctx) {
1284
1312
  // one optional level when consulting returnTy.
1285
1313
  const initCtx = (declTy.kind === "user" || declTy.kind === "array" || declTy.kind === "optional")
1286
1314
  ? { ...ctx, returnTy: declTy } : ctx;
1287
- const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
1315
+ let init = coerceStr(resolveExpr(s.init, initCtx), declTy);
1316
+ // Under noUncheckedIndexedAccess, TS gives `const e = arr[i]` type T | undefined
1317
+ // while the index expression itself resolves to T. Leave that mismatch intact:
1318
+ // narrow.ts's ruleOptionalIndexBinding adds the runtime bounds guard and the
1319
+ // corresponding Some/None branches. Wrapping here would turn it into an
1320
+ // unconditional Some(arr[i]) and prevent that JS-semantics rewrite from firing.
1321
+ const deferOptionalIndex = declTy.kind === "optional" && init.kind === "index" &&
1322
+ init.obj.ty.kind === "array" && init.ty.kind !== "optional";
1323
+ if (!deferOptionalIndex)
1324
+ init = coerceToTargetTy(init, declTy, ctx.typeDecls);
1288
1325
  let ty;
1289
1326
  if (isUnmodeledTy(declTy, ctx.typeDecls) && !isUnmodeledTy(init.ty, ctx.typeDecls)) {
1290
1327
  // ts-morph's declared type is opaque to us (an expanded union it made
@@ -1295,7 +1332,7 @@ function resolveStmt(s, ctx) {
1295
1332
  ? { kind: "optional", inner: init.ty }
1296
1333
  : init.ty;
1297
1334
  }
1298
- else if (declTy.kind === "string" && isStringUnionTy(init.ty, ctx.typeDecls) && !ctx.overrides.has(s.name)) {
1335
+ else if (isWidenedStringUnionTy(declTy, init.ty, ctx.typeDecls) && !ctx.overrides.has(s.name)) {
1299
1336
  // ts-morph widened a string-union to `string`; keep the initializer's
1300
1337
  // datatype so `local === "lit"` lowers to a discriminant test.
1301
1338
  ty = init.ty;
@@ -1321,22 +1358,11 @@ function resolveStmt(s, ctx) {
1321
1358
  // to their named datatypes.
1322
1359
  const valueCtx = (targetTy.kind === "user" || targetTy.kind === "array" || targetTy.kind === "optional")
1323
1360
  ? { ...ctx, returnTy: targetTy } : ctx;
1324
- let value = coerceStr(resolveExpr(s.value, valueCtx), targetTy);
1325
- // Auto-wrap non-optional value in Some when target is optional
1326
- const isUndef = value.kind === "var" && value.name === "undefined";
1327
- if (targetTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "unknown" && !isUndef) {
1328
- value = wrapSome(value, targetTy);
1329
- }
1361
+ const value = coerceToTargetTy(resolveExpr(s.value, valueCtx), targetTy, ctx.typeDecls);
1330
1362
  return [{ kind: "assign", target: s.target, value }, ctx.env];
1331
1363
  }
1332
1364
  case "return": {
1333
- let value = coerceStr(resolveExpr(s.value, ctx), ctx.returnTy);
1334
- // Wrap non-optional return value in Some when function returns optional
1335
- // Skip if already optional, void, or undefined (which maps to None)
1336
- const isUndef = value.kind === "var" && value.name === "undefined";
1337
- if (ctx.returnTy.kind === "optional" && value.ty.kind !== "optional" && !isUndef) {
1338
- value = wrapSome(value, ctx.returnTy);
1339
- }
1365
+ const value = coerceToTargetTy(resolveExpr(s.value, ctx), ctx.returnTy, ctx.typeDecls);
1340
1366
  return [{ kind: "return", value }, ctx.env];
1341
1367
  }
1342
1368
  case "break":
@@ -1452,14 +1478,27 @@ function resolveStmt(s, ctx) {
1452
1478
  }
1453
1479
  }
1454
1480
  // ── Pure / return-in-loop detection ──────────────────────────
1455
- /** Syntactic purity: no while, no for-of, no mutable let. */
1481
+ /** Whether a raw statement or expression tree contains a havoc anywhere. */
1482
+ function containsHavoc(v) {
1483
+ if (Array.isArray(v))
1484
+ return v.some(containsHavoc);
1485
+ if (v === null || typeof v !== "object")
1486
+ return false;
1487
+ if (v.kind === "havoc")
1488
+ return true;
1489
+ return Object.values(v).some(containsHavoc);
1490
+ }
1491
+ /** Syntactic purity: no while, no for-of, no mutable let, no havoc. */
1456
1492
  function isSyntacticallyPure(stmts) {
1457
1493
  for (const s of stmts) {
1494
+ // Havoc lowers to Dafny's `*`, which is only valid in a method.
1495
+ if (containsHavoc(s))
1496
+ return false;
1458
1497
  switch (s.kind) {
1459
1498
  case "while":
1460
1499
  case "forof": return false;
1461
1500
  case "let":
1462
- if (s.mutable || s.init.kind === "havoc")
1501
+ if (s.mutable)
1463
1502
  return false;
1464
1503
  break;
1465
1504
  case "if":
@@ -1779,7 +1818,8 @@ export function resolveModule(raw) {
1779
1818
  // record literals on map-typed constants (e.g. `Record<string, number>`)
1780
1819
  // get their `ty` set to `map<...>` rather than `user("...")`.
1781
1820
  const valueCtx = { ...emptyCtx, returnTy: ty };
1782
- return { name: c.name, ty, value: resolveExpr(c.value, valueCtx) };
1821
+ const value = coerceToTargetTy(resolveExpr(c.value, valueCtx), ty, raw.typeDecls);
1822
+ return { name: c.name, ty, value };
1783
1823
  });
1784
1824
  const moduleConstants = new Map(constants.map(c => [c.name, c.ty]));
1785
1825
  return {
@@ -234,7 +234,10 @@ function kindHelperDecl(decl) {
234
234
  * arm body would still be captured, so prime on any module-wide collision.
235
235
  * Deterministic, so the pattern binder and its body substitutions agree. */
236
236
  function matchBinder(fieldName, prefix) {
237
- 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}`);
238
241
  }
239
242
  /** Build a match arm pattern like `.VariantName _v_field1 _v_field2` from variant info. */
240
243
  function buildMatchPattern(variantName, fields, scopePrefix) {
@@ -358,20 +361,33 @@ function flattenLambdaBody(stmts) {
358
361
  * field, index, record, forall, or exists sub-expressions.
359
362
  */
360
363
  /** JS truthiness coercion for `if`/`while`/`?:` conditions.
361
- * Dafny requires bool; coerce number→`≠0`, string→non-empty, array→`true`
364
+ * Dafny requires bool; coerce number→`!== 0`, string→non-empty, array→`true`
362
365
  * (every array, even `[]`, is truthy in JS).
363
- * Optional conds are handled separately by narrow.ts (rewritten to someMatch). */
364
- function coerceCondToBool(cond, ty) {
365
- if (ty.kind === "bool")
366
- return cond;
367
- if (ty.kind === "int" || ty.kind === "nat")
368
- return { kind: "binop", op: "≠", left: cond, right: { kind: "num", value: 0 } };
369
- if (ty.kind === "string")
370
- 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 };
371
387
  // Arrays, objects, maps, sets, tuples are always truthy in JS (even `[]`/`{}`).
372
- if (["array", "user", "map", "set", "tuple"].includes(ty.kind))
373
- return { kind: "bool", value: true };
374
- return cond;
388
+ if (["array", "user", "map", "set", "tuple"].includes(e.ty.kind))
389
+ return { kind: "bool", value: true, ty: bool };
390
+ return e;
375
391
  }
376
392
  /** Wrap an expression in Some/None for optional-typed conditionals.
377
393
  * If the raw TExpr is `undefined`, emit `.none`; otherwise wrap in `Some`. */
@@ -802,6 +818,12 @@ function lowerExpr(e, binds) {
802
818
  }
803
819
  }
804
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
+ }
805
827
  // Math.abs/min/max → preamble functions
806
828
  if (e.fn.kind === "field" && e.fn.obj.kind === "var" && e.fn.obj.name === "Math") {
807
829
  if (e.fn.field === "abs" && e.args.length === 1)
@@ -1032,7 +1054,7 @@ function lowerExpr(e, binds) {
1032
1054
  // JS truthiness coercion (string/array/int → ... > 0). Matches SPEC §3.1
1033
1055
  // negation forms (`!s` → `s == ""`). Optional conds are already
1034
1056
  // rewritten to someMatch by narrow.ts.
1035
- const cond = coerceCondToBool(lowerExpr(e.cond, binds), e.cond.ty);
1057
+ const cond = lowerExpr(asCondition(e.cond), binds);
1036
1058
  let thenExpr = lowerExpr(e.then, binds);
1037
1059
  let elseExpr = lowerExpr(e.else, binds);
1038
1060
  if (e.ty.kind === "optional") {
@@ -1372,6 +1394,11 @@ function matchToIfChains(stmts) {
1372
1394
  return [{ ...s, arms }]; // not a user union (e.g. Option) — leave as match
1373
1395
  const scrutExpr = s.scrutinee;
1374
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));
1375
1402
  let elseBranch = defaultArm ? defaultArm.body : [];
1376
1403
  for (let k = ctorArms.length - 1; k >= 0; k--) {
1377
1404
  const armBody = ctorArms[k].body;
@@ -1401,6 +1428,13 @@ function matchToIfChains(stmts) {
1401
1428
  value: { kind: "field", obj: scrutExpr, field: f.name, fromUnion: decl.name, ctor },
1402
1429
  });
1403
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
+ }
1404
1438
  elseBranch = [{ kind: "if", cond, then: [...lets, ...armBody], else: elseBranch }];
1405
1439
  }
1406
1440
  return elseBranch;
@@ -1754,13 +1788,13 @@ function transformStmt(s, typeDecls) {
1754
1788
  }
1755
1789
  case "if": {
1756
1790
  // Lift from condition only (Lean rule: don't lift from branches).
1757
- const { binds, expr: cond } = liftMethodCalls(s.cond);
1758
- 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) }];
1759
1793
  }
1760
1794
  case "while":
1761
1795
  return [{
1762
1796
  kind: "while",
1763
- cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty),
1797
+ cond: transformExpr(asCondition(s.cond)),
1764
1798
  invariants: s.invariants.map(transformExpr),
1765
1799
  decreasing: s.decreases ? transformExpr(s.decreases) : null,
1766
1800
  doneWith: s.doneWith ? transformExpr(s.doneWith) : null,
@@ -1967,14 +2001,67 @@ function replaceFieldsInTStmts(stmts, objName, replacements) {
1967
2001
  }));
1968
2002
  }
1969
2003
  /** Replace all variant fields of obj → match binder vars in typed IR.
1970
- * 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. */
1971
2009
  function replaceFieldAccessInTStmts(stmts, varName, fields) {
1972
- return replaceFieldsInTStmts(stmts, varName, fields.map(f => ({
2010
+ const projected = projectStructuralCallArgsInTStmts(stmts, varName, fields);
2011
+ return replaceFieldsInTStmts(projected, varName, fields.map(f => ({
1973
2012
  fieldName: f.name,
1974
2013
  newName: matchBinder(f.name, varName),
1975
2014
  fallbackTy: f.type ?? parseTsType(f.tsType),
1976
2015
  })));
1977
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
+ }
1978
2065
  /** Replace obj.field → replacement var in typed IR expressions (before lowering).
1979
2066
  * Mirrors replaceFieldsInTStmts but operates on a single TExpr tree. */
1980
2067
  function replaceFieldInTExpr(expr, objName, replacements) {
@@ -2062,7 +2149,7 @@ function transformPureBody(stmts, typeDecls) {
2062
2149
  const elseExpr = transformPureBody(elseStmts, typeDecls);
2063
2150
  if (!elseExpr)
2064
2151
  return null;
2065
- 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 };
2066
2153
  }
2067
2154
  case "switch": return transformPureSwitch(s, typeDecls);
2068
2155
  case "someMatch": {
@@ -2112,7 +2199,8 @@ function transformPureSwitch(s, typeDecls) {
2112
2199
  const varName = s.expr.kind === "var" ? s.expr.name : undefined;
2113
2200
  const cases = s.cases.map(c => ({ name: c.label, body: c.body }));
2114
2201
  const arms = buildMatchArms(cases, varName, typeName, typeDecls, (body, vn, fields, ctorName) => {
2115
- let result = transformPureBody(body, typeDecls);
2202
+ const projected = vn ? projectStructuralCallArgsInTStmts(body, vn, fields) : body;
2203
+ let result = transformPureBody(projected, typeDecls);
2116
2204
  if (!result)
2117
2205
  return null;
2118
2206
  if (fields.length > 0 && vn)
@@ -2139,7 +2227,8 @@ function transformPureMatch(chain, typeDecls) {
2139
2227
  // the statement-level counterpart of this substitution.
2140
2228
  const isSynthArrayUnion = decl?.discriminant === "__isArray__";
2141
2229
  const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields, ctorName) => {
2142
- let result = transformPureBody(body, typeDecls);
2230
+ const projected = vn ? projectStructuralCallArgsInTStmts(body, vn, fields) : body;
2231
+ let result = transformPureBody(projected, typeDecls);
2143
2232
  if (!result)
2144
2233
  return null;
2145
2234
  if (fields.length > 0 && vn)
@@ -2159,7 +2248,8 @@ function transformPureMatch(chain, typeDecls) {
2159
2248
  const remaining = remainingVariant(chain.typeName, chain.cases, typeDecls);
2160
2249
  if (remaining) {
2161
2250
  // Exactly one variant left — destructure for variant-specific field access.
2162
- let body = transformPureBody(chain.fallthrough, typeDecls);
2251
+ const projected = projectStructuralCallArgsInTStmts(chain.fallthrough, chain.varName, remaining.fields);
2252
+ let body = transformPureBody(projected, typeDecls);
2163
2253
  if (!body)
2164
2254
  return null;
2165
2255
  if (remaining.fields.length > 0)