lemmascript 0.3.3 → 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
@@ -16,9 +16,11 @@ See the external case studies:
16
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.
17
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).
18
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.
19
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).
20
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.
21
- - **[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.
22
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.
23
25
 
24
26
  ## Setup
@@ -60,7 +62,7 @@ The Dafny backend generates two files per TS source: `foo.dfy.gen` (always regen
60
62
  ### Lean backend
61
63
 
62
64
  ```sh
63
- npx lsc gen src/myModule.ts
65
+ npx lsc gen --backend=lean src/myModule.ts
64
66
  lake build
65
67
  ```
66
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.3.3",
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": {
@@ -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 `[]`;
@@ -331,7 +343,7 @@ function emitExpr(e) {
331
343
  return `(${vals.join(", ")})`;
332
344
  }
333
345
  case "if":
334
- 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)})`;
335
347
  case "match": {
336
348
  const scrut = emitScrutinee(e.scrutinee);
337
349
  const arms = e.arms.map(a => `case ${translatePattern(a.pattern)} => ${emitExpr(a.body)}`);
@@ -339,7 +351,7 @@ function emitExpr(e) {
339
351
  }
340
352
  case "forall": return emitQuantifier(e, "forall");
341
353
  case "exists": return emitQuantifier(e, "exists");
342
- 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)})`;
343
355
  case "havoc": return "*";
344
356
  }
345
357
  }
@@ -511,7 +523,7 @@ function emitDecl(d) {
511
523
  }
512
524
  case "method": {
513
525
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
514
- 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)];
515
527
  for (const r of d.requires)
516
528
  lines.push(` requires ${emitExpr(r)}`);
517
529
  for (const e of d.ensures)
@@ -529,7 +541,7 @@ function emitDecl(d) {
529
541
  if (d.fields.length > 0 && d.methods.length > 0)
530
542
  lines.push("");
531
543
  for (const m of d.methods) {
532
- lines.push(` method ${m.name}(${paramList(m.params)}) returns (res: ${tyToDafny(m.returnType)})`);
544
+ lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
533
545
  for (const r of m.requires)
534
546
  lines.push(` requires ${emitExpr(r)}`);
535
547
  for (const e of m.ensures)
@@ -86,32 +86,36 @@ function extractExpr(node) {
86
86
  return { kind: "var", name: "this" };
87
87
  }
88
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).
89
92
  if (Node.isPropertyAccessExpression(node)) {
90
93
  const obj = extractExpr(node.getExpression());
91
94
  const field = node.getName();
92
- // Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
93
- // Skip if this is a method call (parent is CallExpression) — handled differently.
94
95
  if (node.hasQuestionDotToken()) {
95
- const parent = node.getParent();
96
- const isMethodCall = parent && Node.isCallExpression(parent) && parent.getExpression() === node;
97
- if (!isMethodCall) {
98
- return { kind: "conditional",
99
- cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
100
- then: { kind: "field", obj, field },
101
- else: { kind: "var", name: "undefined" },
102
- };
103
- }
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 }] };
104
100
  }
105
101
  return { kind: "field", obj, field };
106
102
  }
107
- // Element access: arr[i]
103
+ // Element access: arr[i] or arr?.[i]
108
104
  if (Node.isElementAccessExpression(node)) {
109
105
  const arg = node.getArgumentExpression();
110
106
  if (!arg)
111
107
  throw new Error(`Missing index in element access: ${node.getText()}`);
112
- 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 };
113
117
  }
114
- // Call expression: f(a, b)
118
+ // Call expression: f(a, b), x?.foo() (`?.` on the property), or x?.() (`?.` on call)
115
119
  if (Node.isCallExpression(node)) {
116
120
  // Object.fromEntries(map) → identity (Map IS Record in Dafny)
117
121
  const callee = node.getExpression();
@@ -121,11 +125,15 @@ function extractExpr(node) {
121
125
  node.getArguments().length === 1) {
122
126
  return extractExpr(node.getArguments()[0]);
123
127
  }
124
- return {
125
- kind: "call",
126
- fn: extractExpr(node.getExpression()),
127
- args: node.getArguments().map(a => extractExpr(a)),
128
- };
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 };
129
137
  }
130
138
  // Binary expression: a + b, a === b, etc.
131
139
  if (Node.isBinaryExpression(node)) {
@@ -135,6 +143,10 @@ function extractExpr(node) {
135
143
  // This is an assignment expression; extract as binop for now
136
144
  return { kind: "binop", op: "=", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
137
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
+ }
138
150
  return { kind: "binop", op, left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
139
151
  }
140
152
  // Prefix unary: !x, -x
@@ -1073,7 +1085,7 @@ export function extractModule(sourceFile) {
1073
1085
  }
1074
1086
  return false;
1075
1087
  }
1076
- const hasVerifyDirective = allFns.some(hasVerify);
1088
+ const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
1077
1089
  const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
1078
1090
  const functions = fnsToExtract.map(f => {
1079
1091
  // For expression-body arrows, annotations come from the parent variable statement
@@ -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)}`;
package/tools/dist/lsc.js CHANGED
@@ -9,7 +9,9 @@ import { existsSync } from "fs";
9
9
  import path from "path";
10
10
  import { extractModule } from "./extract.js";
11
11
  import { resolveModule } from "./resolve.js";
12
+ import { narrowModule } from "./narrow.js";
12
13
  import { transformModuleLean, transformModuleDafny } from "./transform.js";
14
+ import { peepholeModule } from "./peephole.js";
13
15
  import { emitLeanFile } from "./lean-emit.js";
14
16
  import { emitDafnyFile } from "./dafny-emit.js";
15
17
  import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
@@ -81,12 +83,17 @@ function main() {
81
83
  return;
82
84
  }
83
85
  // Resolve: Raw IR → Typed IR
84
- const typed = resolveModule(raw);
86
+ const resolved = resolveModule(raw);
87
+ // Narrow: Typed IR → Typed IR (rewrites optional-narrowing patterns to someMatch)
88
+ const typed = narrowModule(resolved);
85
89
  const dir = path.dirname(absPath);
86
90
  const base = path.basename(filePath, ".ts");
87
91
  // ── Dafny backend ─────────────────────────────────────────
88
92
  if (backend === "dafny") {
89
- const { typesFile, defFile } = transformModuleDafny(typed);
93
+ let { typesFile, defFile } = transformModuleDafny(typed);
94
+ if (typesFile)
95
+ typesFile = peepholeModule(typesFile, "dafny");
96
+ defFile = peepholeModule(defFile, "dafny");
90
97
  const allDecls = [...(typesFile?.decls ?? []), ...defFile.decls];
91
98
  const merged = { ...defFile, decls: allDecls };
92
99
  const text = emitDafnyFile(merged, path.basename(filePath));
@@ -121,7 +128,10 @@ function main() {
121
128
  // ── Lean backend ──────────────────────────────────────────
122
129
  const specPath = path.join(dir, `${base}.spec.lean`);
123
130
  const specImport = existsSync(specPath) ? `«${base}.spec»` : undefined;
124
- const { typesFile, defFile } = transformModuleLean(typed, specImport);
131
+ let { typesFile, defFile } = transformModuleLean(typed, specImport);
132
+ if (typesFile)
133
+ typesFile = peepholeModule(typesFile, "lean");
134
+ defFile = peepholeModule(defFile, "lean");
125
135
  const typesPath = typesFile ? path.join(dir, `${base}.types.lean`) : null;
126
136
  const typesText = typesFile ? emitLeanFile(typesFile) : null;
127
137
  const defPath = path.join(dir, `${base}.def.lean`);