lemmascript 0.3.2 → 0.4.0
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 +5 -2
- package/package.json +3 -2
- package/tools/dist/dafny-commands.js +31 -14
- package/tools/dist/dafny-emit.js +63 -17
- package/tools/dist/extract.js +130 -21
- package/tools/dist/lean-emit.js +55 -3
- package/tools/dist/lsc.js +13 -3
- package/tools/dist/narrow.js +737 -0
- package/tools/dist/peephole.js +448 -0
- package/tools/dist/resolve.js +370 -194
- package/tools/dist/specparser.js +12 -2
- package/tools/dist/transform.js +337 -318
package/README.md
CHANGED
|
@@ -13,11 +13,14 @@ Each example and case study is verified in Lean 4 and/or Dafny from the same ann
|
|
|
13
13
|
See the internal [examples](examples).
|
|
14
14
|
|
|
15
15
|
See the external case studies:
|
|
16
|
+
- **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
|
|
16
17
|
- **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
|
|
17
18
|
- **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
|
|
19
|
+
- **[github-star-checker-lemmascript](https://github.com/midspiral/github-star-checker-lemmascript/)** — small verified CLI that tracks GitHub star counts across repos and reports per-run deltas. Verifies: per-row diff correctness and `totalDiff == sumDiffs(rows)` (via an inductive `SumDiffs_append` lemma); three sign-classified extractors (gainers / losers / unchanged) with soundness, completeness, **ordered completeness** (gainers appear in the notification in the same order they were listed on the command line), and count/sum equalities against prefix-indexed `upTo` helpers; conservation theorem `decompose(r)` — the three splits partition every row exactly once, and `sumDiffs(increases) + sumDiffs(decreases) == totalDiff`. 33 Dafny VCs, 0 errors; proof additions include a head/tail bridge (`sumDiffs` ↔ `sumDiffsUpTo`) and two partition-on-n inductions. Dafny only.
|
|
20
|
+
- **[equality-game-lemmascript](https://github.com/midspiral/equality-game-lemmascript/)** — greenfield verified arithmetic equality card game (React + Tailwind). Sound + complete decision procedure for "can these two card lists be combined into equal expressions": `canEqualize(L, R) ⟺ ∃ eL, eR. eval(eL) == eval(eR) ∧ multiset(leaves(eL)) == multiset(L) ∧ same for R`. Algorithm is subset-DP over a bitmask `m ∈ [1, 2^n − 1)`; the proof composes a `PopCount` upper/lower bound chain (with stdlib `LemmaDivDenominator` / `LemmaFundamentalDivModConverse`), a `splitLeft`/`splitRight` ↔ imperative-loop connection, a `WitnessCombine` lemma threading existential `Expr` witnesses through the cross-product loops, and a `ChooseMask` combinatorial constructor that, given any sub-multiset of `cards`, produces the realizing mask. Capped by `CompletenessFromMaskCoverage`. **753 verification conditions, 0 errors, 0 `assume`s, 0 axioms** under `--isolate-assertions --verification-time-limit 180`. Dafny only.
|
|
18
21
|
- **[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).
|
|
19
22
|
- **[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. Two CVEs verified: 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 done **in-place**](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/src/utils/cookie.ts#L79). Dafny only.
|
|
20
|
-
- **[charmchat](https://github.com/CHARM-BDF/charmchat/
|
|
23
|
+
- **[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.
|
|
21
24
|
- **[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` (replace — bounded length), `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.
|
|
22
25
|
|
|
23
26
|
## Setup
|
|
@@ -59,7 +62,7 @@ The Dafny backend generates two files per TS source: `foo.dfy.gen` (always regen
|
|
|
59
62
|
### Lean backend
|
|
60
63
|
|
|
61
64
|
```sh
|
|
62
|
-
npx lsc gen src/myModule.ts
|
|
65
|
+
npx lsc gen --backend=lean src/myModule.ts
|
|
63
66
|
lake build
|
|
64
67
|
```
|
|
65
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc -p tools/tsconfig.json",
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
|
-
"typecheck": "tsc -p tools/tsconfig.json --noEmit"
|
|
15
|
+
"typecheck": "tsc -p tools/tsconfig.json --noEmit",
|
|
16
|
+
"typecheck:examples": "tsc -p examples/tsconfig.json"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"ts-morph": "^25.0.0"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Dafny backend commands: gen, check, regen.
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from "fs";
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
6
|
import path from "path";
|
|
7
7
|
function writeGen(genPath, text) {
|
|
8
8
|
writeFileSync(genPath, text);
|
|
@@ -18,27 +18,44 @@ export function dafnyGen(genPath, dfyPath, text) {
|
|
|
18
18
|
export function dafnyCheckDiff(genPath, dfyPath) {
|
|
19
19
|
if (!existsSync(dfyPath))
|
|
20
20
|
return true;
|
|
21
|
+
let diff = "";
|
|
21
22
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
diff = execFileSync("git", ["diff", "--no-index", "--", genPath, dfyPath], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
// git diff exits 1 when files differ; stdout still holds the diff
|
|
27
|
+
if (e && e.stdout != null) {
|
|
28
|
+
diff = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf-8");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
return true;
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
|
-
|
|
34
|
+
const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
|
|
35
|
+
if (deletions.length > 0) {
|
|
36
|
+
console.error(`WARNING: ${path.basename(dfyPath)} has modifications to generated lines (not additions-only):`);
|
|
37
|
+
for (const d of deletions.slice(0, 5))
|
|
38
|
+
console.error(" " + d);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
32
41
|
return true;
|
|
33
42
|
}
|
|
34
43
|
export function dafnyVerify(dfyPath, dir, timeLimit, extraFlags) {
|
|
35
44
|
console.log("Running dafny verify...");
|
|
36
45
|
try {
|
|
37
46
|
const content = readFileSync(dfyPath, "utf-8");
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
const args = ["verify"];
|
|
48
|
+
if (content.includes("Std."))
|
|
49
|
+
args.push("--standard-libraries");
|
|
50
|
+
if (timeLimit)
|
|
51
|
+
args.push("--verification-time-limit", String(timeLimit));
|
|
52
|
+
if (extraFlags) {
|
|
53
|
+
for (const tok of extraFlags.split(/\s+/))
|
|
54
|
+
if (tok)
|
|
55
|
+
args.push(tok);
|
|
56
|
+
}
|
|
57
|
+
args.push(dfyPath);
|
|
58
|
+
execFileSync("dafny", args, { cwd: dir, stdio: "inherit" });
|
|
42
59
|
return true;
|
|
43
60
|
}
|
|
44
61
|
catch {
|
|
@@ -70,7 +87,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir) {
|
|
|
70
87
|
const mergedPath = dfyPath + ".merged";
|
|
71
88
|
console.log("Gen changed. Three-way merging...");
|
|
72
89
|
try {
|
|
73
|
-
|
|
90
|
+
execFileSync("git", ["merge-file", dfyPath, basePath, genPath], { stdio: "pipe" });
|
|
74
91
|
console.log(`Merged: ${path.basename(dfyPath)}`);
|
|
75
92
|
}
|
|
76
93
|
catch (e) {
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -47,6 +47,13 @@ function escapeName(name) {
|
|
|
47
47
|
function paramList(params) {
|
|
48
48
|
return params.map(p => `${escapeName(p.name)}: ${tyToDafny(p.type)}`).join(", ");
|
|
49
49
|
}
|
|
50
|
+
/** Format a method signature header, omitting `returns` for void methods.
|
|
51
|
+
* Dafny's definite-assignment rule rejects unassigned out-parameters, so a
|
|
52
|
+
* `returns (res: ())` on a void method fails verification. */
|
|
53
|
+
function methodHeader(prefix, params, returnType) {
|
|
54
|
+
const sig = `${prefix}(${paramList(params)})`;
|
|
55
|
+
return returnType.kind === "void" ? sig : `${sig} returns (res: ${tyToDafny(returnType)})`;
|
|
56
|
+
}
|
|
50
57
|
// ── Lean op → Dafny op ─────────────────────────────────────
|
|
51
58
|
const OP_MAP = {
|
|
52
59
|
"=": "==", "≠": "!=", "≥": ">=", "≤": "<=",
|
|
@@ -77,7 +84,12 @@ function emitExpr(e) {
|
|
|
77
84
|
case "num": return `${e.value}`;
|
|
78
85
|
case "bool": return e.value ? "true" : "false";
|
|
79
86
|
case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
80
|
-
case "constructor":
|
|
87
|
+
case "constructor": {
|
|
88
|
+
const head = qualifyCtor(e.name, e.type);
|
|
89
|
+
if (!e.args || e.args.length === 0)
|
|
90
|
+
return head;
|
|
91
|
+
return `${head}(${e.args.map(emitExpr).join(", ")})`;
|
|
92
|
+
}
|
|
81
93
|
case "arrayLiteral":
|
|
82
94
|
if (e.elems.length === 0)
|
|
83
95
|
return `[]`;
|
|
@@ -325,11 +337,13 @@ function emitExpr(e) {
|
|
|
325
337
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
326
338
|
return `${ctorName}(${vals.join(", ")})`;
|
|
327
339
|
}
|
|
340
|
+
if (e.fields.length === 0)
|
|
341
|
+
return `map[]`;
|
|
328
342
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
329
343
|
return `(${vals.join(", ")})`;
|
|
330
344
|
}
|
|
331
345
|
case "if":
|
|
332
|
-
return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
|
|
346
|
+
return `(if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)})`;
|
|
333
347
|
case "match": {
|
|
334
348
|
const scrut = emitScrutinee(e.scrutinee);
|
|
335
349
|
const arms = e.arms.map(a => `case ${translatePattern(a.pattern)} => ${emitExpr(a.body)}`);
|
|
@@ -337,7 +351,7 @@ function emitExpr(e) {
|
|
|
337
351
|
}
|
|
338
352
|
case "forall": return emitQuantifier(e, "forall");
|
|
339
353
|
case "exists": return emitQuantifier(e, "exists");
|
|
340
|
-
case "let": return `var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)}`;
|
|
354
|
+
case "let": return `(var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)})`;
|
|
341
355
|
case "havoc": return "*";
|
|
342
356
|
}
|
|
343
357
|
}
|
|
@@ -473,6 +487,8 @@ function emitDecl(d) {
|
|
|
473
487
|
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
474
488
|
for (const r of d.requires)
|
|
475
489
|
lines.push(` requires ${emitExpr(r)}`);
|
|
490
|
+
if (d.decreases)
|
|
491
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
476
492
|
lines.push(`{`);
|
|
477
493
|
lines.push(emitPureExpr(d.body, 1));
|
|
478
494
|
lines.push(`}`);
|
|
@@ -491,9 +507,23 @@ function emitDecl(d) {
|
|
|
491
507
|
}
|
|
492
508
|
return lines.join("\n");
|
|
493
509
|
}
|
|
510
|
+
case "def-by-method": {
|
|
511
|
+
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
512
|
+
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
513
|
+
for (const r of d.requires)
|
|
514
|
+
lines.push(` requires ${emitExpr(r)}`);
|
|
515
|
+
if (d.decreases)
|
|
516
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
517
|
+
lines.push(`{`);
|
|
518
|
+
lines.push(`}`);
|
|
519
|
+
lines.push(`by method {`);
|
|
520
|
+
lines.push(emitStmts(d.methodBody, 1));
|
|
521
|
+
lines.push(`}`);
|
|
522
|
+
return lines.join("\n");
|
|
523
|
+
}
|
|
494
524
|
case "method": {
|
|
495
525
|
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
496
|
-
const lines = [`method ${d.name}${tp}
|
|
526
|
+
const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType)];
|
|
497
527
|
for (const r of d.requires)
|
|
498
528
|
lines.push(` requires ${emitExpr(r)}`);
|
|
499
529
|
for (const e of d.ensures)
|
|
@@ -511,7 +541,7 @@ function emitDecl(d) {
|
|
|
511
541
|
if (d.fields.length > 0 && d.methods.length > 0)
|
|
512
542
|
lines.push("");
|
|
513
543
|
for (const m of d.methods) {
|
|
514
|
-
lines.push(` method ${m.name}
|
|
544
|
+
lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
|
|
515
545
|
for (const r of m.requires)
|
|
516
546
|
lines.push(` requires ${emitExpr(r)}`);
|
|
517
547
|
for (const e of m.ensures)
|
|
@@ -656,6 +686,7 @@ const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
|
|
|
656
686
|
const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
657
687
|
ensures forall x :: x in s <==> x in res
|
|
658
688
|
ensures |res| == |s|
|
|
689
|
+
ensures forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
659
690
|
{
|
|
660
691
|
var remaining := s;
|
|
661
692
|
res := [];
|
|
@@ -663,6 +694,7 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
|
663
694
|
invariant remaining <= s
|
|
664
695
|
invariant forall x :: x in res <==> (x in s && x !in remaining)
|
|
665
696
|
invariant |res| + |remaining| == |s|
|
|
697
|
+
invariant forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
666
698
|
decreases remaining
|
|
667
699
|
{
|
|
668
700
|
var x :| x in remaining;
|
|
@@ -760,26 +792,40 @@ function translatePattern(pattern) {
|
|
|
760
792
|
export function emitDafnyFile(file, tsFileName) {
|
|
761
793
|
buildRecordCtorMap(file.decls);
|
|
762
794
|
_neededPreambles.clear();
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if (d.kind === "namespace") {
|
|
767
|
-
for (const inner of d.decls)
|
|
768
|
-
if (inner.kind === "def")
|
|
769
|
-
pureDefs.add(inner.name);
|
|
770
|
-
}
|
|
771
|
-
if (d.kind === "def")
|
|
772
|
-
pureDefs.add(d.name);
|
|
773
|
-
}
|
|
795
|
+
// Track successfully emitted pure defs — method wrappers are only
|
|
796
|
+
// skipped when the corresponding pure def was actually emitted.
|
|
797
|
+
const emittedPureDefs = new Set();
|
|
774
798
|
// Emit declarations
|
|
775
799
|
const declLines = [];
|
|
776
800
|
const skipped = [];
|
|
777
801
|
for (const decl of file.decls) {
|
|
778
|
-
if (decl.kind === "method" &&
|
|
802
|
+
if (decl.kind === "method" && emittedPureDefs.has(decl.name))
|
|
779
803
|
continue;
|
|
804
|
+
if (decl.kind === "namespace") {
|
|
805
|
+
// Emit each inner decl individually — if one fails, the rest survive
|
|
806
|
+
// and failed defs fall back to their method wrappers
|
|
807
|
+
for (const inner of decl.decls) {
|
|
808
|
+
try {
|
|
809
|
+
declLines.push("");
|
|
810
|
+
declLines.push(emitDecl(inner));
|
|
811
|
+
if (inner.kind === "def")
|
|
812
|
+
emittedPureDefs.add(inner.name);
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
const name = "name" in inner ? inner.name : "unknown";
|
|
816
|
+
const msg = e.message;
|
|
817
|
+
console.error(`WARNING: skipping pure '${name}': ${msg}`);
|
|
818
|
+
declLines.push(`\n// LemmaScript: skipped pure ${name}`);
|
|
819
|
+
skipped.push(name);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
780
824
|
try {
|
|
781
825
|
declLines.push("");
|
|
782
826
|
declLines.push(emitDecl(decl));
|
|
827
|
+
if (decl.kind === "def-by-method")
|
|
828
|
+
emittedPureDefs.add(decl.name);
|
|
783
829
|
}
|
|
784
830
|
catch (e) {
|
|
785
831
|
const name = "name" in decl ? decl.name : "unknown";
|
package/tools/dist/extract.js
CHANGED
|
@@ -8,6 +8,13 @@ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
|
|
|
8
8
|
// ── Expression extraction ────────────────────────────────────
|
|
9
9
|
/** When set, calls whose function/method name matches this key are replaced with havoc. */
|
|
10
10
|
let _havocKey = null;
|
|
11
|
+
/**
|
|
12
|
+
* Maps property-name fingerprints to type alias names for collapsed single-variant unions.
|
|
13
|
+
* TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
|
|
14
|
+
* causing getAliasSymbol() to return null. This map lets typeToString recover the alias.
|
|
15
|
+
* Populated in extractModule before type extraction; used by typeToString.
|
|
16
|
+
*/
|
|
17
|
+
let _collapsedUnionMap = new Map();
|
|
11
18
|
/** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
|
|
12
19
|
let _typeParamMap = new Map();
|
|
13
20
|
function _eraseGenerics(tsType) {
|
|
@@ -79,38 +86,54 @@ function extractExpr(node) {
|
|
|
79
86
|
return { kind: "var", name: "this" };
|
|
80
87
|
}
|
|
81
88
|
// Property access: x.foo or x?.foo
|
|
89
|
+
// Each `?.` is its own short-circuit point — wrap the inner in a new optChain.
|
|
90
|
+
// Non-`?` continuation of an existing optChain extends the chain (no new
|
|
91
|
+
// short-circuit, just keep evaluating after the prior `?` succeeded).
|
|
82
92
|
if (Node.isPropertyAccessExpression(node)) {
|
|
83
93
|
const obj = extractExpr(node.getExpression());
|
|
84
94
|
const field = node.getName();
|
|
85
|
-
// Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
|
|
86
|
-
// Skip if this is a method call (parent is CallExpression) — handled differently.
|
|
87
95
|
if (node.hasQuestionDotToken()) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
|
|
93
|
-
then: { kind: "field", obj, field },
|
|
94
|
-
else: { kind: "var", name: "undefined" },
|
|
95
|
-
};
|
|
96
|
-
}
|
|
96
|
+
return { kind: "optChain", obj, chain: [{ kind: "field", name: field }] };
|
|
97
|
+
}
|
|
98
|
+
if (obj.kind === "optChain") {
|
|
99
|
+
return { ...obj, chain: [...obj.chain, { kind: "field", name: field }] };
|
|
97
100
|
}
|
|
98
101
|
return { kind: "field", obj, field };
|
|
99
102
|
}
|
|
100
|
-
// Element access: arr[i]
|
|
103
|
+
// Element access: arr[i] or arr?.[i]
|
|
101
104
|
if (Node.isElementAccessExpression(node)) {
|
|
102
105
|
const arg = node.getArgumentExpression();
|
|
103
106
|
if (!arg)
|
|
104
107
|
throw new Error(`Missing index in element access: ${node.getText()}`);
|
|
105
|
-
|
|
108
|
+
const obj = extractExpr(node.getExpression());
|
|
109
|
+
const idx = extractExpr(arg);
|
|
110
|
+
if (node.hasQuestionDotToken()) {
|
|
111
|
+
return { kind: "optChain", obj, chain: [{ kind: "index", idx }] };
|
|
112
|
+
}
|
|
113
|
+
if (obj.kind === "optChain") {
|
|
114
|
+
return { ...obj, chain: [...obj.chain, { kind: "index", idx }] };
|
|
115
|
+
}
|
|
116
|
+
return { kind: "index", obj, idx };
|
|
106
117
|
}
|
|
107
|
-
// Call expression: f(a, b)
|
|
118
|
+
// Call expression: f(a, b), x?.foo() (`?.` on the property), or x?.() (`?.` on call)
|
|
108
119
|
if (Node.isCallExpression(node)) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
// Object.fromEntries(map) → identity (Map IS Record in Dafny)
|
|
121
|
+
const callee = node.getExpression();
|
|
122
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
123
|
+
callee.getExpression().getText() === "Object" &&
|
|
124
|
+
callee.getName() === "fromEntries" &&
|
|
125
|
+
node.getArguments().length === 1) {
|
|
126
|
+
return extractExpr(node.getArguments()[0]);
|
|
127
|
+
}
|
|
128
|
+
const fn = extractExpr(callee);
|
|
129
|
+
const args = node.getArguments().map(a => extractExpr(a));
|
|
130
|
+
if (node.hasQuestionDotToken()) {
|
|
131
|
+
return { kind: "optChain", obj: fn, chain: [{ kind: "call", args }] };
|
|
132
|
+
}
|
|
133
|
+
if (fn.kind === "optChain") {
|
|
134
|
+
return { ...fn, chain: [...fn.chain, { kind: "call", args }] };
|
|
135
|
+
}
|
|
136
|
+
return { kind: "call", fn, args };
|
|
114
137
|
}
|
|
115
138
|
// Binary expression: a + b, a === b, etc.
|
|
116
139
|
if (Node.isBinaryExpression(node)) {
|
|
@@ -120,6 +143,10 @@ function extractExpr(node) {
|
|
|
120
143
|
// This is an assignment expression; extract as binop for now
|
|
121
144
|
return { kind: "binop", op: "=", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
|
|
122
145
|
}
|
|
146
|
+
// Nullish coalescing: a ?? b — single-eval, narrow rewrites to someMatch
|
|
147
|
+
if (op === "??") {
|
|
148
|
+
return { kind: "nullish", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
|
|
149
|
+
}
|
|
123
150
|
return { kind: "binop", op, left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
|
|
124
151
|
}
|
|
125
152
|
// Prefix unary: !x, -x
|
|
@@ -318,6 +345,17 @@ function collectAnnotations(node, body) {
|
|
|
318
345
|
return [...own, ...parseAnnotations(body[0])];
|
|
319
346
|
return own;
|
|
320
347
|
}
|
|
348
|
+
/** Check for bare `//@ pure` annotation (no expression). */
|
|
349
|
+
function hasPureAnnotation(node, body) {
|
|
350
|
+
const nodes = body && body.length > 0 ? [node, body[0]] : [node];
|
|
351
|
+
for (const n of nodes) {
|
|
352
|
+
for (const range of n.getLeadingCommentRanges()) {
|
|
353
|
+
if (range.getText().trim() === "//@ pure")
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
321
359
|
// ── Type declaration extraction ──────────────────────────────
|
|
322
360
|
function extractTypeDecl(decl, extraDecls) {
|
|
323
361
|
const name = decl.getName();
|
|
@@ -349,6 +387,30 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
349
387
|
}
|
|
350
388
|
}
|
|
351
389
|
}
|
|
390
|
+
// Single-variant discriminated union: type X = | { kind: 'Foo', ... }
|
|
391
|
+
// TypeScript collapses single-member unions to their member type,
|
|
392
|
+
// so type.isUnion() returns false. Detect by checking the source text
|
|
393
|
+
// for union syntax '|' AND a string-literal discriminant field.
|
|
394
|
+
if (type.isObject() && !type.isIntersection()) {
|
|
395
|
+
const srcText = decl.getTypeNode()?.getText() ?? "";
|
|
396
|
+
if (srcText.includes("|")) {
|
|
397
|
+
const disc = findDiscriminant([type]);
|
|
398
|
+
if (disc) {
|
|
399
|
+
const tagProp = type.getProperty(disc);
|
|
400
|
+
const tagType = tagProp?.getTypeAtLocation(decl);
|
|
401
|
+
const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue()) : null;
|
|
402
|
+
if (tag) {
|
|
403
|
+
const fields = [];
|
|
404
|
+
for (const prop of type.getProperties()) {
|
|
405
|
+
if (prop.getName() === disc)
|
|
406
|
+
continue;
|
|
407
|
+
fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
|
|
408
|
+
}
|
|
409
|
+
return { name, typeParams: tpField, kind: "discriminated-union", discriminant: disc, variants: [{ name: tag, fields }] };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
352
414
|
if (type.isObject() || type.isIntersection())
|
|
353
415
|
return extractRecord(name, type, decl, undefined, extraDecls);
|
|
354
416
|
// Primitive type alias: type TaskId = number → alias
|
|
@@ -461,6 +523,13 @@ function typeToString(type) {
|
|
|
461
523
|
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
|
462
524
|
if (symbol) {
|
|
463
525
|
const name = symbol.getName();
|
|
526
|
+
// Recover collapsed single-variant union alias via property fingerprint
|
|
527
|
+
if (name === "__type" && _collapsedUnionMap.size > 0) {
|
|
528
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
529
|
+
const alias = _collapsedUnionMap.get(props);
|
|
530
|
+
if (alias)
|
|
531
|
+
return alias;
|
|
532
|
+
}
|
|
464
533
|
const typeArgs = type.getTypeArguments();
|
|
465
534
|
if (typeArgs.length > 0) {
|
|
466
535
|
return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
|
|
@@ -632,13 +701,29 @@ function extractStmts(stmts) {
|
|
|
632
701
|
else {
|
|
633
702
|
names.push("_");
|
|
634
703
|
}
|
|
704
|
+
// Unwrap Object.entries(expr) / Object.values(expr) to bare map iteration
|
|
705
|
+
let iterableExpr = s.getExpression();
|
|
706
|
+
if (Node.isCallExpression(iterableExpr)) {
|
|
707
|
+
const callee = iterableExpr.getExpression();
|
|
708
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
709
|
+
callee.getExpression().getText() === "Object") {
|
|
710
|
+
const method = callee.getName();
|
|
711
|
+
if ((method === "entries" || method === "values") && iterableExpr.getArguments().length === 1) {
|
|
712
|
+
iterableExpr = iterableExpr.getArguments()[0];
|
|
713
|
+
// Object.values with single name → prepend "_" so it looks like [_, v] destructuring
|
|
714
|
+
if (method === "values" && names.length === 1) {
|
|
715
|
+
names.unshift("_");
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
635
720
|
const bodyNode = s.getStatement();
|
|
636
721
|
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
637
722
|
const annots = collectAnnotations(s, bodyStmts);
|
|
638
723
|
result.push({
|
|
639
724
|
kind: "forof",
|
|
640
725
|
names,
|
|
641
|
-
iterable: extractExpr(
|
|
726
|
+
iterable: extractExpr(iterableExpr),
|
|
642
727
|
invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
|
|
643
728
|
doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
|
|
644
729
|
body: extractStmts(bodyStmts),
|
|
@@ -864,6 +949,8 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
864
949
|
})(),
|
|
865
950
|
requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
|
|
866
951
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
952
|
+
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
953
|
+
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
867
954
|
typeAnnotations,
|
|
868
955
|
body: extractedBody,
|
|
869
956
|
line: fn.getStartLineNumber(),
|
|
@@ -907,6 +994,22 @@ export function extractModule(sourceFile) {
|
|
|
907
994
|
typeDecls.push({ name, kind: "record", fields });
|
|
908
995
|
}
|
|
909
996
|
}
|
|
997
|
+
// Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
|
|
998
|
+
// TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
|
|
999
|
+
// the alias. We record a fingerprint (sorted property names) → alias name mapping.
|
|
1000
|
+
_collapsedUnionMap = new Map();
|
|
1001
|
+
for (const stmt of sourceFile.getStatements()) {
|
|
1002
|
+
if (Node.isTypeAliasDeclaration(stmt)) {
|
|
1003
|
+
const type = stmt.getType();
|
|
1004
|
+
if (!type.isUnion() && type.isObject() && !type.isIntersection()) {
|
|
1005
|
+
const srcText = stmt.getTypeNode()?.getText() ?? "";
|
|
1006
|
+
if (srcText.includes("|") && findDiscriminant([type])) {
|
|
1007
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
1008
|
+
_collapsedUnionMap.set(props, stmt.getName());
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
910
1013
|
// Extract type declarations in source order to respect dependencies
|
|
911
1014
|
// Skip types already declared via //@ declare-type
|
|
912
1015
|
const declaredNames = new Set(typeDecls.map(d => d.name));
|
|
@@ -982,7 +1085,7 @@ export function extractModule(sourceFile) {
|
|
|
982
1085
|
}
|
|
983
1086
|
return false;
|
|
984
1087
|
}
|
|
985
|
-
const hasVerifyDirective =
|
|
1088
|
+
const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
|
|
986
1089
|
const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
|
|
987
1090
|
const functions = fnsToExtract.map(f => {
|
|
988
1091
|
// For expression-body arrows, annotations come from the parent variable statement
|
|
@@ -1266,6 +1369,12 @@ export function extractModule(sourceFile) {
|
|
|
1266
1369
|
}
|
|
1267
1370
|
const sym = retType.getSymbol();
|
|
1268
1371
|
if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
|
|
1372
|
+
// Try typeToString first — it resolves collapsed single-variant unions
|
|
1373
|
+
const resolved = typeToString(retType);
|
|
1374
|
+
if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
|
|
1375
|
+
fn.returnType = resolved;
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1269
1378
|
const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
|
|
1270
1379
|
if (!knownTypes.has(synName)) {
|
|
1271
1380
|
const extra = [];
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -75,6 +75,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
|
|
|
75
75
|
return `${obj}.set! ${args[0]} ${args[1]}`;
|
|
76
76
|
if (method === "push")
|
|
77
77
|
return `Array.push ${obj} ${args[0]}`;
|
|
78
|
+
if (method === "concat")
|
|
79
|
+
return `Array.push ${obj} ${args[0]}`;
|
|
78
80
|
}
|
|
79
81
|
// String methods
|
|
80
82
|
if (tyKind === "string") {
|
|
@@ -113,7 +115,17 @@ function emitExpr(e, parentPrec) {
|
|
|
113
115
|
case "num": return `${e.value}`;
|
|
114
116
|
case "bool": return e.value ? "true" : "false";
|
|
115
117
|
case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
116
|
-
case "constructor":
|
|
118
|
+
case "constructor": {
|
|
119
|
+
// With type: emit `Type.name` (unambiguous; needed in expression positions
|
|
120
|
+
// like `match ... | .none => Type.some x` where elaboration can't infer).
|
|
121
|
+
// Without type: emit `.name` (dotted form; works in pattern positions
|
|
122
|
+
// and where the expected type is clear from context).
|
|
123
|
+
const head = e.type ? `${e.type}.${e.name}` : `.${e.name}`;
|
|
124
|
+
if (!e.args || e.args.length === 0)
|
|
125
|
+
return head;
|
|
126
|
+
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));
|
|
127
|
+
return `${head} ${args.join(" ")}`;
|
|
128
|
+
}
|
|
117
129
|
case "arrayLiteral":
|
|
118
130
|
if (e.elems.length === 0)
|
|
119
131
|
return `#[]`;
|
|
@@ -143,6 +155,13 @@ function emitExpr(e, parentPrec) {
|
|
|
143
155
|
return `-${e.expr.value}`;
|
|
144
156
|
return `(-${emitExpr(e.expr)})`;
|
|
145
157
|
case "binop": {
|
|
158
|
+
// `k in m` (map/set membership) → `m.contains k` in Lean. Dafny has
|
|
159
|
+
// native `in`; Lean uses the method form for HashMap/HashSet.
|
|
160
|
+
if (e.op === "in") {
|
|
161
|
+
const recv = emitExpr(e.right);
|
|
162
|
+
const wrap = e.right.kind === "binop" || e.right.kind === "app" || e.right.kind === "methodCall";
|
|
163
|
+
return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
|
|
164
|
+
}
|
|
146
165
|
const op = e.op === "arrayConcat" ? "++" : e.op;
|
|
147
166
|
const s = `${emitExpr(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
|
|
148
167
|
return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
|
|
@@ -182,8 +201,12 @@ function emitExpr(e, parentPrec) {
|
|
|
182
201
|
case "if":
|
|
183
202
|
return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
|
|
184
203
|
case "match": {
|
|
204
|
+
// Always parenthesize inline matches — Lean parses alternatives greedily,
|
|
205
|
+
// so any token after an arm body (`→`, another match's `|`, etc.) would
|
|
206
|
+
// bleed into the last `.none` case without explicit bracketing.
|
|
185
207
|
const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
|
|
186
|
-
|
|
208
|
+
const scrut = typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee);
|
|
209
|
+
return `(match ${scrut} with ${arms.join(" ")})`;
|
|
187
210
|
}
|
|
188
211
|
case "forall": return `∀ ${e.var} : ${tyToLean(e.type)}, ${emitExpr(e.body)}`;
|
|
189
212
|
case "exists": return `∃ ${e.var} : ${tyToLean(e.type)}, ${emitExpr(e.body)}`;
|
|
@@ -191,6 +214,24 @@ function emitExpr(e, parentPrec) {
|
|
|
191
214
|
default: throw new Error(`Unsupported Lean expression: ${e.kind}`);
|
|
192
215
|
}
|
|
193
216
|
}
|
|
217
|
+
/** True if the IR expression emits to a Prop-valued Lean term. `transformExpr`
|
|
218
|
+
* routes TS comparisons/logicals through `OP_MAP` (`===`→`=`, `&&`→`∧`, `!`→`¬`)
|
|
219
|
+
* so these top-level ops land in Prop. `in` stays Bool (emits `.contains`), and
|
|
220
|
+
* bare method calls / vars / field accesses remain at their declared type. */
|
|
221
|
+
function isPropValued(e) {
|
|
222
|
+
switch (e.kind) {
|
|
223
|
+
case "binop":
|
|
224
|
+
return ["=", "≠", "≥", "≤", ">", "<", "∧", "∨"].includes(e.op);
|
|
225
|
+
case "unop":
|
|
226
|
+
return e.op === "¬";
|
|
227
|
+
case "implies":
|
|
228
|
+
case "forall":
|
|
229
|
+
case "exists":
|
|
230
|
+
return true;
|
|
231
|
+
default:
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
194
235
|
// ── Statement emission ──────────────────────────────────────
|
|
195
236
|
function emitStmts(stmts, indent) {
|
|
196
237
|
const pad = " ".repeat(indent);
|
|
@@ -207,7 +248,16 @@ function emitStmt(s, indent) {
|
|
|
207
248
|
case "ghostLet":
|
|
208
249
|
return `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`;
|
|
209
250
|
case "ghostAssign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
|
|
210
|
-
case "assert":
|
|
251
|
+
case "assert": {
|
|
252
|
+
// WPGen.assert needs a Prop; bare Bool expressions (`k in m` → `.contains`,
|
|
253
|
+
// method calls, vars) lack a matching WPGen instance and silently fall back
|
|
254
|
+
// to WPGen.default, which drops the assertion. Coerce to Prop via `= true`.
|
|
255
|
+
// Top-level Prop constructs (`=`, `<`, `∧`, `¬`, `∀`, `∃`, `→`) already
|
|
256
|
+
// land in Prop — Lean auto-coerces inner Bools there.
|
|
257
|
+
const inner = emitExpr(s.expr);
|
|
258
|
+
const wrapped = isPropValued(s.expr) ? inner : `(${inner}) = true`;
|
|
259
|
+
return `${pad}assertGadget (${wrapped})`;
|
|
260
|
+
}
|
|
211
261
|
case "bind": return `${pad}${escapeName(s.target)} ← ${emitExpr(s.value)}`;
|
|
212
262
|
case "let-bind": return `${pad}let ${s.name} ← ${emitExpr(s.value)}`;
|
|
213
263
|
case "return": return `${pad}return ${emitExpr(s.value)}`;
|
|
@@ -325,6 +375,8 @@ function emitDecl(d) {
|
|
|
325
375
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
326
376
|
return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
|
|
327
377
|
}
|
|
378
|
+
case "def-by-method":
|
|
379
|
+
throw new Error("function by method is not supported for Lean backend");
|
|
328
380
|
case "method": {
|
|
329
381
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
330
382
|
const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
|