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