lemmascript 0.3.2 → 0.4.0

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.
@@ -21,10 +21,10 @@ function mapExpr(e, f) {
21
21
  case "num":
22
22
  case "bool":
23
23
  case "str":
24
- case "constructor":
25
24
  case "emptyMap":
26
25
  case "emptySet":
27
26
  case "havoc": return e;
27
+ case "constructor": return e.args ? { ...e, args: e.args.map(r) } : e;
28
28
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
29
29
  case "unop": return { ...e, expr: r(e.expr) };
30
30
  case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
@@ -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) };
@@ -87,6 +90,15 @@ function mapTExpr(e, f) {
87
90
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
88
91
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
89
92
  case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
93
+ case "optChain": return { ...e, obj: r(e.obj),
94
+ chain: e.chain.map(s => s.kind === "call" ? { ...s, args: s.args.map(r) }
95
+ : s.kind === "index" ? { ...s, idx: r(s.idx) }
96
+ : s) };
97
+ case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
98
+ case "someMatch": return { ...e, scrutinee: r(e.scrutinee), someBody: r(e.someBody), noneBody: r(e.noneBody) };
99
+ case "tagMatch": return { ...e, scrutinee: r(e.scrutinee),
100
+ cases: e.cases.map(c => ({ ...c, body: r(c.body) })),
101
+ fallthrough: e.fallthrough ? r(e.fallthrough) : null };
90
102
  case "forall": return { ...e, body: r(e.body) };
91
103
  case "exists": return { ...e, body: r(e.body) };
92
104
  case "lambda": return e;
@@ -110,6 +122,10 @@ function mapTStmt(s, f) {
110
122
  case "ghostLet": return { ...s, init: r(s.init) };
111
123
  case "ghostAssign": return { ...s, value: r(s.value) };
112
124
  case "assert": return { ...s, expr: r(s.expr) };
125
+ case "someMatch": return { ...s, scrutinee: r(s.scrutinee), someBody: s.someBody.map(t => mapTStmt(t, f)), noneBody: s.noneBody.map(t => mapTStmt(t, f)) };
126
+ case "tagMatch": return { ...s, scrutinee: r(s.scrutinee),
127
+ cases: s.cases.map(c => ({ ...c, body: c.body.map(t => mapTStmt(t, f)) })),
128
+ fallthrough: s.fallthrough.map(t => mapTStmt(t, f)) };
113
129
  }
114
130
  }
