lemmascript 0.5.3 → 0.5.5

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.3",
3
+ "version": "0.5.5",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,6 +34,8 @@
34
34
  "keywords": [
35
35
  "lemmascript",
36
36
  "verification",
37
+ "formal-verification",
38
+ "verified",
37
39
  "typescript",
38
40
  "lean4",
39
41
  "dafny",
@@ -458,12 +458,19 @@ function emitExpr(e) {
458
458
  let ctorName;
459
459
  if (e.fields.length > 0) {
460
460
  const fieldNames = new Set(e.fields.map(f => f.name));
461
+ const matches = [];
461
462
  for (const [name, fields] of _structureDecls) {
462
463
  if (fields.length >= e.fields.length && fields.every(f => fieldNames.has(f.name) || f.type.kind === "optional")) {
463
- ctorName = name;
464
- break;
464
+ matches.push(name);
465
465
  }
466
466
  }
467
+ // When several datatypes share this field-name set (e.g. Event vs
468
+ // SparseEvent), disambiguate by the resolved record type carried from
469
+ // transform; otherwise take the sole structural match.
470
+ if (e.ctor && matches.includes(e.ctor))
471
+ ctorName = e.ctor;
472
+ else if (matches.length > 0)
473
+ ctorName = matches[0];
467
474
  if (!ctorName)
468
475
  ctorName = _recordCtors.get(e.fields[0].name);
469
476
  }
@@ -708,6 +715,8 @@ function emitDecl(d) {
708
715
  lines.push(` requires ${emitExpr(r)}`);
709
716
  for (const e of d.ensures)
710
717
  lines.push(` ensures ${emitExpr(e)}`);
718
+ if (d.decreases)
719
+ lines.push(` decreases ${emitExpr(d.decreases)}`);
711
720
  lines.push(`{`);
712
721
  lines.push(emitStmts(d.body, 1));
713
722
  lines.push(`}`);
@@ -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).
@@ -246,7 +253,7 @@ function ruleEarlyReturnConsume(s, rest) {
246
253
  kind: "someMatch",
247
254
  scrutinee: check.scrutinee, binderTy: check.innerTy,
248
255
  binder: check.binderHint,
249
- someBody: rest,
256
+ someBody: canBeFalsy(check) ? [{ kind: "if", cond: bound(check), then: rest, else: noneBranch }] : rest,
250
257
  noneBody: noneBranch,
251
258
  };
252
259
  }
@@ -308,7 +315,8 @@ function ruleConditionalOptionalSimple(e) {
308
315
  kind: "someMatch",
309
316
  scrutinee: check.scrutinee, binderTy: check.innerTy,
310
317
  binder: check.binderHint,
311
- someBody, noneBody,
318
+ someBody: canBeFalsy(check) ? { kind: "conditional", cond: bound(check), then: someBody, else: noneBody, ty: e.ty } : someBody,
319
+ noneBody,
312
320
  ty: e.ty,
313
321
  };
314
322
  }
@@ -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) };
@@ -248,15 +248,18 @@ function flattenLambdaBody(stmts) {
248
248
  * field, index, record, forall, or exists sub-expressions.
249
249
  */
250
250
  /** JS truthiness coercion for `if`/`while`/`?:` conditions.
251
- * Dafny requires bool; TS treats number/string/array as truthy when non-empty.
251
+ * Dafny requires bool; coerce number→`≠0`, stringnon-empty, array→`true`
252
+ * (every array, even `[]`, is truthy in JS).
252
253
  * Optional conds are handled separately by narrow.ts (rewritten to someMatch). */
253
254
  function coerceCondToBool(cond, ty) {
254
255
  if (ty.kind === "bool")
255
256
  return cond;
256
257
  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 } };
258
+ return { kind: "binop", op: "", left: cond, right: { kind: "num", value: 0 } };
259
+ if (ty.kind === "string")
260
+ return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "length" }, right: { kind: "num", value: 0 } };
261
+ if (ty.kind === "array")
262
+ return { kind: "bool", value: true };
260
263
  return cond;
261
264
  }
