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 +9 -3
- package/package.json +1 -1
- package/tools/dist/dafny-emit.js +59 -11
- package/tools/dist/extract.js +108 -14
- package/tools/dist/lean-emit.js +64 -9
- package/tools/dist/narrow.js +32 -4
- package/tools/dist/peephole.js +3 -0
- package/tools/dist/resolve.js +131 -6
- package/tools/dist/transform.js +92 -8
- package/tools/dist/typedir.js +4 -1
- package/tools/dist/types.js +12 -2
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
|
-
##
|
|
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
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -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
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
// int→real 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
|
-
|
|
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();
|
package/tools/dist/extract.js
CHANGED
|
@@ -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) —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
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
|
-
|
|
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)
|
|
1944
|
+
tsType: typeToString(p.getTypeAtLocation(f.node)),
|
|
1867
1945
|
}));
|
|
1868
|
-
const returnType = sig.getReturnType()
|
|
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
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -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":
|
|
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
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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"
|
|
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 = `${
|
|
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 =>
|
|
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
|
-
|
|
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");
|
package/tools/dist/narrow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
359
|
-
const
|
|
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. */
|
package/tools/dist/peephole.js
CHANGED
|
@@ -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
|
}
|
package/tools/dist/resolve.js
CHANGED
|
@@ -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
|
|
527
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/tools/dist/transform.js
CHANGED
|
@@ -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):
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
package/tools/dist/typedir.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/tools/dist/types.js
CHANGED
|
@@ -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 === "
|
|
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]) };
|