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.
package/README.md CHANGED
@@ -13,11 +13,14 @@ 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).
19
+ - **[github-star-checker-lemmascript](https://github.com/midspiral/github-star-checker-lemmascript/)** — small verified CLI that tracks GitHub star counts across repos and reports per-run deltas. Verifies: per-row diff correctness and `totalDiff == sumDiffs(rows)` (via an inductive `SumDiffs_append` lemma); three sign-classified extractors (gainers / losers / unchanged) with soundness, completeness, **ordered completeness** (gainers appear in the notification in the same order they were listed on the command line), and count/sum equalities against prefix-indexed `upTo` helpers; conservation theorem `decompose(r)` — the three splits partition every row exactly once, and `sumDiffs(increases) + sumDiffs(decreases) == totalDiff`. 33 Dafny VCs, 0 errors; proof additions include a head/tail bridge (`sumDiffs` ↔ `sumDiffsUpTo`) and two partition-on-n inductions. Dafny only.
20
+ - **[equality-game-lemmascript](https://github.com/midspiral/equality-game-lemmascript/)** — greenfield verified arithmetic equality card game (React + Tailwind). Sound + complete decision procedure for "can these two card lists be combined into equal expressions": `canEqualize(L, R) ⟺ ∃ eL, eR. eval(eL) == eval(eR) ∧ multiset(leaves(eL)) == multiset(L) ∧ same for R`. Algorithm is subset-DP over a bitmask `m ∈ [1, 2^n − 1)`; the proof composes a `PopCount` upper/lower bound chain (with stdlib `LemmaDivDenominator` / `LemmaFundamentalDivModConverse`), a `splitLeft`/`splitRight` ↔ imperative-loop connection, a `WitnessCombine` lemma threading existential `Expr` witnesses through the cross-product loops, and a `ChooseMask` combinatorial constructor that, given any sub-multiset of `cards`, produces the realizing mask. Capped by `CompletenessFromMaskCoverage`. **753 verification conditions, 0 errors, 0 `assume`s, 0 axioms** under `--isolate-assertions --verification-time-limit 180`. Dafny only.
18
21
  - **[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).
19
22
  - **[hono-lemmascript](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [hono](https://github.com/honojs/hono)'s security middleware. Two CVEs verified: IP restriction bypass ([CVE-2026-39409](https://github.com/honojs/hono/security/advisories/GHSA-3mpf-rcc7-5347)) and cookie name bypass ([CVE-2026-39410](https://github.com/honojs/hono/security/advisories/GHSA-r5rp-j6wh-rvv4)) — 51 Dafny lemmas. [Cookie verification done **in-place**](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/src/utils/cookie.ts#L79). Dafny only.
20
- - **[charmchat](https://github.com/CHARM-BDF/charmchat/tree/lemma)** — brownfield verification of an AI agent orchestration backend. `isEmptyResult` (string emptiness predicate, 8 postconditions, <1s) and `topologicalSort` (Kahn's algorithm — memory safety, output bounds, completeness via acyclicity ranking witness, termination; 5 helper lemmas, 28 loop invariants). Dafny only.
23
+ - **[charmchat](https://github.com/CHARM-BDF/charmchat/blob/lemma/README_LemmaScript.md)** — brownfield verification of an AI agent orchestration backend. `isEmptyResult` (string emptiness predicate, 8 postconditions, <1s) and `topologicalSort` (Kahn's algorithm — memory safety, output bounds, completeness via acyclicity ranking witness, termination). Full completeness proof: 23 helper lemmas, 14 opaque ghost predicates, 115 loop invariants; 736 VCs verified under `--isolate-assertions --verification-time-limit 600`. Key technique: snapshot-based inner invariants (`ghost var originalRemDeps := remDeps`) replace the mid-iteration SEEN/UNSEEN split so preservation is frame reasoning against a ghost-constant rather than set-subtraction against mutating state. Dafny only.
21
24
  - **[xyflow-lemmascript](https://github.com/midspiral/xyflow-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [xyflow](https://github.com/xyflow/xyflow)'s core edge and geometry utilities. 9 functions verified: `addEdge` (dedup — never loses edges, adds at most one), `reconnectEdge` (replace — bounded length), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Dafny only.
22
25
 
23
26
  ## Setup
@@ -59,7 +62,7 @@ The Dafny backend generates two files per TS source: `foo.dfy.gen` (always regen
59
62
  ### Lean backend
60
63
 
61
64
  ```sh
62
- npx lsc gen src/myModule.ts
65
+ npx lsc gen --backend=lean src/myModule.ts
63
66
  lake build
64
67
  ```
65
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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"
@@ -2,7 +2,7 @@
2
2
  * Dafny backend commands: gen, check, regen.
3
3
  */
4
4
  import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from "fs";
5
- import { execSync } from "child_process";
5
+ import { execFileSync } from "child_process";
6
6
  import path from "path";
7
7
  function writeGen(genPath, text) {
8
8
  writeFileSync(genPath, text);
@@ -18,27 +18,44 @@ export function dafnyGen(genPath, dfyPath, text) {
18
18
  export function dafnyCheckDiff(genPath, dfyPath) {
19
19
  if (!existsSync(dfyPath))
20
20
  return true;
21
+ let diff = "";
21
22
  try {
22
- const diff = execSync(`git diff --no-index -- "${genPath}" "${dfyPath}" 2>/dev/null || true`, { encoding: "utf-8" });
23
- const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
24
- if (deletions.length > 0) {
25
- console.error(`WARNING: ${path.basename(dfyPath)} has modifications to generated lines (not additions-only):`);
26
- for (const d of deletions.slice(0, 5))
27
- console.error(" " + d);
28
- return false;
23
+ diff = execFileSync("git", ["diff", "--no-index", "--", genPath, dfyPath], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
24
+ }
25
+ catch (e) {
26
+ // git diff exits 1 when files differ; stdout still holds the diff
27
+ if (e && e.stdout != null) {
28
+ diff = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf-8");
29
+ }
30
+ else {
31
+ return true;
29
32
  }
30
33
  }
31
- catch { /* diff not available */ }
34
+ const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
35
+ if (deletions.length > 0) {
36
+ console.error(`WARNING: ${path.basename(dfyPath)} has modifications to generated lines (not additions-only):`);
37
+ for (const d of deletions.slice(0, 5))
38
+ console.error(" " + d);
39
+ return false;
40
+ }
32
41
  return true;
33
42
  }
34
43
  export function dafnyVerify(dfyPath, dir, timeLimit, extraFlags) {
35
44
  console.log("Running dafny verify...");
36
45
  try {
37
46
  const content = readFileSync(dfyPath, "utf-8");
38
- const stdLibFlag = content.includes("Std.") ? " --standard-libraries" : "";
39
- const timeLimitFlag = timeLimit ? ` --verification-time-limit ${timeLimit}` : "";
40
- const extra = extraFlags ? ` ${extraFlags}` : "";
41
- execSync(`dafny verify${stdLibFlag}${timeLimitFlag}${extra} "${dfyPath}"`, { cwd: dir, stdio: "inherit" });
47
+ const args = ["verify"];
48
+ if (content.includes("Std."))
49
+ args.push("--standard-libraries");
50
+ if (timeLimit)
51
+ args.push("--verification-time-limit", String(timeLimit));
52
+ if (extraFlags) {
53
+ for (const tok of extraFlags.split(/\s+/))
54
+ if (tok)
55
+ args.push(tok);
56
+ }
57
+ args.push(dfyPath);
58
+ execFileSync("dafny", args, { cwd: dir, stdio: "inherit" });
42
59
  return true;
43
60
  }
44
61
  catch {
@@ -70,7 +87,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir) {
70
87
  const mergedPath = dfyPath + ".merged";
71
88
  console.log("Gen changed. Three-way merging...");
72
89
  try {
73
- execSync(`git merge-file "${dfyPath}" "${basePath}" "${genPath}"`, { stdio: "pipe" });
90
+ execFileSync("git", ["merge-file", dfyPath, basePath, genPath], { stdio: "pipe" });
74
91
  console.log(`Merged: ${path.basename(dfyPath)}`);
75
92
  }
76
93
  catch (e) {
@@ -47,6 +47,13 @@ function escapeName(name) {
47
47
  function paramList(params) {
48
48
  return params.map(p => `${escapeName(p.name)}: ${tyToDafny(p.type)}`).join(", ");
49
49
  }
50
+ /** Format a method signature header, omitting `returns` for void methods.
51
+ * Dafny's definite-assignment rule rejects unassigned out-parameters, so a
52
+ * `returns (res: ())` on a void method fails verification. */
53
+ function methodHeader(prefix, params, returnType) {
54
+ const sig = `${prefix}(${paramList(params)})`;
55
+ return returnType.kind === "void" ? sig : `${sig} returns (res: ${tyToDafny(returnType)})`;
56
+ }
50
57
  // ── Lean op → Dafny op ─────────────────────────────────────
51
58
  const OP_MAP = {
52
59
  "=": "==", "≠": "!=", "≥": ">=", "≤": "<=",
@@ -77,7 +84,12 @@ function emitExpr(e) {
77
84
  case "num": return `${e.value}`;
78
85
  case "bool": return e.value ? "true" : "false";
79
86
  case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
80
- case "constructor": return qualifyCtor(e.name, e.type);
87
+ case "constructor": {
88
+ const head = qualifyCtor(e.name, e.type);
89
+ if (!e.args || e.args.length === 0)
90
+ return head;
91
+ return `${head}(${e.args.map(emitExpr).join(", ")})`;
92
+ }
81
93
  case "arrayLiteral":
82
94
  if (e.elems.length === 0)
83
95
  return `[]`;
@@ -325,11 +337,13 @@ function emitExpr(e) {
325
337
  const vals = e.fields.map(f => emitExpr(f.value));
326
338
  return `${ctorName}(${vals.join(", ")})`;
327
339
  }
340
+ if (e.fields.length === 0)
341
+ return `map[]`;
328
342
  const vals = e.fields.map(f => emitExpr(f.value));
329
343
  return `(${vals.join(", ")})`;
330
344
  }
331
345
  case "if":
332
- return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
346
+ return `(if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)})`;
333
347
  case "match": {
334
348
  const scrut = emitScrutinee(e.scrutinee);
335
349
  const arms = e.arms.map(a => `case ${translatePattern(a.pattern)} => ${emitExpr(a.body)}`);
@@ -337,7 +351,7 @@ function emitExpr(e) {
337
351
  }
338
352
  case "forall": return emitQuantifier(e, "forall");
339
353
  case "exists": return emitQuantifier(e, "exists");
340
- case "let": return `var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)}`;
354
+ case "let": return `(var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)})`;
341
355
  case "havoc": return "*";
342
356
  }
343
357
  }
@@ -473,6 +487,8 @@ function emitDecl(d) {
473
487
  const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
474
488
  for (const r of d.requires)
475
489
  lines.push(` requires ${emitExpr(r)}`);
490
+ if (d.decreases)
491
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
476
492
  lines.push(`{`);
477
493
  lines.push(emitPureExpr(d.body, 1));
478
494
  lines.push(`}`);
@@ -491,9 +507,23 @@ function emitDecl(d) {
491
507
  }
492
508
  return lines.join("\n");
493
509
  }
510
+ case "def-by-method": {
511
+ const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
512
+ const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
513
+ for (const r of d.requires)
514
+ lines.push(` requires ${emitExpr(r)}`);
515
+ if (d.decreases)
516
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
517
+ lines.push(`{`);
518
+ lines.push(`}`);
519
+ lines.push(`by method {`);
520
+ lines.push(emitStmts(d.methodBody, 1));
521
+ lines.push(`}`);
522
+ return lines.join("\n");
523
+ }
494
524
  case "method": {
495
525
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
496
- const lines = [`method ${d.name}${tp}(${paramList(d.params)}) returns (res: ${tyToDafny(d.returnType)})`];
526
+ const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType)];
497
527
  for (const r of d.requires)
498
528
  lines.push(` requires ${emitExpr(r)}`);
499
529
  for (const e of d.ensures)
@@ -511,7 +541,7 @@ function emitDecl(d) {
511
541
  if (d.fields.length > 0 && d.methods.length > 0)
512
542
  lines.push("");
513
543
  for (const m of d.methods) {
514
- lines.push(` method ${m.name}(${paramList(m.params)}) returns (res: ${tyToDafny(m.returnType)})`);
544
+ lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
515
545
  for (const r of m.requires)
516
546
  lines.push(` requires ${emitExpr(r)}`);
517
547
  for (const e of m.ensures)
@@ -656,6 +686,7 @@ const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
656
686
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
657
687
  ensures forall x :: x in s <==> x in res
658
688
  ensures |res| == |s|
689
+ ensures forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
659
690
  {
660
691
  var remaining := s;
661
692
  res := [];
@@ -663,6 +694,7 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
663
694
  invariant remaining <= s
664
695
  invariant forall x :: x in res <==> (x in s && x !in remaining)
665
696
  invariant |res| + |remaining| == |s|
697
+ invariant forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
666
698
  decreases remaining
667
699
  {
668
700
  var x :| x in remaining;
@@ -760,26 +792,40 @@ function translatePattern(pattern) {
760
792
  export function emitDafnyFile(file, tsFileName) {
761
793
  buildRecordCtorMap(file.decls);
762
794
  _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
- }
795
+ // Track successfully emitted pure defs method wrappers are only
796
+ // skipped when the corresponding pure def was actually emitted.
797
+ const emittedPureDefs = new Set();
774
798
  // Emit declarations
775
799
  const declLines = [];
776
800
  const skipped = [];
777
801
  for (const decl of file.decls) {
778
- if (decl.kind === "method" && pureDefs.has(decl.name))
802
+ if (decl.kind === "method" && emittedPureDefs.has(decl.name))
779
803
  continue;
804
+ if (decl.kind === "namespace") {
805
+ // Emit each inner decl individually — if one fails, the rest survive
806
+ // and failed defs fall back to their method wrappers
807
+ for (const inner of decl.decls) {
808
+ try {
809
+ declLines.push("");
810
+ declLines.push(emitDecl(inner));
811
+ if (inner.kind === "def")
812
+ emittedPureDefs.add(inner.name);
813
+ }
814
+ catch (e) {
815
+ const name = "name" in inner ? inner.name : "unknown";
816
+ const msg = e.message;
817
+ console.error(`WARNING: skipping pure '${name}': ${msg}`);
818
+ declLines.push(`\n// LemmaScript: skipped pure ${name}`);
819
+ skipped.push(name);
820
+ }
821
+ }
822
+ continue;
823
+ }
780
824
  try {
781
825
  declLines.push("");
782
826
  declLines.push(emitDecl(decl));
827
+ if (decl.kind === "def-by-method")
828
+ emittedPureDefs.add(decl.name);
783
829
  }
784
830
  catch (e) {
785
831
  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) {
@@ -79,38 +86,54 @@ function extractExpr(node) {
79
86
  return { kind: "var", name: "this" };
80
87
  }
81
88
  // Property access: x.foo or x?.foo
89
+ // Each `?.` is its own short-circuit point — wrap the inner in a new optChain.
90
+ // Non-`?` continuation of an existing optChain extends the chain (no new
91
+ // short-circuit, just keep evaluating after the prior `?` succeeded).
82
92
  if (Node.isPropertyAccessExpression(node)) {
83
93
  const obj = extractExpr(node.getExpression());
84
94
  const field = node.getName();
85
- // Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
86
- // Skip if this is a method call (parent is CallExpression) — handled differently.
87
95
  if (node.hasQuestionDotToken()) {
88
- const parent = node.getParent();
89
- const isMethodCall = parent && Node.isCallExpression(parent) && parent.getExpression() === node;
90
- if (!isMethodCall) {
91
- return { kind: "conditional",
92
- cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
93
- then: { kind: "field", obj, field },
94
- else: { kind: "var", name: "undefined" },
95
- };
96
- }
96
+ return { kind: "optChain", obj, chain: [{ kind: "field", name: field }] };
97
+ }
98
+ if (obj.kind === "optChain") {
99
+ return { ...obj, chain: [...obj.chain, { kind: "field", name: field }] };
97
100
  }
98
101
  return { kind: "field", obj, field };
99
102
  }
100
- // Element access: arr[i]
103
+ // Element access: arr[i] or arr?.[i]
101
104
  if (Node.isElementAccessExpression(node)) {
102
105
  const arg = node.getArgumentExpression();
103
106
  if (!arg)
104
107
  throw new Error(`Missing index in element access: ${node.getText()}`);
105
- return { kind: "index", obj: extractExpr(node.getExpression()), idx: extractExpr(arg) };
108
+ const obj = extractExpr(node.getExpression());
109
+ const idx = extractExpr(arg);
110
+ if (node.hasQuestionDotToken()) {
111
+ return { kind: "optChain", obj, chain: [{ kind: "index", idx }] };
112
+ }
113
+ if (obj.kind === "optChain") {
114
+ return { ...obj, chain: [...obj.chain, { kind: "index", idx }] };
115
+ }
116
+ return { kind: "index", obj, idx };
106
117
  }
107
- // Call expression: f(a, b)
118
+ // Call expression: f(a, b), x?.foo() (`?.` on the property), or x?.() (`?.` on call)
108
119
  if (Node.isCallExpression(node)) {
109
- return {
110
- kind: "call",
111
- fn: extractExpr(node.getExpression()),
112
- args: node.getArguments().map(a => extractExpr(a)),
113
- };
120
+ // Object.fromEntries(map) → identity (Map IS Record in Dafny)
121
+ const callee = node.getExpression();
122
+ if (Node.isPropertyAccessExpression(callee) &&
123
+ callee.getExpression().getText() === "Object" &&
124
+ callee.getName() === "fromEntries" &&
125
+ node.getArguments().length === 1) {
126
+ return extractExpr(node.getArguments()[0]);
127
+ }
128
+ const fn = extractExpr(callee);
129
+ const args = node.getArguments().map(a => extractExpr(a));
130
+ if (node.hasQuestionDotToken()) {
131
+ return { kind: "optChain", obj: fn, chain: [{ kind: "call", args }] };
132
+ }
133
+ if (fn.kind === "optChain") {
134
+ return { ...fn, chain: [...fn.chain, { kind: "call", args }] };
135
+ }
136
+ return { kind: "call", fn, args };
114
137
  }
115
138
  // Binary expression: a + b, a === b, etc.
116
139
  if (Node.isBinaryExpression(node)) {
@@ -120,6 +143,10 @@ function extractExpr(node) {
120
143
  // This is an assignment expression; extract as binop for now
121
144
  return { kind: "binop", op: "=", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
122
145
  }
146
+ // Nullish coalescing: a ?? b — single-eval, narrow rewrites to someMatch
147
+ if (op === "??") {
148
+ return { kind: "nullish", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
149
+ }
123
150
  return { kind: "binop", op, left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
124
151
  }
125
152
  // Prefix unary: !x, -x
@@ -318,6 +345,17 @@ function collectAnnotations(node, body) {
318
345
  return [...own, ...parseAnnotations(body[0])];
319
346
  return own;
320
347
  }
348
+ /** Check for bare `//@ pure` annotation (no expression). */
349
+ function hasPureAnnotation(node, body) {
350
+ const nodes = body && body.length > 0 ? [node, body[0]] : [node];
351
+ for (const n of nodes) {
352
+ for (const range of n.getLeadingCommentRanges()) {
353
+ if (range.getText().trim() === "//@ pure")
354
+ return true;
355
+ }
356
+ }
357
+ return false;
358
+ }
321
359
  // ── Type declaration extraction ──────────────────────────────
322
360
  function extractTypeDecl(decl, extraDecls) {
323
361
  const name = decl.getName();
@@ -349,6 +387,30 @@ function extractTypeDecl(decl, extraDecls) {
349
387
  }
350
388
  }
351
389
  }
390
+ // Single-variant discriminated union: type X = | { kind: 'Foo', ... }
391
+ // TypeScript collapses single-member unions to their member type,
392
+ // so type.isUnion() returns false. Detect by checking the source text
393
+ // for union syntax '|' AND a string-literal discriminant field.
394
+ if (type.isObject() && !type.isIntersection()) {
395
+ const srcText = decl.getTypeNode()?.getText() ?? "";
396
+ if (srcText.includes("|")) {
397
+ const disc = findDiscriminant([type]);
398
+ if (disc) {
399
+ const tagProp = type.getProperty(disc);
400
+ const tagType = tagProp?.getTypeAtLocation(decl);
401
+ const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue()) : null;
402
+ if (tag) {
403
+ const fields = [];
404
+ for (const prop of type.getProperties()) {
405
+ if (prop.getName() === disc)
406
+ continue;
407
+ fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
408
+ }
409
+ return { name, typeParams: tpField, kind: "discriminated-union", discriminant: disc, variants: [{ name: tag, fields }] };
410
+ }
411
+ }
412
+ }
413
+ }
352
414
  if (type.isObject() || type.isIntersection())
353
415
  return extractRecord(name, type, decl, undefined, extraDecls);
354
416
  // Primitive type alias: type TaskId = number → alias
@@ -461,6 +523,13 @@ function typeToString(type) {
461
523
  const symbol = type.getSymbol() ?? type.getAliasSymbol();
462
524
  if (symbol) {
463
525
  const name = symbol.getName();
526
+ // Recover collapsed single-variant union alias via property fingerprint
527
+ if (name === "__type" && _collapsedUnionMap.size > 0) {
528
+ const props = type.getProperties().map(p => p.getName()).sort().join(",");
529
+ const alias = _collapsedUnionMap.get(props);
530
+ if (alias)
531
+ return alias;
532
+ }
464
533
  const typeArgs = type.getTypeArguments();
465
534
  if (typeArgs.length > 0) {
466
535
  return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
@@ -632,13 +701,29 @@ function extractStmts(stmts) {
632
701
  else {
633
702
  names.push("_");
634
703
  }
704
+ // Unwrap Object.entries(expr) / Object.values(expr) to bare map iteration
705
+ let iterableExpr = s.getExpression();
706
+ if (Node.isCallExpression(iterableExpr)) {
707
+ const callee = iterableExpr.getExpression();
708
+ if (Node.isPropertyAccessExpression(callee) &&
709
+ callee.getExpression().getText() === "Object") {
710
+ const method = callee.getName();
711
+ if ((method === "entries" || method === "values") && iterableExpr.getArguments().length === 1) {
712
+ iterableExpr = iterableExpr.getArguments()[0];
713
+ // Object.values with single name → prepend "_" so it looks like [_, v] destructuring
714
+ if (method === "values" && names.length === 1) {
715
+ names.unshift("_");
716
+ }
717
+ }
718
+ }
719
+ }
635
720
  const bodyNode = s.getStatement();
636
721
  const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
637
722
  const annots = collectAnnotations(s, bodyStmts);
638
723
  result.push({
639
724
  kind: "forof",
640
725
  names,
641
- iterable: extractExpr(s.getExpression()),
726
+ iterable: extractExpr(iterableExpr),
642
727
  invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
643
728
  doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
644
729
  body: extractStmts(bodyStmts),
@@ -864,6 +949,8 @@ function extractFunction(fn, parentAnnotations) {
864
949
  })(),
865
950
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
866
951
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
952
+ decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
953
+ pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
867
954
  typeAnnotations,
868
955
  body: extractedBody,
869
956
  line: fn.getStartLineNumber(),
@@ -907,6 +994,22 @@ export function extractModule(sourceFile) {
907
994
  typeDecls.push({ name, kind: "record", fields });
908
995
  }
909
996
  }
997
+ // Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
998
+ // TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
999
+ // the alias. We record a fingerprint (sorted property names) → alias name mapping.
1000
+ _collapsedUnionMap = new Map();
1001
+ for (const stmt of sourceFile.getStatements()) {
1002
+ if (Node.isTypeAliasDeclaration(stmt)) {
1003
+ const type = stmt.getType();
1004
+ if (!type.isUnion() && type.isObject() && !type.isIntersection()) {
1005
+ const srcText = stmt.getTypeNode()?.getText() ?? "";
1006
+ if (srcText.includes("|") && findDiscriminant([type])) {
1007
+ const props = type.getProperties().map(p => p.getName()).sort().join(",");
1008
+ _collapsedUnionMap.set(props, stmt.getName());
1009
+ }
1010
+ }
1011
+ }
1012
+ }
910
1013
  // Extract type declarations in source order to respect dependencies
911
1014
  // Skip types already declared via //@ declare-type
912
1015
  const declaredNames = new Set(typeDecls.map(d => d.name));
@@ -982,7 +1085,7 @@ export function extractModule(sourceFile) {
982
1085
  }
983
1086
  return false;
984
1087
  }
985
- const hasVerifyDirective = allFns.some(hasVerify);
1088
+ const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
986
1089
  const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
987
1090
  const functions = fnsToExtract.map(f => {
988
1091
  // For expression-body arrows, annotations come from the parent variable statement
@@ -1266,6 +1369,12 @@ export function extractModule(sourceFile) {
1266
1369
  }
1267
1370
  const sym = retType.getSymbol();
1268
1371
  if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
1372
+ // Try typeToString first — it resolves collapsed single-variant unions
1373
+ const resolved = typeToString(retType);
1374
+ if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
1375
+ fn.returnType = resolved;
1376
+ continue;
1377
+ }
1269
1378
  const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
1270
1379
  if (!knownTypes.has(synName)) {
1271
1380
  const extra = [];
@@ -75,6 +75,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
75
75
  return `${obj}.set! ${args[0]} ${args[1]}`;
76
76
  if (method === "push")
77
77
  return `Array.push ${obj} ${args[0]}`;
78
+ if (method === "concat")
79
+ return `Array.push ${obj} ${args[0]}`;
78
80
  }
79
81
  // String methods
80
82
  if (tyKind === "string") {
@@ -113,7 +115,17 @@ function emitExpr(e, parentPrec) {
113
115
  case "num": return `${e.value}`;
114
116
  case "bool": return e.value ? "true" : "false";
115
117
  case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
116
- case "constructor": return `.${e.name}`;
118
+ case "constructor": {
119
+ // With type: emit `Type.name` (unambiguous; needed in expression positions
120
+ // like `match ... | .none => Type.some x` where elaboration can't infer).
121
+ // Without type: emit `.name` (dotted form; works in pattern positions
122
+ // and where the expected type is clear from context).
123
+ const head = e.type ? `${e.type}.${e.name}` : `.${e.name}`;
124
+ if (!e.args || e.args.length === 0)
125
+ return head;
126
+ const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app" || a.kind === "methodCall") ? `(${emitExpr(a)})` : emitExpr(a));
127
+ return `${head} ${args.join(" ")}`;
128
+ }
117
129
  case "arrayLiteral":
118
130
  if (e.elems.length === 0)
119
131
  return `#[]`;
@@ -143,6 +155,13 @@ function emitExpr(e, parentPrec) {
143
155
  return `-${e.expr.value}`;
144
156
  return `(-${emitExpr(e.expr)})`;
145
157
  case "binop": {
158
+ // `k in m` (map/set membership) → `m.contains k` in Lean. Dafny has
159
+ // native `in`; Lean uses the method form for HashMap/HashSet.
160
+ if (e.op === "in") {
161
+ const recv = emitExpr(e.right);
162
+ const wrap = e.right.kind === "binop" || e.right.kind === "app" || e.right.kind === "methodCall";
163
+ return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
164
+ }
146
165
  const op = e.op === "arrayConcat" ? "++" : e.op;
147
166
  const s = `${emitExpr(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
148
167
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
@@ -182,8 +201,12 @@ function emitExpr(e, parentPrec) {
182
201
  case "if":
183
202
  return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
184
203
  case "match": {
204
+ // Always parenthesize inline matches — Lean parses alternatives greedily,
205
+ // so any token after an arm body (`→`, another match's `|`, etc.) would
206
+ // bleed into the last `.none` case without explicit bracketing.
185
207
  const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
186
- return `match ${typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee)} with ${arms.join(" ")}`;
208
+ const scrut = typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee);
209
+ return `(match ${scrut} with ${arms.join(" ")})`;
187
210
  }
188
211
  case "forall": return `∀ ${e.var} : ${tyToLean(e.type)}, ${emitExpr(e.body)}`;
189
212
  case "exists": return `∃ ${e.var} : ${tyToLean(e.type)}, ${emitExpr(e.body)}`;
@@ -191,6 +214,24 @@ function emitExpr(e, parentPrec) {
191
214
  default: throw new Error(`Unsupported Lean expression: ${e.kind}`);
192
215
  }
193
216
  }
217
+ /** True if the IR expression emits to a Prop-valued Lean term. `transformExpr`
218
+ * routes TS comparisons/logicals through `OP_MAP` (`===`→`=`, `&&`→`∧`, `!`→`¬`)
219
+ * so these top-level ops land in Prop. `in` stays Bool (emits `.contains`), and
220
+ * bare method calls / vars / field accesses remain at their declared type. */
221
+ function isPropValued(e) {
222
+ switch (e.kind) {
223
+ case "binop":
224
+ return ["=", "≠", "≥", "≤", ">", "<", "∧", "∨"].includes(e.op);
225
+ case "unop":
226
+ return e.op === "¬";
227
+ case "implies":
228
+ case "forall":
229
+ case "exists":
230
+ return true;
231
+ default:
232
+ return false;
233
+ }
234
+ }
194
235
  // ── Statement emission ──────────────────────────────────────
195
236
  function emitStmts(stmts, indent) {
196
237
  const pad = " ".repeat(indent);
@@ -207,7 +248,16 @@ function emitStmt(s, indent) {
207
248
  case "ghostLet":
208
249
  return `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`;
209
250
  case "ghostAssign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
210
- case "assert": return `${pad}assertGadget (${emitExpr(s.expr)})`;
251
+ case "assert": {
252
+ // WPGen.assert needs a Prop; bare Bool expressions (`k in m` → `.contains`,
253
+ // method calls, vars) lack a matching WPGen instance and silently fall back
254
+ // to WPGen.default, which drops the assertion. Coerce to Prop via `= true`.
255
+ // Top-level Prop constructs (`=`, `<`, `∧`, `¬`, `∀`, `∃`, `→`) already
256
+ // land in Prop — Lean auto-coerces inner Bools there.
257
+ const inner = emitExpr(s.expr);
258
+ const wrapped = isPropValued(s.expr) ? inner : `(${inner}) = true`;
259
+ return `${pad}assertGadget (${wrapped})`;
260
+ }
211
261
  case "bind": return `${pad}${escapeName(s.target)} ← ${emitExpr(s.value)}`;
212
262
  case "let-bind": return `${pad}let ${s.name} ← ${emitExpr(s.value)}`;
213
263
  case "return": return `${pad}return ${emitExpr(s.value)}`;
@@ -325,6 +375,8 @@ function emitDecl(d) {
325
375
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
326
376
  return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
327
377
  }
378
+ case "def-by-method":
379
+ throw new Error("function by method is not supported for Lean backend");
328
380
  case "method": {
329
381
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
330
382
  const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];