lemmascript 0.5.4 → 0.5.6

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
@@ -24,7 +24,7 @@ See the external case studies:
24
24
  - **[talktimer-lemmascript](https://github.com/midspiral/talktimer-lemmascript/)** — verified talk timer React app, ported from a Dafny-only [`talktimer-lemmafit`](https://github.com/midspiral/talktimer-lemmafit/) twin. 17-variant `Action` state machine + verified `History` (undo/redo/preview/commitFrom) all in one `domain.ts` — the original Dafny's `Domain refines Kernel` abstract-module pattern inlined since LS has no abstract modules. 108 VCs in `domain.dfy` (invariant preservation) + 123 in `domain.proofs.dfy` (behavioral lemmas + Kernel round-trip). Dafny only.
25
25
  - **[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).
26
26
  - **[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. Four CVEs covered: 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 **in-place**](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/src/utils/cookie.ts#L79); plus `serveStatic`'s URL-encoded directory traversal ([CVE-2024-32869](https://github.com/honojs/hono/security/advisories/GHSA-q5w7-8mq6-2hxq)) + repeated-slash bypass ([CVE-2026-39407](https://github.com/honojs/hono/security/advisories/GHSA-jw53-c2g8-vmwm)), proved as a *composition* — `decode(rawPath)` before `check(decoded)`, so a buggy implementation that reordered the steps would fail the proof. First use of `//@ assume` + `//@ havoc`-on-assign. Dafny only.
27
- - **[hono-rate-limiter-with-lemmascript](https://github.com/midspiral/hono-rate-limiter-with-lemmascript)** — a greenfield verified feature shipped through a brownfield host's *middleware seam*: a standalone [Hono](https://hono.dev) rate-limit middleware whose admission decision is a machine-checked theorem. Every fixed-window "N per minute" limiter leaks the same way — a client fires `limit` requests just before a boundary and `limit` more just after, a 2× burst the docs swear can't happen. The verified core proves the crisp guarantee people actually want: over an integer-millisecond admission log, `admit` keeps `|log| ≤ limit` and window-faithful, and folding it over **any** monotone request stream (`run`) keeps the cumulative log `Spread` — so **no half-open window `(s, s+W]` ever admits more than `limit`** (`SlidingWindowBound`); the boundary burst is not mitigated but *impossible*. The naive cousin is refuted in the same file: `FixedWindowLeaks` exhibits a concrete trace where a fixed-window counter admits `2 × limit` in one sliding window while the verified `admit` rejects the overflow. An invariant-over-a-stateful-core proof (not a one-shot algorithm), wired into a live Hono server that gates real HTTP. 23 Dafny VCs, 0 errors; the clock's monotonicity and the per-key store's atomicity are the named trust boundary. Dafny only.
27
+ - **[hono-rate-limiter-with-lemmascript](https://github.com/midspiral/hono-rate-limiter-with-lemmascript)** — a greenfield verified feature shipped through a brownfield host's *middleware seam*: a standalone [Hono](https://hono.dev) sliding-window rate-limit middleware whose admission decision is a machine-checked theorem. `SlidingWindowBound` proves no half-open window `(s, s+W]` ever admits more than `limit`, so the boundary-straddling burst that every fixed-window limiter leaks is *impossible* (the naive cousin is refuted in the same file, `FixedWindowLeaks`); the proof is wired into a live Hono server, with the clock's monotonicity and the per-key store's atomicity as the named trust boundary. 23 Dafny VCs, 0 errors. Dafny only.
28
28
  - **[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.
29
29
  - **[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` (semantic: under a unique-id precondition, the result is in-place — `|result| ≤ |edges|`, no insertion — *and* when a matching edge existed with non-empty new endpoints, the output contains an edge with those endpoints. Uses `//@ assume` to characterize destructuring, `find`, and the constructed edge), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Also adds a new verified feature — a **DAG connection gate**: `canReach` decides reachability soundly *and* completely, and `wouldCreateCycle` gates both new connections and edge reconnections so the graph is proven to stay acyclic, with `isAcyclic` (sound + complete) establishing the base case and a topological-rank witness giving a safe evaluation order (+29 obligations), shown live in a React Flow demo that refuses cycle-closing edges — extending the case study from verifying existing code to adding a verified feature. Dafny only.
30
30
  - **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
@@ -36,6 +36,7 @@ See the external case studies:
36
36
  - **[quota-lemmascript](https://github.com/midspiral/quota-lemmascript)** — greenfield verified booking app: providers publish a page of limited-capacity *featured slots*, signed-in users grab them (React + Cloudflare Durable Objects + D1). A deliberate **inverse** of [quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript): where Quorum counts *up to* a threshold over data partitioned **per participant** (no conflicts ⇒ optimistic, lock-free, no rollback), Quota's bookings **contend** for shared inventory, so the load-bearing fact flips from a count to a **bound** — for every slot `j`, `confirmedCount(bookings, j) <= slots[j].capacity` — and so does the concurrency story: the *same* `domain.ts` runs in the browser and the Durable Object, but **server-authoritatively** (it never oversells under contention; no optimistic client apply). Proven: no overbooking (invariant preservation across `tryBook` / cancel), accept-iff-room, an **idempotent three-way `tryBook`** (a retry reads as success, not rejection — only "confirmed" mutates), cancellation frees seats, replay determinism, and full order-invariance of availability under contention — `confirmedCountPerm` / `hasRoomPermInvariant` show availability depends only on the *multiset* of the booking log (any reordering, not just a pairwise swap), via the same `perm(...)` predicate Quorum drove into LemmaScript. NDJSON export is built on the verified `confirmedOnly`. The counting kernel is Quorum's `countFree` re-pointed at bookings — total and precondition-free so it composes. 80 Dafny VCs, 0 errors. Trust boundary (stated plainly): auth, the React UI, WebSocket/DO/D1 I/O, email, slot date/time labeling, and abuse/rate-limiting. Dafny only.
37
37
  - **[henri-lemmascript](https://github.com/midspiral/henri-lemmascript)** — a runnable TypeScript coding-agent CLI (a port of [henri](https://github.com/metareflection/henri/); multi-provider, Anthropic + AWS Bedrock) whose security- and protocol-critical core is verified and **imported directly by the live agent** — `decide()` gates every real tool call and the conversation invariant is asserted on every turn (it streams end-to-end against Bedrock). Three modules, 48 Dafny VCs, 0 errors. (1) **Permission gate**: soundness (`decide == Allow ⟺ isAllowed`), **path-traversal containment** — auto-allow-in-cwd can never resolve outside cwd, with `.`/`..` normalization proved in-core so the shell is trusted only to `resolve().split('/')` — grant monotonicity, and `rejectPrompts` is deny-only. (2) **Conversation protocol**: tool-call/result pairing plus the [pi-lemmascript](https://github.com/midspiral/pi-lemmascript)-style **no-orphaned-`tool_result`** property, proved as an invariant *preserved by the loop* — `wellFormed(msgs + [assistant(calls), tool(makeResults(calls))])` — not checked after the fact. (3) **Hook/config merge**: removal, **tool-name uniqueness — a fix** (henri concatenated hook tool lists with no dedup, so two hooks could shadow a name), order-independence, and additivity composed **cross-module** with the gate's monotonicity (merging only grows the allow-sets, which by P3 never revokes an `Allow`). The merge is verified **in place** via `//@ declare-type Tool { name: string }`, shadowing the real `Tool`'s function-valued `execute` so the actual `mergeTools(Tool[])` is the proof target rather than a parallel model. Dafny only.
38
38
  - **[eslint-plugin-with-lemmascript](https://github.com/midspiral/eslint-plugin-with-lemmascript)** — a greenfield verified feature shipped through a brownfield host's *extension API*: a real, npm-published [ESLint](https://eslint.org) flat-config plugin whose rule decision is a machine-checked theorem. `no-forbidden-reach` enforces architecture boundaries — "the UI must never reach the DB layer" — through **any** import chain, catching the *laundered* `ui → service → db` violation that every one-hop incumbent (`import/no-restricted-paths`, `eslint-plugin-boundaries`, Nx module boundaries) silently passes. The verified core decides reachability soundly *and* completely (`reachesAny` / `violates`) and **constructs** the offending chain in proven code (`findReachPath` — a path-carrying BFS proven sound + complete by mirroring the frontier's endpoints in a ghost seq, so completeness reduces to the same closure argument as the reachability search; the chain printed in the lint error is therefore itself a *verified* import path, not a heuristic guess). The headline is a meta-theorem — `Domination` + `Strictness` — proving the transitive check **strictly dominates** one-hop checking: every direct violation is caught, *and* there provably exist laundered violations that direct-edge checks miss. 30 Dafny VCs, 0 errors. The reachability decision and the witness are proven; the import-graph extraction (which edges exist) is the stated trust boundary, and `dist/*.js` is `tsc`'s erasure of the verified source it ships alongside. Dafny only.
39
+ - **[infisical-lemmascript](https://github.com/midspiral/infisical-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the permission-boundary glob set-containment check in [Infisical](https://github.com/Infisical/infisical) — the privilege-escalation guard that stops a role from delegating broader secret-path access than it holds. The loop-bearing `segmentMatch` compiles to a Dafny `method` (unnameable in specs), so soundness is proven by **refinement**: the method certifies `result == segMatchSpec` (a pure mirror), and a standalone lemma proves that spec sound against a hand-written segment-glob semantics — *if it returns true, every path the subset glob matches, the parent matches too*. 12 Dafny VCs, 0 errors; drove a toolchain fix (recursive `method`s now carry a method-level `//@ decreases`). Dafny only.
39
40
 
40
41
  ## Setup
41
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,7 +28,9 @@ export function dafnyCheckDiff(genPath, dfyPath) {
28
28
  diff = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf-8");
29
29
  }
30
30
  else {
31
- return true;
31
+ // git couldn't be spawned: the check never ran, so fail loud, don't green-pass.
32
+ console.error(`ERROR: could not run \`git diff\` to verify ${path.basename(dfyPath)} is additions-only (is git installed?)`);
33
+ return false;
32
34
  }
33
35
  }
34
36
  const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
@@ -86,7 +86,7 @@ function methodHeader(prefix, params, returnType) {
86
86
  // ── Lean op → Dafny op ─────────────────────────────────────
87
87
  const OP_MAP = {
88
88
  "=": "==", "≠": "!=", "≥": ">=", "≤": "<=",
89
- "∧": "&&", "∨": "||", "¬": "!",
89
+ "∧": "&&", "∨": "||", "¬": "!", "↔": "<==>",
90
90
  "arrayConcat": "+",
91
91
  };
92
92
  function mapOp(op) { return OP_MAP[op] ?? op; }
@@ -152,15 +152,15 @@ function emitExpr(e) {
152
152
  if (e.method === "with")
153
153
  return `${obj}[${args[0]} := ${args[1]}]`;
154
154
  if (e.method === "includes")
155
- return `(${args[0]} in ${obj})`;
155
+ return args.length > 1 ? `(${args[0]} in ${obj}[${args[1]}..])` : `(${args[0]} in ${obj})`;
156
156
  if (e.method === "indexOf") {
157
157
  needPreamble("SeqIndexOf");
158
- return `SeqIndexOf(${obj}, ${args[0]})`;
158
+ return args.length > 1 ? `SeqIndexOfFrom(${obj}, ${args[0]}, ${args[1]})` : `SeqIndexOf(${obj}, ${args[0]})`;
159
159
  }
160
160
  if (e.method === "push")
161
- return `(${obj} + [${args[0]}])`;
161
+ return `(${obj} + [${args.join(", ")}])`;
162
162
  if (e.method === "concat")
163
- return `(${obj} + [${args[0]}])`;
163
+ return `(${obj} + [${args.join(", ")}])`;
164
164
  // No-arg slice is a full copy; Dafny seq is an immutable value type, so
165
165
  // the copy is just the seq itself (the idiom for "copy then mutate").
166
166
  if (e.method === "slice" && args.length === 0)
@@ -399,12 +399,22 @@ function emitExpr(e) {
399
399
  return `{${args.join(", ")}}`;
400
400
  if (e.fn === "JSFloorDiv")
401
401
  needPreamble("JSFloorDiv");
402
+ if (e.fn === "JSRem")
403
+ needPreamble("JSRem");
404
+ if (e.fn === "JSTruncDiv")
405
+ needPreamble("JSTruncDiv");
406
+ if (e.fn === "JSStringLt")
407
+ needPreamble("JSStringLt");
402
408
  if (e.fn === "CeilReal")
403
409
  needPreamble("CeilReal");
404
410
  if (e.fn === "FloorReal")
405
411
  needPreamble("FloorReal");
406
412
  if (e.fn === "NatToString")
407
413
  needPreamble("NatToString");
414
+ if (e.fn === "IntToString") {
415
+ needPreamble("NatToString");
416
+ needPreamble("IntToString");
417
+ }
408
418
  if (e.fn === "MathAbs")
409
419
  needPreamble("MathAbs");
410
420
  if (e.fn === "MathMin")
@@ -421,6 +431,8 @@ function emitExpr(e) {
421
431
  }
422
432
  if (e.fn === "Perm")
423
433
  needPreamble("Perm");
434
+ if (e.fn === "SetFromSeq")
435
+ needPreamble("SetFromSeq");
424
436
  return `${escapeName(e.fn)}(${args.join(", ")})`;
425
437
  }
426
438
  case "field": {
@@ -458,12 +470,19 @@ function emitExpr(e) {
458
470
  let ctorName;
459
471
  if (e.fields.length > 0) {
460
472
  const fieldNames = new Set(e.fields.map(f => f.name));
473
+ const matches = [];
461
474
  for (const [name, fields] of _structureDecls) {
462
475
  if (fields.length >= e.fields.length && fields.every(f => fieldNames.has(f.name) || f.type.kind === "optional")) {
463
- ctorName = name;
464
- break;
476
+ matches.push(name);
465
477
  }
466
478
  }
479
+ // When several datatypes share this field-name set (e.g. Event vs
480
+ // SparseEvent), disambiguate by the resolved record type carried from
481
+ // transform; otherwise take the sole structural match.
482
+ if (e.ctor && matches.includes(e.ctor))
483
+ ctorName = e.ctor;
484
+ else if (matches.length > 0)
485
+ ctorName = matches[0];
467
486
  if (!ctorName)
468
487
  ctorName = _recordCtors.get(e.fields[0].name);
469
488
  }
@@ -708,6 +727,8 @@ function emitDecl(d) {
708
727
  lines.push(` requires ${emitExpr(r)}`);
709
728
  for (const e of d.ensures)
710
729
  lines.push(` ensures ${emitExpr(e)}`);
730
+ if (d.decreases)
731
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
711
732
  lines.push(`{`);
712
733
  lines.push(emitStmts(d.body, 1));
713
734
  lines.push(`}`);
@@ -795,6 +816,26 @@ const JS_FLOOR_DIV = `function JSFloorDiv(a: int, b: int): int
795
816
  if a <= 0 then (-a) / (-b)
796
817
  else -((a - 1) / (-b)) - 1
797
818
  }`;
819
+ const JS_REM = `function JSRem(a: int, b: int): int
820
+ requires b != 0
821
+ {
822
+ var r := (if a < 0 then -a else a) % (if b < 0 then -b else b);
823
+ if a < 0 then -r else r
824
+ }`;
825
+ const JS_TRUNC_DIV = `function JSTruncDiv(a: int, b: int): int
826
+ requires b != 0
827
+ {
828
+ var q := (if a < 0 then -a else a) / (if b < 0 then -b else b);
829
+ if (a < 0) != (b < 0) then -q else q
830
+ }`;
831
+ const JS_STRING_LT = `predicate JSStringLt(s: string, t: string)
832
+ decreases |s|
833
+ {
834
+ if |s| == 0 then |t| > 0
835
+ else if |t| == 0 then false
836
+ else if s[0] != t[0] then s[0] < t[0]
837
+ else JSStringLt(s[1..], t[1..])
838
+ }`;
798
839
  const FLOOR_REAL = `function FloorReal(x: real): int
799
840
  {
800
841
  x.Floor
@@ -1022,11 +1063,16 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
1022
1063
  if n < 10 then [digit]
1023
1064
  else NatToString(n / 10) + [digit]
1024
1065
  }`;
1066
+ const INT_TO_STRING = `function IntToString(n: int): string
1067
+ {
1068
+ if n < 0 then "-" + NatToString(-n) else NatToString(n)
1069
+ }`;
1025
1070
  const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
1026
1071
  // perm(a, b) — `a` and `b` are reorderings of each other (equal as multisets).
1027
1072
  // Transparent (Dafny unfolds it), so hand-proofs can reason with `multiset`
1028
1073
  // directly. The `(==)` bound requires the element type to support equality.
1029
1074
  const PERM = `predicate Perm<T(==)>(a: seq<T>, b: seq<T>) { multiset(a) == multiset(b) }`;
1075
+ const SET_FROM_SEQ = `function SetFromSeq<T(==)>(s: seq<T>): set<T> { set x | x in s }`;
1030
1076
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
1031
1077
  ensures forall x :: x in s <==> x in res
1032
1078
  ensures |res| == |s|
@@ -1054,6 +1100,9 @@ const PREAMBLE_CODE = [
1054
1100
  ["BitAnd", BIT_AND],
1055
1101
  ["BitOr", BIT_OR],
1056
1102
  ["JSFloorDiv", JS_FLOOR_DIV],
1103
+ ["JSRem", JS_REM],
1104
+ ["JSTruncDiv", JS_TRUNC_DIV],
1105
+ ["JSStringLt", JS_STRING_LT],
1057
1106
  ["CeilReal", CEIL_REAL],
1058
1107
  ["FloorReal", FLOOR_REAL],
1059
1108
  ["SeqIndexOf", SEQ_INDEX_OF],
@@ -1069,12 +1118,14 @@ const PREAMBLE_CODE = [
1069
1118
  ["StringToLower", STRING_TO_LOWER],
1070
1119
  ["StringToUpper", STRING_TO_UPPER],
1071
1120
  ["NatToString", NAT_TO_STRING],
1121
+ ["IntToString", INT_TO_STRING],
1072
1122
  ["MathAbs", MATH_ABS],
1073
1123
  ["MathMin", MATH_MIN],
1074
1124
  ["MathMax", MATH_MAX],
1075
1125
  ["MaxOfSeq", MAX_OF_SEQ],
1076
1126
  ["MinOfSeq", MIN_OF_SEQ],
1077
1127
  ["Perm", PERM],
1128
+ ["SetFromSeq", SET_FROM_SEQ],
1078
1129
  ];
1079
1130
  // ── Constructor and record helpers ───────────────────────────
1080
1131
  let _recordCtors = new Map();
@@ -4,7 +4,7 @@
4
4
  * Produces structured AST nodes, not strings.
5
5
  * The only strings are //@ annotation expressions (parsed later by specparser).
6
6
  */
7
- import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
7
+ import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
8
8
  import { initTypeParser } from "./types.js";
9
9
  // ── Expression extraction ────────────────────────────────────
10
10
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
@@ -87,11 +87,16 @@ function detectCrossFileExtern(callee, sourceFile) {
87
87
  // types in the callee's own type-parameter namespace, so these names match
88
88
  // what `params`/`returnType` reference — declare them on the emitted axiom.
89
89
  const typeParams = sig.getTypeParameters().map(tp => tp.getText());
90
+ // Print types relative to the call site (enclosingNode = callee, alias names
91
+ // kept): a bare `TMsg`, not `import("/abs/path/transcript").TMsg` — the
92
+ // importing module declares the datatype locally, so the axiom must use the
93
+ // local name.
94
+ const externTypeText = (t) => t.getText(callee, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
90
95
  const params = sig.getParameters().map(p => ({
91
96
  name: p.getName(),
92
- tsType: p.getTypeAtLocation(callee).getText(),
97
+ tsType: externTypeText(p.getTypeAtLocation(callee)),
93
98
  }));
94
- const returnType = sig.getReturnType().getText();
99
+ const returnType = externTypeText(sig.getReturnType());
95
100
  let qualified;
96
101
  if (Node.isPropertyAccessExpression(callee)) {
97
102
  qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
@@ -309,9 +314,10 @@ function extractExpr(node) {
309
314
  // Template literal: `foo${x}bar` → "foo" + x + "bar"
310
315
  if (Node.isTemplateExpression(node)) {
311
316
  const parts = [];
312
- const head = node.getHead().getLiteralText();
313
- if (head)
314
- parts.push({ kind: "str", value: head });
317
+ // Always push the head, even when empty: a leading string literal anchors the
318
+ // whole chain as string-typed so each interpolated value is stringified (not
319
+ // added numerically — `${a}${b}` is concatenation, not `a + b`).
320
+ parts.push({ kind: "str", value: node.getHead().getLiteralText() });
315
321
  for (const span of node.getTemplateSpans()) {
316
322
  parts.push(extractExpr(span.getExpression()));
317
323
  const text = span.getLiteral().getLiteralText();
@@ -481,43 +487,42 @@ function extractExpr(node) {
481
487
  }
482
488
  // Object literal: { res: true, done: false } or { ...obj, res: true }
483
489
  if (Node.isObjectLiteralExpression(node)) {
484
- let spread = null;
485
- const fields = [];
486
- const computedFields = [];
490
+ // Fold properties in source order: a later spread overrides everything before
491
+ // it, a named field is a record-update on the accumulator, a computed key is a
492
+ // map `.set`. Order matters: if `a` has `k`, `{ k: v, ...a }` is `a`, not `a.(k := v)`.
493
+ let acc = null;
494
+ const update = (name, value) => {
495
+ acc = acc && acc.kind === "record"
496
+ ? { kind: "record", spread: acc.spread, fields: [...acc.fields, { name, value }] }
497
+ : { kind: "record", spread: acc, fields: [{ name, value }] };
498
+ };
487
499
  for (const prop of node.getProperties()) {
488
500
  if (Node.isSpreadAssignment(prop)) {
489
- spread = extractExpr(prop.getExpression());
501
+ acc = extractExpr(prop.getExpression());
490
502
  }
491
503
  else if (Node.isShorthandPropertyAssignment(prop)) {
492
504
  const name = prop.getName();
493
- fields.push({ name, value: { kind: "var", name } });
505
+ update(name, { kind: "var", name });
494
506
  }
495
507
  else if (Node.isPropertyAssignment(prop)) {
496
508
  const nameNode = prop.getNameNode();
497
509
  const init = prop.getInitializer();
498
- if (init && Node.isComputedPropertyName(nameNode)) {
499
- computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
510
+ if (!init)
511
+ continue;
512
+ if (Node.isComputedPropertyName(nameNode)) {
513
+ // { ...base, [k]: v } → base.set(k, v); { [k]: v } → {}.set(k, v)
514
+ const base = acc ?? { kind: "record", spread: null, fields: [] };
515
+ acc = { kind: "call", fn: { kind: "field", obj: base, field: "set" }, args: [extractExpr(nameNode.getExpression()), extractExpr(init)] };
500
516
  }
501
- else if (init) {
517
+ else {
502
518
  // String-literal keys (e.g. `"bun run": 3`) — use the unquoted literal
503
519
  // value; otherwise `prop.getName()` may include surrounding quotes.
504
520
  const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName();
505
- fields.push({ name, value: extractExpr(init) });
521
+ update(name, extractExpr(init));
506
522
  }
507
523
  }
508
524
  }
509
- // Desugar computed keys: { ...base, [k]: v } → base.set(k, v)
510
- // No spread: { [k]: v } → {}.set(k, v) (empty map base)
511
- if (computedFields.length > 0) {
512
- let result = spread ?? { kind: "record", spread: null, fields: [] };
513
- for (const cf of computedFields) {
514
- result = { kind: "call",
515
- fn: { kind: "field", obj: result, field: "set" },
516
- args: [cf.key, cf.value] };
517
- }
518
- return result;
519
- }
520
- return { kind: "record", spread, fields };
525
+ return acc ?? { kind: "record", spread: null, fields: [] };
521
526
  }
522
527
  // Ternary: cond ? then : else
523
528
  if (Node.isConditionalExpression(node)) {
@@ -555,8 +560,12 @@ function extractExpr(node) {
555
560
  if (Node.isArrayLiteralExpression(arg)) {
556
561
  return { kind: "emptyCollection", collectionType: "Set", tsType, initElems: arg.getElements().map(e => extractExpr(e)) };
557
562
  }
558
- // new Set(existingSet) pass through
559
- return extractExpr(arg);
563
+ const argSymbol = arg.getType().getSymbol()?.getName() ?? arg.getType().getAliasSymbol()?.getName();
564
+ // new Set(existingSet) — identity (sets are value types)
565
+ if (argSymbol === "Set")
566
+ return extractExpr(arg);
567
+ // new Set(arr) — build a deduplicated set from the array's elements
568
+ return { kind: "call", fn: { kind: "var", name: "__setFromArray" }, args: [extractExpr(arg)] };
560
569
  }
561
570
  return { kind: "emptyCollection", collectionType: name, tsType };
562
571
  }
@@ -1557,6 +1566,7 @@ function extractStmts(stmts) {
1557
1566
  // inside a `{ }` case block is stripped, while a `break` inside a
1558
1567
  // nested loop stays put.
1559
1568
  const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
1569
+ const isExit = (st) => !!st && ["break", "return", "throw", "continue"].includes(st.kind);
1560
1570
  let fallthrough = [];
1561
1571
  for (const clause of s.getClauses()) {
1562
1572
  if (Node.isCaseClause(clause)) {
@@ -1565,7 +1575,10 @@ function extractStmts(stmts) {
1565
1575
  fallthrough.push(label);
1566
1576
  continue;
1567
1577
  }
1568
- const body = stripExitBreaks(extractStmts(clause.getStatements()));
1578
+ const raw = extractStmts(clause.getStatements());
1579
+ if (!isExit(raw[raw.length - 1]))
1580
+ throw new Error(`switch case "${label}" at line ${line}: a non-empty case must end with break/return/throw; fall-through into the next case is not supported`);
1581
+ const body = stripExitBreaks(raw);
1569
1582
  for (const l of fallthrough)
1570
1583
  cases.push({ label: l, body });
1571
1584
  cases.push({ label, body });
@@ -77,6 +77,7 @@ function escapeName(name) {
77
77
  }
78
78
  // ── Operator precedence (for parenthesization) ──────────────
79
79
  const PREC = {
80
+ "↔": 0, // Lean: Iff (20) binds looser than → (25)
80
81
  "→": 1, "∨": 2, "∧": 3,
81
82
  "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
82
83
  "+": 5, "-": 5, "++": 5, "arrayConcat": 5, "*": 6, "/": 6, "%": 6,
@@ -96,15 +97,15 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
96
97
  if (method === "some")
97
98
  return `${obj}.${monadic ? "anyM" : "any"} ${args[0]}`;
98
99
  if (method === "includes")
99
- return `${obj}.contains ${args[0]}`;
100
+ return args.length > 1 ? `(${obj}.extract ${args[1]} ${obj}.size).contains ${args[0]}` : `${obj}.contains ${args[0]}`;
100
101
  if (method === "find")
101
102
  return `${obj}.find? ${args[0]}`;
102
103
  if (method === "with")
103
104
  return `${obj}.set! ${args[0]} ${args[1]}`;
104
105
  if (method === "push")
105
- return `Array.push ${obj} ${args[0]}`;
106
+ return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
106
107
  if (method === "concat")
107
- return `Array.push ${obj} ${args[0]}`;
108
+ return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
108
109
  // arr.slice → Array.extract. No-arg slice is a full copy (Array is a value
109
110
  // type in Lean, so the receiver itself); one arg drops the prefix, two args
110
111
  // give the half-open range. Matches JS for non-negative bounds (negative
@@ -184,7 +185,7 @@ function emitExpr(e, parentPrec) {
184
185
  case "emptySet": return `Std.HashSet.empty`;
185
186
  case "methodCall": {
186
187
  const obj = emitExpr(e.obj);
187
- const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "methodCall";
188
+ const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "methodCall" || e.obj.kind === "if" || e.obj.kind === "let";
188
189
  const receiver = wrap ? `(${obj})` : obj;
189
190
  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));
190
191
  return emitMethodCall(e.objTy.kind, e.method, e.monadic, receiver, args);
@@ -213,11 +214,23 @@ function emitExpr(e, parentPrec) {
213
214
  return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
214
215
  }
215
216
  const op = e.op === "arrayConcat" ? "++" : e.op;
216
- const s = `${wrapOperand(e.left, prec(e.op))} ${op} ${wrapOperand(e.right, prec(e.op))}`;
217
+ // does not chain in Lean — a nested iff operand needs parens.
218
+ const childPrec = e.op === "↔" ? prec(e.op) + 1 : prec(e.op);
219
+ // `-`, `/`, `%` are left-associative and non-associative, so an equal-
220
+ // precedence right operand must be parenthesized: `a - (b - c)` would
221
+ // otherwise emit as `a - b - c`, i.e. `(a - b) - c`.
222
+ const rightPrec = e.op === "↔" ? childPrec
223
+ : ["-", "/", "%"].includes(e.op) ? prec(e.op) + 1 : childPrec;
224
+ const s = `${wrapOperand(e.left, childPrec)} ${op} ${wrapOperand(e.right, rightPrec)}`;
217
225
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
218
226
  }
219
227
  case "implies": {
220
- const parts = [...e.premises.map(p => wrapOperand(p)), emitExpr(e.conclusion)];
228
+ // Premises bind at →'s level: a nested-implication premise must keep its
229
+ // parens (→ is right-associative, so `(a → b) → c` ≠ `a → b → c`), and
230
+ // ↔ binds looser than → in Lean. The conclusion is the right-assoc tail,
231
+ // where a nested implication is safe bare — only ↔ needs parens there.
232
+ const wrapIff = (x) => x.kind === "binop" && x.op === "↔" ? `(${emitExpr(x)})` : undefined;
233
+ const parts = [...e.premises.map(p => wrapOperand(p, prec("→"))), wrapIff(e.conclusion) ?? emitExpr(e.conclusion)];
221
234
  const s = parts.join(" → ");
222
235
  return parentPrec !== undefined ? `(${s})` : s;
223
236
  }
@@ -231,6 +244,16 @@ function emitExpr(e, parentPrec) {
231
244
  // SetToSeq → .toArray for Lean (HashSet has native toArray)
232
245
  if (e.fn === "SetToSeq" && args.length === 1)
233
246
  return `${args[0]}.toArray`;
247
+ if (e.fn === "SetFromSeq" && args.length === 1)
248
+ return `Std.HashSet.ofList ${args[0]}.toList`;
249
+ if (e.fn === "ToString" && args.length === 1)
250
+ return `toString ${args[0]}`;
251
+ // JSRem (JS truncated remainder) → Lean's native truncated `Int.tmod`
252
+ if (e.fn === "JSRem" && args.length === 2)
253
+ return `Int.tmod ${args[0]} ${args[1]}`;
254
+ // JSTruncDiv (JS truncated bigint division) → Lean's native `Int.tdiv`
255
+ if (e.fn === "JSTruncDiv" && args.length === 2)
256
+ return `Int.tdiv ${args[0]} ${args[1]}`;
234
257
  // perm(a, b) → `List.Perm` on the underlying lists. Dafny lowers it to
235
258
  // `multiset(a) == multiset(b)`; the Lean image is `a.toList ~ b.toList`,
236
259
  // which mathlib's `List.Perm` provides (reflexivity, symmetry,
@@ -44,25 +44,29 @@ let _typeDecls = [];
44
44
  * pure-access-path optional-typed e. `!e` is equivalent to `=== undefined`.
45
45
  * Following TS, only pure access paths narrow; complex scrutinees return null. */
46
46
  function parseOptionalCheck(cond) {
47
- // `!e` where e is optional — same as `e === undefined` (negated: true).
47
+ // `!e` where e is optional — a truthiness form: false iff e is absent OR its
48
+ // inner value is itself falsy (so `Some(0)`/`Some("")` count as falsy too).
48
49
  if (cond.kind === "unop" && cond.op === "!" && cond.expr.ty.kind === "optional") {
49
50
  const e = cond.expr;
50
51
  const innerTy = cond.expr.ty.inner;
51
52
  const hint = binderHintFor(e);
52
53
  if (hint === null)
53
54
  return null;
54
- return { scrutinee: e, innerTy, negated: true, binderHint: hint };
55
+ return { scrutinee: e, innerTy, negated: true, binderHint: hint, truthiness: true };
55
56
  }
56
57
  if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "===")) {
57
- // Bare optional truthiness: `if (e)` where e: T | undefined — same as `e !== undefined`.
58
+ // Bare optional truthiness: `if (e)` where e: T | undefined — true iff e is
59
+ // present AND its inner value is truthy.
58
60
  if (cond.ty.kind === "optional") {
59
61
  const hint = binderHintFor(cond);
60
62
  if (hint === null)
61
63
  return null;
62
- return { scrutinee: cond, innerTy: cond.ty.inner, negated: false, binderHint: hint };
64
+ return { scrutinee: cond, innerTy: cond.ty.inner, negated: false, binderHint: hint, truthiness: true };
63
65
  }
64
66
  return null;
65
67
  }
68
+ // Explicit `e === undefined` / `e !== undefined` — a pure presence check,
69
+ // independent of the inner value (so NOT a truthiness form).
66
70
  let e = null;
67
71
  if (cond.right.kind === "var" && cond.right.name === "undefined")
68
72
  e = cond.left;
@@ -73,7 +77,7 @@ function parseOptionalCheck(cond) {
73
77
  const hint = binderHintFor(e);
74
78
  if (hint === null)
75
79
  return null;
76
- return { scrutinee: e, innerTy: e.ty.inner, negated: cond.op === "===", binderHint: hint };
80
+ return { scrutinee: e, innerTy: e.ty.inner, negated: cond.op === "===", binderHint: hint, truthiness: false };
77
81
  }
78
82
  function binderHintFor(e) {
79
83
  // Pure access paths: var(x) or field(purePath, name).
@@ -205,6 +209,8 @@ function recurseStmt(s) {
205
209
  fallthrough: rs(s.fallthrough) };
206
210
  }
207
211
  }
212
+ const canBeFalsy = (c) => c.truthiness && ["int", "nat", "string", "bool"].includes(c.innerTy.kind);
213
+ const bound = (c) => ({ kind: "var", name: c.binderHint, ty: c.innerTy });
208
214
  // ── Rules ───────────────────────────────────────────────────
209
215
  /** Rule: `if (e !== undefined) then else` where e is a simple optional var or
210
216
  * `obj.field` chain, and the Some branch is non-empty.
@@ -223,7 +229,8 @@ function ruleIfOptionalSimple(s) {
223
229
  kind: "someMatch",
224
230
  scrutinee: check.scrutinee, binderTy: check.innerTy,
225
231
  binder: check.binderHint,
226
- someBody, noneBody,
232
+ someBody: canBeFalsy(check) ? [{ kind: "if", cond: bound(check), then: someBody, else: noneBody }] : someBody,
233
+ noneBody,
227
234
  };
228
235
  }
229
236
  /** Rule: `if (e === undefined) terminate; rest` (early return / throw / break).
@@ -242,11 +249,13 @@ function ruleEarlyReturnConsume(s, rest) {
242
249
  const noneBranch = check.negated ? s.then : s.else;
243
250
  if (someBranch.length !== 0)
244
251
  return null;
252
+ if (!isTerminating(noneBranch))
253
+ return null;
245
254
  return {
246
255
  kind: "someMatch",
247
256
  scrutinee: check.scrutinee, binderTy: check.innerTy,
248
257
  binder: check.binderHint,
249
- someBody: rest,
258
+ someBody: canBeFalsy(check) ? [{ kind: "if", cond: bound(check), then: rest, else: noneBranch }] : rest,
250
259
  noneBody: noneBranch,
251
260
  };
252
261
  }
@@ -285,11 +294,14 @@ function ruleEarlyReturnOrChain(s, rest) {
285
294
  let inner = rest;
286
295
  for (let i = checks.length - 1; i >= 0; i--) {
287
296
  const check = checks[i];
297
+ const someBody = canBeFalsy(check)
298
+ ? [{ kind: "if", cond: bound(check), then: inner, else: s.then }]
299
+ : inner;
288
300
  inner = [{
289
301
  kind: "someMatch",
290
302
  scrutinee: check.scrutinee, binderTy: check.innerTy,
291
303
  binder: check.binderHint,
292
- someBody: inner,
304
+ someBody,
293
305
  noneBody: s.then,
294
306
  }];
295
307
  }
@@ -308,7 +320,8 @@ function ruleConditionalOptionalSimple(e) {
308
320
  kind: "someMatch",
309
321
  scrutinee: check.scrutinee, binderTy: check.innerTy,
310
322
  binder: check.binderHint,
311
- someBody, noneBody,
323
+ someBody: canBeFalsy(check) ? { kind: "conditional", cond: bound(check), then: someBody, else: noneBody, ty: e.ty } : someBody,
324
+ noneBody,
312
325
  ty: e.ty,
313
326
  };
314
327
  }
@@ -574,11 +587,14 @@ function ruleIfAndOptional(s) {
574
587
  return null;
575
588
  const { check, restCond } = extracted;
576
589
  const innerIf = { kind: "if", cond: restCond, then: s.then, else: [] };
590
+ const someBody = canBeFalsy(check)
591
+ ? [{ kind: "if", cond: bound(check), then: [walkStmt(innerIf)], else: [] }]
592
+ : [walkStmt(innerIf)];
577
593
  return {
578
594
  kind: "someMatch",
579
595
  scrutinee: check.scrutinee, binderTy: check.innerTy,
580
596
  binder: check.binderHint,
581
- someBody: [walkStmt(innerIf)],
597
+ someBody,
582
598
  noneBody: [],
583
599
  };
584
600
  }
@@ -768,8 +784,16 @@ function ruleDiscriminantChain(stmts) {
768
784
  }
769
785
  if (cases.length === 0)
770
786
  return null;
787
+ // If every case terminates, the trailing statements are the default arm
788
+ // (preserving the clean dispatch-as-expression shape). Otherwise the tail runs
789
+ // after the match for every variant, so leave it to the caller (empty default)
790
+ // rather than mis-routing it into the default arm only.
791
+ if (cases.every(c => isTerminating(c.body))) {
792
+ return { stmt: { kind: "tagMatch", scrutinee: first.scrutinee, typeName: first.typeName,
793
+ cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
794
+ }
771
795
  return { stmt: { kind: "tagMatch", scrutinee: first.scrutinee, typeName: first.typeName,
772
- cases, fallthrough: stmts.slice(consumed) }, consumed: stmts.length };
796
+ cases, fallthrough: [] }, consumed };
773
797
  }
774
798
  /** Rule (list-level): `if (x.kind !== "v") terminate; rest` → tagMatch
775
799
  * with cases = [{ variant: v, body: rest }] and fallthrough = terminate. */
@@ -801,12 +825,16 @@ function ruleLetCondAndOptional(s) {
801
825
  if (!extracted)
802
826
  return null;
803
827
  const { check, restCond } = extracted;
828
+ const assignIf = { kind: "if", cond: restCond,
829
+ then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] };
830
+ const someBody = canBeFalsy(check)
831
+ ? [{ kind: "if", cond: bound(check), then: [assignIf], else: [] }]
832
+ : [assignIf];
804
833
  const sm = {
805
834
  kind: "someMatch",
806
835
  scrutinee: check.scrutinee, binderTy: check.innerTy,
807
836
  binder: check.binderHint,
808
- someBody: [{ kind: "if", cond: restCond,
809
- then: [{ kind: "assign", target: s.name, value: s.init.then }], else: [] }],
837
+ someBody,
810
838
  noneBody: [],
811
839
  };
812
840
  return [
@@ -881,11 +909,15 @@ function ruleConditionalAndOptional(e) {
881
909
  kind: "conditional",
882
910
  cond: restCond, then: e.then, else: e.else, ty: e.ty,
883
911
  };
912
+ const someExpr = walkExpr(innerCond);
913
+ const someBody = canBeFalsy(check)
914
+ ? { kind: "conditional", cond: bound(check), then: someExpr, else: e.else, ty: e.ty }
915
+ : someExpr;
884
916
  return {
885
917
  kind: "someMatch",
886
918
  scrutinee: check.scrutinee, binderTy: check.innerTy,
887
919
  binder: check.binderHint,
888
- someBody: walkExpr(innerCond), noneBody: e.else, ty: e.ty,
920
+ someBody, noneBody: e.else, ty: e.ty,
889
921
  };
890
922
  }
891
923
  /** Rule (statement): `if (<rest> && Array.isArray(path) && <more>) then [else]`
@@ -440,7 +440,8 @@ function peepholeDecl(d) {
440
440
  decreases: d.decreases ? peepholeExpr(d.decreases) : null };
441
441
  case "method":
442
442
  return { ...d, body: peepholeStmts(d.body),
443
- requires: d.requires.map(peepholeExpr), ensures: d.ensures.map(peepholeExpr) };
443
+ requires: d.requires.map(peepholeExpr), ensures: d.ensures.map(peepholeExpr),
444
+ decreases: d.decreases ? peepholeExpr(d.decreases) : null };
444
445
  case "namespace": return { ...d, decls: d.decls.map(peepholeDecl) };
445
446
  case "class": return { ...d, methods: d.methods.map(m => peepholeDecl(m)) };
446
447
  case "const": return { ...d, value: peepholeExpr(d.value) };
@@ -685,7 +685,9 @@ function resolveExpr(e, ctx) {
685
685
  }
686
686
  }
687
687
  let ty = { kind: "unknown" };
688
- if (["===", "!==", ">=", "<=", ">", "<", "in"].includes(e.op))
688
+ // <==> is bool like the comparisons; unlike ==>, neither side narrows
689
+ // the other (no premise to assume).
690
+ if (["===", "!==", ">=", "<=", ">", "<", "in", "<==>"].includes(e.op))
689
691
  ty = { kind: "bool" };
690
692
  else if (e.op === "&&")
691
693
  ty = right.ty;
@@ -727,6 +729,16 @@ function resolveExpr(e, ctx) {
727
729
  const fn = { kind: "var", name: "Perm", ty: { kind: "unknown" } };
728
730
  return { kind: "call", fn, args: [a, b], ty: { kind: "bool" }, callKind: "pure" };
729
731
  }
732
+ // new Set(arr): build a deduplicated set from the array's elements (extract
733
+ // marks the array form `__setFromArray`). Lowers to the SetFromSeq preamble
734
+ // (Dafny `set x | x in s`); size/membership are then set semantics.
735
+ if (e.fn.kind === "var" && e.fn.name === "__setFromArray" && e.args.length === 1) {
736
+ const arr = resolveExpr(e.args[0], ctx);
737
+ if (arr.ty.kind !== "array")
738
+ throw new Error(`new Set(...) expects an array argument (got ${arr.ty.kind})`);
739
+ const fn = { kind: "var", name: "SetFromSeq", ty: { kind: "unknown" } };
740
+ return { kind: "call", fn, args: [arr], ty: { kind: "set", elem: arr.ty.elem }, callKind: "pure" };
741
+ }
730
742
  // Extern dispatch: `NS.method(args)` where NS.method is declared via
731
743
  // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
732
744
  // rest of the pipeline sees an ordinary pure function. The extern's
@@ -2,7 +2,7 @@
2
2
  * Spec expression parser.
3
3
  * Parses //@ annotation expressions into RawExpr AST nodes.
4
4
  */
5
- const MULTI_OPS = ["==>", "===", "!==", "==", "!=", ">=", "<=", "&&", "||"];
5
+ const MULTI_OPS = ["<==>", "==>", "===", "!==", "==", "!=", ">=", "<=", "&&", "||"];
6
6
  function tokenize(input) {
7
7
  const tokens = [];
8
8
  let i = 0;
@@ -20,8 +20,22 @@ function tokenize(input) {
20
20
  const quote = input[i];
21
21
  i++;
22
22
  let s = "";
23
- while (i < input.length && input[i] !== quote)
24
- s += input[i++];
23
+ while (i < input.length && input[i] !== quote) {
24
+ if (input[i] === "\\") {
25
+ // Standard escapes, where TS source, Dafny, and Lean all agree.
26
+ // The emitters re-escape on output, so the round trip is faithful.
27
+ const esc = input[i + 1];
28
+ const mapped = esc === "n" ? "\n" : esc === "r" ? "\r" : esc === "t" ? "\t"
29
+ : esc === "0" ? "\0" : esc === "\\" || esc === '"' || esc === "'" ? esc : null;
30
+ if (mapped === null)
31
+ throw new Error(`Unsupported string escape '\\${esc}' at ${i} in: ${input}`);
32
+ s += mapped;
33
+ i += 2;
34
+ }
35
+ else {
36
+ s += input[i++];
37
+ }
38
+ }
25
39
  if (i < input.length)
26
40
  i++;
27
41
  tokens.push({ type: "str", value: s });
@@ -103,11 +117,19 @@ class Parser {
103
117
  return false;
104
118
  }
105
119
  parse() {
106
- const r = this.parseImplies();
120
+ const r = this.parseIff();
107
121
  if (this.pos < this.tokens.length)
108
122
  throw new Error(`Unexpected: ${JSON.stringify(this.peek())}`);
109
123
  return r;
110
124
  }
125
+ // <==> binds loosest (Dafny precedence: a ==> b <==> c is (a ==> b) <==> c),
126
+ // right-associative like ==> — immaterial semantically, iff is associative.
127
+ parseIff() {
128
+ const left = this.parseImplies();
129
+ if (this.match("op", "<==>"))
130
+ return { kind: "binop", op: "<==>", left, right: this.parseIff() };
131
+ return left;
132
+ }
111
133
  parseImplies() {
112
134
  const left = this.parseTernary();
113
135
  if (this.match("op", "==>"))
@@ -117,9 +139,9 @@ class Parser {
117
139
  parseTernary() {
118
140
  const cond = this.parseOr();
119
141
  if (this.match("op", "?")) {
120
- const then_ = this.parseImplies();
142
+ const then_ = this.parseIff();
121
143
  this.expect("punc", ":");
122
- const else_ = this.parseImplies();
144
+ const else_ = this.parseIff();
123
145
  return { kind: "conditional", cond, then: then_, else: else_ };
124
146
  }
125
147
  return cond;
@@ -187,16 +209,16 @@ class Parser {
187
209
  expr = { kind: "field", obj: expr, field: this.expect("ident").value };
188
210
  }
189
211
  else if (this.match("punc", "[")) {
190
- const idx = this.parseImplies();
212
+ const idx = this.parseIff();
191
213
  this.expect("punc", "]");
192
214
  expr = { kind: "index", obj: expr, idx };
193
215
  }
194
216
  else if (this.match("punc", "(")) {
195
217
  const args = [];
196
218
  if (!this.match("punc", ")")) {
197
- args.push(this.parseImplies());
219
+ args.push(this.parseIff());
198
220
  while (this.match("punc", ","))
199
- args.push(this.parseImplies());
221
+ args.push(this.parseIff());
200
222
  this.expect("punc", ")");
201
223
  }
202
224
  expr = { kind: "call", fn: expr, args };
@@ -276,7 +298,7 @@ class Parser {
276
298
  varType = ty;
277
299
  }
278
300
  this.expect("punc", ",");
279
- const body = this.parseImplies();
301
+ const body = this.parseIff();
280
302
  this.expect("punc", ")");
281
303
  return { kind: q, var: v, varType, body };
282
304
  }
@@ -285,7 +307,7 @@ class Parser {
285
307
  }
286
308
  if (t.type === "punc" && t.value === "(") {
287
309
  this.advance();
288
- const expr = this.parseImplies();
310
+ const expr = this.parseIff();
289
311
  this.expect("punc", ")");
290
312
  return expr;
291
313
  }
@@ -293,9 +315,9 @@ class Parser {
293
315
  this.advance();
294
316
  const elems = [];
295
317
  if (!this.match("punc", "]")) {
296
- elems.push(this.parseImplies());
318
+ elems.push(this.parseIff());
297
319
  while (this.match("punc", ","))
298
- elems.push(this.parseImplies());
320
+ elems.push(this.parseIff());
299
321
  this.expect("punc", "]");
300
322
  }
301
323
  return { kind: "arrayLiteral", elems };
@@ -306,11 +328,11 @@ class Parser {
306
328
  if (!this.match("punc", "}")) {
307
329
  const name = this.expect("ident").value;
308
330
  this.expect("punc", ":");
309
- fields.push({ name, value: this.parseImplies() });
331
+ fields.push({ name, value: this.parseIff() });
310
332
  while (this.match("punc", ",")) {
311
333
  const n = this.expect("ident").value;
312
334
  this.expect("punc", ":");
313
- fields.push({ name: n, value: this.parseImplies() });
335
+ fields.push({ name: n, value: this.parseIff() });
314
336
  }
315
337
  this.expect("punc", "}");
316
338
  }
@@ -158,6 +158,24 @@ function isNat(ty) { return ty.kind === "nat"; }
158
158
  function isIntegral(ty) { return ty.kind === "int" || ty.kind === "nat"; }
159
159
  function isArray(ty) { return ty.kind === "array"; }
160
160
  function isUser(ty) { return ty.kind === "user"; }
161
+ /** Truthiness test for a *lowered* value of source type `ty`, used by `||`
162
+ * falsiness lowering. Mirrors narrow.ts's `canBeFalsy`: only int/nat/string/bool
163
+ * values can be falsy in JS (`0`, `""`, `false`); every other value (array, user
164
+ * type, …) is always truthy. Returns null for the always-truthy types so callers
165
+ * can unwrap directly instead of emitting a redundant guard. */
166
+ function valueTruthyCond(value, ty) {
167
+ switch (ty.kind) {
168
+ case "int":
169
+ case "nat":
170
+ return { kind: "binop", op: "≠", left: value, right: { kind: "num", value: 0 } };
171
+ case "string":
172
+ return { kind: "binop", op: ">", left: { kind: "field", obj: value, field: "length" }, right: { kind: "num", value: 0 } };
173
+ case "bool":
174
+ return value;
175
+ default:
176
+ return null;
177
+ }
178
+ }
161
179
  /** Check if transformed lambda body contains monadic binds. */
162
180
  function isMonadicBody(stmts) {
163
181
  for (const s of stmts) {
@@ -182,7 +200,7 @@ function isMonadicBody(stmts) {
182
200
  const OP_MAP = {
183
201
  "===": "=", "!==": "≠", ">=": "≥", "<=": "≤", ">": ">", "<": "<",
184
202
  "&&": "∧", "||": "∨", "+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
185
- "==": "=", "!=": "≠",
203
+ "==": "=", "!=": "≠", "<==>": "↔",
186
204
  };
187
205
  /** Bool-valued operators (for code-level conditions needing Decidable). */
188
206
  const BOOL_OP_MAP = {
@@ -248,15 +266,18 @@ function flattenLambdaBody(stmts) {
248
266
  * field, index, record, forall, or exists sub-expressions.
249
267
  */
250
268
  /** JS truthiness coercion for `if`/`while`/`?:` conditions.
251
- * Dafny requires bool; TS treats number/string/array as truthy when non-empty.
269
+ * Dafny requires bool; coerce number→`≠0`, stringnon-empty, array→`true`
270
+ * (every array, even `[]`, is truthy in JS).
252
271
  * Optional conds are handled separately by narrow.ts (rewritten to someMatch). */
253
272
  function coerceCondToBool(cond, ty) {
254
273
  if (ty.kind === "bool")
255
274
  return cond;
256
275
  if (ty.kind === "int" || ty.kind === "nat")
257
- return { kind: "binop", op: ">", left: cond, right: { kind: "num", value: 0 } };
258
- if (ty.kind === "string" || ty.kind === "array")
259
- return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "size" }, right: { kind: "num", value: 0 } };
276
+ return { kind: "binop", op: "", left: cond, right: { kind: "num", value: 0 } };
277
+ if (ty.kind === "string")
278
+ return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "length" }, right: { kind: "num", value: 0 } };
279
+ if (ty.kind === "array")
280
+ return { kind: "bool", value: true };
260
281
  return cond;
261
282
  }
262
283
  /** Wrap an expression in Some/None for optional-typed conditionals.
@@ -272,6 +293,55 @@ function wrapOptionalBranch(expr, raw) {
272
293
  return expr; // already Option<T>, don't double-wrap
273
294
  return { kind: "constructor", name: "some", type: "Option", args: [expr] };
274
295
  }
296
+ /** Lean needs `let mut` for any local that is later reassigned. A const local
297
+ * whose collection field is mutated (`b.items.push(v)` → `b := b.(items := …)`)
298
+ * becomes an assign in the lowered body, so scan for assign targets and flip
299
+ * matching lets to mutable. Harmless on Dafny (method locals are `var`); and an
300
+ * assigned let already forces a method, so purity is unaffected. */
301
+ function promoteAssignedLets(stmts) {
302
+ const assigned = new Set();
303
+ const collect = (ss) => {
304
+ for (const s of ss) {
305
+ if (s.kind === "assign")
306
+ assigned.add(s.target);
307
+ else if (s.kind === "if") {
308
+ collect(s.then);
309
+ collect(s.else);
310
+ }
311
+ else if (s.kind === "while" || s.kind === "forin")
312
+ collect(s.body);
313
+ else if (s.kind === "match")
314
+ s.arms.forEach(a => collect(a.body));
315
+ }
316
+ };
317
+ collect(stmts);
318
+ if (assigned.size === 0)
319
+ return stmts;
320
+ const fix = (ss) => ss.map(s => {
321
+ const s2 = s.kind === "let" && !s.mutable && assigned.has(s.name) ? { ...s, mutable: true } : s;
322
+ if (s2.kind === "if")
323
+ return { ...s2, then: fix(s2.then), else: fix(s2.else) };
324
+ if (s2.kind === "while" || s2.kind === "forin")
325
+ return { ...s2, body: fix(s2.body) };
326
+ if (s2.kind === "match")
327
+ return { ...s2, arms: s2.arms.map(a => ({ ...a, body: fix(a.body) })) };
328
+ return s2;
329
+ });
330
+ return fix(stmts);
331
+ }
332
+ /** Build a nested record-update assigning `newVal` to a field-path receiver
333
+ * rooted at a var: `b.a.items` → `b := b.(a := b.a.(items := newVal))`.
334
+ * Returns null if the path isn't a chain of field accesses ending at a var. */
335
+ function buildNestedFieldUpdate(recv, newVal) {
336
+ if (recv.kind !== "field")
337
+ return null;
338
+ const upd = { kind: "record", spread: lowerExpr(recv.obj, null), fields: [{ name: recv.field, value: newVal }] };
339
+ if (recv.obj.kind === "var")
340
+ return { root: recv.obj.name, value: upd };
341
+ if (recv.obj.kind === "field")
342
+ return buildNestedFieldUpdate(recv.obj, upd);
343
+ return null;
344
+ }
275
345
  function lowerExpr(e, binds) {
276
346
  // Monadic lifting: extract embedded method calls to let-binds.
277
347
  // `callKind: "method"` means a global var-fn call (classifyCall returns
@@ -298,17 +368,29 @@ function lowerExpr(e, binds) {
298
368
  // String truthiness: !str → str == ""
299
369
  if (e.op === "!" && e.expr.ty.kind === "string")
300
370
  return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "str", value: "" } };
301
- // Optional truthiness: !opt → opt is None
371
+ // Optional truthiness: !opt → None negates to `true`. The Some branch is
372
+ // `!(value truthy)`: always-truthy inners (array/user) give a plain `false`,
373
+ // while falsy-capable inners re-test the wrapped value (`!Some(0)` is `true`).
374
+ // Mirrors the `||` falsiness rule.
302
375
  if (e.op === "!" && e.expr.ty.kind === "optional") {
303
376
  const bound = matchBinder("value");
377
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.expr.ty.inner);
304
378
  return {
305
379
  kind: "match", scrutinee: lowerExpr(e.expr, binds),
306
380
  arms: [
307
- { pattern: `.some ${bound}`, body: { kind: "bool", value: false } },
381
+ { pattern: `.some ${bound}`, body: truthy ? { kind: "unop", op: "¬", expr: truthy } : { kind: "bool", value: false } },
308
382
  { pattern: ".none", body: { kind: "bool", value: true } },
309
383
  ],
310
384
  };
311
385
  }
386
+ // Number truthiness: !n → n == 0
387
+ if (e.op === "!" && (e.expr.ty.kind === "int" || e.expr.ty.kind === "nat"))
388
+ return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "num", value: 0 } };
389
+ // Array truthiness: every array is truthy in JS, so !xs is always false.
390
+ if (e.op === "!" && e.expr.ty.kind === "array") {
391
+ lowerExpr(e.expr, binds); // preserve any lifted side effects; value is the constant false
392
+ return { kind: "bool", value: false };
393
+ }
312
394
  return { kind: "unop", op: e.op === "!" ? "¬" : e.op, expr: lowerExpr(e.expr, binds) };
313
395
  case "binop": {
314
396
  // Implication: flatten (A && B) ==> C → implies [A, B] C
@@ -327,8 +409,10 @@ function lowerExpr(e, binds) {
327
409
  right: { kind: "constructor", name: e.right.value, type: objTy },
328
410
  };
329
411
  }
330
- // String literal comparison — constructor if user type, string literal if string
331
- if ((e.op === "===" || e.op === "!==") && e.right.kind === "str") {
412
+ // String literal comparison — constructor if user type, string literal if string.
413
+ // Skip when the left is optional: fall through to the optional-comparison rule,
414
+ // which unwraps and compares the inner value (`Some(v) => v == "")`.
415
+ if ((e.op === "===" || e.op === "!==") && e.right.kind === "str" && e.left.ty.kind !== "optional") {
332
416
  const left = lowerExpr(e.left, binds);
333
417
  const leftTy = e.left.ty.kind === "user" ? e.left.ty.name : undefined;
334
418
  const right = isUser(e.left.ty)
@@ -352,7 +436,13 @@ function lowerExpr(e, binds) {
352
436
  ],
353
437
  };
354
438
  }
355
- const valExpr = lowerExpr(valSide, binds);
439
+ // A string literal compared against an optional user type is the variant
440
+ // constructor (`o === "red"` → `Some(v) => v == Color.red`), mirroring the
441
+ // non-optional string-literal rule above.
442
+ const innerTy = optSide.ty.kind === "optional" ? optSide.ty.inner : optSide.ty;
443
+ const valExpr = valSide.kind === "str" && innerTy.kind === "user"
444
+ ? { kind: "constructor", name: valSide.value, type: innerTy.name }
445
+ : lowerExpr(valSide, binds);
356
446
  const cmpOp = BOOL_OP_MAP[e.op] ?? e.op;
357
447
  const noneVal = e.op === "!==" ? true : false;
358
448
  const bound = matchBinder("value");
@@ -364,37 +454,76 @@ function lowerExpr(e, binds) {
364
454
  ],
365
455
  };
366
456
  }
367
- // || undefined on optional → identity (no-op: x || undefined = x)
457
+ // || undefined on optional → identity (no-op: x || undefined = x) when the
458
+ // inner type is always truthy. When it can be falsy, JS still drops the
459
+ // wrapped value: `Some(0) || undefined === undefined`, so the Some arm
460
+ // re-tests and falls back to None.
368
461
  if (e.op === "||" && e.left.ty.kind === "optional" &&
369
462
  e.right.kind === "var" && e.right.name === "undefined") {
370
- return lowerExpr(e.left, binds);
463
+ const optExpr = lowerExpr(e.left, binds);
464
+ const bound = matchBinder("value");
465
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.left.ty.inner);
466
+ if (!truthy)
467
+ return optExpr;
468
+ return {
469
+ kind: "match", scrutinee: optExpr,
470
+ arms: [
471
+ { pattern: `.some ${bound}`, body: {
472
+ kind: "if", cond: truthy,
473
+ then: { kind: "app", fn: "Some", args: [{ kind: "var", name: bound }] },
474
+ else: { kind: "var", name: "undefined" }
475
+ } },
476
+ { pattern: ".none", body: { kind: "var", name: "undefined" } },
477
+ ],
478
+ };
371
479
  }
372
- // || on optional → match Some/None with default
480
+ // || on optional → match Some/None with default. JS `||` tests falsiness of
481
+ // the *unwrapped* value, so when the inner type can be falsy the Some arm must
482
+ // re-test (`Some(0) || 1 === 1`); array/user inners are always truthy and
483
+ // unwrap directly. Mirrors narrow.ts's canBeFalsy gate.
373
484
  if (e.op === "||" && e.left.ty.kind === "optional") {
374
485
  const optExpr = lowerExpr(e.left, binds);
375
486
  const defaultExpr = lowerExpr(e.right, binds);
376
487
  const bound = matchBinder("value");
488
+ const truthy = valueTruthyCond({ kind: "var", name: bound }, e.left.ty.inner);
489
+ const someBody = truthy
490
+ ? { kind: "if", cond: truthy, then: { kind: "var", name: bound }, else: defaultExpr }
491
+ : { kind: "var", name: bound };
377
492
  return {
378
493
  kind: "match", scrutinee: optExpr,
379
494
  arms: [
380
- { pattern: `.some ${bound}`, body: { kind: "var", name: bound } },
495
+ { pattern: `.some ${bound}`, body: someBody },
381
496
  { pattern: ".none", body: defaultExpr },
382
497
  ],
383
498
  };
384
499
  }
385
- // || on map index → if key in map then map[key] else default
500
+ // || on map index → if key in map then map[key] else default. The stored
501
+ // value is still subject to JS falsiness (`counts.get(k) || 1` returns 1 when
502
+ // the stored value is 0), so for falsy-capable value types the present branch
503
+ // re-tests the value too. Always-truthy value types unwrap directly.
386
504
  if (e.op === "||" && e.left.kind === "index" && e.left.obj.ty.kind === "map") {
387
505
  const map = lowerExpr(e.left.obj, binds);
388
506
  const key = lowerExpr(e.left.idx, binds);
389
507
  const right = lowerExpr(e.right, binds);
508
+ const got = { kind: "index", arr: map, idx: key };
509
+ const truthy = valueTruthyCond(got, e.left.obj.ty.value);
390
510
  return {
391
511
  kind: "if",
392
512
  cond: { kind: "binop", op: "in", left: key, right: map },
393
- then: { kind: "index", arr: map, idx: key }, else: right,
513
+ then: truthy ? { kind: "if", cond: truthy, then: got, else: right } : got,
514
+ else: right,
394
515
  };
395
516
  }
396
- // || on non-optional string/array/userif non-empty then x else default
397
- if (e.op === "||" && (e.left.ty.kind === "string" || e.left.ty.kind === "array" ||
517
+ // || on non-optional array → `xs` itself: every array (even `[]`) is truthy
518
+ // in JS, so `xs || ys` short-circuits to `xs` and `ys` is never evaluated.
519
+ // resolve types the whole `||` as the array, so any optional context (e.g.
520
+ // `xs || undefined`) gets its single Some-wrap from the standard coercion at
521
+ // the use site — this rule must not add one. Mirrors the `!array` rule above.
522
+ if (e.op === "||" && e.left.ty.kind === "array") {
523
+ return lowerExpr(e.left, binds);
524
+ }
525
+ // || on non-optional string/user → if non-empty then x else default
526
+ if (e.op === "||" && (e.left.ty.kind === "string" ||
398
527
  (e.left.ty.kind === "user" && e.right.ty.kind === "string"))) {
399
528
  const left = lowerExpr(e.left, binds);
400
529
  const right = lowerExpr(e.right, binds);
@@ -402,7 +531,9 @@ function lowerExpr(e, binds) {
402
531
  const rightIsUndef = e.right.kind === "var" && e.right.name === "undefined";
403
532
  return {
404
533
  kind: "if",
405
- cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: "size" }, right: { kind: "num", value: 0 } },
534
+ // strings carry the `length` marker it renders to `|x|` in Dafny and
535
+ // `.length` in Lean (whose String has no `.size` field).
536
+ cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: e.left.ty.kind === "string" ? "length" : "size" }, right: { kind: "num", value: 0 } },
406
537
  then: rightIsUndef ? { kind: "app", fn: "Some", args: [left] } : left,
407
538
  else: right,
408
539
  };
@@ -421,20 +552,43 @@ function lowerExpr(e, binds) {
421
552
  else: { kind: "var", name: "undefined" },
422
553
  };
423
554
  }
424
- // int + string NatToString(int) + string (string concatenation)
425
- if (e.op === "+" && _opts.backend === "dafny") {
426
- const isIntL = e.left.ty.kind === "int" || e.left.ty.kind === "nat";
427
- const isIntR = e.right.ty.kind === "int" || e.right.ty.kind === "nat";
428
- if (isIntL && e.right.ty.kind === "string") {
429
- return { kind: "binop", op: "+",
430
- left: { kind: "app", fn: "NatToString", args: [lowerExpr(e.left, binds)] },
431
- right: lowerExpr(e.right, binds) };
432
- }
433
- if (e.left.ty.kind === "string" && isIntR) {
434
- return { kind: "binop", op: "+",
435
- left: lowerExpr(e.left, binds),
436
- right: { kind: "app", fn: "NatToString", args: [lowerExpr(e.right, binds)] } };
437
- }
555
+ // String concatenation: `+` with a string operand. Stringify int/nat
556
+ // operands (Dafny NatToString, Lean toString) and join with arrayConcat
557
+ // (rendered `+` in Dafny, `++` in Lean).
558
+ if (e.op === "+" && (e.left.ty.kind === "string" || e.right.ty.kind === "string")) {
559
+ const strify = (o) => {
560
+ if (o.ty.kind !== "int" && o.ty.kind !== "nat")
561
+ return lowerExpr(o, binds);
562
+ // Lean `toString` handles any Int; Dafny needs IntToString for signed
563
+ // ints (NatToString is nat-only).
564
+ const fn = _opts.backend !== "dafny" ? "ToString" : o.ty.kind === "nat" ? "NatToString" : "IntToString";
565
+ return { kind: "app", fn, args: [lowerExpr(o, binds)] };
566
+ };
567
+ return { kind: "binop", op: "arrayConcat", left: strify(e.left), right: strify(e.right) };
568
+ }
569
+ // JS `%` is truncated (sign of the dividend); a signed `int` differs from the
570
+ // Euclidean `%` of Dafny/Lean, so route it through JSRem (Lean: `Int.tmod`).
571
+ if (e.op === "%" && e.left.ty.kind === "int") {
572
+ return { kind: "app", fn: "JSRem", args: [lowerExpr(e.left, binds), lowerExpr(e.right, binds)] };
573
+ }
574
+ // JS bigint `/` truncates toward zero (`-3n / 2n === -1n`); it differs from the
575
+ // floored `/` of Dafny/Lean, so route it through JSTruncDiv (Lean: `Int.tdiv`).
576
+ if (e.op === "/" && e.ty.kind === "int") {
577
+ return { kind: "app", fn: "JSTruncDiv", args: [lowerExpr(e.left, binds), lowerExpr(e.right, binds)] };
578
+ }
579
+ // JS string ordering is lexicographic vs Dafny's seq prefix order, so route
580
+ // through JSStringLt. Dafny-only: Lean's native `<` is already lexicographic.
581
+ if (_opts.backend === "dafny" && ["<", "<=", ">", ">="].includes(e.op) && e.left.ty.kind === "string") {
582
+ const l = lowerExpr(e.left, binds), r = lowerExpr(e.right, binds);
583
+ const lt = (x, y) => ({ kind: "app", fn: "JSStringLt", args: [x, y] });
584
+ const not = (x) => ({ kind: "unop", op: "¬", expr: x });
585
+ if (e.op === "<")
586
+ return lt(l, r);
587
+ if (e.op === ">")
588
+ return lt(r, l);
589
+ if (e.op === "<=")
590
+ return not(lt(r, l));
591
+ return not(lt(l, r)); // >=
438
592
  }
439
593
  // Numeric int→real coercion. After resolve, `/` is always real, and any
440
594
  // arithmetic/comparison mixing real and integral operands is real-valued.
@@ -550,14 +704,21 @@ function lowerExpr(e, binds) {
550
704
  let method = e.fn.field;
551
705
  const args = e.args.map((a, i) => {
552
706
  const lowered = lowerExpr(a, binds);
553
- // arr.with index (first arg) needs .toNat when Int-typed
554
- if (e.fn.kind === "field" && e.fn.field === "with" && e.fn.obj.ty.kind === "array" && i === 0 && !isNat(a.ty))
707
+ // Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1).
708
+ const isArrIdxArg = e.fn.kind === "field" && e.fn.obj.ty.kind === "array" &&
709
+ ((e.fn.field === "with" && i === 0) || ((e.fn.field === "includes" || e.fn.field === "indexOf") && i === 1));
710
+ if (isArrIdxArg && !isNat(a.ty))
555
711
  return { kind: "toNat", expr: lowered };
556
712
  return lowered;
557
713
  });
558
- // arr.concat(otherArr): array argument real concatenation, not push
559
- if (method === "concat" && e.fn.obj.ty.kind === "array" && e.args.length === 1 && e.args[0].ty.kind === "array") {
560
- return { kind: "binop", op: "arrayConcat", left: recv, right: args[0] };
714
+ // arr.concat(...args): each array arg is spread, each value arg appended.
715
+ if (method === "concat" && e.fn.obj.ty.kind === "array") {
716
+ let acc = recv;
717
+ for (let k = 0; k < args.length; k++) {
718
+ const piece = e.args[k].ty.kind === "array" ? args[k] : { kind: "arrayLiteral", elems: [args[k]] };
719
+ acc = { kind: "binop", op: "arrayConcat", left: acc, right: piece };
720
+ }
721
+ return acc;
561
722
  }
562
723
  // Spec-context map get: result type is non-optional → direct access
563
724
  if (method === "get" && e.fn.obj.ty.kind === "map" && e.ty.kind !== "optional") {
@@ -676,7 +837,14 @@ function lowerExpr(e, binds) {
676
837
  })),
677
838
  };
678
839
  }
679
- return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
840
+ // Carry the resolved record type so the emitter can pick the right
841
+ // constructor when two datatypes share a field-name set (Event vs
842
+ // SparseEvent) — structural matching alone would take the first-declared.
843
+ const recName = e.ty.kind === "user"
844
+ ? (e.ty.name.includes("<") ? e.ty.name.slice(0, e.ty.name.indexOf("<")) : e.ty.name)
845
+ : undefined;
846
+ const ctor = recName && _typeDecls.find(d => d.name === recName && d.kind === "record") ? recName : undefined;
847
+ return { kind: "record", spread: null, ctor, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
680
848
  }
681
849
  case "arrayLiteral":
682
850
  if (e.ty.kind === "map" && e.elems.length === 0)
@@ -1184,18 +1352,24 @@ function transformStmt(s, typeDecls) {
1184
1352
  case "break": return [{ kind: "break" }];
1185
1353
  case "continue": return [{ kind: "continue" }];
1186
1354
  case "expr": {
1187
- // Mutating collection call: m.set(k, v) → m := m.set(k, v)
1188
- // Same for s.add(x) on sets, arr.push(x)
1189
- if (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1190
- s.expr.fn.obj.kind === "var" &&
1191
- ((s.expr.fn.obj.ty.kind === "map" || s.expr.fn.obj.ty.kind === "set") &&
1192
- (s.expr.fn.field === "set" || s.expr.fn.field === "add" || s.expr.fn.field === "delete")) ||
1193
- (s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1194
- s.expr.fn.obj.kind === "var" && s.expr.fn.obj.ty.kind === "array" &&
1195
- s.expr.fn.field === "push")) {
1196
- const receiver = s.expr.fn.obj.name;
1197
- const { binds, expr } = liftMethodCalls(s.expr);
1198
- return [...binds, { kind: "assign", target: receiver, value: expr }];
1355
+ // Mutating collection call: m.set(k, v) → m := m.set(k, v) (same for set
1356
+ // .add/.delete and array .push). The receiver may be a bare var, or a
1357
+ // field path rooted at a var (b.items.push(v) b := b.(items := b.items + [v])).
1358
+ if (s.expr.kind === "call" && s.expr.fn.kind === "field") {
1359
+ const recv = s.expr.fn.obj;
1360
+ const f = s.expr.fn.field;
1361
+ const isMutating = ((recv.ty.kind === "map" || recv.ty.kind === "set") && (f === "set" || f === "add" || f === "delete")) ||
1362
+ (recv.ty.kind === "array" && f === "push");
1363
+ if (isMutating && recv.kind === "var") {
1364
+ const { binds, expr } = liftMethodCalls(s.expr);
1365
+ return [...binds, { kind: "assign", target: recv.name, value: expr }];
1366
+ }
1367
+ if (isMutating && recv.kind === "field") {
1368
+ const { binds, expr } = liftMethodCalls(s.expr);
1369
+ const upd = buildNestedFieldUpdate(recv, expr);
1370
+ if (upd)
1371
+ return [...binds, { kind: "assign", target: upd.root, value: upd.value }];
1372
+ }
1199
1373
  }
1200
1374
  // Optional chaining on map.get at statement level: m.get(k)?.push(v)
1201
1375
  // → if k in m { m[k] := m[k] + [v] } (actual mutation, not value-discard).
@@ -1514,7 +1688,7 @@ function transformPureBody(stmts, typeDecls) {
1514
1688
  const elseExpr = transformPureBody(elseStmts, typeDecls);
1515
1689
  if (!elseExpr)
1516
1690
  return null;
1517
- return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
1691
+ return { kind: "if", cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty), then: thenExpr, else: elseExpr };
1518
1692
  }
1519
1693
  case "switch": return transformPureSwitch(s, typeDecls);
1520
1694
  case "someMatch": {
@@ -1798,7 +1972,7 @@ export function transformModule(mod, specImport) {
1798
1972
  else if (fn.forcePure) {
1799
1973
  // //@ pure but body can't be auto-converted — emit function by method
1800
1974
  _forofCounters.clear();
1801
- const methodBody = transformStmts(fn.body, mod.typeDecls);
1975
+ const methodBody = promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1802
1976
  defByMethods.push({
1803
1977
  kind: "def-by-method",
1804
1978
  name: fn.name,
@@ -1860,7 +2034,7 @@ export function transformModule(mod, specImport) {
1860
2034
  _forofCounters.clear();
1861
2035
  let body = pureDefNames.has(fn.name)
1862
2036
  ? [{ kind: "return", value: { kind: "app", fn: `Pure.${fn.name}`, args: fn.params.map(p => ({ kind: "var", name: p.name })) } }]
1863
- : transformStmts(fn.body, mod.typeDecls);
2037
+ : promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1864
2038
  // Shadow reassigned parameters with mutable locals
1865
2039
  const paramNames = new Set(fn.params.map(p => p.name));
1866
2040
  const reassigned = findReassignedNames(fn.body, paramNames);
@@ -1878,6 +2052,7 @@ export function transformModule(mod, specImport) {
1878
2052
  returnType: fn.returnTy,
1879
2053
  requires: fn.requires.map(transformExpr),
1880
2054
  ensures,
2055
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1881
2056
  body,
1882
2057
  };
1883
2058
  });
@@ -1886,7 +2061,7 @@ export function transformModule(mod, specImport) {
1886
2061
  const classMethods = cls.methods.map(fn => {
1887
2062
  const ensures = fn.ensures.map(transformExpr);
1888
2063
  _forofCounters.clear();
1889
- const body = transformStmts(fn.body, mod.typeDecls);
2064
+ const body = promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
1890
2065
  return {
1891
2066
  kind: "method",
1892
2067
  name: fn.name,
@@ -1895,6 +2070,7 @@ export function transformModule(mod, specImport) {
1895
2070
  returnType: fn.returnTy,
1896
2071
  requires: fn.requires.map(transformExpr),
1897
2072
  ensures,
2073
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1898
2074
  body,
1899
2075
  };
1900
2076
  });