115
131
  export const LEAN_OPTIONS = {
@@ -183,18 +199,26 @@ function transformExpr(e) { return lowerExpr(e, null); }
183
199
  /** Wrap an expression in Some/None for optional-typed conditionals.
184
200
  * If the raw TExpr is `undefined`, emit `.none`; otherwise wrap in `Some`. */
185
201
  function wrapOptionalBranch(expr, raw) {
186
- return (raw.kind === "var" && raw.name === "undefined")
187
- ? { kind: "constructor", name: ".none" }
188
- : { kind: "app", fn: "Some", args: [expr] };
202
+ // Set type: "Option" so Lean emits `Option.some`/`Option.none` (qualified).
203
+ // The dotted form `.some`/`.none` would be ambiguous in expression positions
204
+ // like the scrutinee of an outer match. Dafny treats `Option.Some` and bare
205
+ // `Some` equivalently — the qualification is harmless there.
206
+ if (raw.kind === "var" && raw.name === "undefined")
207
+ return { kind: "constructor", name: "none", type: "Option" };
208
+ if (raw.ty.kind === "optional")
209
+ return expr; // already Option<T>, don't double-wrap
210
+ return { kind: "constructor", name: "some", type: "Option", args: [expr] };
189
211
  }
190
212
  function lowerExpr(e, binds) {
191
- // Monadic lifting: extract embedded method calls to let-binds
192
- // Pass binds through to args so nested method calls are also lifted
193
- if (binds && e.kind === "call" && e.callKind === "method") {
213
+ // Monadic lifting: extract embedded method calls to let-binds.
214
+ // `callKind: "method"` means a global var-fn call (classifyCall returns
215
+ // "method" only for `fn.kind === "var"`). Receiver method calls have
216
+ // callKind "unknown" and fall through to the regular case below where
217
+ // they become `methodCall`.
218
+ if (binds && e.kind === "call" && e.callKind === "method" && e.fn.kind === "var") {
194
219
  const name = `_t${_liftCounter++}`;
195
- const fn = e.fn.kind === "var" ? e.fn.name : `${lowerExpr(e.fn, binds)}`;
196
220
  const args = e.args.map(a => lowerExpr(a, binds));
197
- binds.push({ kind: "let-bind", name, value: { kind: "app", fn, args } });
221
+ binds.push({ kind: "let-bind", name, value: { kind: "app", fn: e.fn.name, args } });
198
222
  return { kind: "var", name };
199
223
  }
200
224
  switch (e.kind) {
@@ -347,11 +371,27 @@ function lowerExpr(e, binds) {
347
371
  return { kind: "field", obj: transformExpr(e.obj), field: "length" };
348
372
  if (e.field === "size" && (e.obj.ty.kind === "map" || e.obj.ty.kind === "set"))
349
373
  return { kind: "field", obj: transformExpr(e.obj), field: "collectionSize" };
374
+ // Boolean discriminant bare access: `result.ok` where Result has variants
375
+ // {ok: true, ...} | {ok: false, ...}. String discriminants are always used via
376
+ // comparison (x.kind === 'Foo' → x.Foo?), but boolean discriminants are used
377
+ // as bare truthiness checks. Emit as the Dafny discriminator predicate for the
378
+ // 'true' variant: result.ok → result.true_?
379
+ if (e.isDiscriminant && e.obj.ty.kind === "user") {
380
+ const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
381
+ const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
382
+ if (decl?.variants?.some(v => v.name === "true")) {
383
+ return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
384
+ }
385
+ }
350
386
  return { kind: "field", obj: transformExpr(e.obj), field: e.field };
351
387
  case "index": {
352
388
  const idx = transformExpr(e.idx);
353
389
  if (e.obj.ty.kind === "map") {
354
- return { kind: "methodCall", obj: transformExpr(e.obj), objTy: e.obj.ty, method: "get", args: [idx], monadic: false };
390
+ // Mirrors the .get() .getDirect switch at line ~453: when resolve has
391
+ // narrowed the index type to non-optional (via `k in m` atoms in scope),
392
+ // emit direct access; otherwise keep the Option-producing `get`.
393
+ const method = e.ty.kind !== "optional" ? "getDirect" : "get";
394
+ return { kind: "methodCall", obj: transformExpr(e.obj), objTy: e.obj.ty, method, args: [idx], monadic: false };
355
395
  }
356
396
  const wrappedIdx = isArray(e.obj.ty) && !isNat(e.idx.ty) ? { kind: "toNat", expr: idx } : idx;
357
397
  return { kind: "index", arr: transformExpr(e.obj), idx: wrappedIdx };
@@ -393,10 +433,23 @@ function lowerExpr(e, binds) {
393
433
  return { kind: "toNat", expr: lowered };
394
434
  return lowered;
395
435
  });
436
+ // arr.concat(otherArr): array argument → real concatenation, not push
437
+ if (method === "concat" && e.fn.obj.ty.kind === "array" && e.args.length === 1 && e.args[0].ty.kind === "array") {
438
+ return { kind: "binop", op: "arrayConcat", left: recv, right: args[0] };
439
+ }
396
440
  // Spec-context map get: result type is non-optional → direct access
397
441
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
398
442
  method = "getDirect";
399
443
  }
444
+ // map.set(k, v): if v is an Optional-wrapped map get, unwrap to getDirect
445
+ // (the desugared spread { ...m, [k]: m2[k] } becomes m.set(k, m2.get(k)),
446
+ // but the value should be direct access, not Optional)
447
+ if (method === "set" && e.fn.obj.ty.kind === "map" && args.length === 2) {
448
+ const val = args[1];
449
+ if (val.kind === "methodCall" && val.method === "get" && val.objTy.kind === "map") {
450
+ args[1] = { ...val, method: "getDirect" };
451
+ }
452
+ }
400
453
  // Check if any lambda arg has monadic body
401
454
  const needsMonadic = _opts.monadic && args.some(a => a.kind === "lambda" && isMonadicBody(a.body));
402
455
  const result = { kind: "methodCall", obj: recv, objTy: e.fn.obj.ty, method, args, monadic: needsMonadic };
@@ -479,6 +532,10 @@ function lowerExpr(e, binds) {
479
532
  });
480
533
  return { kind: "record", spread: lowerExpr(e.spread, binds), fields: loweredFields };
481
534
  }
535
+ // Empty record with map type → empty map
536
+ if (e.fields.length === 0 && !e.spread && e.ty.kind === "map") {
537
+ return { kind: "emptyMap" };
538
+ }
482
539
  return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
483
540
  }
484
541
  case "arrayLiteral":
@@ -497,56 +554,21 @@ function lowerExpr(e, binds) {
497
554
  case "exists":
498
555
  return { kind: "exists", var: e.var, type: e.varTy, body: transformExpr(e.body) };
499
556
  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);
557
+ const cond = lowerExpr(e.cond, binds);
503
558
  let thenExpr = lowerExpr(e.then, binds);
504
559
  let elseExpr = lowerExpr(e.else, binds);
505
- // Explicit !== undefined with narrowedExpr → match Some/None on the optional expression
506
- if (e.narrowedVar && e.narrowedExpr) {
507
- const scrutinee = lowerExpr(e.narrowedExpr, binds);
508
- const bound = matchBinder(e.narrowedVar);
509
- if (bound !== e.narrowedVar) {
510
- thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
511
- }
512
- // Wrap in Some/None only when result is optional (one branch is undefined)
513
- if (e.ty.kind === "optional") {
514
- thenExpr = wrapOptionalBranch(thenExpr, e.then);
515
- elseExpr = wrapOptionalBranch(elseExpr, e.else);
516
- }
517
- return {
518
- kind: "match", scrutinee,
519
- arms: [
520
- { pattern: `.some ${bound}`, body: thenExpr },
521
- { pattern: ".none", body: elseExpr },
522
- ],
523
- };
524
- }
525
- // Optional cond with narrowedVar → match Some/None (truthiness)
526
- if (e.narrowedVar && e.cond.ty.kind === "optional") {
527
- const bound = matchBinder(e.narrowedVar);
528
- // Replace the synthetic/narrowed var with the match-bound name
529
- if (bound !== e.narrowedVar) {
530
- thenExpr = replaceVar(thenExpr, e.narrowedVar, { kind: "var", name: bound });
531
- }
532
- // The match produces an Optional: wrap branches in Some/None.
533
- thenExpr = wrapOptionalBranch(thenExpr, e.then);
534
- elseExpr = wrapOptionalBranch(elseExpr, e.else);
535
- return {
536
- kind: "match", scrutinee: cond,
537
- arms: [
538
- { pattern: `.some ${bound}`, body: thenExpr },
539
- { pattern: ".none", body: elseExpr },
540
- ],
541
- };
542
- }
543
- // Non-optional: regular if with optional wrapping
544
560
  if (e.ty.kind === "optional") {
545
561
  thenExpr = wrapOptionalBranch(thenExpr, e.then);
546
562
  elseExpr = wrapOptionalBranch(elseExpr, e.else);
547
563
  }
548
564
  return { kind: "if", cond, then: thenExpr, else: elseExpr };
549
565
  }
