lemmascript 0.3.3 → 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 +4 -2
- package/package.json +1 -1
- package/tools/dist/dafny-commands.js +31 -14
- package/tools/dist/dafny-emit.js +17 -5
- package/tools/dist/extract.js +32 -20
- package/tools/dist/lean-emit.js +53 -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 +313 -220
- package/tools/dist/specparser.js +12 -2
- package/tools/dist/transform.js +217 -419
package/README.md
CHANGED
|
@@ -16,9 +16,11 @@ See the external case studies:
|
|
|
16
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.
|
|
17
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).
|
|
18
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.
|
|
19
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).
|
|
20
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.
|
|
21
|
-
- **[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.
|
|
22
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.
|
|
23
25
|
|
|
24
26
|
## Setup
|
|
@@ -60,7 +62,7 @@ The Dafny backend generates two files per TS source: `foo.dfy.gen` (always regen
|
|
|
60
62
|
### Lean backend
|
|
61
63
|
|
|
62
64
|
```sh
|
|
63
|
-
npx lsc gen src/myModule.ts
|
|
65
|
+
npx lsc gen --backend=lean src/myModule.ts
|
|
64
66
|
lake build
|
|
65
67
|
```
|
|
66
68
|
|
package/package.json
CHANGED
|
@@ -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 `[]`;
|
|
@@ -331,7 +343,7 @@ function emitExpr(e) {
|
|
|
331
343
|
return `(${vals.join(", ")})`;
|
|
332
344
|
}
|
|
333
345
|
case "if":
|
|
334
|
-
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)})`;
|
|
335
347
|
case "match": {
|
|
336
348
|
const scrut = emitScrutinee(e.scrutinee);
|
|
337
349
|
const arms = e.arms.map(a => `case ${translatePattern(a.pattern)} => ${emitExpr(a.body)}`);
|
|
@@ -339,7 +351,7 @@ function emitExpr(e) {
|
|
|
339
351
|
}
|
|
340
352
|
case "forall": return emitQuantifier(e, "forall");
|
|
341
353
|
case "exists": return emitQuantifier(e, "exists");
|
|
342
|
-
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)})`;
|
|
343
355
|
case "havoc": return "*";
|
|
344
356
|
}
|
|
345
357
|
}
|
|
@@ -511,7 +523,7 @@ function emitDecl(d) {
|
|
|
511
523
|
}
|
|
512
524
|
case "method": {
|
|
513
525
|
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
514
|
-
const lines = [`method ${d.name}${tp}
|
|
526
|
+
const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType)];
|
|
515
527
|
for (const r of d.requires)
|
|
516
528
|
lines.push(` requires ${emitExpr(r)}`);
|
|
517
529
|
for (const e of d.ensures)
|
|
@@ -529,7 +541,7 @@ function emitDecl(d) {
|
|
|
529
541
|
if (d.fields.length > 0 && d.methods.length > 0)
|
|
530
542
|
lines.push("");
|
|
531
543
|
for (const m of d.methods) {
|
|
532
|
-
lines.push(` method ${m.name}
|
|
544
|
+
lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
|
|
533
545
|
for (const r of m.requires)
|
|
534
546
|
lines.push(` requires ${emitExpr(r)}`);
|
|
535
547
|
for (const e of m.ensures)
|
package/tools/dist/extract.js
CHANGED
|
@@ -86,32 +86,36 @@ function extractExpr(node) {
|
|
|
86
86
|
return { kind: "var", name: "this" };
|
|
87
87
|
}
|
|
88
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).
|
|
89
92
|
if (Node.isPropertyAccessExpression(node)) {
|
|
90
93
|
const obj = extractExpr(node.getExpression());
|
|
91
94
|
const field = node.getName();
|
|
92
|
-
// Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
|
|
93
|
-
// Skip if this is a method call (parent is CallExpression) — handled differently.
|
|
94
95
|
if (node.hasQuestionDotToken()) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
|
|
100
|
-
then: { kind: "field", obj, field },
|
|
101
|
-
else: { kind: "var", name: "undefined" },
|
|
102
|
-
};
|
|
103
|
-
}
|
|
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 }] };
|
|
104
100
|
}
|
|
105
101
|
return { kind: "field", obj, field };
|
|
106
102
|
}
|
|
107
|
-
// Element access: arr[i]
|
|
103
|
+
// Element access: arr[i] or arr?.[i]
|
|
108
104
|
if (Node.isElementAccessExpression(node)) {
|
|
109
105
|
const arg = node.getArgumentExpression();
|
|
110
106
|
if (!arg)
|
|
111
107
|
throw new Error(`Missing index in element access: ${node.getText()}`);
|
|
112
|
-
|
|
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 };
|
|
113
117
|
}
|
|
114
|
-
// Call expression: f(a, b)
|
|
118
|
+
// Call expression: f(a, b), x?.foo() (`?.` on the property), or x?.() (`?.` on call)
|
|
115
119
|
if (Node.isCallExpression(node)) {
|
|
116
120
|
// Object.fromEntries(map) → identity (Map IS Record in Dafny)
|
|
117
121
|
const callee = node.getExpression();
|
|
@@ -121,11 +125,15 @@ function extractExpr(node) {
|
|
|
121
125
|
node.getArguments().length === 1) {
|
|
122
126
|
return extractExpr(node.getArguments()[0]);
|
|
123
127
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
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 };
|
|
129
137
|
}
|
|
130
138
|
// Binary expression: a + b, a === b, etc.
|
|
131
139
|
if (Node.isBinaryExpression(node)) {
|
|
@@ -135,6 +143,10 @@ function extractExpr(node) {
|
|
|
135
143
|
// This is an assignment expression; extract as binop for now
|
|
136
144
|
return { kind: "binop", op: "=", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
|
|
137
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
|
+
}
|
|
138
150
|
return { kind: "binop", op, left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
|
|
139
151
|
}
|
|
140
152
|
// Prefix unary: !x, -x
|
|
@@ -1073,7 +1085,7 @@ export function extractModule(sourceFile) {
|
|
|
1073
1085
|
}
|
|
1074
1086
|
return false;
|
|
1075
1087
|
}
|
|
1076
|
-
const hasVerifyDirective =
|
|
1088
|
+
const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
|
|
1077
1089
|
const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
|
|
1078
1090
|
const functions = fnsToExtract.map(f => {
|
|
1079
1091
|
// For expression-body arrows, annotations come from the parent variable statement
|
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)}`;
|
package/tools/dist/lsc.js
CHANGED
|
@@ -9,7 +9,9 @@ import { existsSync } from "fs";
|
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { extractModule } from "./extract.js";
|
|
11
11
|
import { resolveModule } from "./resolve.js";
|
|
12
|
+
import { narrowModule } from "./narrow.js";
|
|
12
13
|
import { transformModuleLean, transformModuleDafny } from "./transform.js";
|
|
14
|
+
import { peepholeModule } from "./peephole.js";
|
|
13
15
|
import { emitLeanFile } from "./lean-emit.js";
|
|
14
16
|
import { emitDafnyFile } from "./dafny-emit.js";
|
|
15
17
|
import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
|
|
@@ -81,12 +83,17 @@ function main() {
|
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
// Resolve: Raw IR → Typed IR
|
|
84
|
-
const
|
|
86
|
+
const resolved = resolveModule(raw);
|
|
87
|
+
// Narrow: Typed IR → Typed IR (rewrites optional-narrowing patterns to someMatch)
|
|
88
|
+
const typed = narrowModule(resolved);
|
|
85
89
|
const dir = path.dirname(absPath);
|
|
86
90
|
const base = path.basename(filePath, ".ts");
|
|
87
91
|
// ── Dafny backend ─────────────────────────────────────────
|
|
88
92
|
if (backend === "dafny") {
|
|
89
|
-
|
|
93
|
+
let { typesFile, defFile } = transformModuleDafny(typed);
|
|
94
|
+
if (typesFile)
|
|
95
|
+
typesFile = peepholeModule(typesFile, "dafny");
|
|
96
|
+
defFile = peepholeModule(defFile, "dafny");
|
|
90
97
|
const allDecls = [...(typesFile?.decls ?? []), ...defFile.decls];
|
|
91
98
|
const merged = { ...defFile, decls: allDecls };
|
|
92
99
|
const text = emitDafnyFile(merged, path.basename(filePath));
|
|
@@ -121,7 +128,10 @@ function main() {
|
|
|
121
128
|
// ── Lean backend ──────────────────────────────────────────
|
|
122
129
|
const specPath = path.join(dir, `${base}.spec.lean`);
|
|
123
130
|
const specImport = existsSync(specPath) ? `«${base}.spec»` : undefined;
|
|
124
|
-
|
|
131
|
+
let { typesFile, defFile } = transformModuleLean(typed, specImport);
|
|
132
|
+
if (typesFile)
|
|
133
|
+
typesFile = peepholeModule(typesFile, "lean");
|
|
134
|
+
defFile = peepholeModule(defFile, "lean");
|
|
125
135
|
const typesPath = typesFile ? path.join(dir, `${base}.types.lean`) : null;
|
|
126
136
|
const typesText = typesFile ? emitLeanFile(typesFile) : null;
|
|
127
137
|
const defPath = path.join(dir, `${base}.def.lean`);
|