lemmascript 0.5.0 → 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.0",
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": {
@@ -35,6 +35,15 @@ const DAFNY_KEYWORDS = new Set([
35
35
  "datatype", "type", "const", "ghost", "static",
36
36
  "reads", "modifies", "assert", "assume", "print",
37
37
  "by", "calc", "reveal",
38
+ // Further reserved words (validated against the Dafny parser) that are also
39
+ // legal TS identifiers. `this` stays excluded above — class methods emit it
40
+ // directly. `then`/`else` already covered.
41
+ "bool", "char", "int", "nat", "real", "string", "object", "array",
42
+ "as", "is", "label", "modify", "expect", "yield", "yields", "returns",
43
+ "unchanged", "witness", "constructor", "iterator", "abstract", "extends",
44
+ "refines", "opened", "provides", "reveals", "include", "newtype",
45
+ "codatatype", "nameonly", "twostate", "opaque", "replaceable", "colemma",
46
+ "copredicate", "inductive",
38
47
  ]);
39
48
  function escapeName(name) {
40
49
  // \result is carried through the IR as the var name "\\result"; render it
@@ -157,6 +166,12 @@ function emitExpr(e) {
157
166
  return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
158
167
  if (e.method === "filter")
159
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
+ }
160
175
  if (e.method === "every")
161
176
  return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
162
177
  if (e.method === "findLast") {
@@ -341,16 +356,9 @@ function emitExpr(e) {
341
356
  needPreamble("BitAnd");
342
357
  return `BitAnd(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
343
358
  }
344
- // int * real coercion: wrap int side with "as real"
345
- if (["+", "-", "*", "/"].includes(op)) {
346
- const leftIsReal = e.left.kind === "num" && !Number.isInteger(e.left.value);
347
- const rightIsReal = e.right.kind === "num" && !Number.isInteger(e.right.value);
348
- if (leftIsReal !== rightIsReal) {
349
- const left = leftIsReal ? emitExpr(e.left) : `(${emitExpr(e.left)} as real)`;
350
- const right = rightIsReal ? emitExpr(e.right) : `(${emitExpr(e.right)} as real)`;
351
- return `(${left} ${op} ${right})`;
352
- }
353
- }
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.
354
362
  return `(${wrapQuantifier(e.left)} ${op} ${emitExpr(e.right)})`;
355
363
  }
356
364
  case "implies": {
@@ -390,6 +398,8 @@ function emitExpr(e) {
390
398
  needPreamble("MathMin");
391
399
  needPreamble("MinOfSeq");
392
400
  }
401
+ if (e.fn === "Perm")
402
+ needPreamble("Perm");
393
403
  return `${escapeName(e.fn)}(${args.join(", ")})`;
394
404
  }
395
405
  case "field": {
@@ -405,6 +415,8 @@ function emitExpr(e) {
405
415
  case "toNat":
406
416
  // Dafny doesn't need toNat — just emit the inner expression
407
417
  return emitExpr(e.expr);
418
+ case "toReal":
419
+ return `(${emitExpr(e.expr)} as real)`;
408
420
  case "index": {
409
421
  const obj = emitExpr(e.arr);
410
422
  const idx = emitExpr(e.idx);
@@ -587,10 +599,29 @@ function emitDecl(d) {
587
599
  switch (d.kind) {
588
600
  case "inductive": {
589
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));
590
620
  const ctors = d.constructors.map(c => {
591
621
  if (c.fields.length === 0)
592
622
  return escapeName(c.name);
593
- 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)})`;
594
625
  });
595
626
  return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
596
627
  }
