lemmascript 0.3.1 → 0.3.3

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.
@@ -58,7 +58,10 @@ function mapStmt(s, f) {
58
58
  case "break":
59
59
  case "continue": return s;
60
60
  case "if": return { ...s, cond: r(s.cond), then: s.then.map(t => mapStmt(t, f)), else: s.else.map(t => mapStmt(t, f)) };
61
- case "match": return { ...s, arms: s.arms.map(a => ({ ...a, body: a.body.map(t => mapStmt(t, f)) })) };
61
+ case "match": {
62
+ const scr = typeof s.scrutinee === "string" ? s.scrutinee : r(s.scrutinee);
63
+ return { ...s, scrutinee: scr, arms: s.arms.map(a => ({ ...a, body: a.body.map(t => mapStmt(t, f)) })) };
64
+ }
62
65
  case "while": return { ...s, cond: r(s.cond), invariants: s.invariants.map(r), body: s.body.map(t => mapStmt(t, f)) };
63
66
  case "forin": return { ...s, bound: r(s.bound), invariants: s.invariants.map(r), body: s.body.map(t => mapStmt(t, f)) };
64
67
  case "ghostLet": return { ...s, value: r(s.value) };
@@ -347,9 +350,24 @@ function lowerExpr(e, binds) {
347
350
  return { kind: "field", obj: transformExpr(e.obj), field: "length" };
348
351
  if (e.field === "size" && (e.obj.ty.kind === "map" || e.obj.ty.kind === "set"))
349
352
  return { kind: "field", obj: transformExpr(e.obj), field: "collectionSize" };
353
+ // Boolean discriminant bare access: `result.ok` where Result has variants
354
+ // {ok: true, ...} | {ok: false, ...}. String discriminants are always used via
355
+ // comparison (x.kind === 'Foo' → x.Foo?), but boolean discriminants are used
356
+ // as bare truthiness checks. Emit as the Dafny discriminator predicate for the
357
+ // 'true' variant: result.ok → result.true_?
358
+ if (e.isDiscriminant && e.obj.ty.kind === "user") {
359
+ const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
360
+ const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
361
+ if (decl?.variants?.some(v => v.name === "true")) {
362
+ return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
363
+ }
364
+ }
350
365
  return { kind: "field", obj: transformExpr(e.obj), field: e.field };
351
366
  case "index": {
352
367
  const idx = transformExpr(e.idx);
368
+ if (e.obj.ty.kind === "map") {
369
+ return { kind: "methodCall", obj: transformExpr(e.obj), objTy: e.obj.ty, method: "get", args: [idx], monadic: false };
370
+ }
353
371
  const wrappedIdx = isArray(e.obj.ty) && !isNat(e.idx.ty) ? { kind: "toNat", expr: idx } : idx;
354
372
  return { kind: "index", arr: transformExpr(e.obj), idx: wrappedIdx };
355
373
  }
@@ -394,6 +412,15 @@ function lowerExpr(e, binds) {
394
412
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
395
413
  method = "getDirect";
396
414
  }
415
+ // map.set(k, v): if v is an Optional-wrapped map get, unwrap to getDirect
416
+ // (the desugared spread { ...m, [k]: m2[k] } becomes m.set(k, m2.get(k)),
417
+ // but the value should be direct access, not Optional)
418
+ if (method === "set" && e.fn.obj.ty.kind === "map" && args.length === 2) {
419
+ const val = args[1];
420
+ if (val.kind === "methodCall" && val.method === "get" && val.objTy.kind === "map") {
421
+ args[1] = { ...val, method: "getDirect" };
422
+ }
423
+ }
397
424
  // Check if any lambda arg has monadic body
398
425
  const needsMonadic = _opts.monadic && args.some(a => a.kind === "lambda" && isMonadicBody(a.body));
399
426
  const result = { kind: "methodCall", obj: recv, objTy: e.fn.obj.ty, method, args, monadic: needsMonadic };
@@ -476,6 +503,10 @@ function lowerExpr(e, binds) {
476
503
  });
477
504
  return { kind: "record", spread: lowerExpr(e.spread, binds), fields: loweredFields };
478
505
  }