262
265
  /** Wrap an expression in Some/None for optional-typed conditionals.
@@ -309,6 +312,14 @@ function lowerExpr(e, binds) {
309
312
  ],
310
313
  };
311
314
  }
315
+ // Number truthiness: !n → n == 0
316
+ if (e.op === "!" && (e.expr.ty.kind === "int" || e.expr.ty.kind === "nat"))
317
+ return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "num", value: 0 } };
318
+ // Array truthiness: every array is truthy in JS, so !xs is always false.
319
+ if (e.op === "!" && e.expr.ty.kind === "array") {
320
+ lowerExpr(e.expr, binds); // preserve any lifted side effects; value is the constant false
321
+ return { kind: "bool", value: false };
322
+ }
312
323
  return { kind: "unop", op: e.op === "!" ? "¬" : e.op, expr: lowerExpr(e.expr, binds) };
313
324
  case "binop": {
314
325
  // Implication: flatten (A && B) ==> C → implies [A, B] C
@@ -327,8 +338,10 @@ function lowerExpr(e, binds) {
327
338
  right: { kind: "constructor", name: e.right.value, type: objTy },
328
339
  };
329
340
  }
330
- // String literal comparison — constructor if user type, string literal if string
331
- if ((e.op === "===" || e.op === "!==") && e.right.kind === "str") {
341
+ // String literal comparison — constructor if user type, string literal if string.
342
+ // Skip when the left is optional: fall through to the optional-comparison rule,
343
+ // which unwraps and compares the inner value (`Some(v) => v == "")`.
344
+ if ((e.op === "===" || e.op === "!==") && e.right.kind === "str" && e.left.ty.kind !== "optional") {
332
345
  const left = lowerExpr(e.left, binds);
333
346
  const leftTy = e.left.ty.kind === "user" ? e.left.ty.name : undefined;
334
347
  const right = isUser(e.left.ty)
@@ -352,7 +365,13 @@ function lowerExpr(e, binds) {
352
365
  ],
353
366
  };
354
367
  }
355
- const valExpr = lowerExpr(valSide, binds);
368
+ // A string literal compared against an optional user type is the variant
369
+ // constructor (`o === "red"` → `Some(v) => v == Color.red`), mirroring the
370
+ // non-optional string-literal rule above.
371
+ const innerTy = optSide.ty.kind === "optional" ? optSide.ty.inner : optSide.ty;
372
+ const valExpr = valSide.kind === "str" && innerTy.kind === "user"
373
+ ? { kind: "constructor", name: valSide.value, type: innerTy.name }
374
+ : lowerExpr(valSide, binds);
356
375
  const cmpOp = BOOL_OP_MAP[e.op] ?? e.op;
357
376
  const noneVal = e.op === "!==" ? true : false;
358
377
  const bound = matchBinder("value");
@@ -402,7 +421,9 @@ function lowerExpr(e, binds) {
402
421
  const rightIsUndef = e.right.kind === "var" && e.right.name === "undefined";
403
422
  return {
404
423
  kind: "if",
405
- cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: "size" }, right: { kind: "num", value: 0 } },
424
+ // strings carry the `length` marker, arrays `size` both render to `|x|`
425
+ // in Dafny, but Lean's String has no `.size` field (it's `.length`).
426
+ cond: { kind: "binop", op: ">", left: { kind: "field", obj: left, field: e.left.ty.kind === "string" ? "length" : "size" }, right: { kind: "num", value: 0 } },
406
427
  then: rightIsUndef ? { kind: "app", fn: "Some", args: [left] } : left,
407
428
  else: right,
408
429
  };
@@ -676,7 +697,14 @@ function lowerExpr(e, binds) {
676
697
  })),
677
698
  };
678
699
  }
679
- return { kind: "record", spread: null, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
700
+ // Carry the resolved record type so the emitter can pick the right
701
+ // constructor when two datatypes share a field-name set (Event vs
702
+ // SparseEvent) — structural matching alone would take the first-declared.
703
+ const recName = e.ty.kind === "user"
704
+ ? (e.ty.name.includes("<") ? e.ty.name.slice(0, e.ty.name.indexOf("<")) : e.ty.name)
705
+ : undefined;
706
+ const ctor = recName && _typeDecls.find(d => d.name === recName && d.kind === "record") ? recName : undefined;
707
+ return { kind: "record", spread: null, ctor, fields: e.fields.map(f => ({ name: f.name, value: lowerExpr(f.value, binds) })) };
680
708
  }
681
709
  case "arrayLiteral":
682
710
  if (e.ty.kind === "map" && e.elems.length === 0)
@@ -1514,7 +1542,7 @@ function transformPureBody(stmts, typeDecls) {
1514
1542
  const elseExpr = transformPureBody(elseStmts, typeDecls);
1515
1543
  if (!elseExpr)
1516
1544
  return null;
1517
- return { kind: "if", cond: transformExpr(s.cond), then: thenExpr, else: elseExpr };
1545
+ return { kind: "if", cond: coerceCondToBool(transformExpr(s.cond), s.cond.ty), then: thenExpr, else: elseExpr };
1518
1546
  }
1519
1547
  case "switch": return transformPureSwitch(s, typeDecls);
1520
1548
  case "someMatch": {
@@ -1878,6 +1906,7 @@ export function transformModule(mod, specImport) {
1878
1906
  returnType: fn.returnTy,
1879
1907
  requires: fn.requires.map(transformExpr),
1880
1908
  ensures,
1909
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1881
1910
  body,
1882
1911
  };
1883
1912
  });
@@ -1895,6 +1924,7 @@ export function transformModule(mod, specImport) {
1895
1924
  returnType: fn.returnTy,
1896
1925
  requires: fn.requires.map(transformExpr),
1897
1926
  ensures,
1927
+ decreases: fn.decreases ? transformExpr(fn.decreases) : null,
1898
1928
  body,
1899
1929
  };
1900
1930
  });