@@ -600,6 +631,11 @@ function emitDecl(d) {
600
631
  case "type-alias": {
601
632
  return `type ${d.name} = ${tyToDafny(d.target)}`;
602
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
+ }
603
639
  case "def": {
604
640
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
605
641
  const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
@@ -734,6 +770,12 @@ const CEIL_REAL = `function CeilReal(x: real): int
734
770
  if x == (x.Floor as real) then x.Floor
735
771
  else x.Floor + 1
736
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
+ }`;
737
779
  const SEQ_FIND_INDEX = `function SeqFindIndex<T>(s: seq<T>, p: T -> bool): int
738
780
  ensures -1 <= SeqFindIndex(s, p) < |s|
739
781
  ensures SeqFindIndex(s, p) >= 0 ==> p(s[SeqFindIndex(s, p)])
@@ -947,6 +989,10 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
947
989
  else NatToString(n / 10) + [digit]
948
990
  }`;
949
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) }`;
950
996
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
951
997
  ensures forall x :: x in s <==> x in res
952
998
  ensures |res| == |s|
@@ -977,6 +1023,7 @@ const PREAMBLE_CODE = [
977
1023
  ["FloorReal", FLOOR_REAL],
978
1024
  ["SeqIndexOf", SEQ_INDEX_OF],
979
1025
  ["SeqFindIndex", SEQ_FIND_INDEX],
1026
+ ["SeqFilterSome", SEQ_FILTER_SOME],
980
1027
  ["SeqFindLast", SEQ_FIND_LAST],
981
1028
  ["SeqFlatten", SEQ_FLATTEN],
982
1029
  ["SeqJoin", SEQ_JOIN],
@@ -992,6 +1039,7 @@ const PREAMBLE_CODE = [
992
1039
  ["MathMax", MATH_MAX],
993
1040
  ["MaxOfSeq", MAX_OF_SEQ],
994
1041
  ["MinOfSeq", MIN_OF_SEQ],
1042
+ ["Perm", PERM],
995
1043
  ];
996
1044
  // ── Constructor and record helpers ───────────────────────────
997
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(", ")}]`;
@@ -1502,17 +1543,39 @@ function extractStmts(stmts) {
1502
1543
  const switchExpr = exprAst.kind === "field" ? exprAst.obj : exprAst;
1503
1544
  const cases = [];
1504
1545
  let defaultBody = [];
1546
+ // Two JS-`switch` faithfulness concerns the Dafny `match` doesn't share:
1547
+ // (1) Fall-through: stacked `case A: case B: body` is several clauses
1548
+ // where the leading ones have no statements; those labels share the
1549
+ // next clause's body (we duplicate it per label).
1550
+ // (2) `break` is the switch exit, not a loop break. We extract the full
1551
+ // body (extractStmts flattens `{ }` blocks but keeps loop bodies
1552
+ // nested) and strip the *top-level* breaks — so a `break` written
1553
+ // inside a `{ }` case block is stripped, while a `break` inside a
1554
+ // nested loop stays put.
1555
+ const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
1556
+ let fallthrough = [];
1505
1557
  for (const clause of s.getClauses()) {
1506
1558
  if (Node.isCaseClause(clause)) {
1507
1559
  const label = clause.getExpression().getText().replace(/^["']|["']$/g, "");
1508
- const bodyStmts = clause.getStatements().filter(st => !Node.isBreakStatement(st));
1509
- cases.push({ label, body: extractStmts(bodyStmts) });
1560
+ if (clause.getStatements().length === 0) {
1561
+ fallthrough.push(label);
1562
+ continue;
1563
+ }
1564
+ const body = stripExitBreaks(extractStmts(clause.getStatements()));
1565
+ for (const l of fallthrough)
1566
+ cases.push({ label: l, body });
1567
+ cases.push({ label, body });
1568
+ fallthrough = [];
1510
1569
  }
1511
1570
  else {
1512
- const bodyStmts = clause.getStatements().filter(st => !Node.isBreakStatement(st));
1513
- defaultBody = extractStmts(bodyStmts);
1571
+ defaultBody = stripExitBreaks(extractStmts(clause.getStatements()));
1572
+ for (const l of fallthrough)
1573
+ cases.push({ label: l, body: defaultBody });
1574
+ fallthrough = [];
1514
1575
  }
1515
1576
  }
1577
+ for (const l of fallthrough)
1578
+ cases.push({ label: l, body: [] });
1516
1579
  result.push({ kind: "switch", expr: switchExpr, discriminant, cases, defaultBody, line });
1517
1580
  continue;
1518
1581
  }
@@ -1735,7 +1798,19 @@ export function extractModule(sourceFile) {
1735
1798
  }
1736
1799
  const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
1737
1800
  if (aliasMatch) {
1738
- typeDecls.push({ name: aliasMatch[1], kind: "alias", aliasOf: aliasMatch[2].trim() });
1801
+ const rhs = aliasMatch[2].trim();
1802
+ // A string-literal union (`= "a" | "b" | …`) becomes an enum datatype —
1803
+ // the same shape a real string-union alias resolves to. Dafny has no
1804
+ // string-literal type, so a plain alias (`type X = "a" | "b"`) would be
1805
+ // invalid. Other RHS forms (`Rule[]`, `number`, `A | B`) fall through.
1806
+ const parts = rhs.split("|").map(s => s.trim());
1807
+ const lits = parts.map(p => p.match(/^["'](.+)["']$/));
1808
+ if (parts.length >= 2 && lits.every(m => m !== null)) {
1809
+ typeDecls.push({ name: aliasMatch[1], kind: "string-union", values: lits.map(m => m[1]) });
1810
+ }
1811
+ else {
1812
+ typeDecls.push({ name: aliasMatch[1], kind: "alias", aliasOf: rhs });
1813
+ }
1739
1814
  }
1740
1815
  }
1741
1816
  for (const range of sourceFile.getLeadingCommentRanges()) {
@@ -1861,11 +1936,14 @@ export function extractModule(sourceFile) {
1861
1936
  if (!sig)
1862
1937
  continue;
1863
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`.
1864
1942
  const params = sig.getParameters().map(p => ({
1865
1943
  name: p.getName(),
1866
- tsType: p.getTypeAtLocation(f.node).getText(),
1944
+ tsType: typeToString(p.getTypeAtLocation(f.node)),
1867
1945
  }));
1868
- const returnType = sig.getReturnType().getText();
1946
+ const returnType = typeToString(sig.getReturnType());
1869
1947
  const annots = collectFunctionAnnotations(f.node);
1870
1948
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
1871
1949
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
@@ -2087,10 +2165,17 @@ export function extractModule(sourceFile) {
2087
2165
  collectNamesExpr(e.else);
2088
2166
  }
2089
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();
2090
2172
  for (const fn of functions) {
2091
- for (const p of fn.params)
2173
+ for (const p of fn.params) {
2092
2174
  referencedNames.add(p.tsType);
2175
+ sigTypes.add(p.tsType);
2176
+ }
2093
2177
  referencedNames.add(fn.returnType);
2178
+ sigTypes.add(fn.returnType);
2094
2179
  collectNames(fn.body);
2095
2180
  // Also scan spec annotations for identifier references
2096
2181
  for (const spec of [...fn.requires, ...fn.ensures]) {
@@ -2119,6 +2204,15 @@ export function extractModule(sourceFile) {
2119
2204
  }
2120
2205
  for (const name of referencedNames)
2121
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
+ }
2122
2216
  typeDecls.splice(0, typeDecls.length, ...typeDecls.filter(d => neededTypes.has(d.name) || declaredNames.has(d.name)));
2123
2217
  }
2124
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) {
@@ -290,6 +291,41 @@ function expandAlias(ty, typeDecls, seen = new Set()) {
290
291
  function getDiscriminant(ctx, typeName) {
291
292
  return findDecl(ctx, typeName)?.discriminant;
292
293
  }
294
+ // ── Equality hazard: structural in the proof vs reference at runtime ─────────
295
+ // `===`/`!==` is modeled as Dafny structural equality, but the SAME TypeScript
296
+ // runs `===` as JS *reference* equality on objects/arrays. The two agree only
297
+ // when the operand is a primitive at runtime: number / string / bool, or a
298
+ // string-union enum (which runs as a plain string). Records, discriminated
299
+ // unions, arrays, maps, sets, and unresolved generics are reference-compared at
300
+ // runtime, so a structural proof over them is unsound. Returns true for those.
301
+ function refEqHazard(ty, typeDecls) {
302
+ if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set")
303
+ return true;
304
+ if (ty.kind === "user") {
305
+ let decl = typeDecls.find(d => d.name === ty.name);
306
+ if (!decl && ty.name.includes(".")) {
307
+ const tail = ty.name.slice(ty.name.lastIndexOf(".") + 1);
308
+ decl = typeDecls.find(d => d.name === tail);
309
+ }
310
+ if (!decl)
311
+ return true; // generic type parameter / unknown → assume reference
312
+ if (decl.kind === "string-union")
313
+ return false; // runs as a JS string → `===` is structural
314
+ if (decl.kind === "alias")
315
+ return decl.aliasOfTy ? refEqHazard(decl.aliasOfTy, typeDecls) : false;
316
+ return true; // record / discriminated-union → reference at runtime
317
+ }
318
+ return false; // primitives, optional, unknown, fn, void
319
+ }
320
+ const _warnedRefEq = new Set();
321
+ function warnRefEq(op, l, r) {
322
+ const label = (t) => t.kind === "user" ? t.name : t.kind;
323
+ const msg = `'${op}' compares non-primitive operands (${label(l)} ${op} ${label(r)}): structural equality in the proof, but reference equality when this TypeScript runs. Sound only if operands are primitives or a canonical (string/number) encoding; otherwise compare via an explicit structural equals.`;
324
+ if (_warnedRefEq.has(msg))
325
+ return;
326
+ _warnedRefEq.add(msg);
327
+ console.error(`WARNING: ${msg}`);
328
+ }
293
329
  /** A type ts-morph handed us that LemmaScript hasn't modeled: contains
294
330
  * `unknown` (TS `any`), or a `user` type whose name isn't a known declaration
295
331
  * (an opaque expanded union like `"AssistantMsg | ToolMsg"` that ts-morph
@@ -412,6 +448,12 @@ function tyToTsStr(ty) {
412
448
  return "number";
413
449
  if (ty.kind === "bool")
414
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
+ }
415
457
  return undefined;
416
458
  }
417
459
  function inferLambdaParamTypes(fn, rawArgs, ctx) {
@@ -451,6 +493,21 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
451
493
  }
452
494
  return rawArgs;
453
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
+ }
454
511
  /** Coerce call arguments: string literals → user types, non-optional → Some, pad missing optional args. */
455
512
  function coerceCallArgs(args, fn, ctx) {
456
513
  if (fn.kind !== "var" || !ctx.fnParams.has(fn.name))
@@ -482,6 +539,16 @@ function inferMethodReturnTy(fn, args, ctx) {
482
539
  if (fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray") {
483
540
  return { kind: "bool" };
484
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
+ }
485
552
  const objTy = fn.obj.ty;
486
553
  if (objTy.kind === "map") {
487
554
  if (fn.field === "get")
@@ -523,8 +590,11 @@ function inferMethodReturnTy(fn, args, ctx) {
523
590
  if (fn.field === "join" && objTy.elem.kind === "string")
524
591
  return { kind: "string" };
525
592
  if (fn.field === "map" && args.length >= 1 && args[0].kind === "lambda") {
526
- const retTy = args[0].body.length > 0 && args[0].body[0].kind === "return"
527
- ? 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" };
528
598
  return { kind: "array", elem: retTy };
529
599
  }
530
600
  }
@@ -578,6 +648,8 @@ function resolveExpr(e, ctx) {
578
648
  case "num":
579
649
  if (!Number.isInteger(e.value))
580
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 } };
581
653
  return { kind: "num", value: e.value, ty: e.value >= 0 ? { kind: "nat" } : { kind: "int" } };
582
654
  case "str":
583
655
  return { kind: "str", value: e.value, ty: { kind: "string" } };
@@ -606,6 +678,11 @@ function resolveExpr(e, ctx) {
606
678
  if (e.op === "===" || e.op === "!==") {
607
679
  left = coerceStr(left, right.ty);
608
680
  right = coerceStr(right, left.ty);
681
+ // Spec (`//@`) comparisons are proof-only, so they can't diverge at
682
+ // runtime; only warn on executable code.
683
+ if (!ctx.inSpec && refEqHazard(left.ty, ctx.typeDecls) && refEqHazard(right.ty, ctx.typeDecls)) {
684
+ warnRefEq(e.op, left.ty, right.ty);
685
+ }
609
686
  }
610
687
  let ty = { kind: "unknown" };
611
688
  if (["===", "!==", ">=", "<=", ">", "<", "in"].includes(e.op))
@@ -618,16 +695,38 @@ function resolveExpr(e, ctx) {
618
695
  }
619
696
  else if (e.op === "||")
620
697
  ty = right.ty;
621
- 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)) {
622
706
  ty = (left.ty.kind === "real" || right.ty.kind === "real") ? { kind: "real" } : left.ty;
623
707
  }
624
708
  return { kind: "binop", op: e.op, left, right, ty };
625
709
  }
626
710
  case "unop": {
627
711
  const expr = resolveExpr(e.expr, ctx);
628
- 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 };
629
714
  }
630
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
+ }
631
730
  // Extern dispatch: `NS.method(args)` where NS.method is declared via
