lemmascript 0.3.2 → 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.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Each example and case study is verified in Lean 4 and/or Dafny from the same ann
13
13
  See the internal [examples](examples).
14
14
 
15
15
  See the external case studies:
16
+ - **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
16
17
  - **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
17
18
  - **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
18
19
  - **[node-casbin-lemmascript](https://github.com/midspiral/node-casbin-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [node-casbin](https://github.com/casbin/node-casbin). 5 functions verified, 217 existing tests pass. End-to-end correctness and order independence for all 4 effect modes in both Lean and Dafny (39 lemmas).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "tsc -p tools/tsconfig.json",
14
14
  "prepublishOnly": "npm run build",
15
- "typecheck": "tsc -p tools/tsconfig.json --noEmit"
15
+ "typecheck": "tsc -p tools/tsconfig.json --noEmit",
16
+ "typecheck:examples": "tsc -p examples/tsconfig.json"
16
17
  },
17
18
  "dependencies": {
18
19
  "ts-morph": "^25.0.0"
@@ -325,6 +325,8 @@ function emitExpr(e) {
325
325
  const vals = e.fields.map(f => emitExpr(f.value));
326
326
  return `${ctorName}(${vals.join(", ")})`;
327
327
  }
328
+ if (e.fields.length === 0)
329
+ return `map[]`;
328
330
  const vals = e.fields.map(f => emitExpr(f.value));
329
331
  return `(${vals.join(", ")})`;
330
332
  }
@@ -473,6 +475,8 @@ function emitDecl(d) {
473
475
  const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
474
476
  for (const r of d.requires)
475
477
  lines.push(` requires ${emitExpr(r)}`);
478
+ if (d.decreases)
479
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
476
480
  lines.push(`{`);
477
481
  lines.push(emitPureExpr(d.body, 1));
478
482
  lines.push(`}`);
@@ -491,6 +495,20 @@ function emitDecl(d) {
491
495
  }
492
496
  return lines.join("\n");
493
497
  }