566
+ case "optChain":
567
+ // Narrow should have rewritten optChain to someMatch.
568
+ throw new Error(`optChain reached transform — narrow should have rewritten it`);
569
+ case "nullish":
570
+ // Narrow should have rewritten nullish to someMatch.
571
+ throw new Error(`nullish reached transform — narrow should have rewritten it`);
550
572
  case "havoc":
551
573
  // Dafny's * only works in var/assign positions — lift to own declaration
552
574
  if (binds) {
@@ -555,6 +577,41 @@ function lowerExpr(e, binds) {
555
577
  return { kind: "var", name };
556
578
  }
557
579
  return { kind: "havoc", type: e.ty };
580
+ case "someMatch": {
581
+ let someBody;
582
+ let scrutinee;
583
+ const path = asTAccessPath(e.scrutinee);
584
+ if (path) {
585
+ // Pure access path (var or any depth of obj.f.g.h) — substitute the
586
+ // path with the binder pre-lowering.
587
+ const replaced = replacePathInTExpr(e.someBody, path, e.binder, e.binderTy);
588
+ someBody = lowerExpr(replaced, binds);
589
+ scrutinee = path.fields.length === 0 ? path.rootVar : lowerExpr(e.scrutinee, binds);
590
+ }
591
+ else {
592
+ // Complex scrutinee — narrow pre-bound the someBody to use the binder directly,
593
+ // so no substitution needed. Used by optChain rewrites.
594
+ someBody = lowerExpr(e.someBody, binds);
595
+ scrutinee = lowerExpr(e.scrutinee, binds);
596
+ }
597
+ let noneBody = lowerExpr(e.noneBody, binds);
598
+ if (e.ty.kind === "optional") {
599
+ someBody = wrapOptionalBranch(someBody, e.someBody);
600
+ noneBody = wrapOptionalBranch(noneBody, e.noneBody);
601
+ }
602
+ return {
603
+ kind: "match", scrutinee,
604
+ arms: [
605
+ { pattern: `.some ${e.binder}`, body: someBody },
606
+ { pattern: ".none", body: noneBody },
607
+ ],
608
+ };
609
+ }
610
+ case "tagMatch":
611
+ // Spec/expr-position tagMatch — narrow shouldn't produce these (it only
612
+ // emits stmt-form tagMatch from if-chain detection on stmts). If reached,
613
+ // bug in narrow.
614
+ throw new Error(`tagMatch reached lowerExpr — narrow only emits stmt-form tagMatch`);
558
615
  }
559
616
  }