632
731
  // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
633
732
  // rest of the pipeline sees an ordinary pure function. The extern's
@@ -671,6 +770,16 @@ function resolveExpr(e, ctx) {
671
770
  if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
672
771
  ty = ctx.fnReturns.get(fn.name);
673
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
+ }
674
783
  return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
675
784
  }
676
785
  case "index": {
@@ -855,12 +964,21 @@ function resolveExpr(e, ctx) {
855
964
  let lambdaEnv = ctx.env;
856
965
  for (const p of params)
857
966
  lambdaEnv = extend(lambdaEnv, p.name, p.ty);
858
- 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 };
859
972
  // Body: expression (wrap in return stmt) or statement block
860
973
  const body = Array.isArray(e.body)
861
974
  ? resolveBlock(e.body, lambdaCtx)
862
975
  : [{ kind: "return", value: resolveExpr(e.body, lambdaCtx) }];
863
- 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 };
864
982
  }
865
983
  case "conditional": {
866
984
  const cond = resolveExpr(e.cond, ctx);
@@ -1002,6 +1120,12 @@ function resolveStmt(s, ctx) {
1002
1120
  ? { kind: "optional", inner: init.ty }
1003
1121
  : init.ty;
1004
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
+ }
1005
1129
  else {
1006
1130
  // Map indexing: TS says T, but access can fail → use Optional<T> from init
1007
1131
  ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
@@ -1388,6 +1512,7 @@ function precomputeFieldTypesInner(typeDecls) {
1388
1512
  }
1389
1513
  }
1390
1514
  export function resolveModule(raw) {
1515
+ _warnedRefEq.clear();
1391
1516
  precomputeFieldTypes(raw.typeDecls);
1392
1517
  const pureFns = computePureFns(raw.functions);
1393
1518
  // Pre-compute function parameter and return types
@@ -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]) };