506
+ // Empty record with map type → empty map
507
+ if (e.fields.length === 0 && !e.spread && e.ty.kind === "map") {
508
+ return { kind: "emptyMap" };
509
+ }
479
510
  return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
480
511
  }
481
512
  case "arrayLiteral":
@@ -494,19 +525,16 @@ function lowerExpr(e, binds) {
494
525
  case "exists":
495
526
  return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
496
527
  case "conditional": {
497
- // When narrowedExpr is set, the match replaces the condition don't lift from it
498
- const condBinds = (e.narrowedVar && e.narrowedExpr) ? null : binds;
499
- const cond = lowerExpr(e.cond, condBinds);
500
- let thenExpr = lowerExpr(e.then, binds);
501
- let elseExpr = lowerExpr(e.else, binds);
502
- // Explicit !== undefined with narrowedExpr → match Some/None on the optional expression
528
+ // Phase 0: Complex expression check (call results, etc.)resolve substituted
529
+ // and set narrowedVar + narrowedExpr because transform can't detect these.
503
530
  if (e.narrowedVar && e.narrowedExpr) {
504
531
  const scrutinee = lowerExpr(e.narrowedExpr, binds);
505
532
  const bound = matchBinder(e.narrowedVar);
533
+ let thenExpr = lowerExpr(e.then, binds);
534
+ let elseExpr = lowerExpr(e.else, binds);
506
535
  if (bound !== e.narrowedVar) {
507
536
  thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
508
537
  }
509
- // Wrap in Some/None only when result is optional (one branch is undefined)
510
538
  if (e.ty.kind === "optional") {
511
539
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
512
540
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
@@ -519,14 +547,16 @@ function lowerExpr(e, binds) {
519
547
  ],
520
548
  };
521
549
  }
522
- // Optional cond with narrowedVar match Some/None (truthiness)
550
+ // Phase 1: Truthiness — cond itself is optional (e.g. opt ? X : Y)
551
+ // Uses narrowedVar set by resolve's Phase 1 (unchanged).
523
552
  if (e.narrowedVar && e.cond.ty.kind === "optional") {
553
+ const cond = lowerExpr(e.cond, binds);
554
+ let thenExpr = lowerExpr(e.then, binds);
555
+ let elseExpr = lowerExpr(e.else, binds);
524
556
  const bound = matchBinder(e.narrowedVar);
525
- // Replace the synthetic/narrowed var with the match-bound name
526
557
  if (bound !== e.narrowedVar) {
527
558
  thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
528
559
  }
529
- // The match produces an Optional: wrap branches in Some/None.
530
560
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
531
561
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
532
562
  return {
@@ -537,7 +567,26 @@ function lowerExpr(e, binds) {
537
567
  ],
538
568
  };
539
569
  }
540
- // Non-optional: regular if with optional wrapping
570
+ // Phase 2: Explicit check x !== undefined ? A : B
571
+ // Transform detects the pattern itself (no narrowedVar/narrowedExpr from resolve).
572
+ const check = parseOptionalCheck(e.cond);
573
+ if (check && !check.negated) {
574
+ return lowerOptionalConditional(e, check, null, binds);
575
+ }
576
+ // Phase 3: && with optional check — x !== undefined && guard(x) ? A : B
577
+ if (e.cond.kind === "binop" && e.cond.op === "&&") {
578
+ const extracted = extractLeftmostOptional(e.cond);
579
+ if (extracted) {
580
+ const innerCheck = parseOptionalCheck(extracted.optCond);
581
+ if (innerCheck && !innerCheck.negated) {
582
+ return lowerOptionalConditional(e, innerCheck, extracted.rest, binds);
583
+ }
584
+ }
585
+ }
586
+ // Phase 4: Regular conditional (no optional narrowing)
587
+ const cond = lowerExpr(e.cond, binds);
588
+ let thenExpr = lowerExpr(e.then, binds);
589
+ let elseExpr = lowerExpr(e.else, binds);
541
590
  if (e.ty.kind === "optional") {
542
591
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
543
592
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
@@ -637,18 +686,43 @@ function transformStmts(stmts, typeDecls) {
637
686
  const varName = s.names[0];
638
687
  const varTy = s.nameTypes[0] ?? { kind: "unknown" };
639
688
  let iterExpr = transformExpr(s.iterable);
689
+ // Map key-only iteration: for (const k in record) → iterate keys only
690
+ if (s.names.length === 1 && s.iterable.ty.kind === "map") {
691
+ const keyName = s.names[0];
692
+ const keyTy = s.nameTypes[0] ?? s.iterable.ty.key ?? { kind: "unknown" };
693
+ const count = _forofCounters.get(keyName) ?? 0;
694
+ _forofCounters.set(keyName, count + 1);
695
+ const suffix = count === 0 ? "" : `${count + 1}`;
696
+ const keysSeqName = `_${keyName}_keys${suffix}`;
697
+ const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
698
+ result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
699
+ const keysVar = { kind: "var", name: keysSeqName };
700
+ const idxName = `_${keyName}_idx${suffix}`;
701
+ const idx = { kind: "var", name: idxName };
702
+ const arrSize = { kind: "field", obj: keysVar, field: "size" };
703
+ const bodyStmts = transformStmts(s.body, typeDecls);
704
+ const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
705
+ const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
706
+ result.push({
707
+ kind: "forin", idx: idxName, bound: arrSize,
708
+ invariants: [boundInv, ...s.invariants.map(transformExpr)],
709
+ body: [letKey, ...bodyStmts],
710
+ });
711
+ i++;
712
+ continue;
713
+ }
640
714
  // Map iteration: for (const [k, v] of map) → iterate keys, look up values
641
715
  if (s.names.length >= 2 && s.iterable.ty.kind === "map") {
642
716
  const keyName = s.names[0], valueName = s.names[1];
643
717
  const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
644
718
  const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
645
- const keysSeqName = `_${keyName}_keys`;
646
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
647
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
648
- const keysVar = { kind: "var", name: keysSeqName };
649
719
  const count = _forofCounters.get(keyName) ?? 0;
650
720
  _forofCounters.set(keyName, count + 1);
651
721
  const suffix = count === 0 ? "" : `${count + 1}`;
722
+ const keysSeqName = `_${keyName}_keys${suffix}`;
723
+ const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
724
+ result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
725
+ const keysVar = { kind: "var", name: keysSeqName };
652
726
  const idxName = `_${keyName}_idx${suffix}`;
653
727
  const idx = { kind: "var", name: idxName };
654
728
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
@@ -770,6 +844,21 @@ function transformStmt(s, typeDecls) {
770
844
  return stmts;
771
845
  }
772
846
  }
847
+ // Desugar: const x = optCheck && guard ? A : B
848
+ // → var x := B; if (optCheck && guard) { x := A; }
849
+ // This avoids putting method calls (from guard) inside a match expression,
850
+ // which Dafny doesn't allow. The if-case in transformStmt handles the &&
851
+ // via extractLeftmostOptional → emitOptionalMatch (statement-level match).
852
+ if (s.init.kind === "conditional" && s.init.cond.kind === "binop" && s.init.cond.op === "&&") {
853
+ const extracted = extractLeftmostOptional(s.init.cond);
854
+ if (extracted) {
855
+ const desugared = [
856
+ { kind: "let", name: s.name, ty: s.ty, mutable: true, init: s.init.else },
857
+ { kind: "if", cond: s.init.cond, then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] },
858
+ ];
859
+ return desugared.flatMap(ds => transformStmt(ds, typeDecls));
860
+ }
861
+ }
773
862
  const { binds, expr } = liftMethodCalls(s.init);
774
863
  return [...binds, { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: expr }];
775
864
  }
@@ -917,7 +1006,8 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
917
1006
  if (someBranch.length === 0 && restStmts && restStmts.length > 0) {
918
1007
  someBranch = restStmts;
919
1008
  }
920
- const bound = matchBinder(`${varName}_val`);
1009
+ const sanitized = varName.replace(/\./g, "_");
1010
+ const bound = matchBinder(`${sanitized}_val`);
921
1011
  // Replace the narrowed variable/field in the Some branch body.
922
1012
  // Field chains: replace in TStmt before transform (so downstream narrowing sees simple vars).
923
1013
  // Simple vars: replace in IR after transform (the original mechanism).
@@ -931,10 +1021,14 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
931
1021
  }
932
1022
  else {
933
1023
  const transformed = transformStmts(someBranch, typeDecls);
934
- someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound })));
1024
+ someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound }, true)));
935
1025
  }