560
617
  function flattenImpl(e) {
@@ -614,27 +671,6 @@ function transformStmts(stmts, typeDecls) {
614
671
  let i = 0;
615
672
  while (i < stmts.length) {
616
673
  const s = stmts[i];
617
- // Detect discriminant if-chain → match
618
- if (s.kind === "if") {
619
- const chain = detectDiscriminantChain(stmts.slice(i));
620
- if (chain) {
621
- result.push(emitMatchStmt(chain.chain, typeDecls));
622
- i += chain.consumed;
623
- continue;
624
- }
625
- // Detect optional check → match on Some/None
626
- const optMatch = prepareOptionalMatch(s, stmts.slice(i + 1));
627
- if (optMatch) {
628
- result.push(emitOptionalMatch(optMatch.check.varName, optMatch.check.negated, s, typeDecls, stmts.slice(i + 1), optMatch.check.fieldExpr));
629
- // If rest was consumed into the Some branch, skip remaining
630
- const origSome = optMatch.check.negated ? s.else : s.then;
631
- if (origSome.length === 0 && i + 1 < stmts.length) {
632
- return result;
633
- }
634
- i++;
635
- continue;
636
- }
637
- }
638
674
  // Transform for-of → for-in over range
639
675
  if (s.kind === "forof") {
640
676
  const varName = s.names[0];
@@ -644,13 +680,13 @@ function transformStmts(stmts, typeDecls) {
644
680
  if (s.names.length === 1 && s.iterable.ty.kind === "map") {
645
681
  const keyName = s.names[0];
646
682
  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
683
  const count = _forofCounters.get(keyName) ?? 0;
652
684
  _forofCounters.set(keyName, count + 1);
653
685
  const suffix = count === 0 ? "" : `${count + 1}`;
686
+ const keysSeqName = `_${keyName}_keys${suffix}`;
687
+ const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
688
+ result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
689
+ const keysVar = { kind: "var", name: keysSeqName };
654
690
  const idxName = `_${keyName}_idx${suffix}`;
655
691
  const idx = { kind: "var", name: idxName };
656
692
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
@@ -670,13 +706,13 @@ function transformStmts(stmts, typeDecls) {
670
706
  const keyName = s.names[0], valueName = s.names[1];
671
707
  const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
672
708
  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
709
  const count = _forofCounters.get(keyName) ?? 0;
678
710
  _forofCounters.set(keyName, count + 1);
679
711
  const suffix = count === 0 ? "" : `${count + 1}`;
712
+ const keysSeqName = `_${keyName}_keys${suffix}`;
713
+ const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
714
+ result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
715
+ const keysVar = { kind: "var", name: keysSeqName };
680
716
  const idxName = `_${keyName}_idx${suffix}`;
681
717
  const idx = { kind: "var", name: idxName };
682
718
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
@@ -828,15 +864,19 @@ function transformStmt(s, typeDecls) {
828
864
  const { binds, expr } = liftMethodCalls(s.expr);
829
865
  return [...binds, { kind: "assign", target: receiver, value: expr }];
830
866
  }
831
- // Optional chaining on map.get: m.get(k)?.push(v) → if k in m { m[k] := m[k] + [v] }
832
- if (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
833
- s.expr.fn.obj.kind === "call" && s.expr.fn.obj.fn.kind === "field" &&
834
- s.expr.fn.obj.fn.obj.ty.kind === "map" && s.expr.fn.obj.fn.field === "get" &&
835
- s.expr.fn.field === "push") {
836
- const mapExpr = s.expr.fn.obj.fn.obj;
867
+ // Optional chaining on map.get at statement level: m.get(k)?.push(v)
868
+ // → if k in m { m[k] := m[k] + [v] } (actual mutation, not value-discard).
869
+ // Narrow rewrote this to a someMatch — destructure to find the underlying
870
+ // m.get(k) scrutinee and the .push(v) body call.
871
+ if (s.expr.kind === "someMatch" &&
872
+ s.expr.scrutinee.kind === "call" && s.expr.scrutinee.fn.kind === "field" &&
873
+ s.expr.scrutinee.fn.field === "get" && s.expr.scrutinee.fn.obj.ty.kind === "map" &&
874
+ s.expr.someBody.kind === "call" && s.expr.someBody.fn.kind === "field" &&
875
+ s.expr.someBody.fn.field === "push") {
876
+ const mapExpr = s.expr.scrutinee.fn.obj;
837
877
  const mapName = mapExpr.kind === "var" ? mapExpr.name : undefined;
838
- const keyExpr = lowerExpr(s.expr.fn.obj.args[0], null);
839
- const pushArg = lowerExpr(s.expr.args[0], null);
878
+ const keyExpr = lowerExpr(s.expr.scrutinee.args[0], null);
879
+ const pushArg = lowerExpr(s.expr.someBody.args[0], null);
840
880
  if (mapName) {
841
881
  const mapVar = { kind: "var", name: mapName };
842
882
  const directGet = { kind: "methodCall", obj: mapVar, objTy: mapExpr.ty, method: "getDirect", args: [keyExpr], monadic: false };
@@ -850,18 +890,7 @@ function transformStmt(s, typeDecls) {
850
890
  return [...binds, { kind: "assign", target: "_", value: expr }];
851
891
  }
852
892
  case "if": {
853
- // Restructure && with optional check: extract the leftmost optional check
854
- // from a && chain and nest the rest inside. Handles left-associative chains:
855
- // if ((x !== undefined && b) && c) → if (x !== undefined) { if (b && c) { ... } }
856
- if (s.cond.kind === "binop" && s.cond.op === "&&" && s.else.length === 0) {
857
- const extracted = extractLeftmostOptional(s.cond);
858
- if (extracted) {
859
- const innerIf = { kind: "if", cond: extracted.rest, then: s.then, else: [] };
860
- const outerIf = { kind: "if", cond: extracted.optCond, then: [innerIf], else: [] };
861
- return transformStmts([outerIf], typeDecls);
862
- }
863
- }
864
- // Lift from condition only (Lean rule: don't lift from branches)
893
+ // Lift from condition only (Lean rule: don't lift from branches).
865
894
  const { binds, expr: cond } = liftMethodCalls(s.cond);
866
895
  return [...binds, { kind: "if", cond, then: transformStmts(s.then, typeDecls), else: transformStmts(s.else, typeDecls) }];
867
896
  }
@@ -886,178 +915,34 @@ function transformStmt(s, typeDecls) {
886
915
  return [{ kind: "ghostAssign", target: s.target, value: transformExpr(s.value) }];
887
916
  case "assert":
888
917
  return [{ kind: "assert", expr: transformExpr(s.expr) }];
889
- }
890
- }
891
- function detectDiscriminantChain(stmts) {
892
- if (stmts.length === 0 || stmts[0].kind !== "if")
893
- return null;
894
- const first = parseDiscriminantCond(stmts[0].cond);
895
- if (!first)
896
- return null;
897
- const cases = [];
898
- // Follow else branches within one if-else-if tree
899
- function collectElse(s) {
900
- const p = parseDiscriminantCond(s.cond);
901
- if (!p || p.varName !== first.varName)
902
- return [s];
903
- cases.push({ variant: p.variant, body: s.then });
904
- if (s.else.length === 0)
905
- return [];
906
- if (s.else.length === 1 && s.else[0].kind === "if")
907
- return collectElse(s.else[0]);
908
- return s.else;
909
- }
910
- // Walk consecutive top-level ifs on the same discriminant
911
- let consumed = 0;
912
- for (let i = 0; i < stmts.length; i++) {
913
- const s = stmts[i];
914
- if (s.kind !== "if")
915
- break;
916
- const p = parseDiscriminantCond(s.cond);
917
- if (!p || p.varName !== first.varName)
918
- break;
919
- cases.push({ variant: p.variant, body: s.then });
920
- consumed = i + 1;
921
- if (s.else.length > 0) {
922
- const ft = (s.else.length === 1 && s.else[0].kind === "if") ? collectElse(s.else[0]) : s.else;
923
- return cases.length > 0 ? { chain: { ...first, cases, fallthrough: ft }, consumed } : null;
918
+ case "someMatch": {
919
+ const path = asTAccessPath(s.scrutinee);
920
+ if (path) {
921
+ const replaced = replacePathInTStmts(s.someBody, path, s.binder, s.binderTy);
922
+ const someBody = transformStmts(replaced, typeDecls);
923
+ const noneBody = transformStmts(s.noneBody, typeDecls);
924
+ const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
925
+ return [{
926
+ kind: "match", scrutinee,
927
+ arms: [
928
+ { pattern: `.some ${s.binder}`, body: someBody },
929
+ { pattern: ".none", body: noneBody },
930
+ ],
931
+ }];
932
+ }
933
+ throw new Error(`someMatch stmt scrutinee must be a pure access path, got ${s.scrutinee.kind}`);
934
+ }
935
+ case "tagMatch": {
936
+ const varName = s.scrutinee.kind === "var" ? s.scrutinee.name : "?";
937
+ const chain = { varName, typeName: s.typeName, cases: s.cases, fallthrough: s.fallthrough };
938
+ return [emitMatchStmt(chain, typeDecls)];
924
939
  }
925
940
  }
926
- if (cases.length === 0)
927
- return null;
928
- return { chain: { ...first, cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
929
- }
930
- function parseDiscriminantCond(cond) {
931
- // Pattern: x.discriminant === "variant"
932
- if (cond.kind !== "binop" || cond.op !== "===" || cond.right.kind !== "str")
933
- return null;
934
- if (cond.left.kind !== "field" || !cond.left.isDiscriminant)
935
- return null;
936
- if (cond.left.obj.kind !== "var" || cond.left.obj.ty.kind !== "user")
937
- return null;
938
- return { varName: cond.left.obj.name, typeName: cond.left.obj.ty.name, variant: cond.right.value };
939
- }
940
- function emitOptionalMatch(varName, negated, s, typeDecls, restStmts, fieldExpr) {
941
- let someBranch = negated ? s.else : s.then;
942
- const noneBranch = negated ? s.then : s.else;
943
- // Early-return pattern: if (x === undefined) { return ... } — Some branch is empty,
944
- // so include remaining statements as the Some body
945
- if (someBranch.length === 0 && restStmts && restStmts.length > 0) {
946
- someBranch = restStmts;
947
- }
948
- const bound = matchBinder(`${varName}_val`);
949
- // Replace the narrowed variable/field in the Some branch body.
950
- // Field chains: replace in TStmt before transform (so downstream narrowing sees simple vars).
951
- // Simple vars: replace in IR after transform (the original mechanism).
952
- let someBody;
953
- if (fieldExpr && fieldExpr.kind === "field" && fieldExpr.obj.kind === "var") {
954
- const innerTy = fieldExpr.ty.kind === "optional" ? fieldExpr.ty.inner : fieldExpr.ty;
955
- const replaced = replaceFieldsInTStmts(someBranch, fieldExpr.obj.name, [
956
- { fieldName: fieldExpr.field, newName: bound, fallbackTy: innerTy },
957
- ]);
958
- someBody = transformStmts(replaced, typeDecls);
959
- }
960
- else {
961
- const transformed = transformStmts(someBranch, typeDecls);
962
- someBody = transformed.map(stmt => mapStmtExprs(stmt, e => replaceVar(e, varName, { kind: "var", name: bound })));
963
- }
964
- return {
965
- kind: "match", scrutinee: varName,
966
- arms: [
967
- { pattern: `.some ${bound}`, body: someBody },
968
- { pattern: ".none", body: noneBranch.length > 0 ? transformStmts(noneBranch, typeDecls) : [] },
969
- ],
970
- };
971
941
  }
972
942
  /** Apply an expression transform to all expressions in a statement (convenience wrapper). */
973
943
  function mapStmtExprs(s, r) {
974
944
  return mapStmt(s, e => r(e));
975
945
  }
976
- // ── Optional narrowing helpers ──────────────────────────────
977
- //
978
- // Optional narrowing converts TS `if (x === undefined)` patterns to Dafny
979
- // `match x { Some(val) => ..., None => ... }`.
980
- //
981
- // The resolve phase (resolve.ts) handles:
982
- // - Flow narrowing: after `if (x === undefined) return`, x is non-optional
983
- // - && 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
986
- //
987
- // The transform phase (here) handles:
988
- // - 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.
997
- /** Shared logic for optional match in both imperative and pure function paths.
998
- * Detects optional check, selects branches, handles early-return consumption.
999
- * Returns null if the condition is not an optional check. */
1000
- function prepareOptionalMatch(s, restStmts) {
1001
- const check = parseOptionalCheck(s.cond);
1002
- if (!check)
1003
- return null;
1004
- let someBranch = check.negated ? s.else : s.then;
1005
- const noneBranch = check.negated ? s.then : (s.else.length > 0 ? s.else : restStmts);
1006
- // Early-return pattern: Some branch is empty → consume rest of block
1007
- if (someBranch.length === 0 && restStmts.length > 0)
1008
- someBranch = restStmts;
1009
- const bound = matchBinder(`${check.varName}_val`);
1010
- return { check, someBranch, noneBranch, bound };
1011
- }
1012
- /** Extract the leftmost optional check from a && chain, returning the check and the rest.
1013
- * (x !== undefined && b) && c → { optCond: x !== undefined, rest: b && c } */
1014
- function extractLeftmostOptional(cond) {
1015
- if (cond.kind !== "binop" || cond.op !== "&&")
1016
- return null;
1017
- const check = parseOptionalCheck(cond.left);
1018
- if (check && !check.negated)
1019
- return { optCond: cond.left, rest: cond.right };
1020
- if (cond.left.kind === "binop" && cond.left.op === "&&") {
1021
- const inner = extractLeftmostOptional(cond.left);
1022
- if (inner)
1023
- return { optCond: inner.optCond, rest: { ...cond, left: inner.rest } };
1024
- }
1025
- return null;
1026
- }
1027
- /** Detect `v !== undefined` or `undefined !== v` where v has optional type.
1028
- * Also handles field access chains like `obj.field !== undefined`.
1029
- * When `fieldExpr` is returned, callers must use field-aware replacement. */
1030
- function parseOptionalCheck(cond) {
1031
- if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "==="))
1032
- return null;
1033
- let varExpr = null;
1034
- if (cond.right.kind === "var" && cond.right.name === "undefined")
1035
- varExpr = cond.left;
1036
- if (cond.left.kind === "var" && cond.left.name === "undefined")
1037
- varExpr = cond.right;
1038
- if (!varExpr)
1039
- return null;
1040
- if (varExpr.kind === "var" && varExpr.ty.kind === "optional") {
1041
- return { varName: varExpr.name, negated: cond.op === "===" };
1042
- }
1043
- if (varExpr.kind === "field" && varExpr.ty.kind === "optional") {
1044
- // Serialize field chain as a dotted name for use as match scrutinee
1045
- const chain = serializeFieldChain(varExpr);
1046
- if (chain)
1047
- return { varName: chain, negated: cond.op === "===", fieldExpr: varExpr };
1048
- }
1049
- return null;
1050
- }
1051
- /** Serialize a field access chain to a dotted variable path, or null if not a simple chain. */
1052
- function serializeFieldChain(e) {
1053
- if (e.kind === "var")
1054
- return e.name;
1055
- if (e.kind === "field") {
1056
- const parent = serializeFieldChain(e.obj);
1057
- return parent ? `${parent}.${e.field}` : null;
1058
- }
1059
- return null;
1060
- }
1061
946
  /** Build match arms from variant cases — shared by imperative and pure paths.
1062
947
  * Looks up variant fields from typeDecls, builds patterns via buildMatchPattern,
1063
948
  * and delegates body transformation to the caller-provided function.
@@ -1079,10 +964,32 @@ function buildMatchArms(cases, varName, typeName, typeDecls, transformBody) {
1079
964
  function emitMatchStmt(chain, typeDecls) {
1080
965
  const cases = chain.cases.map(c => ({ name: c.variant, body: c.body }));
1081
966
  const arms = buildMatchArms(cases, chain.varName, chain.typeName, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1082
- if (chain.fallthrough.length > 0)
1083
- arms.push({ pattern: "_", body: transformStmts(chain.fallthrough, typeDecls) });
967
+ if (chain.fallthrough.length > 0) {
968
+ const remaining = remainingVariant(chain, typeDecls);
969
+ if (remaining) {
970
+ // Exactly one variant left — destructure so the fallthrough body can
971
+ // access variant-specific fields (Lean requires this; Dafny tolerates `_`).
972
+ const pattern = buildMatchPattern(remaining.name, remaining.fields, chain.varName);
973
+ const body = transformStmts(replaceFieldAccessInTStmts(chain.fallthrough, chain.varName, remaining.fields), typeDecls);
974
+ arms.push({ pattern, body });
975
+ }
976
+ else {
977
+ arms.push({ pattern: "_", body: transformStmts(chain.fallthrough, typeDecls) });
978
+ }
979
+ }
1084
980
  return { kind: "match", scrutinee: chain.varName, arms };
1085
981
  }
982
+ /** If the chain has matched all variants but one, return that remaining variant. */
983
+ function remainingVariant(chain, typeDecls) {
984
+ const decl = typeDecls.find(d => d.name === chain.typeName);
985
+ if (!decl?.variants)
986
+ return null;
987
+ const matched = new Set(chain.cases.map(c => c.variant));
988
+ const remaining = decl.variants.filter(v => !matched.has(v.name));
989
+ if (remaining.length !== 1)
990
+ return null;
991
+ return remaining[0];
992
+ }
1086
993
  function emitSwitchStmt(s, typeDecls) {
1087
994
  const varName = s.expr.kind === "var" ? s.expr.name : "?";
1088
995
  const typeName = s.expr.ty.kind === "user" ? s.expr.ty.name : undefined;
@@ -1092,10 +999,7 @@ function emitSwitchStmt(s, typeDecls) {
1092
999
  arms.push({ pattern: "_", body: transformStmts(s.defaultBody, typeDecls) });
1093
1000
  return { kind: "match", scrutinee: varName, arms };
1094
1001
  }
1095
- /** Replace obj.field → replacement var in typed IR (before transform).
1096
- * Used by discriminant match/switch and optional match to rewrite field accesses
1097
- * into simple variables before the transform phase, so downstream narrowing
1098
- * (parseOptionalCheck, extractLeftmostOptional) sees simple variable references.
1002
+ /** Replace obj.field → replacement var in typed IR.
1099
1003
  * Uses the TExpr's resolved type when available, falling back to `fallbackTy`. */
1100
1004
  function replaceFieldsInTStmts(stmts, objName, replacements) {
1101
1005
  if (replacements.length === 0)
@@ -1120,13 +1024,71 @@ function replaceFieldAccessInTStmts(stmts, varName, fields) {
1120
1024
  fallbackTy: f.type ?? parseTsType(f.tsType),
1121
1025
  })));
1122
1026
  }
1027
+ /** Replace obj.field → replacement var in typed IR expressions (before lowering).
1028
+ * Mirrors replaceFieldsInTStmts but operates on a single TExpr tree. */
1029
+ function replaceFieldInTExpr(expr, objName, replacements) {
1030
+ if (replacements.length === 0)
1031
+ return expr;
1032
+ return mapTExpr(expr, e => {
1033
+ if (e.kind === "field" && e.obj.kind === "var" && e.obj.name === objName) {
1034
+ const r = replacements.find(r => r.fieldName === e.field);
1035
+ if (r) {
1036
+ const ty = e.ty.kind !== "unknown" ? e.ty : r.fallbackTy;
1037
+ return { kind: "var", name: r.newName, ty };
1038
+ }
1039
+ }
1040
+ return null;
1041
+ });
1042
+ }
1043
+ function asTAccessPath(e) {
1044
+ if (e.kind === "var")
1045
+ return { rootVar: e.name, fields: [] };
1046
+ if (e.kind === "field") {
1047
+ const inner = asTAccessPath(e.obj);
1048
+ if (!inner)
1049
+ return null;
1050
+ return { rootVar: inner.rootVar, fields: [...inner.fields, e.field] };
1051
+ }
1052
+ return null;
1053
+ }
1054
+ /** Does TExpr `e` match the given access path exactly? */
1055
+ function matchesAccessPath(e, path) {
1056
+ const collected = [];
1057
+ let cur = e;
1058
+ while (cur.kind === "field") {
1059
+ collected.unshift(cur.field);
1060
+ cur = cur.obj;
1061
+ }
1062
+ if (cur.kind !== "var" || cur.name !== path.rootVar)
1063
+ return false;
1064
+ if (collected.length !== path.fields.length)
1065
+ return false;
1066
+ return collected.every((f, i) => f === path.fields[i]);
1067
+ }
1068
+ /** Replace every TExpr matching `path` with `var(binder, binderTy)`. */
1069
+ function replacePathInTExpr(expr, path, binder, binderTy) {
1070
+ return mapTExpr(expr, e => matchesAccessPath(e, path)
1071
+ ? { kind: "var", name: binder, ty: binderTy } : null);
1072
+ }
1073
+ function replacePathInTStmts(stmts, path, binder, binderTy) {
1074
+ return stmts.map(s => mapTStmt(s, e => matchesAccessPath(e, path)
1075
+ ? { kind: "var", name: binder, ty: binderTy } : null));
1076
+ }
1077
+ /** Unwrap optional type on match-bound variables in TExpr.
1078
+ * After replaceFieldInTExpr, the replaced variable carries the original optional
1079
+ * type from the field declaration. The match binding unwraps it to the inner type. */
1080
+ function fixBoundType(expr, boundName) {
1081
+ return mapTExpr(expr, e => e.kind === "var" && e.name === boundName && e.ty.kind === "optional"
1082
+ ? { ...e, ty: e.ty.inner } : null);
1083
+ }
1123
1084
  // ── Pure function generation ─────────────────────────────────
1124
1085
  function transformPureBody(stmts, typeDecls) {
1125
- // Detect discriminant if-chain
1126
- if (stmts.length > 0 && stmts[0].kind === "if") {
1127
- const chain = detectDiscriminantChain(stmts);
1128
- if (chain)
1129
- return transformPureMatch(chain.chain, typeDecls);
1086
+ // tagMatch (from narrow's discriminant detection) is the leading stmt and consumes the rest.
1087
+ if (stmts.length > 0 && stmts[0].kind === "tagMatch") {
1088
+ const t = stmts[0];
1089
+ const varName = t.scrutinee.kind === "var" ? t.scrutinee.name : "?";
1090
+ const chain = { varName, typeName: t.typeName, cases: t.cases, fallthrough: t.fallthrough };
1091
+ return transformPureMatch(chain, typeDecls);
1130
1092
  }
1131
1093
  for (let i = 0; i < stmts.length; i++) {
1132
1094
  const s = stmts[i];
@@ -1140,34 +1102,39 @@ function transformPureBody(stmts, typeDecls) {
1140
1102
  return { kind: "let", name: s.name, value: transformExpr(s.init), body: restExpr };
1141
1103
  }
1142
1104
  case "if": {
1143
- // Optional narrowing: if (x === undefined) match x { None => ..., Some(x_val) => ... }
1144
- const optMatch = prepareOptionalMatch(s, rest);
1145
- if (optMatch) {
1146
- const someExpr = transformPureBody(optMatch.someBranch, typeDecls);
1105
+ // Append rest to both branches so nested ifs that fall through
1106
+ // can reach the continuation (e.g. early return inside then-branch).
1107
+ const thenExpr = transformPureBody([...s.then, ...rest], typeDecls);
1108
+ if (!thenExpr)
1109
+ return null;
1110
+ const elseStmts = s.else.length > 0 ? [...s.else, ...rest] : rest;
1111
+ const elseExpr = transformPureBody(elseStmts, typeDecls);
1112
+ if (!elseExpr)
1113
+ return null;
1114
+ return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
1115
+ }
1116
+ case "switch": return transformPureSwitch(s, typeDecls);
1117
+ case "someMatch": {
1118
+ const path = asTAccessPath(s.scrutinee);
1119
+ if (path) {
1120
+ const replaced = replacePathInTStmts(s.someBody, path, s.binder, s.binderTy);
1121
+ const someExpr = transformPureBody([...replaced, ...rest], typeDecls);
1147
1122
  if (!someExpr)
1148
1123
  return null;
1149
- const noneExpr = transformPureBody(optMatch.noneBranch, typeDecls);
1124
+ const noneExpr = transformPureBody([...s.noneBody, ...rest], typeDecls);
1150
1125
  if (!noneExpr)
1151
1126
  return null;
1152
- const someReplaced = replaceVar(someExpr, optMatch.check.varName, { kind: "var", name: optMatch.bound });
1127
+ const scrutinee = path.fields.length === 0 ? path.rootVar : transformExpr(s.scrutinee);
1153
1128
  return {
1154
- kind: "match", scrutinee: optMatch.check.varName,
1129
+ kind: "match", scrutinee,
1155
1130
  arms: [
1156
- { pattern: `.some ${optMatch.bound}`, body: someReplaced },
1131
+ { pattern: `.some ${s.binder}`, body: someExpr },
1157
1132
  { pattern: ".none", body: noneExpr },
1158
1133
  ],
1159
1134
  };
1160
1135
  }
1161
- const thenExpr = transformPureBody(s.then, typeDecls);
1162
- if (!thenExpr)
1163
- return null;
1164
- const elseBranch = s.else.length > 0 ? s.else : rest;
1165
- const elseExpr = transformPureBody(elseBranch, typeDecls);
1166
- if (!elseExpr)
1167
- return null;
1168
- return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
1136
+ throw new Error(`someMatch pure-body scrutinee must be a pure access path, got ${s.scrutinee.kind}`);
1169
1137
  }
1170
- case "switch": return transformPureSwitch(s, typeDecls);
1171
1138
  default: return null;
1172
1139
  }
1173
1140
  }
@@ -1217,10 +1184,22 @@ function transformPureMatch(chain, typeDecls) {
1217
1184
  const decl = typeDecls.find(d => d.name === chain.typeName);
1218
1185
  const allCovered = decl?.variants && chain.cases.length >= decl.variants.length;
1219
1186
  if (chain.fallthrough.length > 0 && !allCovered) {
1220
- const body = transformPureBody(chain.fallthrough, typeDecls);
1221
- if (!body)
1222
- return null;
1223
- arms.push({ pattern: "_", body });
1187
+ const remaining = remainingVariant(chain, typeDecls);
1188
+ if (remaining) {
1189
+ // Exactly one variant left — destructure for variant-specific field access.
1190
+ let body = transformPureBody(chain.fallthrough, typeDecls);
1191
+ if (!body)
1192
+ return null;
1193
+ if (remaining.fields.length > 0)
1194
+ body = replaceFieldAccess(body, chain.varName, remaining.fields);
1195
+ arms.push({ pattern: buildMatchPattern(remaining.name, remaining.fields, chain.varName), body });
1196
+ }
1197
+ else {
1198
+ const body = transformPureBody(chain.fallthrough, typeDecls);
1199
+ if (!body)
1200
+ return null;
1201
+ arms.push({ pattern: "_", body });
1202
+ }
1224
1203
  }
1225
1204
  return { kind: "match", scrutinee: chain.varName, arms };
1226
1205
  }
@@ -1293,17 +1272,38 @@ function findReassignedNames(stmts, names) {
1293
1272
  return found;
1294
1273
  }
1295
1274
  /** Replace all occurrences of a variable name with a new expression. */
1296
- function replaceVar(e, name, replacement) {
1275
+ /**
1276
+ * Replace all occurrences of variable `name` with `replacement`.
1277
+ * If `narrowing` is true, the replacement is an unwrapped Optional value
1278
+ * (e.g., replacing `x: Option<T>` with `x_val: T`). In that case, when the
1279
+ * variable appears directly as a record spread field value, it's wrapped in
1280
+ * Some() to preserve the field's Optional type.
1281
+ */
1282
+ function replaceVar(e, name, replacement, narrowing) {
1283
+ const rec = (expr) => replaceVar(expr, name, replacement, narrowing);
1297
1284
  return mapExpr(e, x => {
1298
1285
  if (x.kind === "var" && x.name === name)
1299
1286
  return replacement;
1287
+ // Record spread: wrap direct variable uses in field values with Some when narrowing
1288
+ if (narrowing && x.kind === "record" && x.spread) {
1289
+ return {
1290
+ ...x,
1291
+ spread: rec(x.spread),
1292
+ fields: x.fields.map(f => {
1293
+ if (f.value.kind === "var" && f.value.name === name) {
1294
+ return { ...f, value: { kind: "app", fn: "Some", args: [replacement] } };
1295
+ }
1296
+ return { ...f, value: rec(f.value) };
1297
+ }),
1298
+ };
1299
+ }
1300
1300
  // Don't descend past bindings that shadow the name
1301
1301
  if (x.kind === "forall" && x.var === name)
1302
1302
  return x;
1303
1303
  if (x.kind === "exists" && x.var === name)
1304
1304
  return x;
1305
1305
  if (x.kind === "let" && x.name === name)
1306
- return { ...x, value: replaceVar(x.value, name, replacement) };
1306
+ return { ...x, value: replaceVar(x.value, name, replacement, narrowing) };
1307
1307
  return null;
1308
1308
  });
1309
1309
  }
@@ -1344,25 +1344,43 @@ export function transformModule(mod, specImport) {
1344
1344
  }));
1345
1345
  // Pure function mirrors
1346
1346
  const pureDefs = [];
1347
+ const defByMethods = [];
1347
1348
  for (const fn of mod.functions) {
1348
1349
  if (!fn.isPure)
1349
1350
  continue;
1350
1351
  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
- });
1352
+ if (body) {
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
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1365
+ body,
1366
+ });
1367
+ }
1368
+ else if (fn.forcePure) {
1369
+ // //@ pure but body can't be auto-converted — emit function by method
1370
+ _forofCounters.clear();
1371
+ const methodBody = transformStmts(fn.body, mod.typeDecls);
1372
+ defByMethods.push({
1373
+ kind: "def-by-method",
1374
+ name: fn.name,
1375
+ typeParams: fn.typeParams,
1376
+ params: fn.params.map(p => ({ name: p.name, type: p.ty })),
1377
+ returnType: fn.returnTy,
1378
+ requires: fn.requires.map(transformExpr),
1379
+ ensures: fn.ensures.map(transformExpr),
1380
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1381
+ methodBody,
1382
+ });
1383
+ }
1366
1384
  }
1367
1385
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
1368
1386
  // Types file
@@ -1381,7 +1399,8 @@ export function transformModule(mod, specImport) {
1381
1399
  }
1382
1400
  // Def file: Velvet methods
1383
1401
  // Pure functions get a thin wrapper that calls Pure.fnName
1384
- const pureDefNames = new Set(pureDefs.map(d => d.name));
1402
+ // def-by-method functions also skip their method wrappers
1403
+ const pureDefNames = new Set([...pureDefs.map(d => d.name), ...defByMethods.map(d => d.name)]);
1385
1404
  const methods = mod.functions.map(fn => {
1386
1405
  const ensures = [];
1387
1406
  for (const e of fn.ensures) {
@@ -1448,7 +1467,7 @@ export function transformModule(mod, specImport) {
1448
1467
  { key: "loom.semantics.termination", value: '"total"' },
1449
1468
  { key: "loom.semantics.choice", value: '"demonic"' },
1450
1469
  ],
1451
- decls: [...constDecls, ...methods, ...classDecls],
1470
+ decls: [...constDecls, ...defByMethods, ...methods, ...classDecls],
1452
1471
  };
1453
1472
  return { typesFile, defFile };
1454
1473
  }