lemmascript 0.5.1 → 0.5.2

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
@@ -27,6 +27,12 @@ See the external case studies:
27
27
  - **[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. Dafny only.
28
28
  - **[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.
29
29
  - **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
30
+ - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. Dafny only.
31
+ - **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
32
+ - **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
33
+ - **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
34
+ - **[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.
35
+ - **[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.
30
36
 
31
37
  ## Setup
32
38
 
@@ -71,9 +77,7 @@ npx lsc gen --backend=lean src/myModule.ts
71
77
  lake build
72
78
  ```
73
79
 
74
- ## What's Supported
75
-
76
- ### Annotations
80
+ ## Annotations
77
81
 
78
82
  ```typescript
79
83
  //@ requires arr.length > 0
@@ -83,6 +87,8 @@ lake build
83
87
  //@ type i nat
84
88
  ```
85
89
 
90
+ For the full surface, see [SPEC.md](SPEC.md).
91
+
86
92
  ## File Structure
87
93
 
88
94
  ### Dafny backend
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -166,6 +166,12 @@ function emitExpr(e) {
166
166
  return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
167
167
  if (e.method === "filter")
168
168
  return `Std.Collections.Seq.Filter(${args[0]}, ${obj})`;
169
+ // filterMap (synthesized in resolve): drop Nones and unwrap to seq<T>.
170
+ if (e.method === "filterSome") {
171
+ needPreamble("SeqFilterSome");
172
+ needPreamble("OptionType");
173
+ return `SeqFilterSome(${obj})`;
174
+ }
169
175
  if (e.method === "every")
170
176
  return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
171
177
  if (e.method === "findLast") {
@@ -350,16 +356,9 @@ function emitExpr(e) {
350
356
  needPreamble("BitAnd");
351
357
  return `BitAnd(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
352
358
  }
353
- // int * real coercion: wrap int side with "as real"
354
- if (["+", "-", "*", "/"].includes(op)) {
355
- const leftIsReal = e.left.kind === "num" && !Number.isInteger(e.left.value);
356
- const rightIsReal = e.right.kind === "num" && !Number.isInteger(e.right.value);
357
- if (leftIsReal !== rightIsReal) {
358
- const left = leftIsReal ? emitExpr(e.left) : `(${emitExpr(e.left)} as real)`;
359
- const right = rightIsReal ? emitExpr(e.right) : `(${emitExpr(e.right)} as real)`;
360
- return `(${left} ${op} ${right})`;
361
- }
362
- }
359
+ // intreal coercion is now injected upstream in transform (toReal nodes),
360
+ // which has full type information — including real-typed variables, not
361
+ // just literals so no literal-based coercion is needed here.
363
362
  return `(${wrapQuantifier(e.left)} ${op} ${emitExpr(e.right)})`;
364
363
  }
365
364
  case "implies": {
@@ -399,6 +398,8 @@ function emitExpr(e) {
399
398
  needPreamble("MathMin");
400
399
  needPreamble("MinOfSeq");
401
400
  }
401
+ if (e.fn === "Perm")
402
+ needPreamble("Perm");
402
403
  return `${escapeName(e.fn)}(${args.join(", ")})`;
403
404
  }
404
405
  case "field": {
@@ -414,6 +415,8 @@ function emitExpr(e) {
414
415
  case "toNat":
415
416
  // Dafny doesn't need toNat — just emit the inner expression
416
417
  return emitExpr(e.expr);
418
+ case "toReal":
419
+ return `(${emitExpr(e.expr)} as real)`;
417
420
  case "index": {
418
421
  const obj = emitExpr(e.arr);
419
422
  const idx = emitExpr(e.idx);
@@ -596,10 +599,29 @@ function emitDecl(d) {
596
599
  switch (d.kind) {
597
600
  case "inductive": {
598
601
  const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
602
+ // Dafny requires a destructor shared across constructors to have a single
603
+ // type. Two TS variants can legitimately share a field name with different
604
+ // types (e.g. label.targetId: string vs leaf.targetId: string?). Detect
605
+ // such collisions and make those destructors per-constructor unique. Safe:
606
+ // variant-field reads lower to positional match bindings, never named
607
+ // destructors on the union (a name shared with differing types isn't even
608
+ // accessible on the union type in TS), so nothing references the old name.
609
+ const typesByField = new Map();
610
+ for (const c of d.constructors)
611
+ for (const f of c.fields) {
612
+ let s = typesByField.get(f.name);
613
+ if (!s) {
614
+ s = new Set();
615
+ typesByField.set(f.name, s);
616
+ }
617
+ s.add(tyToDafny(f.type));
618
+ }
619
+ const collides = new Set([...typesByField].filter(([, s]) => s.size > 1).map(([n]) => n));
599
620
  const ctors = d.constructors.map(c => {
600
621
  if (c.fields.length === 0)
601
622
  return escapeName(c.name);
602
- return `${escapeName(c.name)}(${paramList(c.fields)})`;
623
+ const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name}` } : f);
624
+ return `${escapeName(c.name)}(${paramList(fields)})`;
603
625
  });
604
626
  return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
605
627
  }
@@ -609,6 +631,11 @@ function emitDecl(d) {
609
631
  case "type-alias": {
610
632
  return `type ${d.name} = ${tyToDafny(d.target)}`;
611
633
  }
634
+ case "opaque-type": {
635
+ // Abstract type — no definition. `(==)` so it can sit inside datatypes
636
+ // that derive structural equality. Never constructed or destructured.
637
+ return `type ${d.name}(==)`;
638
+ }
612
639
  case "def": {
613
640
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
614
641
  const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
@@ -743,6 +770,12 @@ const CEIL_REAL = `function CeilReal(x: real): int
743
770
  if x == (x.Floor as real) then x.Floor
744
771
  else x.Floor + 1
745
772
  }`;
773
+ const SEQ_FILTER_SOME = `function SeqFilterSome<T>(xs: seq<Option<T>>): seq<T>
774
+ ensures |SeqFilterSome(xs)| <= |xs|
775
+ {
776
+ if |xs| == 0 then []
777
+ else (if xs[0].Some? then [xs[0].value] else []) + SeqFilterSome(xs[1..])
778
+ }`;
746
779
  const SEQ_FIND_INDEX = `function SeqFindIndex<T>(s: seq<T>, p: T -> bool): int
747
780
  ensures -1 <= SeqFindIndex(s, p) < |s|
748
781
  ensures SeqFindIndex(s, p) >= 0 ==> p(s[SeqFindIndex(s, p)])
@@ -956,6 +989,10 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
956
989
  else NatToString(n / 10) + [digit]
957
990
  }`;
958
991
  const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
992
+ // perm(a, b) — `a` and `b` are reorderings of each other (equal as multisets).
993
+ // Transparent (Dafny unfolds it), so hand-proofs can reason with `multiset`
994
+ // directly. The `(==)` bound requires the element type to support equality.
995
+ const PERM = `predicate Perm<T(==)>(a: seq<T>, b: seq<T>) { multiset(a) == multiset(b) }`;
959
996
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
960
997
  ensures forall x :: x in s <==> x in res
961
998
  ensures |res| == |s|
@@ -986,6 +1023,7 @@ const PREAMBLE_CODE = [
986
1023
  ["FloorReal", FLOOR_REAL],
987
1024
  ["SeqIndexOf", SEQ_INDEX_OF],
988
1025
  ["SeqFindIndex", SEQ_FIND_INDEX],
1026
+ ["SeqFilterSome", SEQ_FILTER_SOME],
989
1027
  ["SeqFindLast", SEQ_FIND_LAST],
990
1028
  ["SeqFlatten", SEQ_FLATTEN],
991
1029
  ["SeqJoin", SEQ_JOIN],
@@ -1001,6 +1039,7 @@ const PREAMBLE_CODE = [
1001
1039
  ["MathMax", MATH_MAX],
1002
1040
  ["MaxOfSeq", MAX_OF_SEQ],
1003
1041
  ["MinOfSeq", MIN_OF_SEQ],
1042
+ ["Perm", PERM],
1004
1043
  ];
1005
1044
  // ── Constructor and record helpers ───────────────────────────
1006
1045
  let _recordCtors = new Map();
@@ -159,6 +159,26 @@ function _synthName(elemName, otherName) {
159
159
  const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
160
160
  return `ArrayOf_${sanitize(elemName)}_Or_${sanitize(otherName)}`;
161
161
  }
162
+ /**
163
+ * Fall-through for a union LS can't model as a tagged union (no runtime type
164
+ * test maps to a tag — e.g. members are unreachable imports with no visible
165
+ * discriminant). Registers an opaque-type TypeDeclInfo and returns its name, so
166
+ * the union becomes one abstract `type` rather than invalid raw-union Dafny.
167
+ *
168
+ * Sound because an opaque type has no constructor and no tag predicate: any
169
+ * attempt to build or type-test the value fails to lower, so it can only be
170
+ * passed through — the one sound use of a union we can't discriminate. Distinct
171
+ * from dropping the field (which collapses values and is unsound): the value is
172
+ * preserved, just uninspectable.
173
+ */
174
+ function _synthOpaque(memberNames) {
175
+ const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
176
+ const name = `Opaque_${memberNames.map(sanitize).join("_or_")}`;
177
+ if (_synthArrayUnions !== null && !_synthArrayUnions.some(d => d.name === name)) {
178
+ _synthArrayUnions.push({ name, kind: "opaque" });
179
+ }
180
+ return name;
181
+ }
162
182
  /**
163
183
  * String-level fallback for synth detection on a `T[] | U` shape, used by
164
184
  * declare-type field parsing (where no ts-morph TypeNode is available). The
@@ -277,10 +297,10 @@ function extractExpr(node) {
277
297
  if (Node.isNumericLiteral(node)) {
278
298
  return { kind: "num", value: Number(node.getLiteralValue()) };
279
299
  }
280
- // BigInt literal (e.g. 32n, 0xffffn) — treat as integer
300
+ // BigInt literal (e.g. 32n, 0xffffn) — integer, with bigint division semantics
281
301
  if (Node.isBigIntLiteral(node)) {
282
302
  const text = node.getText().replace(/n$/, '');
283
- return { kind: "num", value: Number(text) };
303
+ return { kind: "num", value: Number(text), big: true };
284
304
  }
285
305
  // Template literal: `foo${x}bar` → "foo" + x + "bar"
286
306
  if (Node.isTemplateExpression(node)) {
@@ -431,12 +451,16 @@ function extractExpr(node) {
431
451
  const typeNode = p.getTypeNode();
432
452
  return { name: p.getName(), tsType: typeNode ? typeNode.getText() : undefined };
433
453
  });
454
+ // Capture an explicit return annotation (`(x): Out => …`) so resolve can type
455
+ // return-position record literals to their named type instead of a tuple.
456
+ const retNode = node.getReturnTypeNode();
457
+ const returnTsType = retNode ? typeToString(node.getReturnType()) : undefined;
434
458
  const body = node.getBody();
435
459
  if (Node.isExpression(body)) {
436
- return { kind: "lambda", params, body: extractExpr(body) };
460
+ return { kind: "lambda", params, body: extractExpr(body), returnTsType };
437
461
  }
438
462
  if (Node.isBlock(body)) {
439
- return { kind: "lambda", params, body: extractStmts(body.getStatements()) };
463
+ return { kind: "lambda", params, body: extractStmts(body.getStatements()), returnTsType };
440
464
  }
441
465
  throw new Error(`Unsupported arrow function body: ${node.getText().slice(0, 80)}`);
442
466
  }
@@ -552,6 +576,12 @@ function extractExpr(node) {
552
576
  if (Node.isNullLiteral(node)) {
553
577
  return { kind: "var", name: "undefined" };
554
578
  }
579
+ // `typeof X` — only meaningful in a `typeof X === "string"` discriminator over
580
+ // a synth `U | T[]` union (see narrow's parseTypeofStringCheck); any other use
581
+ // survives to emit and errors there.
582
+ if (Node.isTypeOfExpression(node)) {
583
+ return { kind: "unop", op: "typeof", expr: extractExpr(node.getExpression()) };
584
+ }
555
585
  throw new Error(`Unsupported expression: ${node.getText()}`);
556
586
  }
557
587
  // ── Annotation parsing ───────────────────────────────────────
@@ -796,7 +826,10 @@ function typeToString(type) {
796
826
  return "bigint";
797
827
  if (type.isString() || type.isStringLiteral())
798
828
  return "string";
799
- if (type.isBoolean())
829
+ // TS expands `boolean` to the literal union `false | true`; normalize the
830
+ // literals back so the union dedupes to a single `boolean` rather than being
831
+ // mistaken for an unmodelable multi-member union.
832
+ if (type.isBoolean() || type.isBooleanLiteral())
800
833
  return "boolean";
801
834
  // Named type alias (e.g. Priority = "low" | "medium" | "high") — use the alias name
802
835
  if (type.getAliasSymbol()) {
@@ -838,7 +871,15 @@ function typeToString(type) {
838
871
  }
839
872
  }
840
873
  const parts = [...new Set(unionTypes.map(typeToString))];
841
- return parts.join(" | ");
874
+ // `undefined`/`null` are optional markers; a single real member with them
875
+ // is an Option, left as `X | undefined` for the optional lowering.
876
+ const real = parts.filter(p => p !== "undefined" && p !== "null");
877
+ if (real.length <= 1)
878
+ return parts.join(" | ");
879
+ // A genuine multi-member union with no tagged-union shape LS can model →
880
+ // a single opaque type. (See _synthOpaque.) Preserve an outer optional.
881
+ const opaque = _synthOpaque(real);
882
+ return real.length === parts.length ? opaque : `${opaque} | undefined`;
842
883
  }
843
884
  if (type.isTuple()) {
844
885
  return `[${type.getTupleElements().map(t => typeToString(t)).join(", ")}]`;
@@ -1895,11 +1936,14 @@ export function extractModule(sourceFile) {
1895
1936
  if (!sig)
1896
1937
  continue;
1897
1938
  const typeParams = sig.getTypeParameters().map(tp => tp.getText());
1939
+ // Normalize via typeToString (not raw getText): resolves declare-type
1940
+ // shadows and yields bare names, so a param typed by an unreachable import
1941
+ // becomes `AgentMessage`, not `import("/abs/path").AgentMessage`.
1898
1942
  const params = sig.getParameters().map(p => ({
1899
1943
  name: p.getName(),
1900
- tsType: p.getTypeAtLocation(f.node).getText(),
1944
+ tsType: typeToString(p.getTypeAtLocation(f.node)),
1901
1945
  }));
1902
- const returnType = sig.getReturnType().getText();
1946
+ const returnType = typeToString(sig.getReturnType());
1903
1947
  const annots = collectFunctionAnnotations(f.node);
1904
1948
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
1905
1949
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
@@ -2121,10 +2165,17 @@ export function extractModule(sourceFile) {
2121
2165
  collectNamesExpr(e.else);
2122
2166
  }
2123
2167
  }
2168
+ // Signature types (params + return) get base-name stripping below; body /
2169
+ // spec references stay exact-match (so a body `let xs: Hunk[]` doesn't pull
2170
+ // `Hunk` into the filter early and reorder output that resolveType re-adds).
2171
+ const sigTypes = new Set();
2124
2172
  for (const fn of functions) {
2125
- for (const p of fn.params)
2173
+ for (const p of fn.params) {
2126
2174
  referencedNames.add(p.tsType);
2175
+ sigTypes.add(p.tsType);
2176
+ }
2127
2177
  referencedNames.add(fn.returnType);
2178
+ sigTypes.add(fn.returnType);
2128
2179
  collectNames(fn.body);
2129
2180
  // Also scan spec annotations for identifier references
2130
2181
  for (const spec of [...fn.requires, ...fn.ensures]) {
@@ -2153,6 +2204,15 @@ export function extractModule(sourceFile) {
2153
2204
  }
2154
2205
  for (const name of referencedNames)
2155
2206
  markType(name);
2207
+ // Signature types also mark their base after stripping array/optional
2208
+ // WRAPPERS (`Out[]`/`Msg | undefined` → `Out`/`Msg`), so a function returning
2209
+ // a local `Out[]` keeps `Out`. Wrappers only — never dig into generic args
2210
+ // (`Omit<FilePathOptions, …>` must not pull in the inner type).
2211
+ for (const name of sigTypes) {
2212
+ const base = name.replace(/\s*\|\s*(undefined|null)\s*$/, "").replace(/(\[\])+$/, "").trim();
2213
+ if (base !== name && /^[A-Za-z_]\w*$/.test(base))
2214
+ markType(base);
2215
+ }
2156
2216
  typeDecls.splice(0, typeDecls.length, ...typeDecls.filter(d => neededTypes.has(d.name) || declaredNames.has(d.name)));
2157
2217
  }
2158
2218
  // Resolve imported types: extract types referenced in function signatures but not in this file
@@ -7,7 +7,22 @@ function tyToLean(ty) {
7
7
  switch (ty.kind) {
8
8
  case "nat": return "Nat";
9
9
  case "int": return "Int";
10
- case "real": return "Float"; // Lean doesn't have exact reals; Float is approximate
10
+ case "real":
11
+ // Real arithmetic isn't supported by the Lean backend yet: ℝ is
12
+ // noncomputable and needs Mathlib's real-number development, so we fail
13
+ // fast here rather than emit Lean that can't compile.
14
+ //
15
+ // Workarounds, in order of preference:
16
+ // 1. If integer division was intended, write `Math.floor(a / b)` — it
17
+ // lowers to flooring integer division on Lean (no real involved).
18
+ // 2. For `bigint` operands, `/` is already integer division — declaring
19
+ // the value `bigint` instead of `number` keeps it off the real path.
20
+ // 3. If the file genuinely needs reals, restrict it to Dafny with a
21
+ // `//@ backend dafny` directive.
22
+ // Full Lean real support is feasible but was set aside: it needs
23
+ // `import Mathlib.Data.Real.Basic`, `noncomputable def`s for real-valued
24
+ // functions, and the Int→ℝ coercion (see the stashed WIP for a sketch).
25
+ throw new Error("real arithmetic is not supported by the Lean backend (needs noncomputable ℝ / Mathlib).");
11
26
  case "bool": return "Bool";
12
27
  case "string": return "String";
13
28
  case "void": return "Unit";
@@ -90,6 +105,16 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
90
105
  return `Array.push ${obj} ${args[0]}`;
91
106
  if (method === "concat")
92
107
  return `Array.push ${obj} ${args[0]}`;
108
+ // arr.slice → Array.extract. No-arg slice is a full copy (Array is a value
109
+ // type in Lean, so the receiver itself); one arg drops the prefix, two args
110
+ // give the half-open range. Matches JS for non-negative bounds (negative
111
+ // indices are unsupported — same caveat as the Dafny backend's direct slice).
112
+ if (method === "slice" && args.length === 0)
113
+ return obj;
114
+ if (method === "slice" && args.length === 1)
115
+ return `${obj}.extract ${args[0]} ${obj}.size`;
116
+ if (method === "slice" && args.length === 2)
117
+ return `${obj}.extract ${args[0]} ${args[1]}`;
93
118
  }
94
119
  // String methods
95
120
  if (tyKind === "string") {
@@ -122,12 +147,17 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
122
147
  throw new Error(`Unsupported Lean method call: .${method}() on ${tyKind}`);
123
148
  }
124
149
  // ── Expression emission ─────────────────────────────────────
125
- // Lean's `∀`/`∃` body extends as far as possible. So `(∃ x, P) <op> Q`
126
- // (or `∃ x, P Q`) would parse with the operator absorbed into the body.
127
- // Wrap a quantifier in parens to terminate its body before the operator.
128
- function wrapQuantifier(sub, parentPrec) {
150
+ // Some Lean term forms extend their body as far as possible: `∀`/`∃` bodies,
151
+ // and `if`/`let` tails. As an operator operand they would swallow the operator
152
+ // `(if c then 1 else 0) + r` written bare parses as `if c then 1 else (0 + r)`.
153
+ // Wrap these forms in parens so the operand is closed before the operator.
154
+ // (`match` self-parenthesizes in `emitExpr`, and other forms close via
155
+ // precedence, so neither needs wrapping here.)
156
+ function wrapOperand(sub, parentPrec) {
129
157
  const inner = emitExpr(sub, parentPrec);
130
- return (sub.kind === "forall" || sub.kind === "exists") ? `(${inner})` : inner;
158
+ return (sub.kind === "forall" || sub.kind === "exists" ||
159
+ sub.kind === "if" || sub.kind === "let")
160
+ ? `(${inner})` : inner;
131
161
  }
132
162
  function emitExpr(e, parentPrec) {
133
163
  switch (e.kind) {
@@ -183,11 +213,11 @@ function emitExpr(e, parentPrec) {
183
213
  return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
184
214
  }
185
215
  const op = e.op === "arrayConcat" ? "++" : e.op;
186
- const s = `${wrapQuantifier(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
216
+ const s = `${wrapOperand(e.left, prec(e.op))} ${op} ${wrapOperand(e.right, prec(e.op))}`;
187
217
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
188
218
  }
189
219
  case "implies": {
190
- const parts = [...e.premises.map(p => wrapQuantifier(p)), emitExpr(e.conclusion)];
220
+ const parts = [...e.premises.map(p => wrapOperand(p)), emitExpr(e.conclusion)];
191
221
  const s = parts.join(" → ");
192
222
  return parentPrec !== undefined ? `(${s})` : s;
193
223
  }
@@ -196,6 +226,12 @@ function emitExpr(e, parentPrec) {
196
226
  // SetToSeq → .toArray for Lean (HashSet has native toArray)
197
227
  if (e.fn === "SetToSeq" && args.length === 1)
198
228
  return `${args[0]}.toArray`;
229
+ // perm(a, b) → `List.Perm` on the underlying lists. Dafny lowers it to
230
+ // `multiset(a) == multiset(b)`; the Lean image is `a.toList ~ b.toList`,
231
+ // which mathlib's `List.Perm` provides (reflexivity, symmetry,
232
+ // `perm_append_comm`, and `Perm.count_eq` for the count-invariance payoff).
233
+ if (e.fn === "Perm" && args.length === 2)
234
+ return `(${args[0]}.toList).Perm (${args[1]}.toList)`;
199
235
  return `${e.fn} ${args.join(" ")}`;
200
236
  }
201
237
  case "field": {
@@ -210,6 +246,10 @@ function emitExpr(e, parentPrec) {
210
246
  const wrap = e.expr.kind !== "var" && e.expr.kind !== "num";
211
247
  return wrap ? `(${inner}).toNat` : `${inner}.toNat`;
212
248
  }
249
+ case "toReal":
250
+ // A real value reached the Lean backend via coercion (e.g. number `/`).
251
+ // Same unsupported-real story as the `real` type case in tyToLean.
252
+ throw new Error("real arithmetic is not supported by the Lean backend (needs noncomputable ℝ / Mathlib).");
213
253
  case "index":
214
254
  return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]!`;
215
255
  case "record": {
@@ -393,9 +433,24 @@ function emitDecl(d) {
393
433
  case "type-alias": {
394
434
  return `abbrev ${d.name} := ${tyToLean(d.target)}`;
395
435
  }
436
+ case "opaque-type": {
437
+ // Abstract type — no definition. Never constructed or destructured.
438
+ return `opaque ${d.name} : Type`;
439
+ }
396
440
  case "def": {
397
441
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
398
- return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
442
+ let out = `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
443
+ // A `//@ decreases` on a pure function marks it recursive and names its
444
+ // termination measure — emit it as Lean's `termination_by`. This is
445
+ // required when the recursion is on `arr.slice(...)` (→ `Array.extract`,
446
+ // which Lean cannot see as a structural subterm); for a bare-Nat counter
447
+ // Lean could infer structural recursion on its own, but honoring the
448
+ // clause uniformly is simpler and harmless. Lean's default `decreasing_by`
449
+ // discharges the goal in both cases (it knows `Array.size_extract`), so no
450
+ // explicit tactic is needed.
451
+ if (d.decreases)
452
+ out += `\ntermination_by ${emitExpr(d.decreases)}`;
453
+ return out;
399
454
  }
400
455
  case "def-by-method":
401
456
  throw new Error("function by method is not supported for Lean backend");
@@ -349,14 +349,18 @@ function ruleConditionalArrayIsArray(e) {
349
349
  if (e.kind !== "conditional")
350
350
  return null;
351
351
  const pos = parseArrayIsArrayCall(e.cond);
352
- const neg = e.cond.kind === "unop" && e.cond.op === "!"
352
+ // `typeof x === "string"` is a positive check like `Array.isArray`, but selects
353
+ // the NonArrayBranch — its then-branch is the matched-variant body.
354
+ const tof = pos ? null : parseTypeofStringCheck(e.cond);
355
+ const neg = !pos && !tof && e.cond.kind === "unop" && e.cond.op === "!"
353
356
  ? parseArrayIsArrayCall(e.cond.expr)
354
357
  : null;
355
- const matched = pos ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
358
+ const matched = pos ?? tof ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
356
359
  if (!matched)
357
360
  return null;
358
- const thenBody = pos ? e.then : e.else;
359
- const elseBody = pos ? e.else : e.then;
361
+ const positive = pos ?? tof;
362
+ const thenBody = positive ? e.then : e.else;
363
+ const elseBody = positive ? e.else : e.then;
360
364
  return {
361
365
  kind: "tagMatch",
362
366
  scrutinee: matched.scrutinee,
@@ -602,6 +606,30 @@ function parseArrayIsArrayCall(call) {
602
606
  return null;
603
607
  return { scrutinee: arg, typeName: arg.ty.name, variant: "ArrayBranch" };
604
608
  }
609
+ /** Detect `typeof <path> === "string"` where `<path>`'s type is a synth array-
610
+ * union (`U | T[]`) AND its `NonArrayBranch` payload `U` is itself `string`.
611
+ * The runtime `=== "string"` test matches that branch only when `U` is string —
612
+ * for any other non-array payload (`number | T[]`, …) it never holds, so we must
613
+ * NOT narrow. Returns the `NonArrayBranch` variant; the dual of `Array.isArray`. */
614
+ function parseTypeofStringCheck(e) {
615
+ if (e.kind !== "binop" || e.op !== "===")
616
+ return null;
617
+ const tof = e.left.kind === "unop" && e.left.op === "typeof" ? e.left.expr
618
+ : e.right.kind === "unop" && e.right.op === "typeof" ? e.right.expr : null;
619
+ const lit = e.left.kind === "str" ? e.left.value : e.right.kind === "str" ? e.right.value : null;
620
+ if (!tof || lit !== "string")
621
+ return null;
622
+ if (!isNarrowablePath(tof) || tof.ty.kind !== "user")
623
+ return null;
624
+ const baseTyName = tof.ty.name.includes("<") ? tof.ty.name.slice(0, tof.ty.name.indexOf("<")) : tof.ty.name;
625
+ const decl = _typeDecls.find(d => d.name === baseTyName);
626
+ if (decl?.kind !== "discriminated-union" || decl.discriminant !== "__isArray__")
627
+ return null;
628
+ const valTy = decl.variants?.find(v => v.name === "NonArrayBranch")?.fields.find(f => f.name === "val")?.type;
629
+ if (valTy?.kind !== "string")
630
+ return null; // guard: the non-array branch must actually be `string`
631
+ return { scrutinee: tof, typeName: tof.ty.name, variant: "NonArrayBranch" };
632
+ }
605
633
  /** A "narrowable path" is a var or a chain of field accesses rooted at a var
606
634
  * — i.e., pure and structurally addressable, so transforms can substitute
607
635
  * occurrences inside a matched arm without worrying about re-evaluation. */
@@ -20,6 +20,7 @@ function mapExpr(e, f) {
20
20
  case "app": return { ...e, args: e.args.map(r) };
21
21
  case "field": return { ...e, obj: r(e.obj) };
22
22
  case "toNat": return { ...e, expr: r(e.expr) };
23
+ case "toReal": return { ...e, expr: r(e.expr) };
23
24
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
24
25
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
25
26
  fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
@@ -364,6 +365,7 @@ function rewriteChildrenExpr(e) {
364
365
  case "app": return { ...e, args: e.args.map(r) };
365
366
  case "field": return { ...e, obj: r(e.obj) };
366
367
  case "toNat": return { ...e, expr: r(e.expr) };
368
+ case "toReal": return { ...e, expr: r(e.expr) };
367
369
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
368
370
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
369
371
  fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
@@ -445,6 +447,7 @@ function peepholeDecl(d) {
445
447
  case "inductive":
446
448
  case "structure":
447
449
  case "type-alias":
450
+ case "opaque-type":
448
451
  case "extern":
449
452
  return d;
450
453
  }
@@ -4,6 +4,7 @@
4
4
  * Uses linked environments (Scheme-style) for lexical scoping.
5
5
  * No mutation — each let extends the chain, lookup walks it.
6
6
  */
7
+ import { isBigInt } from "./typedir.js";
7
8
  import { parseTsType } from "./types.js";
8
9
  import { parseExpr } from "./specparser.js";
9
10
  function lookup(env, name) {
@@ -447,6 +448,12 @@ function tyToTsStr(ty) {
447
448
  return "number";
448
449
  if (ty.kind === "bool")
449
450
  return "boolean";
451
+ // Optional element (e.g. a `.filter` over a `T | undefined`-typed map result):
452
+ // type the callback param so its `x !== undefined` check narrows correctly.
453
+ if (ty.kind === "optional") {
454
+ const inner = tyToTsStr(ty.inner);
455
+ return inner ? `${inner} | undefined` : undefined;
456
+ }
450
457
  return undefined;
451
458
  }
452
459
  function inferLambdaParamTypes(fn, rawArgs, ctx) {
@@ -486,6 +493,21 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
486
493
  }
487
494
  return rawArgs;
488
495
  }
496
+ /** A defined-check filter predicate: `(x) => x !== undefined` (expression or
497
+ * single-return body). Detected on the raw IR before narrowing rewrites it. */
498
+ function isDefinedCheckRawLambda(raw) {
499
+ if (raw.kind !== "lambda" || raw.params.length !== 1)
500
+ return false;
501
+ const p = raw.params[0].name;
502
+ const body = Array.isArray(raw.body)
503
+ ? (raw.body.length === 1 && raw.body[0].kind === "return" ? raw.body[0].value : null)
504
+ : raw.body;
505
+ if (!body || body.kind !== "binop" || body.op !== "!==")
506
+ return false;
507
+ const isParam = (x) => x.kind === "var" && x.name === p;
508
+ const isUndef = (x) => x.kind === "var" && x.name === "undefined";
509
+ return (isParam(body.left) && isUndef(body.right)) || (isParam(body.right) && isUndef(body.left));
510
+ }
489
511
  /** Coerce call arguments: string literals → user types, non-optional → Some, pad missing optional args. */
490
512
  function coerceCallArgs(args, fn, ctx) {
491
513
  if (fn.kind !== "var" || !ctx.fnParams.has(fn.name))
@@ -517,6 +539,16 @@ function inferMethodReturnTy(fn, args, ctx) {
517
539
  if (fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray") {
518
540
  return { kind: "bool" };
519
541
  }
542
+ // Math.* numeric builtins: abs/min/max preserve the operand's numeric type
543
+ // (real if any operand is real); floor/ceil/round/trunc return an integer.
544
+ if (fn.obj.kind === "var" && fn.obj.name === "Math") {
545
+ if (fn.field === "abs" && args.length === 1)
546
+ return args[0].ty;
547
+ if ((fn.field === "min" || fn.field === "max") && args.length >= 1)
548
+ return args.some(a => a.ty.kind === "real") ? { kind: "real" } : args[0].ty;
549
+ if (["floor", "ceil", "round", "trunc"].includes(fn.field))
550
+ return { kind: "int" };
551
+ }
520
552
  const objTy = fn.obj.ty;
521
553
  if (objTy.kind === "map") {
522
554
  if (fn.field === "get")
@@ -558,8 +590,11 @@ function inferMethodReturnTy(fn, args, ctx) {
558
590
  if (fn.field === "join" && objTy.elem.kind === "string")
559
591
  return { kind: "string" };
560
592
  if (fn.field === "map" && args.length >= 1 && args[0].kind === "lambda") {
561
- const retTy = args[0].body.length > 0 && args[0].body[0].kind === "return"
562
- ? args[0].body[0].value.ty : { kind: "unknown" };
593
+ const lam = args[0];
594
+ // Prefer the lambda's declared return type (handles multi-statement bodies
595
+ // where body[0] is an `if`, not a `return`); fall back to the body's return.
596
+ const retTy = lam.ty.kind === "fn" ? lam.ty.result
597
+ : lam.body.length > 0 && lam.body[0].kind === "return" ? lam.body[0].value.ty : { kind: "unknown" };
563
598
  return { kind: "array", elem: retTy };
564
599
  }
565
600
  }
@@ -613,6 +648,8 @@ function resolveExpr(e, ctx) {
613
648
  case "num":
614
649
  if (!Number.isInteger(e.value))
615
650
  return { kind: "num", value: e.value, ty: { kind: "real" } };
651
+ if (e.big)
652
+ return { kind: "num", value: e.value, ty: { kind: "int", big: true } };
616
653
  return { kind: "num", value: e.value, ty: e.value >= 0 ? { kind: "nat" } : { kind: "int" } };
617
654
  case "str":
618
655
  return { kind: "str", value: e.value, ty: { kind: "string" } };
@@ -658,16 +695,38 @@ function resolveExpr(e, ctx) {
658
695
  }
659
696
  else if (e.op === "||")
660
697
  ty = right.ty;
661
- else if (["+", "-", "*", "/", "%"].includes(e.op)) {
698
+ else if (e.op === "/") {
699
+ // `number / number` is real (floating-point) division: 3 / 2 === 1.5,
700
+ // never 1 — an integer quotient requires an explicit Math.floor (which
701
+ // lowers to JSFloorDiv). But `bigint / bigint` is genuinely integer
702
+ // division in JS (3n / 2n === 1n), so keep it integer.
703
+ ty = (isBigInt(left.ty) || isBigInt(right.ty)) ? { kind: "int", big: true } : { kind: "real" };
704
+ }
705
+ else if (["+", "-", "*", "%"].includes(e.op)) {
662
706
  ty = (left.ty.kind === "real" || right.ty.kind === "real") ? { kind: "real" } : left.ty;
663
707
  }
664
708
  return { kind: "binop", op: e.op, left, right, ty };
665
709
  }
666
710
  case "unop": {
667
711
  const expr = resolveExpr(e.expr, ctx);
668
- return { kind: "unop", op: e.op, expr, ty: e.op === "!" ? { kind: "bool" } : expr.ty };
712
+ const ty = e.op === "!" ? { kind: "bool" } : e.op === "typeof" ? { kind: "string" } : expr.ty;
713
+ return { kind: "unop", op: e.op, expr, ty };
669
714
  }
670
715
  case "call": {
716
+ // perm(a, b): spec-only permutation predicate — true iff `a` and `b` are
717
+ // reorderings of each other (equal as multisets). Lowers to the `Perm`
718
+ // preamble (Dafny `multiset(a) == multiset(b)`; Lean `a.toList ~ b.toList`).
719
+ // It has no runtime counterpart, so it is rejected outside `//@` specs.
720
+ if (e.fn.kind === "var" && e.fn.name === "perm" && e.args.length === 2) {
721
+ if (!ctx.inSpec)
722
+ throw new Error("perm(a, b) may only be used in //@ specifications");
723
+ const a = resolveExpr(e.args[0], ctx);
724
+ const b = resolveExpr(e.args[1], ctx);
725
+ if (a.ty.kind !== "array" || b.ty.kind !== "array")
726
+ throw new Error(`perm(a, b) requires two array arguments (got ${a.ty.kind} and ${b.ty.kind})`);
727
+ const fn = { kind: "var", name: "Perm", ty: { kind: "unknown" } };
728
+ return { kind: "call", fn, args: [a, b], ty: { kind: "bool" }, callKind: "pure" };
729
+ }
671
730
  // Extern dispatch: `NS.method(args)` where NS.method is declared via
672
731
  // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
673
732
  // rest of the pipeline sees an ordinary pure function. The extern's
@@ -711,6 +770,16 @@ function resolveExpr(e, ctx) {
711
770
  if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
712
771
  ty = ctx.fnReturns.get(fn.name);
713
772
  }
773
+ // filterMap: `seqOfOption.filter(x => x !== undefined)` (a defined-check,
774
+ // typically with an `x is T` type guard) drops the Nones AND unwraps to
775
+ // seq<T>. Rewrite to a synthetic `filterSome` call lowered to the proven
776
+ // SeqFilterSome preamble (a plain `Map(.value, Filter(.Some?))` wouldn't
777
+ // verify — `.value` is partial).
778
+ if (e.fn.kind === "field" && e.fn.field === "filter" && e.args.length === 1
779
+ && isDefinedCheckRawLambda(e.args[0])
780
+ && fn.kind === "field" && fn.obj.ty.kind === "array" && fn.obj.ty.elem.kind === "optional") {
781
+ return { kind: "call", fn: { ...fn, field: "filterSome" }, args: [], ty: { kind: "array", elem: fn.obj.ty.elem.inner }, callKind: "method" };
782
+ }
714
783
  return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
715
784
  }
716
785
  case "index": {
@@ -895,12 +964,21 @@ function resolveExpr(e, ctx) {
895
964
  let lambdaEnv = ctx.env;
896
965
  for (const p of params)
897
966
  lambdaEnv = extend(lambdaEnv, p.name, p.ty);
898
- const lambdaCtx = { ...withEnv(ctx, lambdaEnv), inLambda: true };
967
+ // Set returnTy to the lambda's own return annotation (not the enclosing
968
+ // function's), so return-position record literals in the body resolve to
969
+ // their named type rather than an anonymous tuple.
970
+ const lambdaReturnTy = e.returnTsType ? parseTsType(e.returnTsType) : { kind: "unknown" };
971
+ const lambdaCtx = { ...withEnv(ctx, lambdaEnv), inLambda: true, returnTy: lambdaReturnTy };
899
972
  // Body: expression (wrap in return stmt) or statement block
900
973
  const body = Array.isArray(e.body)
901
974
  ? resolveBlock(e.body, lambdaCtx)
902
975
  : [{ kind: "return", value: resolveExpr(e.body, lambdaCtx) }];
903
- return { kind: "lambda", params, body, ty: { kind: "unknown" } };
976
+ // Carry the lambda's type as a fn type when its return is known, so chained
977
+ // array methods (`.map(...).filter(...)`) can infer downstream element types.
978
+ const lamTy = e.returnTsType
979
+ ? { kind: "fn", params: params.map(p => p.ty), result: lambdaReturnTy }
980
+ : { kind: "unknown" };
981
+ return { kind: "lambda", params, body, ty: lamTy };
904
982
  }
905
983
  case "conditional": {
906
984
  const cond = resolveExpr(e.cond, ctx);
@@ -1042,6 +1120,12 @@ function resolveStmt(s, ctx) {
1042
1120
  ? { kind: "optional", inner: init.ty }
1043
1121
  : init.ty;
1044
1122
  }
1123
+ else if ((declTy.kind === "int" || declTy.kind === "nat") && init.ty.kind === "real" && !ctx.overrides.has(s.name)) {
1124
+ // TS infers `number` (→ int/nat) for an expression LS computes as `real`
1125
+ // (e.g. `a / b`, now real division). `number` can't tell them apart, so
1126
+ // trust the real-valued initializer — unless the user pinned the type.
1127
+ ty = init.ty;
1128
+ }
1045
1129
  else {
1046
1130
  // Map indexing: TS says T, but access can fail → use Optional<T> from init
1047
1131
  ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
@@ -32,6 +32,7 @@ function mapExpr(e, f) {
32
32
  case "app": return { ...e, args: e.args.map(r) };
33
33
  case "field": return { ...e, obj: r(e.obj) };
34
34
  case "toNat": return { ...e, expr: r(e.expr) };
35
+ case "toReal": return { ...e, expr: r(e.expr) };
35
36
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
36
37
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
37
38
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
@@ -154,6 +155,7 @@ function buildMatchPattern(variantName, fields, scopePrefix) {
154
155
  }
155
156
  const _forofCounters = new Map();
156
157
  function isNat(ty) { return ty.kind === "nat"; }
158
+ function isIntegral(ty) { return ty.kind === "int" || ty.kind === "nat"; }
157
159
  function isArray(ty) { return ty.kind === "array"; }
158
160
  function isUser(ty) { return ty.kind === "user"; }
159
161
  /** Check if transformed lambda body contains monadic binds. */
@@ -186,6 +188,8 @@ const OP_MAP = {
186
188
  const BOOL_OP_MAP = {
187
189
  ...OP_MAP, "===": "==", "!==": "!=",
188
190
  };
191
+ /** Arithmetic + comparison ops eligible for int→real operand coercion. */
192
+ const NUMERIC_OPS = new Set(["+", "-", "*", "/", "===", "!==", ">=", "<=", ">", "<"]);
189
193
  function transformExpr(e) { return lowerExpr(e, null); }
190
194
  /** Reduce an if/let/return-shaped statement body to a single expression, for
191
195
  * expression-only lambda bodies. Returns null for shapes that can't be a pure
@@ -219,6 +223,19 @@ function flattenLambdaBody(stmts) {
219
223
  const elseExpr = flattenLambdaBody(first.else.length > 0 ? [...first.else, ...rest] : rest);
220
224
  return thenExpr === null || elseExpr === null ? null : { kind: "if", cond: first.cond, then: thenExpr, else: elseExpr };
221
225
  }
226
+ // A `switch` lowered to a match-statement: reduce each arm's body to an
227
+ // expression (an arm that doesn't return falls through into `rest`), giving a
228
+ // match-expression — same reduction the `if` case does, one level wider.
229
+ if (first.kind === "match") {
230
+ const arms = [];
231
+ for (const arm of first.arms) {
232
+ const armExpr = flattenLambdaBody([...arm.body, ...rest]);
233
+ if (armExpr === null)
234
+ return null;
235
+ arms.push({ pattern: arm.pattern, body: armExpr });
236
+ }
237
+ return { kind: "match", scrutinee: first.scrutinee, arms };
238
+ }
222
239
  return null;
223
240
  }
224
241
  /**
@@ -419,6 +436,20 @@ function lowerExpr(e, binds) {
419
436
  right: { kind: "app", fn: "NatToString", args: [lowerExpr(e.right, binds)] } };
420
437
  }
421
438
  }
439
+ // Numeric int→real coercion. After resolve, `/` is always real, and any
440
+ // arithmetic/comparison mixing real and integral operands is real-valued.
441
+ // Lift each integral operand to `real` so the backend sees homogeneous
442
+ // real operations (Dafny `as real`, Lean Int→Float).
443
+ if (NUMERIC_OPS.has(e.op)) {
444
+ const realCtx = e.ty.kind === "real" || e.left.ty.kind === "real" || e.right.ty.kind === "real";
445
+ if (realCtx) {
446
+ const lift = (operand) => {
447
+ const lowered = lowerExpr(operand, binds);
448
+ return isIntegral(operand.ty) ? { kind: "toReal", expr: lowered } : lowered;
449
+ };
450
+ return { kind: "binop", op: OP_MAP[e.op] ?? e.op, left: lift(e.left), right: lift(e.right) };
451
+ }
452
+ }
422
453
  return {
423
454
  kind: "binop",
424
455
  op: OP_MAP[e.op] ?? e.op,
@@ -494,13 +525,23 @@ function lowerExpr(e, binds) {
494
525
  return { kind: "app", fn: "CeilReal", args: [lowerExpr(arg, binds)] };
495
526
  return lowerExpr(arg, binds);
496
527
  }
497
- // Math.floor(x): FloorReal on real args, JSFloorDiv for int division, identity on int
528
+ // Math.floor(x):
529
+ // - a / b on integral operands → integer floor division, kept in
530
+ // integer arithmetic (JSFloorDiv on Dafny; native Int/Nat `/` floors
531
+ // on Lean). Checked first: after resolve, `a / b` is typed `real`, so
532
+ // the real branch below would otherwise drag it into real arithmetic.
533
+ // - real arg → FloorReal (Dafny's .Floor)
534
+ // - int arg → identity
498
535
  if (e.fn.kind === "field" && e.fn.field === "floor" && e.fn.obj.kind === "var" && e.fn.obj.name === "Math" && e.args.length === 1) {
499
536
  const arg = e.args[0];
537
+ if (arg.kind === "binop" && arg.op === "/" && isIntegral(arg.left.ty) && isIntegral(arg.right.ty)) {
538
+ const l = lowerExpr(arg.left, binds), r = lowerExpr(arg.right, binds);
539
+ return _opts.backend === "dafny"
540
+ ? { kind: "app", fn: "JSFloorDiv", args: [l, r] }
541
+ : { kind: "binop", op: "/", left: l, right: r };
542
+ }
500
543
  if (arg.ty.kind === "real")
501
544
  return { kind: "app", fn: "FloorReal", args: [lowerExpr(arg, binds)] };
502
- if (_opts.backend === "dafny" && arg.kind === "binop" && arg.op === "/")
503
- return { kind: "app", fn: "JSFloorDiv", args: [lowerExpr(arg.left, binds), lowerExpr(arg.right, binds)] };
504
545
  return lowerExpr(arg, binds);
505
546
  }
506
547
  // Method call: receiver.method(args) → methodCall node
@@ -642,7 +683,10 @@ function lowerExpr(e, binds) {
642
683
  return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
643
684
  return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
644
685
  case "lambda": {
645
- const body = transformStmts(e.body, []);
686
+ // Pass the module typeDecls (not []), so type lookups inside the lambda
687
+ // body — e.g. a `switch`'s variant fields — resolve. A bare `[]` left a
688
+ // discriminated-union switch in a lambda with binderless patterns.
689
+ const body = transformStmts(e.body, _typeDecls);
646
690
  // Flatten an if/let/return-shaped multi-statement body into a single
647
691
  // `return <expr>` so both backends' single-return-lambda fast path emits
648
692
  // it (Dafny lambdas are expression-only; Lean prefers the expression form
@@ -1321,14 +1365,37 @@ function remainingVariant(typeName, cases, typeDecls) {
1321
1365
  return null;
1322
1366
  return remaining[0];
1323
1367
  }
1368
+ /** `switch(obj.field)` is stripped at extraction to scrutinee `obj` + discriminant
1369
+ * `field`, assuming `obj` is a discriminated union with `field` as its
1370
+ * discriminant. When that's NOT so — e.g. `obj` is a plain record with an
1371
+ * enum-typed `field` — the switch is really on the enum VALUE. This returns the
1372
+ * enum scrutinee `obj.field` (+ the field's enum type) to match directly; null
1373
+ * for a genuine discriminant switch or `switch(localVar)`, which callers handle
1374
+ * their usual way. Shared by emitSwitchStmt and transformPureSwitch. */
1375
+ function enumFieldSwitch(s, typeDecls) {
1376
+ if (!s.discriminant)
1377
+ return null;
1378
+ const objBase = s.expr.ty.kind === "user"
1379
+ ? (s.expr.ty.name.includes("<") ? s.expr.ty.name.slice(0, s.expr.ty.name.indexOf("<")) : s.expr.ty.name)
1380
+ : undefined;
1381
+ const objDecl = objBase ? typeDecls.find(d => d.name === objBase) : undefined;
1382
+ if (objDecl?.kind === "discriminated-union" && objDecl.discriminant === s.discriminant)
1383
+ return null;
1384
+ const fieldTy = objDecl?.kind === "record" ? objDecl.fields?.find(f => f.name === s.discriminant)?.type : undefined;
1385
+ return {
1386
+ scrutinee: { kind: "field", obj: transformExpr(s.expr), field: s.discriminant },
1387
+ enumTyName: fieldTy?.kind === "user" ? fieldTy.name : undefined,
1388
+ };
1389
+ }
1324
1390
  function emitSwitchStmt(s, typeDecls) {
1325
- const varName = s.expr.kind === "var" ? s.expr.name : "?";
1326
- const typeName = s.expr.ty.kind === "user" ? s.expr.ty.name : undefined;
1327
1391
  const cases = s.cases.map(c => ({ name: c.label, body: c.body }));
1328
- const arms = buildMatchArms(cases, varName, typeName, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1392
+ const ef = enumFieldSwitch(s, typeDecls);
1393
+ const arms = ef
1394
+ ? buildMatchArms(cases, undefined, ef.enumTyName, typeDecls, (body) => transformStmts(body, typeDecls))
1395
+ : buildMatchArms(cases, s.expr.kind === "var" ? s.expr.name : "?", s.expr.ty.kind === "user" ? s.expr.ty.name : undefined, typeDecls, (body, vn, fields) => transformStmts(replaceFieldAccessInTStmts(body, vn, fields), typeDecls));
1329
1396
  if (s.defaultBody.length > 0)
1330
1397
  arms.push({ pattern: "_", body: transformStmts(s.defaultBody, typeDecls) });
1331
- return { kind: "match", scrutinee: varName, arms };
1398
+ return { kind: "match", scrutinee: ef ? ef.scrutinee : (s.expr.kind === "var" ? s.expr.name : "?"), arms };
1332
1399
  }
1333
1400
  /** Replace obj.field → replacement var in typed IR.
1334
1401
  * Uses the TExpr's resolved type when available, falling back to `fallbackTy`. */
@@ -1472,6 +1539,20 @@ function transformPureBody(stmts, typeDecls) {
1472
1539
  return null;
1473
1540
  }
1474
1541
  function transformPureSwitch(s, typeDecls) {
1542
+ const ef = enumFieldSwitch(s, typeDecls);
1543
+ if (ef) {
1544
+ const cases = s.cases.map(c => ({ name: c.label, body: c.body }));
1545
+ const arms = buildMatchArms(cases, undefined, ef.enumTyName, typeDecls, (body) => transformPureBody(body, typeDecls));
1546
+ if (!arms)
1547
+ return null;
1548
+ if (s.defaultBody.length > 0) {
1549
+ const body = transformPureBody(s.defaultBody, typeDecls);
1550
+ if (!body)
1551
+ return null;
1552
+ arms.push({ pattern: "_", body });
1553
+ }
1554
+ return { kind: "match", scrutinee: ef.scrutinee, arms };
1555
+ }
1475
1556
  const typeName = s.expr.ty.kind === "user" ? s.expr.ty.name : "";
1476
1557
  if (!typeDecls.find(d => d.name === typeName))
1477
1558
  return null;
@@ -1570,6 +1651,9 @@ function transformTypeDecl(d) {
1570
1651
  target: d.aliasOfTy,
1571
1652
  };
1572
1653
  }
1654
+ else if (d.kind === "opaque") {
1655
+ return { kind: "opaque-type", name: d.name };
1656
+ }
1573
1657
  else {
1574
1658
  return {
1575
1659
  kind: "structure", name: d.name,
@@ -4,4 +4,7 @@
4
4
  * Produced by the resolve pass. Consumed by the transform.
5
5
  * Still TS-shaped (not Lean-shaped).
6
6
  */
7
- export {};
7
+ /** True for an int/nat that came from a TS `bigint` (integer division semantics). */
8
+ export function isBigInt(ty) {
9
+ return (ty.kind === "int" || ty.kind === "nat") && !!ty.big;
10
+ }
@@ -32,6 +32,11 @@ export function parseTsType(tsType) {
32
32
  function tyFromTypeNode(tn) {
33
33
  if (Node.isParenthesizedTypeNode(tn))
34
34
  return tyFromTypeNode(tn.getTypeNode());
35
+ // `readonly T[]` / `readonly [A, B]` — the modifier is a TypeOperator wrapping
36
+ // the array/tuple; verification treats it identically to the mutable form.
37
+ if (Node.isTypeOperatorTypeNode(tn) && tn.getOperator() === SyntaxKind.ReadonlyKeyword) {
38
+ return tyFromTypeNode(tn.getTypeNode());
39
+ }
35
40
  if (Node.isUnionTypeNode(tn)) {
36
41
  const arms = tn.getTypeNodes();
37
42
  const isBoolLit = (a) => Node.isLiteralTypeNode(a) && (a.getLiteral().getKind() === SyntaxKind.TrueKeyword || a.getLiteral().getKind() === SyntaxKind.FalseKeyword);
@@ -91,8 +96,9 @@ function tyFromTypeNode(tn) {
91
96
  }
92
97
  switch (tn.getKind()) {
93
98
  case SyntaxKind.NumberKeyword:
94
- case SyntaxKind.BigIntKeyword:
95
99
  return { kind: "int" };
100
+ case SyntaxKind.BigIntKeyword:
101
+ return { kind: "int", big: true };
96
102
  case SyntaxKind.BooleanKeyword:
97
103
  return { kind: "bool" };
98
104
  case SyntaxKind.StringKeyword:
@@ -109,7 +115,11 @@ function tyFromTypeNode(tn) {
109
115
  const args = tn.getTypeArguments();
110
116
  if (name === "nat" && args.length === 0)
111
117
  return { kind: "nat" };
112
- if (name === "Array" && args.length === 1)
118
+ if (name === "real" && args.length === 0)
119
+ return { kind: "real" };
120
+ if (name === "int" && args.length === 0)
121
+ return { kind: "int" };
122
+ if ((name === "Array" || name === "ReadonlyArray") && args.length === 1)
113
123
  return { kind: "array", elem: tyFromTypeNode(args[0]) };
114
124
  if (name === "Set" && args.length === 1)
115
125
  return { kind: "set", elem: tyFromTypeNode(args[0]) };