1026
+ // For field chains, use an Expr scrutinee so outer match replaceVar can
1027
+ // substitute the object variable (e.g. task → i_task_val in task.deletedFromList).
1028
+ // String scrutinees are opaque to replaceVar/mapExpr.
1029
+ const scrutinee = fieldExpr ? transformExpr(fieldExpr) : varName;
936
1030
  return {
937
- kind: "match", scrutinee: varName,
1031
+ kind: "match", scrutinee,
938
1032
  arms: [
939
1033
  { pattern: `.some ${bound}`, body: someBody },
940
1034
  { pattern: ".none", body: noneBranch.length > 0 ? transformStmts(noneBranch, typeDecls) : [] },
@@ -947,25 +1041,21 @@ function mapStmtExprs(s, r) {
947
1041
  }
948
1042
  // ── Optional narrowing helpers ──────────────────────────────
949
1043
  //
950
- // Optional narrowing converts TS `if (x === undefined)` patterns to Dafny
951
- // `match x { Some(val) => ..., None => ... }`.
1044
+ // Optional narrowing converts TS `if (x === undefined)` / `x !== undefined ? a : b`
1045
+ // patterns to `match x { Some(val) => ..., None => ... }`.
952
1046
  //
953
- // The resolve phase (resolve.ts) handles:
1047
+ // The resolve phase (resolve.ts) handles TYPE narrowing only:
954
1048
  // - Flow narrowing: after `if (x === undefined) return`, x is non-optional
955
1049
  // - && narrowing: in `x !== undefined && f(x)`, f(x) sees x as non-optional
956
- // - Conditional narrowing: in `x !== undefined ? x.field : default`, sets
957
- // narrowedVar/narrowedExpr on TExpr for the transform phase
1050
+ // - Conditional type narrowing: extends env (simple vars) or narrowedFields context
1051
+ // (field chains) so the then-branch resolves with the unwrapped type
958
1052
  //
959
- // The transform phase (here) handles:
1053
+ // The transform phase (here) handles ALL structural narrowing:
960
1054
  // - Statement-level: `transformStmts` detects optional checks → `emitOptionalMatch`
961
- // - Expression-level: `lowerExpr` conditional reads narrowedVar/narrowedExpr match
962
- // - && restructuring: `extractLeftmostOptional` splits `&&` chains into nested ifs
963
- // so `emitOptionalMatch` can detect the inner optional check
964
- //
965
- // Both phases detect `v !== undefined` patterns. The resolve phase uses
966
- // `detectOptionalCheck` (on RawExpr), the transform uses `parseOptionalCheck` (on TExpr).
967
- // These are separate because they operate on different IR types, but both handle
968
- // simple variables and field access chains.
1055
+ // - Expression-level: `lowerExpr` detects optional checks via `parseOptionalCheck`
1056
+ // and `extractLeftmostOptional` `lowerOptionalConditional`
1057
+ // - && restructuring: `extractLeftmostOptional` splits `&&` chains, generating
1058
+ // match with guard: `match x { Some(val) => if guard then A else B, None => B }`
969
1059
  /** Shared logic for optional match in both imperative and pure function paths.
970
1060
  * Detects optional check, selects branches, handles early-return consumption.
971
1061
  * Returns null if the condition is not an optional check. */
@@ -978,7 +1068,8 @@ function prepareOptionalMatch(s, restStmts) {
978
1068
  // Early-return pattern: Some branch is empty → consume rest of block
979
1069
  if (someBranch.length === 0 && restStmts.length > 0)
980
1070
  someBranch = restStmts;
981
- const bound = matchBinder(`${check.varName}_val`);
1071
+ const sanitized = check.varName.replace(/\./g, "_");
1072
+ const bound = matchBinder(`${sanitized}_val`);
982
1073
  return { check, someBranch, noneBranch, bound };
983
1074
  }
984
1075
  /** Extract the leftmost optional check from a && chain, returning the check and the rest.
@@ -1092,6 +1183,96 @@ function replaceFieldAccessInTStmts(stmts, varName, fields) {
1092
1183
  fallbackTy: f.type ?? parseTsType(f.tsType),
1093
1184
  })));
1094
1185
  }
1186
+ /** Replace obj.field → replacement var in typed IR expressions (before lowering).
1187
+ * Mirrors replaceFieldsInTStmts but operates on a single TExpr tree. */
1188
+ function replaceFieldInTExpr(expr, objName, replacements) {
1189
+ if (replacements.length === 0)
1190
+ return expr;
1191
+ return mapTExpr(expr, e => {
1192
+ if (e.kind === "field" && e.obj.kind === "var" && e.obj.name === objName) {
1193
+ const r = replacements.find(r => r.fieldName === e.field);
1194
+ if (r) {
1195
+ const ty = e.ty.kind !== "unknown" ? e.ty : r.fallbackTy;
1196
+ return { kind: "var", name: r.newName, ty };
1197
+ }
1198
+ }
1199
+ return null;
1200
+ });
1201
+ }
1202
+ /** Unwrap optional type on match-bound variables in TExpr.
1203
+ * After replaceFieldInTExpr, the replaced variable carries the original optional
1204
+ * type from the field declaration. The match binding unwraps it to the inner type. */
1205
+ function fixBoundType(expr, boundName) {
1206
+ return mapTExpr(expr, e => e.kind === "var" && e.name === boundName && e.ty.kind === "optional"
1207
+ ? { ...e, ty: e.ty.inner } : null);
1208
+ }
1209
+ /** Lower a conditional with an optional check (Phase 2: explicit, Phase 3: && with guard).
1210
+ * Generates: match scrutinee { Some(val) => [if guard then] A [else B], None => B }
1211
+ * For field chains, replaces field accesses in TExpr before lowering.
1212
+ * For simple vars, replaces variable names in Expr after lowering. */
1213
+ function lowerOptionalConditional(e, check, guard, binds) {
1214
+ const sanitized = check.varName.replace(/\./g, "_");
1215
+ const bound = matchBinder(`${sanitized}_val`);
1216
+ const isFieldChain = check.fieldExpr && check.fieldExpr.kind === "field" &&
1217
+ check.fieldExpr.obj.kind === "var";
1218
+ // Determine which branch is Some (unwrapped) vs None
1219
+ let thenTExpr = e.then;
1220
+ let guardTExpr = guard;
1221
+ // For field chains: replace field access with bound var in TExpr before lowering
1222
+ if (isFieldChain) {
1223
+ const fe = check.fieldExpr;
1224
+ const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
1225
+ const replacements = [{ fieldName: fe.field, newName: bound, fallbackTy: innerTy }];
1226
+ thenTExpr = fixBoundType(replaceFieldInTExpr(thenTExpr, fe.obj.name, replacements), bound);
1227
+ if (guardTExpr) {
1228
+ guardTExpr = fixBoundType(replaceFieldInTExpr(guardTExpr, fe.obj.name, replacements), bound);
1229
+ }
1230
+ }
1231
+ let thenExpr = lowerExpr(thenTExpr, binds);
1232
+ let elseExpr = lowerExpr(e.else, binds);
1233
+ // For simple vars: replace after lowering
1234
+ if (!isFieldChain) {
1235
+ thenExpr = replaceVar(thenExpr, check.varName, { kind: "var", name: bound }, true);
1236
+ }
1237
+ // Optional wrapping: check if either branch is undefined (produces optional result)
1238
+ const isOptionalResult = (e.then.kind === "var" && e.then.name === "undefined") ||
1239
+ (e.else.kind === "var" && e.else.name === "undefined");
1240
+ if (isOptionalResult) {
1241
+ thenExpr = wrapOptionalBranch(thenExpr, e.then);
1242
+ elseExpr = wrapOptionalBranch(elseExpr, e.else);
1243
+ }
1244
+ // Build Some arm body — add guard for && patterns
1245
+ // Note: if the guard has method calls, the impure path desugars the let to a
1246
+ // statement-level if+match in transformStmt, so this expression-level path only
1247
+ // runs for pure guards. Use null for binds to avoid escaping the match scope.
1248
+ let someBody;
1249
+ if (guard) {
1250
+ let guardExpr = lowerExpr(guardTExpr, null);
1251
+ if (!isFieldChain) {
1252
+ guardExpr = replaceVar(guardExpr, check.varName, { kind: "var", name: bound }, true);
1253
+ }
1254
+ // Guard-else gets the same expression as None arm
1255
+ let guardElse = lowerExpr(e.else, null);
1256
+ if (isOptionalResult) {
1257
+ guardElse = wrapOptionalBranch(guardElse, e.else);
1258
+ }
1259
+ someBody = { kind: "if", cond: guardExpr, then: thenExpr, else: guardElse };
1260
+ }
1261
+ else {
1262
+ someBody = thenExpr;
1263
+ }
1264
+ // Build scrutinee
1265
+ const scrutinee = isFieldChain
1266
+ ? lowerExpr(check.fieldExpr, binds)
1267
+ : check.varName;
1268
+ return {
1269
+ kind: "match", scrutinee,
1270
+ arms: [
1271
+ { pattern: `.some ${bound}`, body: someBody },
1272
+ { pattern: ".none", body: elseExpr },
1273
+ ],
1274
+ };
1275
+ }
1095
1276
  // ── Pure function generation ─────────────────────────────────
1096
1277
  function transformPureBody(stmts, typeDecls) {
1097
1278
  // Detect discriminant if-chain
@@ -1112,16 +1293,42 @@ function transformPureBody(stmts, typeDecls) {
1112
1293
  return { kind: "let", name: s.name, value: transformExpr(s.init), body: restExpr };
1113
1294
  }
1114
1295
  case "if": {
1296
+ // Restructure && with optional check: split into nested ifs so
1297
+ // prepareOptionalMatch can detect the optional check and bind the unwrapped value
1298
+ if (s.cond.kind === "binop" && s.cond.op === "&&" && s.else.length === 0) {
1299
+ const extracted = extractLeftmostOptional(s.cond);
1300
+ if (extracted) {
1301
+ const innerIf = { kind: "if", cond: extracted.rest, then: s.then, else: [] };
1302
+ const outerIf = { kind: "if", cond: extracted.optCond, then: [innerIf], else: [] };
1303
+ return transformPureBody([outerIf, ...rest], typeDecls);
1304
+ }
1305
+ }
1115
1306
  // Optional narrowing: if (x === undefined) → match x { None => ..., Some(x_val) => ... }
1116
1307
  const optMatch = prepareOptionalMatch(s, rest);
1117
1308
  if (optMatch) {
1118
- const someExpr = transformPureBody(optMatch.someBranch, typeDecls);
1309
+ // For field chains (a.dueDate !== undefined), replace in TStmt before transform
1310
+ let someBranch = [...optMatch.someBranch, ...rest];
1311
+ const fe = optMatch.check.fieldExpr;
1312
+ if (fe && fe.kind === "field" && fe.obj.kind === "var") {
1313
+ const innerTy = fe.ty.kind === "optional" ? fe.ty.inner : fe.ty;
1314
+ someBranch = replaceFieldsInTStmts(someBranch, fe.obj.name, [
1315
+ { fieldName: fe.field, newName: optMatch.bound, fallbackTy: innerTy },
1316
+ ]);
1317
+ // The replacement keeps the original optional type, but the match binding
1318
+ // unwraps it. Fix the type so downstream record coercion re-wraps with Some().
1319
+ someBranch = someBranch.map(s => mapTStmt(s, e => e.kind === "var" && e.name === optMatch.bound && e.ty.kind === "optional"
1320
+ ? { ...e, ty: e.ty.inner } : null));
1321
+ }
1322
+ const someExpr = transformPureBody(someBranch, typeDecls);
1119
1323
  if (!someExpr)
1120
1324
  return null;
1121
1325
  const noneExpr = transformPureBody(optMatch.noneBranch, typeDecls);
1122
1326
  if (!noneExpr)
1123
1327
  return null;
1124
- const someReplaced = replaceVar(someExpr, optMatch.check.varName, { kind: "var", name: optMatch.bound });
1328
+ // For simple vars, replace in Expr after transform
1329
+ const someReplaced = optMatch.check.fieldExpr
1330
+ ? someExpr
1331
+ : replaceVar(someExpr, optMatch.check.varName, { kind: "var", name: optMatch.bound }, true);
1125
1332
  return {
1126
1333
  kind: "match", scrutinee: optMatch.check.varName,
1127
1334
  arms: [
@@ -1130,11 +1337,13 @@ function transformPureBody(stmts, typeDecls) {
1130
1337
  ],
1131
1338
  };
1132
1339
  }
1133
- const thenExpr = transformPureBody(s.then, typeDecls);
1340
+ // Append rest to both branches so nested ifs that fall through
1341
+ // can reach the continuation (e.g. early return inside then-branch)
1342
+ const thenExpr = transformPureBody([...s.then, ...rest], typeDecls);
1134
1343
  if (!thenExpr)
1135
1344
  return null;
1136
- const elseBranch = s.else.length > 0 ? s.else : rest;
1137
- const elseExpr = transformPureBody(elseBranch, typeDecls);
1345
+ const elseStmts = s.else.length > 0 ? [...s.else, ...rest] : rest;
1346
+ const elseExpr = transformPureBody(elseStmts, typeDecls);
1138
1347
  if (!elseExpr)
1139
1348
  return null;
1140
1349
  return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
@@ -1265,17 +1474,38 @@ function findReassignedNames(stmts, names) {
1265
1474
  return found;
1266
1475
  }
1267
1476
  /** Replace all occurrences of a variable name with a new expression. */
1268
- function replaceVar(e, name, replacement) {
1477
+ /**
1478
+ * Replace all occurrences of variable `name` with `replacement`.
1479
+ * If `narrowing` is true, the replacement is an unwrapped Optional value
1480
+ * (e.g., replacing `x: Option<T>` with `x_val: T`). In that case, when the
1481
+ * variable appears directly as a record spread field value, it's wrapped in
1482
+ * Some() to preserve the field's Optional type.
1483
+ */
1484
+ function replaceVar(e, name, replacement, narrowing) {
1485
+ const rec = (expr) => replaceVar(expr, name, replacement, narrowing);
1269
1486
  return mapExpr(e, x => {
1270
1487
  if (x.kind === "var" && x.name === name)
1271
1488
  return replacement;
1489
+ // Record spread: wrap direct variable uses in field values with Some when narrowing
1490
+ if (narrowing && x.kind === "record" && x.spread) {
1491
+ return {
1492
+ ...x,
1493
+ spread: rec(x.spread),
1494
+ fields: x.fields.map(f => {
1495
+ if (f.value.kind === "var" && f.value.name === name) {
1496
+ return { ...f, value: { kind: "app", fn: "Some", args: [replacement] } };
1497
+ }
1498
+ return { ...f, value: rec(f.value) };
1499
+ }),
1500
+ };
1501
+ }
1272
1502
  // Don't descend past bindings that shadow the name
1273
1503
  if (x.kind === "forall" && x.var === name)
1274
1504
  return x;
1275
1505
  if (x.kind === "exists" && x.var === name)
1276
1506
  return x;
1277
1507
  if (x.kind === "let" && x.name === name)
1278
- return { ...x, value: replaceVar(x.value, name, replacement) };
1508
+ return { ...x, value: replaceVar(x.value, name, replacement, narrowing) };
1279
1509
  return null;
1280
1510
  });
1281
1511
  }
@@ -1316,25 +1546,43 @@ export function transformModule(mod, specImport) {
1316
1546
  }));
1317
1547
  // Pure function mirrors
1318
1548
  const pureDefs = [];
1549
+ const defByMethods = [];
1319
1550
  for (const fn of mod.functions) {
1320
1551
  if (!fn.isPure)
1321
1552
  continue;
1322
1553
  const body = transformPureBody(fn.body, mod.typeDecls);
1323
- if (!body)
1324
- continue;
1325
- // For ensures, replace \result ( "res") with the function call
1326
- const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
1327
- const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
1328
- pureDefs.push({
1329
- kind: "def",
1330
- name: fn.name,
1331
- typeParams: fn.typeParams,
1332
- params: fn.params.map(p => ({ name: p.name, type: p.ty })),
1333
- returnType: fn.returnTy,
1334
- requires: fn.requires.map(transformExpr),
1335
- ensures,
1336
- body,
1337
- });
1554
+ if (body) {
1555
+ // For ensures, replace \result (→ "res") with the function call
1556
+ const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
1557
+ const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
1558
+ pureDefs.push({
1559
+ kind: "def",
1560
+ name: fn.name,
1561
+ typeParams: fn.typeParams,
1562
+ params: fn.params.map(p => ({ name: p.name, type: p.ty })),
1563
+ returnType: fn.returnTy,
1564
+ requires: fn.requires.map(transformExpr),
1565
+ ensures,
1566
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1567
+ body,
1568
+ });
1569
+ }
1570
+ else if (fn.forcePure) {
1571
+ // //@ pure but body can't be auto-converted — emit function by method
1572
+ _forofCounters.clear();
1573
+ const methodBody = transformStmts(fn.body, mod.typeDecls);
1574
+ defByMethods.push({
1575
+ kind: "def-by-method",
1576
+ name: fn.name,
1577
+ typeParams: fn.typeParams,
1578
+ params: fn.params.map(p => ({ name: p.name, type: p.ty })),
1579
+ returnType: fn.returnTy,
1580
+ requires: fn.requires.map(transformExpr),
1581
+ ensures: fn.ensures.map(transformExpr),
1582
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1583
+ methodBody,
1584
+ });
1585
+ }
1338
1586
  }
1339
1587
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
1340
1588
  // Types file
@@ -1353,7 +1601,8 @@ export function transformModule(mod, specImport) {
1353
1601
  }
1354
1602
  // Def file: Velvet methods
1355
1603
  // Pure functions get a thin wrapper that calls Pure.fnName
1356
- const pureDefNames = new Set(pureDefs.map(d => d.name));
1604
+ // def-by-method functions also skip their method wrappers
1605
+ const pureDefNames = new Set([...pureDefs.map(d => d.name), ...defByMethods.map(d => d.name)]);
1357
1606
  const methods = mod.functions.map(fn => {
1358
1607
  const ensures = [];
1359
1608
  for (const e of fn.ensures) {
@@ -1420,7 +1669,7 @@ export function transformModule(mod, specImport) {
1420
1669
  { key: "loom.semantics.termination", value: '"total"' },
1421
1670
  { key: "loom.semantics.choice", value: '"demonic"' },
1422
1671
  ],
1423
- decls: [...constDecls, ...methods, ...classDecls],
1672
+ decls: [...constDecls, ...defByMethods, ...methods, ...classDecls],
1424
1673
  };
1425
1674
  return { typesFile, defFile };
1426
1675
  }