498
+ case "def-by-method": {
499
+ const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
500
+ const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
501
+ for (const r of d.requires)
502
+ lines.push(` requires ${emitExpr(r)}`);
503
+ if (d.decreases)
504
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
505
+ lines.push(`{`);
506
+ lines.push(`}`);
507
+ lines.push(`by method {`);
508
+ lines.push(emitStmts(d.methodBody, 1));
509
+ lines.push(`}`);
510
+ return lines.join("\n");
511
+ }
494
512
  case "method": {
495
513
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
496
514
  const lines = [`method ${d.name}${tp}(${paramList(d.params)}) returns (res: ${tyToDafny(d.returnType)})`];
@@ -656,6 +674,7 @@ const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
656
674
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
657
675
  ensures forall x :: x in s <==> x in res
658
676
  ensures |res| == |s|
677
+ ensures forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
659
678
  {
660
679
  var remaining := s;
661
680
  res := [];
@@ -663,6 +682,7 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
663
682
  invariant remaining <= s
664
683
  invariant forall x :: x in res <==> (x in s && x !in remaining)
665
684
  invariant |res| + |remaining| == |s|
685
+ invariant forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
666
686
  decreases remaining
667
687
  {
668
688
  var x :| x in remaining;
@@ -760,26 +780,40 @@ function translatePattern(pattern) {
760
780
  export function emitDafnyFile(file, tsFileName) {
761
781
  buildRecordCtorMap(file.decls);
762
782
  _neededPreambles.clear();
763
- // Collect pure def names so we can skip their method wrappers
764
- const pureDefs = new Set();
765
- for (const d of file.decls) {
766
- if (d.kind === "namespace") {
767
- for (const inner of d.decls)
768
- if (inner.kind === "def")
769
- pureDefs.add(inner.name);
770
- }
771
- if (d.kind === "def")
772
- pureDefs.add(d.name);
773
- }
783
+ // Track successfully emitted pure defs method wrappers are only
784
+ // skipped when the corresponding pure def was actually emitted.
785
+ const emittedPureDefs = new Set();
774
786
  // Emit declarations
775
787
  const declLines = [];
776
788
  const skipped = [];
777
789
  for (const decl of file.decls) {
778
- if (decl.kind === "method" && pureDefs.has(decl.name))
790
+ if (decl.kind === "method" && emittedPureDefs.has(decl.name))
779
791
  continue;
792
+ if (decl.kind === "namespace") {
793
+ // Emit each inner decl individually — if one fails, the rest survive
794
+ // and failed defs fall back to their method wrappers
795
+ for (const inner of decl.decls) {
796
+ try {
797
+ declLines.push("");
798
+ declLines.push(emitDecl(inner));
799
+ if (inner.kind === "def")
800
+ emittedPureDefs.add(inner.name);
801
+ }
802
+ catch (e) {
803
+ const name = "name" in inner ? inner.name : "unknown";
804
+ const msg = e.message;
805
+ console.error(`WARNING: skipping pure '${name}': ${msg}`);
806
+ declLines.push(`\n// LemmaScript: skipped pure ${name}`);
807
+ skipped.push(name);
808
+ }
809
+ }
810
+ continue;
811
+ }
780
812
  try {
781
813
  declLines.push("");
782
814
  declLines.push(emitDecl(decl));
815
+ if (decl.kind === "def-by-method")
816
+ emittedPureDefs.add(decl.name);
783
817
  }
784
818
  catch (e) {
785
819
  const name = "name" in decl ? decl.name : "unknown";
@@ -8,6 +8,13 @@ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
8
8
  // ── Expression extraction ────────────────────────────────────
9
9
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
10
10
  let _havocKey = null;
11
+ /**
12
+ * Maps property-name fingerprints to type alias names for collapsed single-variant unions.
13
+ * TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
14
+ * causing getAliasSymbol() to return null. This map lets typeToString recover the alias.
15
+ * Populated in extractModule before type extraction; used by typeToString.
16
+ */
17
+ let _collapsedUnionMap = new Map();
11
18
  /** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
12
19
  let _typeParamMap = new Map();
13
20
  function _eraseGenerics(tsType) {
@@ -106,6 +113,14 @@ function extractExpr(node) {
106
113
  }
107
114
  // Call expression: f(a, b)
108
115
  if (Node.isCallExpression(node)) {
116
+ // Object.fromEntries(map) → identity (Map IS Record in Dafny)
117
+ const callee = node.getExpression();
118
+ if (Node.isPropertyAccessExpression(callee) &&
119
+ callee.getExpression().getText() === "Object" &&
120
+ callee.getName() === "fromEntries" &&
121
+ node.getArguments().length === 1) {
122
+ return extractExpr(node.getArguments()[0]);
123
+ }
109
124
  return {
110
125
  kind: "call",
111
126
  fn: extractExpr(node.getExpression()),
@@ -318,6 +333,17 @@ function collectAnnotations(node, body) {
318
333
  return [...own, ...parseAnnotations(body[0])];
319
334
  return own;
320
335
  }
336
+ /** Check for bare `//@ pure` annotation (no expression). */
337
+ function hasPureAnnotation(node, body) {
338
+ const nodes = body && body.length > 0 ? [node, body[0]] : [node];
339
+ for (const n of nodes) {
340
+ for (const range of n.getLeadingCommentRanges()) {
341
+ if (range.getText().trim() === "//@ pure")
342
+ return true;
343
+ }
344
+ }
345
+ return false;
346
+ }
321
347
  // ── Type declaration extraction ──────────────────────────────
322
348
  function extractTypeDecl(decl, extraDecls) {
323
349
  const name = decl.getName();
@@ -349,6 +375,30 @@ function extractTypeDecl(decl, extraDecls) {
349
375
  }
350
376
  }
351
377
  }
378
+ // Single-variant discriminated union: type X = | { kind: 'Foo', ... }
379
+ // TypeScript collapses single-member unions to their member type,
380
+ // so type.isUnion() returns false. Detect by checking the source text
381
+ // for union syntax '|' AND a string-literal discriminant field.
382
+ if (type.isObject() && !type.isIntersection()) {
383
+ const srcText = decl.getTypeNode()?.getText() ?? "";
384
+ if (srcText.includes("|")) {
385
+ const disc = findDiscriminant([type]);
386
+ if (disc) {
387
+ const tagProp = type.getProperty(disc);
388
+ const tagType = tagProp?.getTypeAtLocation(decl);
389
+ const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue()) : null;
390
+ if (tag) {
391
+ const fields = [];
392
+ for (const prop of type.getProperties()) {
393
+ if (prop.getName() === disc)
394
+ continue;
395
+ fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
396
+ }
397
+ return { name, typeParams: tpField, kind: "discriminated-union", discriminant: disc, variants: [{ name: tag, fields }] };
398
+ }
399
+ }
400
+ }
401
+ }
352
402
  if (type.isObject() || type.isIntersection())
353
403
  return extractRecord(name, type, decl, undefined, extraDecls);
354
404
  // Primitive type alias: type TaskId = number → alias
@@ -461,6 +511,13 @@ function typeToString(type) {
461
511
  const symbol = type.getSymbol() ?? type.getAliasSymbol();
462
512
  if (symbol) {
463
513
  const name = symbol.getName();
514
+ // Recover collapsed single-variant union alias via property fingerprint
515
+ if (name === "__type" && _collapsedUnionMap.size > 0) {
516
+ const props = type.getProperties().map(p => p.getName()).sort().join(",");
517
+ const alias = _collapsedUnionMap.get(props);
518
+ if (alias)
519
+ return alias;
520
+ }
464
521
  const typeArgs = type.getTypeArguments();
465
522
  if (typeArgs.length > 0) {
466
523
  return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
@@ -632,13 +689,29 @@ function extractStmts(stmts) {
632
689
  else {
633
690
  names.push("_");
634
691
  }
692
+ // Unwrap Object.entries(expr) / Object.values(expr) to bare map iteration
693
+ let iterableExpr = s.getExpression();
694
+ if (Node.isCallExpression(iterableExpr)) {
695
+ const callee = iterableExpr.getExpression();
696
+ if (Node.isPropertyAccessExpression(callee) &&
697
+ callee.getExpression().getText() === "Object") {
698
+ const method = callee.getName();
699
+ if ((method === "entries" || method === "values") && iterableExpr.getArguments().length === 1) {
700
+ iterableExpr = iterableExpr.getArguments()[0];
701
+ // Object.values with single name → prepend "_" so it looks like [_, v] destructuring
702
+ if (method === "values" && names.length === 1) {
703
+ names.unshift("_");
704
+ }
705
+ }
706
+ }
707
+ }
635
708
  const bodyNode = s.getStatement();
636
709
  const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
637
710
  const annots = collectAnnotations(s, bodyStmts);
638
711
  result.push({
639
712
  kind: "forof",
640
713
  names,
641
- iterable: extractExpr(s.getExpression()),
714
+ iterable: extractExpr(iterableExpr),
642
715
  invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
643
716
  doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
644
717
  body: extractStmts(bodyStmts),
@@ -864,6 +937,8 @@ function extractFunction(fn, parentAnnotations) {
864
937
  })(),
865
938
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
866
939
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
940
+ decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
941
+ pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
867
942
  typeAnnotations,
868
943
  body: extractedBody,
869
944
  line: fn.getStartLineNumber(),
@@ -907,6 +982,22 @@ export function extractModule(sourceFile) {
907
982
  typeDecls.push({ name, kind: "record", fields });
908
983
  }
909
984
  }
985
+ // Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
986
+ // TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
987
+ // the alias. We record a fingerprint (sorted property names) → alias name mapping.
988
+ _collapsedUnionMap = new Map();
989
+ for (const stmt of sourceFile.getStatements()) {
990
+ if (Node.isTypeAliasDeclaration(stmt)) {
991
+ const type = stmt.getType();
992
+ if (!type.isUnion() && type.isObject() && !type.isIntersection()) {
993
+ const srcText = stmt.getTypeNode()?.getText() ?? "";
994
+ if (srcText.includes("|") && findDiscriminant([type])) {
995
+ const props = type.getProperties().map(p => p.getName()).sort().join(",");
996
+ _collapsedUnionMap.set(props, stmt.getName());
997
+ }
998
+ }
999
+ }
1000
+ }
910
1001
  // Extract type declarations in source order to respect dependencies
911
1002
  // Skip types already declared via //@ declare-type
912
1003
  const declaredNames = new Set(typeDecls.map(d => d.name));
@@ -1266,6 +1357,12 @@ export function extractModule(sourceFile) {
1266
1357
  }
1267
1358
  const sym = retType.getSymbol();
1268
1359
  if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
1360
+ // Try typeToString first — it resolves collapsed single-variant unions
1361
+ const resolved = typeToString(retType);
1362
+ if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
1363
+ fn.returnType = resolved;
1364
+ continue;
1365
+ }
1269
1366
  const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
1270
1367
  if (!knownTypes.has(synName)) {
1271
1368
  const extra = [];
@@ -325,6 +325,8 @@ function emitDecl(d) {
325
325
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
326
326
  return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
327
327
  }
328
+ case "def-by-method":
329
+ throw new Error("function by method is not supported for Lean backend");
328
330
  case "method": {
329
331
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
330
332
  const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
@@ -108,7 +108,9 @@ function wrapSome(value, optionalTy) {
108
108
  }
109
109
  /** Detect `v !== undefined` or `undefined !== v` where v: optional<T>.
110
110
  * Handles simple variables, field access chains, and arbitrary expressions.
111
- * When `fieldExpr` is returned, callers must use `substituteRawExpr` to narrow.
111
+ * When `fieldExpr` is returned for a simple field chain (obj.field), callers
112
+ * can narrow via `narrowedFields` context. For complex expressions (calls, etc.),
113
+ * callers should fall back to `substituteRawExpr`.
112
114
  *
113
115
  * Does NOT recurse into `&&` — callers that need to detect optional checks
114
116
  * inside `&&` conditions should check `cond.left` explicitly. */
@@ -141,6 +143,19 @@ function detectOptionalCheck(cond, ctx) {
141
143
  }
142
144
  return null;
143
145
  }
146
+ /** Collect all optional narrowings from an early-return condition.
147
+ * Handles single checks (x === undefined) and compound || chains
148
+ * (x === undefined || y === undefined). */
149
+ function collectEarlyReturnNarrowings(cond, ctx) {
150
+ if (cond.kind === "binop" && cond.op === "||") {
151
+ return [...collectEarlyReturnNarrowings(cond.left, ctx), ...collectEarlyReturnNarrowings(cond.right, ctx)];
152
+ }
153
+ const narrowed = detectOptionalCheck(cond, ctx);
154
+ if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
155
+ return [{ varName: narrowed.varName, innerTy: narrowed.innerTy }];
156
+ }
157
+ return [];
158
+ }
144
159
  /** TS reference types that become value types in Dafny/Lean — const bindings need mutable var. */
145
160
  function isRefMutableInTS(ty) {
146
161
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
@@ -389,8 +404,21 @@ function resolveExpr(e, ctx) {
389
404
  fn.obj.ty.elem.kind === "user") {
390
405
  argCtx = { ...ctx, returnTy: fn.obj.ty.elem };
391
406
  }
392
- const args = coerceCallArgs(rawArgs.map(a => resolveExpr(a, argCtx)), fn, ctx);
393
- const ty = inferMethodReturnTy(fn, args, ctx);
407
+ // Propagate parameter types to arguments for record literal resolution
408
+ // (enables inline discriminated union construction in function arguments)
409
+ const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
410
+ const args = coerceCallArgs(rawArgs.map((a, i) => {
411
+ let aCtx = argCtx;
412
+ if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
413
+ aCtx = { ...aCtx, returnTy: paramTypes[i] };
414
+ }
415
+ return resolveExpr(a, aCtx);
416
+ }), fn, ctx);
417
+ let ty = inferMethodReturnTy(fn, args, ctx);
418
+ // For same-file function calls, use the known return type
419
+ if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
420
+ ty = ctx.fnReturns.get(fn.name);
421
+ }
394
422
  return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
395
423
  }
396
424
  case "index": {
@@ -405,16 +433,24 @@ function resolveExpr(e, ctx) {
405
433
  const obj = resolveExpr(e.obj, ctx);
406
434
  let isDiscriminant = false;
407
435
  let ty = { kind: "unknown" };
408
- if (e.field === "length" && (obj.ty.kind === "array" || obj.ty.kind === "string")) {
436
+ // Check narrowed field context (from conditional optional checks on field chains)
437
+ if (obj.kind === "var" && ctx.narrowedFields.length > 0) {
438
+ const nf = ctx.narrowedFields.find(n => n.objName === obj.name && n.fieldName === e.field);
439
+ if (nf)
440
+ ty = nf.narrowedTy;
441
+ }
442
+ if (ty.kind === "unknown" && e.field === "length" && (obj.ty.kind === "array" || obj.ty.kind === "string")) {
409
443
  ty = { kind: "nat" };
410
444
  }
411
- else if (e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
445
+ else if (ty.kind === "unknown" && e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
412
446
  ty = { kind: "nat" };
413
447
  }
414
- else if (obj.ty.kind === "user") {
415
- if (getDiscriminant(ctx, obj.ty.name) === e.field)
448
+ else if (ty.kind === "unknown" && obj.ty.kind === "user") {
449
+ // Strip generic args for type lookup: "Result<Model, Err>" "Result"
450
+ const baseTyName = obj.ty.name.includes("<") ? obj.ty.name.slice(0, obj.ty.name.indexOf("<")) : obj.ty.name;
451
+ if (getDiscriminant(ctx, baseTyName) === e.field)
416
452
  isDiscriminant = true;
417
- const decl = findDecl(ctx, obj.ty.name);
453
+ const decl = findDecl(ctx, baseTyName);
418
454
  if (decl?.kind === "record") {
419
455
  const f = decl.fields?.find(f => f.name === e.field);
420
456
  if (f)
@@ -442,8 +478,13 @@ function resolveExpr(e, ctx) {
442
478
  // Clear returnTy for field values — it applies to THIS record, not nested ones
443
479
  const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
444
480
  const fields = e.fields.map(f => {
445
- let value = resolveExpr(f.value, fieldCtx);
446
481
  const fieldDecl = decl?.fields?.find(df => df.name === f.name);
482
+ // Propagate declared field type into context so nested records resolve
483
+ // their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle)
484
+ const valueCtx = (fieldDecl?.type?.kind === "user")
485
+ ? { ...fieldCtx, returnTy: fieldDecl.type }
486
+ : fieldCtx;
487
+ let value = resolveExpr(f.value, valueCtx);
447
488
  if (fieldDecl) {
448
489
  const declTy = fieldDecl.type;
449
490
  value = coerceStr(value, declTy);
@@ -499,7 +540,6 @@ function resolveExpr(e, ctx) {
499
540
  case "conditional": {
500
541
  const cond = resolveExpr(e.cond, ctx);
501
542
  let narrowedVar;
502
- let narrowedExprResolved;
503
543
  let thenCtx = ctx;
504
544
  let rawThen = e.then;
505
545
  // Phase 1: Optional truthiness — cond itself is optional (e.g. opt ? X : Y)
@@ -515,23 +555,56 @@ function resolveExpr(e, ctx) {
515
555
  thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, innerTy));
516
556
  }
517
557
  }
518
- // Phase 2: Explicit check — v !== undefined (simple vars, field chains,
519
- // complex expressions all handled uniformly by detectOptionalCheck)
558
+ // Phase 2/3: Explicit check — v !== undefined, or && with optional check.
559
+ // Resolve only narrows the type environment; transform handles all structural
560
+ // narrowing (match generation, variable binding, && splitting).
561
+ let narrowedExprResolved;
562
+ let elseCtx = ctx;
520
563
  if (!narrowedVar) {
521
564
  const narrowed = detectOptionalCheck(e.cond, ctx)
522
- // Phase 3: && with optional check — (v !== undefined && ...) ? ... : ...
523
565
  ?? (e.cond.kind === "binop" && e.cond.op === "&&" ? detectOptionalCheck(e.cond.left, ctx) : null);
524
566
  if (narrowed && narrowed.inThen) {
525
- narrowedVar = narrowed.varName;
526
- thenCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
527
- if (narrowed.fieldExpr) {
567
+ if (!narrowed.fieldExpr) {
568
+ // Simple var: extend env only — transform will detect and generate match
569
+ thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
570
+ }
571
+ else if (narrowed.fieldExpr.kind === "field" && narrowed.fieldExpr.obj.kind === "var") {
572
+ // Simple field chain (obj.field): narrow via field context — no substitution
573
+ thenCtx = {
574
+ ...thenCtx,
575
+ narrowedFields: [...thenCtx.narrowedFields, {
576
+ objName: narrowed.fieldExpr.obj.name,
577
+ fieldName: narrowed.fieldExpr.field,
578
+ narrowedTy: narrowed.innerTy,
579
+ }],
580
+ };
581
+ }
582
+ else {
583
+ // Complex expression (call result, deep chain, etc.): transform can't detect these,
584
+ // so keep old behavior — substitute + narrowedVar + narrowedExpr
585
+ narrowedVar = narrowed.varName;
528
586
  narrowedExprResolved = narrowed.narrowedExpr ?? resolveExpr(narrowed.fieldExpr, ctx);
529
587
  rawThen = substituteRawExpr(e.then, narrowed.fieldExpr, { kind: "var", name: narrowed.varName });
588
+ thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
589
+ }
590
+ }
591
+ else if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
592
+ // v === undefined: narrow v in the else branch
593
+ elseCtx = withEnv(elseCtx, extend(elseCtx.env, narrowed.varName, narrowed.innerTy));
594
+ }
595
+ // Compound || with === undefined: narrow all checked vars in else branch
596
+ // e.g. if (a === undefined || b === undefined) then X else Y → narrow a,b in Y
597
+ // TODO: resolve-time narrowing works but transform doesn't emit match unwrap
598
+ // for || conditions yet — decompose || into nested matches in transform.
599
+ // Workaround: split || into separate if guards in user code.
600
+ if (!narrowed && e.cond.kind === "binop" && e.cond.op === "||") {
601
+ for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
602
+ elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
530
603
  }
531
604
  }
532
605
  }
533
606
  let then_ = resolveExpr(rawThen, thenCtx);
534
- let else_ = resolveExpr(e.else, ctx);
607
+ let else_ = resolveExpr(e.else, elseCtx);
535
608
  then_ = coerceStr(then_, else_.ty);
536
609
  else_ = coerceStr(else_, then_.ty);
537
610
  let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
@@ -543,8 +616,7 @@ function resolveExpr(e, ctx) {
543
616
  ty = { kind: "optional", inner: then_.ty };
544
617
  }
545
618
  // When narrowedExpr is set AND a branch is void, the match produces Optional
546
- const hasVoidBranch = then_.ty.kind === "void" || else_.ty.kind === "void";
547
- if (narrowedExprResolved && hasVoidBranch && ty.kind !== "optional") {
619
+ if (narrowedExprResolved && (then_.ty.kind === "void" || else_.ty.kind === "void") && ty.kind !== "optional") {
548
620
  ty = { kind: "optional", inner: ty };
549
621
  }
550
622
  return { kind: "conditional", cond, then: then_, else: else_, ty, narrowedVar, narrowedExpr: narrowedExprResolved };
@@ -585,12 +657,13 @@ function resolveBlock(stmts, ctx) {
585
657
  result.push(typed);
586
658
  env = nextEnv;
587
659
  // Flow narrowing: if (x === undefined) { return } narrows x for rest of block.
660
+ // Also handles compound: if (x === undefined || y === undefined) { return }
588
661
  // Field chains are excluded — resolve can't substitute in statement lists;
589
662
  // transform's emitOptionalMatch handles field chains in statement contexts.
590
663
  if (s.kind === "if" && s.then.length > 0 && s.then[s.then.length - 1].kind === "return" && s.else.length === 0) {
591
- const narrowed = detectOptionalCheck(s.cond, withEnv(ctx, env));
592
- if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
593
- env = extend(env, narrowed.varName, narrowed.innerTy);
664
+ const narrowings = collectEarlyReturnNarrowings(s.cond, withEnv(ctx, env));
665
+ for (const n of narrowings) {
666
+ env = extend(env, n.varName, n.innerTy);
594
667
  }
595
668
  }
596
669
  }
@@ -600,7 +673,10 @@ function resolveStmt(s, ctx) {
600
673
  switch (s.kind) {
601
674
  case "let": {
602
675
  const declTy = resolveTsType(s.tsType, ctx.overrides, s.name);
603
- const init = coerceStr(resolveExpr(s.init, ctx), declTy);
676
+ // Propagate declared type as returnTy so nested record expressions
677
+ // resolve union variants correctly (e.g., EffectState → mode: EffectMode → { kind: 'Idle' })
678
+ const initCtx = declTy.kind === "user" ? { ...ctx, returnTy: declTy } : ctx;
679
+ const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
604
680
  // Map indexing: TS says T, but access can fail → use Optional<T> from init
605
681
  const ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
606
682
  // const collections are mutable in value-semantics world (TS mutates in place, Dafny/Lean reassign)
@@ -833,6 +909,8 @@ function collectCallsStmts(stmts, fns, out) {
833
909
  }
834
910
  function computePureFns(functions) {
835
911
  const allFnNames = new Set(functions.map(fn => fn.name));
912
+ // //@ pure functions are always considered pure — never taint callers
913
+ const forcePure = new Set(functions.filter(fn => fn.pure).map(fn => fn.name));
836
914
  // Build call graph: fn → set of same-file functions it calls
837
915
  const callGraph = new Map();
838
916
  for (const fn of functions) {
@@ -840,8 +918,8 @@ function computePureFns(functions) {
840
918
  collectCallsStmts(fn.body, allFnNames, calls);
841
919
  callGraph.set(fn.name, calls);
842
920
  }
843
- // Seed: syntactically non-pure functions
844
- const nonPure = new Set(functions.filter(fn => !isSyntacticallyPure(fn.body)).map(fn => fn.name));
921
+ // Seed: syntactically non-pure functions (skip //@ pure)
922
+ const nonPure = new Set(functions.filter(fn => !forcePure.has(fn.name) && !isSyntacticallyPure(fn.body)).map(fn => fn.name));
845
923
  // Build reverse graph: fn → set of functions that call it
846
924
  const callers = new Map();
847
925
  for (const name of allFnNames)
@@ -850,12 +928,12 @@ function computePureFns(functions) {
850
928
  for (const callee of callees)
851
929
  callers.get(callee).add(caller);
852
930
  }
853
- // Propagate impurity through reverse call graph
931
+ // Propagate impurity through reverse call graph (skip //@ pure)
854
932
  const worklist = [...nonPure];
855
933
  while (worklist.length > 0) {
856
934
  const fn = worklist.pop();
857
935
  for (const caller of callers.get(fn) ?? []) {
858
- if (!nonPure.has(caller)) {
936
+ if (!nonPure.has(caller) && !forcePure.has(caller)) {
859
937
  nonPure.add(caller);
860
938
  worklist.push(caller);
861
939
  }
@@ -888,7 +966,7 @@ function containsReturn(stmts) {
888
966
  return false;
889
967
  }
890
968
  // ── Resolve function / module ────────────────────────────────
891
- function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
969
+ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), opts) {
892
970
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
893
971
  const params = fn.params.map(p => ({ name: p.name, ty: resolveTsType(p.tsType, overrides, p.name) }));
894
972
  const returnTy = resolveTsType(fn.returnType, overrides, "\\result");
@@ -897,7 +975,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
897
975
  env = extend(env, opts.thisBinding.name, opts.thisBinding.ty);
898
976
  for (const p of params)
899
977
  env = extend(env, p.name, p.ty);
900
- const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, inSpec: false, inLambda: false };
978
+ const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
901
979
  const requiresCtx = { ...baseCtx, inSpec: true };
902
980
  const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
903
981
  // Apply type parameter constraints from //@ type T (==) annotations
@@ -909,17 +987,19 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
909
987
  name: fn.name, typeParams, params, returnTy,
910
988
  requires: resolveSpecs(fn.requires, requiresCtx),
911
989
  ensures: resolveSpecs(fn.ensures, ensuresCtx),
990
+ decreases: fn.decreases ? resolveSpec(fn.decreases, requiresCtx) : null,
912
991
  isPure: opts?.forcePure !== undefined ? opts.forcePure : pureFns.has(fn.name),
992
+ forcePure: fn.pure,
913
993
  body: resolveBlock(fn.body, baseCtx),
914
994
  };
915
995
  }
916
- function resolveClass(cls, typeDecls, pureFns, fnParams = new Map()) {
996
+ function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map()) {
917
997
  const fields = cls.fields.map(f => ({ name: f.name, ty: parseTsType(f.tsType) }));
918
998
  // Create a synthetic record type for 'this' so field access resolves
919
999
  const thisType = { kind: "user", name: cls.name };
920
1000
  const thisDecl = { name: cls.name, kind: "record", fields: cls.fields.map(f => ({ name: f.name, tsType: f.tsType, type: parseTsType(f.tsType) })) };
921
1001
  const allTypeDecls = [...typeDecls, thisDecl];
922
- const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, {
1002
+ const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, fnReturns, {
923
1003
  thisBinding: { name: "this", ty: thisType },
924
1004
  forcePure: false, // class methods are never pure (they access this)
925
1005
  }));
@@ -941,15 +1021,18 @@ function precomputeFieldTypes(typeDecls) {
941
1021
  }
942
1022
  }
943
1023
  export function resolveModule(raw) {
1024
+ _synVarCounter = 0;
944
1025
  precomputeFieldTypes(raw.typeDecls);
945
1026
  const pureFns = computePureFns(raw.functions);
946
- // Pre-compute function parameter types for optional coercion
1027
+ // Pre-compute function parameter and return types
947
1028
  const fnParams = new Map();
1029
+ const fnReturns = new Map();
948
1030
  for (const fn of raw.functions) {
949
1031
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
950
1032
  fnParams.set(fn.name, fn.params.map(p => resolveTsType(p.tsType, overrides, p.name)));
1033
+ fnReturns.set(fn.name, resolveTsType(fn.returnType, overrides, "\\result"));
951
1034
  }
952
- const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, inSpec: false, inLambda: false };
1035
+ const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
953
1036
  const constants = (raw.constants ?? []).map(c => ({
954
1037
  name: c.name,
955
1038
  ty: parseTsType(c.tsType),
@@ -959,7 +1042,7 @@ export function resolveModule(raw) {
959
1042
  file: raw.file,
960
1043
  typeDecls: raw.typeDecls,
961
1044
  constants,
962
- functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams)),
963
- classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams)),
1045
+ functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams, fnReturns)),
1046
+ classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams, fnReturns)),
964
1047
  };
965
1048
  }
@@ -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,6 +350,18 @@ 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);
@@ -397,6 +412,15 @@ function lowerExpr(e, binds) {
397
412
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
398
413
  method = "getDirect";
399
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
+ }
400
424
  // Check if any lambda arg has monadic body
401
425
  const needsMonadic = _opts.monadic && args.some(a => a.kind === "lambda" && isMonadicBody(a.body));
402
426
  const result = { kind: "methodCall", obj: recv, objTy: e.fn.obj.ty, method, args, monadic: needsMonadic };
@@ -479,6 +503,10 @@ function lowerExpr(e, binds) {
479
503
  });
480
504
  return { kind: "record", spread: lowerExpr(e.spread, binds), fields: loweredFields };
481
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
+ }
482
510
  return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
483
511
  }
484
512
  case "arrayLiteral":
@@ -497,19 +525,16 @@ function lowerExpr(e, binds) {
497
525
  case "exists":
498
526
  return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
499
527
  case "conditional": {
500
- // When narrowedExpr is set, the match replaces the condition don't lift from it
501
- const condBinds = (e.narrowedVar && e.narrowedExpr) ? null : binds;
502
- const cond = lowerExpr(e.cond, condBinds);
503
- let thenExpr = lowerExpr(e.then, binds);
504
- let elseExpr = lowerExpr(e.else, binds);
505
- // 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.
506
530
  if (e.narrowedVar && e.narrowedExpr) {
507
531
  const scrutinee = lowerExpr(e.narrowedExpr, binds);
508
532
  const bound = matchBinder(e.narrowedVar);
533
+ let thenExpr = lowerExpr(e.then, binds);
534
+ let elseExpr = lowerExpr(e.else, binds);
509
535
  if (bound !== e.narrowedVar) {
510
536
  thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
511
537
  }
512
- // Wrap in Some/None only when result is optional (one branch is undefined)
513
538
  if (e.ty.kind === "optional") {
514
539
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
515
540
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
@@ -522,14 +547,16 @@ function lowerExpr(e, binds) {
522
547
  ],
523
548
  };
524
549
  }
525
- // 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).
526
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);
527
556
  const bound = matchBinder(e.narrowedVar);
528
- // Replace the synthetic/narrowed var with the match-bound name
529
557
  if (bound !== e.narrowedVar) {
530
558
  thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
531
559
  }
532
- // The match produces an Optional: wrap branches in Some/None.
533
560
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
534
561
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
535
562
  return {
@@ -540,7 +567,26 @@ function lowerExpr(e, binds) {
540
567
  ],
541
568
  };
542
569
  }
543
- // 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);
544
590
  if (e.ty.kind === "optional") {
545
591
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
546
592
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
@@ -644,13 +690,13 @@ function transformStmts(stmts, typeDecls) {
644
690
  if (s.names.length === 1 && s.iterable.ty.kind === "map") {
645
691
  const keyName = s.names[0];
646
692
  const keyTy = s.nameTypes[0] ?? s.iterable.ty.key ?? { kind: "unknown" };
647
- const keysSeqName = `_${keyName}_keys`;
648
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
649
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
650
- const keysVar = { kind: "var", name: keysSeqName };
651
693
  const count = _forofCounters.get(keyName) ?? 0;
652
694
  _forofCounters.set(keyName, count + 1);
653
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 };
654
700
  const idxName = `_${keyName}_idx${suffix}`;
655
701
  const idx = { kind: "var", name: idxName };
656
702
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
@@ -670,13 +716,13 @@ function transformStmts(stmts, typeDecls) {
670
716
  const keyName = s.names[0], valueName = s.names[1];
671
717
  const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
672
718
  const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
673
- const keysSeqName = `_${keyName}_keys`;
674
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
675
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
676
- const keysVar = { kind: "var", name: keysSeqName };
677
719
  const count = _forofCounters.get(keyName) ?? 0;
678
720
  _forofCounters.set(keyName, count + 1);
679
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 };
680
726
  const idxName = `_${keyName}_idx${suffix}`;
681
727
  const idx = { kind: "var", name: idxName };
682
728
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
@@ -798,6 +844,21 @@ function transformStmt(s, typeDecls) {
798
844
  return stmts;
799
845
  }
800
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
+ }
801
862
  const { binds, expr } = liftMethodCalls(s.init);
802
863
  return [...binds, { kind: "let", name: s.name, type: s.ty, mutable: s.mutable, value: expr }];
803
864
  }
@@ -945,7 +1006,8 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
945
1006
  if (someBranch.length === 0 && restStmts && restStmts.length > 0) {
946
1007
  someBranch = restStmts;
947
1008
  }
948
- const bound = matchBinder(`${varName}_val`);
1009
+ const sanitized = varName.replace(/\./g, "_");
1010
+ const bound = matchBinder(`${sanitized}_val`);
949
1011
  // Replace the narrowed variable/field in the Some branch body.
950
1012
  // Field chains: replace in TStmt before transform (so downstream narrowing sees simple vars).
951
1013
  // Simple vars: replace in IR after transform (the original mechanism).
@@ -959,10 +1021,14 @@ function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr)
959
1021
  }
960
1022
  else {
961
1023
  const transformed = transformStmts(someBranch, typeDecls);
962
- 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)));
963
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;
964
1030
  return {
965
- kind: "match", scrutinee: varName,
1031
+ kind: "match", scrutinee,
966
1032
  arms: [
967
1033
  { pattern: `.some ${bound}`, body: someBody },
968
1034
  { pattern: ".none", body: noneBranch.length > 0 ? transformStmts(noneBranch, typeDecls) : [] },
@@ -975,25 +1041,21 @@ function mapStmtExprs(s, r) {
975
1041
  }
976
1042
  // ── Optional narrowing helpers ──────────────────────────────
977
1043
  //
978
- // Optional narrowing converts TS `if (x === undefined)` patterns to Dafny
979
- // `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 => ... }`.
980
1046
  //
981
- // The resolve phase (resolve.ts) handles:
1047
+ // The resolve phase (resolve.ts) handles TYPE narrowing only:
982
1048
  // - Flow narrowing: after `if (x === undefined) return`, x is non-optional
983
1049
  // - && narrowing: in `x !== undefined && f(x)`, f(x) sees x as non-optional
984
- // - Conditional narrowing: in `x !== undefined ? x.field : default`, sets
985
- // 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
986
1052
  //
987
- // The transform phase (here) handles:
1053
+ // The transform phase (here) handles ALL structural narrowing:
988
1054
  // - Statement-level: `transformStmts` detects optional checks → `emitOptionalMatch`
989
- // - Expression-level: `lowerExpr` conditional reads narrowedVar/narrowedExpr match
990
- // - && restructuring: `extractLeftmostOptional` splits `&&` chains into nested ifs
991
- // so `emitOptionalMatch` can detect the inner optional check
992
- //
993
- // Both phases detect `v !== undefined` patterns. The resolve phase uses
994
- // `detectOptionalCheck` (on RawExpr), the transform uses `parseOptionalCheck` (on TExpr).
995
- // These are separate because they operate on different IR types, but both handle
996
- // 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 }`
997
1059
  /** Shared logic for optional match in both imperative and pure function paths.
998
1060
  * Detects optional check, selects branches, handles early-return consumption.
999
1061
  * Returns null if the condition is not an optional check. */
@@ -1006,7 +1068,8 @@ function prepareOptionalMatch(s, restStmts) {
1006
1068
  // Early-return pattern: Some branch is empty → consume rest of block
1007
1069
  if (someBranch.length === 0 && restStmts.length > 0)
1008
1070
  someBranch = restStmts;
1009
- const bound = matchBinder(`${check.varName}_val`);
1071
+ const sanitized = check.varName.replace(/\./g, "_");
1072
+ const bound = matchBinder(`${sanitized}_val`);
1010
1073
  return { check, someBranch, noneBranch, bound };
1011
1074
  }
1012
1075
  /** Extract the leftmost optional check from a && chain, returning the check and the rest.
@@ -1120,6 +1183,96 @@ function replaceFieldAccessInTStmts(stmts, varName, fields) {
1120
1183
  fallbackTy: f.type ?? parseTsType(f.tsType),
1121
1184
  })));
1122
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
+ }
1123
1276
  // ── Pure function generation ─────────────────────────────────
1124
1277
  function transformPureBody(stmts, typeDecls) {
1125
1278
  // Detect discriminant if-chain
@@ -1140,16 +1293,42 @@ function transformPureBody(stmts, typeDecls) {
1140
1293
  return { kind: "let", name: s.name, value: transformExpr(s.init), body: restExpr };
1141
1294
  }
1142
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
+ }
1143
1306
  // Optional narrowing: if (x === undefined) → match x { None => ..., Some(x_val) => ... }
1144
1307
  const optMatch = prepareOptionalMatch(s, rest);
1145
1308
  if (optMatch) {
1146
- 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);
1147
1323
  if (!someExpr)
1148
1324
  return null;
1149
1325
  const noneExpr = transformPureBody(optMatch.noneBranch, typeDecls);
1150
1326
  if (!noneExpr)
1151
1327
  return null;
1152
- 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);
1153
1332
  return {
1154
1333
  kind: "match", scrutinee: optMatch.check.varName,
1155
1334
  arms: [
@@ -1158,11 +1337,13 @@ function transformPureBody(stmts, typeDecls) {
1158
1337
  ],
1159
1338
  };
1160
1339
  }
1161
- 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);
1162
1343
  if (!thenExpr)
1163
1344
  return null;
1164
- const elseBranch = s.else.length > 0 ? s.else : rest;
1165
- const elseExpr = transformPureBody(elseBranch, typeDecls);
1345
+ const elseStmts = s.else.length > 0 ? [...s.else, ...rest] : rest;
1346
+ const elseExpr = transformPureBody(elseStmts, typeDecls);
1166
1347
  if (!elseExpr)
1167
1348
  return null;
1168
1349
  return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
@@ -1293,17 +1474,38 @@ function findReassignedNames(stmts, names) {
1293
1474
  return found;
1294
1475
  }
1295
1476
  /** Replace all occurrences of a variable name with a new expression. */
1296
- 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);
1297
1486
  return mapExpr(e, x => {
1298
1487
  if (x.kind === "var" && x.name === name)
1299
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
+ }
1300
1502
  // Don't descend past bindings that shadow the name
1301
1503
  if (x.kind === "forall" && x.var === name)
1302
1504
  return x;
1303
1505
  if (x.kind === "exists" && x.var === name)
1304
1506
  return x;
1305
1507
  if (x.kind === "let" && x.name === name)
1306
- return { ...x, value: replaceVar(x.value, name, replacement) };
1508
+ return { ...x, value: replaceVar(x.value, name, replacement, narrowing) };
1307
1509
  return null;
1308
1510
  });
1309
1511
  }
@@ -1344,25 +1546,43 @@ export function transformModule(mod, specImport) {
1344
1546
  }));
1345
1547
  // Pure function mirrors
1346
1548
  const pureDefs = [];
1549
+ const defByMethods = [];
1347
1550
  for (const fn of mod.functions) {
1348
1551
  if (!fn.isPure)
1349
1552
  continue;
1350
1553
  const body = transformPureBody(fn.body, mod.typeDecls);
1351
- if (!body)
1352
- continue;
1353
- // For ensures, replace \result ( "res") with the function call
1354
- const fnCall = { kind: "app", fn: fn.name, args: fn.params.map(p => ({ kind: "var", name: p.name })) };
1355
- const ensures = fn.ensures.map(e => replaceVar(transformExpr(e), "res", fnCall));
1356
- pureDefs.push({
1357
- kind: "def",
1358
- name: fn.name,
1359
- typeParams: fn.typeParams,
1360
- params: fn.params.map(p => ({ name: p.name, type: p.ty })),
1361
- returnType: fn.returnTy,
1362
- requires: fn.requires.map(transformExpr),
1363
- ensures,
1364
- body,
1365
- });
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
+ }
1366
1586
  }
1367
1587
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
1368
1588
  // Types file
@@ -1381,7 +1601,8 @@ export function transformModule(mod, specImport) {
1381
1601
  }
1382
1602
  // Def file: Velvet methods
1383
1603
  // Pure functions get a thin wrapper that calls Pure.fnName
1384
- 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)]);
1385
1606
  const methods = mod.functions.map(fn => {
1386
1607
  const ensures = [];
1387
1608
  for (const e of fn.ensures) {
@@ -1448,7 +1669,7 @@ export function transformModule(mod, specImport) {
1448
1669
  { key: "loom.semantics.termination", value: '"total"' },
1449
1670
  { key: "loom.semantics.choice", value: '"demonic"' },
1450
1671
  ],
1451
- decls: [...constDecls, ...methods, ...classDecls],
1672
+ decls: [...constDecls, ...defByMethods, ...methods, ...classDecls],
1452
1673
  };
1453
1674
  return { typesFile, defFile };
1454
1675
  }