lemmascript 0.4.0 → 0.5.1
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 +17 -12
- package/package.json +4 -1
- package/tools/dist/dafny-emit.js +294 -12
- package/tools/dist/extract.js +1093 -165
- package/tools/dist/info-command.js +38 -0
- package/tools/dist/lean-emit.js +28 -2
- package/tools/dist/lsc.js +16 -6
- package/tools/dist/narrow.js +211 -16
- package/tools/dist/peephole.js +5 -2
- package/tools/dist/resolve.js +416 -46
- package/tools/dist/specparser.js +6 -0
- package/tools/dist/transform.js +400 -42
- package/tools/dist/types.js +128 -69
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A verification toolchain for TypeScript. Write ordinary TypeScript with `//@ ` specification annotations. The toolchain generates verifiable code from your TypeScript — either in Dafny or Lean 4 (with Velvet/Loom).
|
|
4
4
|
|
|
5
|
-
See [SPEC.md](SPEC.md)
|
|
5
|
+
See [SPEC.md](SPEC.md), [DESIGN.md](DESIGN.md), and [GETTING_STARTED.md](GETTING_STARTED.md).
|
|
6
6
|
|
|
7
7
|
This is a **Tech Preview**: the core idea is there, but support, semantics, and ergonomics are still evolving.
|
|
8
8
|
|
|
9
|
+
See our [blog post](https://midspiral.com/blog/lemmascript-a-verification-toolchain-for-typescript/).
|
|
10
|
+
|
|
9
11
|
## Examples and Case Studies
|
|
10
12
|
|
|
11
13
|
Each example and case study is verified in Lean 4 and/or Dafny from the same annotated TypeScript source.
|
|
@@ -17,11 +19,14 @@ See the external case studies:
|
|
|
17
19
|
- **[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
20
|
- **[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
21
|
- **[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`.
|
|
22
|
+
- **[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.
|
|
23
|
+
- **[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.
|
|
21
24
|
- **[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).
|
|
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.
|
|
25
|
+
- **[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.
|
|
23
26
|
- **[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.
|
|
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` (
|
|
27
|
+
- **[xyflow-lemmascript](https://github.com/midspiral/xyflow-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [xyflow](https://github.com/xyflow/xyflow)'s core edge and geometry utilities. 9 functions verified: `addEdge` (dedup — never loses edges, adds at most one), `reconnectEdge` (semantic: under a unique-id precondition, the result is in-place — `|result| ≤ |edges|`, no insertion — *and* when a matching edge existed with non-empty new endpoints, the output contains an edge with those endpoints. Uses `//@ assume` to characterize destructuring, `find`, and the constructed edge), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Dafny only.
|
|
28
|
+
- **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
|
|
29
|
+
- **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
|
|
25
30
|
|
|
26
31
|
## Setup
|
|
27
32
|
|
|
@@ -84,17 +89,17 @@ lake build
|
|
|
84
89
|
|
|
85
90
|
| File | Generated? | Purpose |
|
|
86
91
|
|------|-----------|---------|
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
92
|
+
| [**.ts**](examples/majority.ts) | — | TypeScript source with `//@ ` annotations |
|
|
93
|
+
| [**.dfy.gen**](examples/majority.dfy.gen) | Yes | Generated Dafny (merge base, always regeneratable) |
|
|
94
|
+
| [**.dfy**](examples/majority.dfy) | Yes (initial) | Annotated Dafny (gen + proof additions) |
|
|
90
95
|
|
|
91
96
|
### Lean backend
|
|
92
97
|
|
|
93
98
|
| File | Generated? | Purpose |
|
|
94
99
|
|------|-----------|---------|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
+
| [**.ts**](examples/majority.ts) | — | TypeScript source with `//@ ` annotations |
|
|
101
|
+
| [**.types.lean**](examples/majority.types.lean) | Yes | Lean types, `namespace Pure` defs |
|
|
102
|
+
| [**.spec.lean**](examples/majority.spec.lean) | No | Ghost definitions, helper lemmas |
|
|
103
|
+
| [**.def.lean**](examples/majority.def.lean) | Yes | Velvet method definitions |
|
|
104
|
+
| [**.proof.lean**](examples/majority.proof.lean) | No | `prove_correct` with proof tactics |
|
|
100
105
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
6
9
|
"bin": {
|
|
7
10
|
"lsc": "tools/dist/lsc.js"
|
|
8
11
|
},
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -18,6 +18,7 @@ function tyToDafny(ty) {
|
|
|
18
18
|
return `Option<${tyToDafny(ty.inner)}>`;
|
|
19
19
|
}
|
|
20
20
|
case "user": return ty.name;
|
|
21
|
+
case "fn": return `(${ty.params.map(tyToDafny).join(", ")}) -> ${tyToDafny(ty.result)}`;
|
|
21
22
|
case "unknown": return "int";
|
|
22
23
|
}
|
|
23
24
|
}
|
|
@@ -34,8 +35,21 @@ const DAFNY_KEYWORDS = new Set([
|
|
|
34
35
|
"datatype", "type", "const", "ghost", "static",
|
|
35
36
|
"reads", "modifies", "assert", "assume", "print",
|
|
36
37
|
"by", "calc", "reveal",
|
|
38
|
+
// Further reserved words (validated against the Dafny parser) that are also
|
|
39
|
+
// legal TS identifiers. `this` stays excluded above — class methods emit it
|
|
40
|
+
// directly. `then`/`else` already covered.
|
|
41
|
+
"bool", "char", "int", "nat", "real", "string", "object", "array",
|
|
42
|
+
"as", "is", "label", "modify", "expect", "yield", "yields", "returns",
|
|
43
|
+
"unchanged", "witness", "constructor", "iterator", "abstract", "extends",
|
|
44
|
+
"refines", "opened", "provides", "reveals", "include", "newtype",
|
|
45
|
+
"codatatype", "nameonly", "twostate", "opaque", "replaceable", "colemma",
|
|
46
|
+
"copredicate", "inductive",
|
|
37
47
|
]);
|
|
38
48
|
function escapeName(name) {
|
|
49
|
+
// \result is carried through the IR as the var name "\\result"; render it
|
|
50
|
+
// as Dafny's canonical return-value identifier.
|
|
51
|
+
if (name === "\\result")
|
|
52
|
+
return "res";
|
|
39
53
|
if (DAFNY_KEYWORDS.has(name))
|
|
40
54
|
return `${name}_`;
|
|
41
55
|
// Dafny doesn't allow identifiers starting with _
|
|
@@ -78,6 +92,15 @@ function emitQuantifier(e, keyword) {
|
|
|
78
92
|
}
|
|
79
93
|
return `${keyword} ${vars.join(", ")} :: ${emitExpr(body)}`;
|
|
80
94
|
}
|
|
95
|
+
// Dafny's `forall`/`exists ::` body extends as far as possible. So
|
|
96
|
+
// `(forall i :: P(i)) <op> Q` (or `... ==> Q`) would parse with the operator
|
|
97
|
+
// absorbed into the body. Wrap a quantifier in parens to terminate its body
|
|
98
|
+
// before the operator. Only the LEFT operand needs this — a quantifier in
|
|
99
|
+
// right-operand position is fine because its body correctly spans the rest.
|
|
100
|
+
function wrapQuantifier(sub) {
|
|
101
|
+
const inner = emitExpr(sub);
|
|
102
|
+
return (sub.kind === "forall" || sub.kind === "exists") ? `(${inner})` : inner;
|
|
103
|
+
}
|
|
81
104
|
function emitExpr(e) {
|
|
82
105
|
switch (e.kind) {
|
|
83
106
|
case "var": return e.name === "undefined" ? "None" : escapeName(e.name);
|
|
@@ -85,6 +108,11 @@ function emitExpr(e) {
|
|
|
85
108
|
case "bool": return e.value ? "true" : "false";
|
|
86
109
|
case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
87
110
|
case "constructor": {
|
|
111
|
+
// Option constructors (Some/None) may appear in inferred positions
|
|
112
|
+
// (e.g. lambda result) without an explicit `optional<T>` in any
|
|
113
|
+
// signature, so request the preamble here.
|
|
114
|
+
if (e.type === "Option")
|
|
115
|
+
needPreamble("OptionType");
|
|
88
116
|
const head = qualifyCtor(e.name, e.type);
|
|
89
117
|
if (!e.args || e.args.length === 0)
|
|
90
118
|
return head;
|
|
@@ -96,6 +124,10 @@ function emitExpr(e) {
|
|
|
96
124
|
return `[${e.elems.map(emitExpr).join(", ")}]`;
|
|
97
125
|
case "emptyMap": return `map[]`;
|
|
98
126
|
case "emptySet": return `{}`;
|
|
127
|
+
case "mapLiteral": {
|
|
128
|
+
const entries = e.entries.map(en => `${emitExpr(en.key)} := ${emitExpr(en.value)}`);
|
|
129
|
+
return `map[${entries.join(", ")}]`;
|
|
130
|
+
}
|
|
99
131
|
case "methodCall": {
|
|
100
132
|
const obj = emitExpr(e.obj);
|
|
101
133
|
const args = e.args.map(emitExpr);
|
|
@@ -114,16 +146,45 @@ function emitExpr(e) {
|
|
|
114
146
|
return `(${obj} + [${args[0]}])`;
|
|
115
147
|
if (e.method === "concat")
|
|
116
148
|
return `(${obj} + [${args[0]}])`;
|
|
149
|
+
// No-arg slice is a full copy; Dafny seq is an immutable value type, so
|
|
150
|
+
// the copy is just the seq itself (the idiom for "copy then mutate").
|
|
151
|
+
if (e.method === "slice" && args.length === 0)
|
|
152
|
+
return obj;
|
|
117
153
|
if (e.method === "slice" && args.length === 1)
|
|
118
154
|
return `${obj}[${args[0]}..]`;
|
|
119
|
-
if (e.method === "slice" && args.length === 2)
|
|
155
|
+
if (e.method === "slice" && args.length === 2) {
|
|
156
|
+
// JS slice clamps both bounds; Dafny requires `0 <= lo <= hi <= |s|`.
|
|
157
|
+
// Direct slice is default (matches existing case studies that wrote
|
|
158
|
+
// bounded calls). Files needing JS clamping opt in via `//@ safe-slice`.
|
|
159
|
+
if (_useSafeSlice) {
|
|
160
|
+
needPreamble("SafeSlice");
|
|
161
|
+
return `SafeSlice(${obj}, ${args[0]}, ${args[1]})`;
|
|
162
|
+
}
|
|
120
163
|
return `${obj}[${args[0]}..${args[1]}]`;
|
|
164
|
+
}
|
|
121
165
|
if (e.method === "map")
|
|
122
166
|
return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
|
|
123
167
|
if (e.method === "filter")
|
|
124
168
|
return `Std.Collections.Seq.Filter(${args[0]}, ${obj})`;
|
|
125
169
|
if (e.method === "every")
|
|
126
170
|
return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
|
|
171
|
+
if (e.method === "findLast") {
|
|
172
|
+
needPreamble("OptionType");
|
|
173
|
+
needPreamble("SeqFindLast");
|
|
174
|
+
return `SeqFindLast(${obj}, ${args[0]})`;
|
|
175
|
+
}
|
|
176
|
+
if (e.method === "findIndex") {
|
|
177
|
+
needPreamble("SeqFindIndex");
|
|
178
|
+
return `SeqFindIndex(${obj}, ${args[0]})`;
|
|
179
|
+
}
|
|
180
|
+
if (e.method === "flat" && args.length === 0) {
|
|
181
|
+
needPreamble("SeqFlatten");
|
|
182
|
+
return `SeqFlatten(${obj})`;
|
|
183
|
+
}
|
|
184
|
+
if (e.method === "join") {
|
|
185
|
+
needPreamble("SeqJoin");
|
|
186
|
+
return `SeqJoin(${obj}, ${args[0]})`;
|
|
187
|
+
}
|
|
127
188
|
if (e.method === "some" && e.args[0].kind === "lambda" &&
|
|
128
189
|
e.args[0].body.length === 1 && e.args[0].body[0].kind === "return") {
|
|
129
190
|
const lam = e.args[0];
|
|
@@ -139,14 +200,46 @@ function emitExpr(e) {
|
|
|
139
200
|
if (ty === "string") {
|
|
140
201
|
if (e.method === "indexOf") {
|
|
141
202
|
needPreamble("StringIndexOf");
|
|
203
|
+
if (args.length === 2)
|
|
204
|
+
return `StringIndexOfFrom(${obj}, ${args[0]}, ${args[1]})`;
|
|
142
205
|
return `StringIndexOf(${obj}, ${args[0]})`;
|
|
143
206
|
}
|
|
144
|
-
if (e.method === "
|
|
207
|
+
if (e.method === "split") {
|
|
208
|
+
needPreamble("StringSplit");
|
|
209
|
+
return `StringSplit(${obj}, ${args[0]})`;
|
|
210
|
+
}
|
|
211
|
+
if (e.method === "slice") {
|
|
212
|
+
// JS negative index: arr.slice(0, -N) → arr[0..|arr|-N]. After
|
|
213
|
+
// transform, unary minus on a numeric literal is folded to a
|
|
214
|
+
// negative `num` IR node, so check for that here.
|
|
215
|
+
const negVal = (a) => a.kind === "num" && a.value < 0 ? -a.value : null;
|
|
216
|
+
const loN = negVal(e.args[0]);
|
|
217
|
+
const loEx = loN !== null ? `|${obj}|-${loN}` : args[0];
|
|
218
|
+
if (args.length === 1)
|
|
219
|
+
return `${obj}[${loEx}..]`;
|
|
220
|
+
const hiN = negVal(e.args[1]);
|
|
221
|
+
const hiEx = hiN !== null ? `|${obj}|-${hiN}` : args[1];
|
|
222
|
+
return `${obj}[${loEx}..${hiEx}]`;
|
|
223
|
+
}
|
|
224
|
+
if (e.method === "substring") {
|
|
225
|
+
if (args.length === 1)
|
|
226
|
+
return `${obj}[${args[0]}..]`;
|
|
145
227
|
return `${obj}[${args[0]}..${args[1]}]`;
|
|
228
|
+
}
|
|
229
|
+
if (e.method === "endsWith")
|
|
230
|
+
return `(|${obj}| >= |${args[0]}| && ${obj}[|${obj}|-|${args[0]}|..] == ${args[0]})`;
|
|
146
231
|
if (e.method === "trim") {
|
|
147
232
|
needPreamble("StringTrim");
|
|
148
233
|
return `StringTrim(${obj})`;
|
|
149
234
|
}
|
|
235
|
+
if (e.method === "trimEnd") {
|
|
236
|
+
needPreamble("StringTrim");
|
|
237
|
+
return `StringTrimRight(${obj})`;
|
|
238
|
+
}
|
|
239
|
+
if (e.method === "trimStart") {
|
|
240
|
+
needPreamble("StringTrim");
|
|
241
|
+
return `StringTrimLeft(${obj})`;
|
|
242
|
+
}
|
|
150
243
|
if (e.method === "toLowerCase") {
|
|
151
244
|
needPreamble("StringToLower");
|
|
152
245
|
return `StringToLower(${obj})`;
|
|
@@ -159,6 +252,8 @@ function emitExpr(e) {
|
|
|
159
252
|
needPreamble("StringIndexOf");
|
|
160
253
|
return `(StringIndexOf(${obj}, ${args[0]}) >= 0)`;
|
|
161
254
|
}
|
|
255
|
+
if (e.method === "startsWith")
|
|
256
|
+
return `(|${obj}| >= |${args[0]}| && ${obj}[..|${args[0]}|] == ${args[0]})`;
|
|
162
257
|
if (e.method === "charCodeAt")
|
|
163
258
|
return `(${obj}[${args[0]}] as int)`;
|
|
164
259
|
}
|
|
@@ -185,6 +280,20 @@ function emitExpr(e) {
|
|
|
185
280
|
return `(${obj} + {${args[0]}})`;
|
|
186
281
|
if (e.method === "delete")
|
|
187
282
|
return `(${obj} - {${args[0]}})`;
|
|
283
|
+
// `.filter(pred)` on a set: extract collapses the JS idiom
|
|
284
|
+
// `new Set([...s].filter(p))` into `s.filter(p)` with set receiver
|
|
285
|
+
// (the spread → array → set round-trip is identity over set
|
|
286
|
+
// semantics). Lower to Dafny set-builder: `set x | x in s && p(x)`.
|
|
287
|
+
if (e.method === "filter" && e.args.length === 1 && e.args[0].kind === "lambda" &&
|
|
288
|
+
e.args[0].body.length === 1 && e.args[0].body[0].kind === "return") {
|
|
289
|
+
const lam = e.args[0];
|
|
290
|
+
const ret = lam.body[0];
|
|
291
|
+
if (ret.kind !== "return")
|
|
292
|
+
throw new Error("unreachable");
|
|
293
|
+
const p = escapeName(lam.params[0]?.name ?? "x");
|
|
294
|
+
const body = emitExpr(ret.value);
|
|
295
|
+
return `(set ${p} | ${p} in ${obj} && ${body})`;
|
|
296
|
+
}
|
|
188
297
|
}
|
|
189
298
|
throw new Error(`Unsupported Dafny method call: .${e.method}() on ${ty}`);
|
|
190
299
|
}
|
|
@@ -251,10 +360,10 @@ function emitExpr(e) {
|
|
|
251
360
|
return `(${left} ${op} ${right})`;
|
|
252
361
|
}
|
|
253
362
|
}
|
|
254
|
-
return `(${
|
|
363
|
+
return `(${wrapQuantifier(e.left)} ${op} ${emitExpr(e.right)})`;
|
|
255
364
|
}
|
|
256
365
|
case "implies": {
|
|
257
|
-
const parts = [...e.premises.map(
|
|
366
|
+
const parts = [...e.premises.map(wrapQuantifier), emitExpr(e.conclusion)];
|
|
258
367
|
return `(${parts.join(" ==> ")})`;
|
|
259
368
|
}
|
|
260
369
|
case "app": {
|
|
@@ -282,6 +391,14 @@ function emitExpr(e) {
|
|
|
282
391
|
needPreamble("MathMin");
|
|
283
392
|
if (e.fn === "MathMax")
|
|
284
393
|
needPreamble("MathMax");
|
|
394
|
+
if (e.fn === "MaxOfSeq") {
|
|
395
|
+
needPreamble("MathMax");
|
|
396
|
+
needPreamble("MaxOfSeq");
|
|
397
|
+
}
|
|
398
|
+
if (e.fn === "MinOfSeq") {
|
|
399
|
+
needPreamble("MathMin");
|
|
400
|
+
needPreamble("MinOfSeq");
|
|
401
|
+
}
|
|
285
402
|
return `${escapeName(e.fn)}(${args.join(", ")})`;
|
|
286
403
|
}
|
|
287
404
|
case "field": {
|
|
@@ -297,10 +414,19 @@ function emitExpr(e) {
|
|
|
297
414
|
case "toNat":
|
|
298
415
|
// Dafny doesn't need toNat — just emit the inner expression
|
|
299
416
|
return emitExpr(e.expr);
|
|
300
|
-
case "index":
|
|
301
|
-
|
|
417
|
+
case "index": {
|
|
418
|
+
const obj = emitExpr(e.arr);
|
|
419
|
+
const idx = emitExpr(e.idx);
|
|
420
|
+
// Plain seq/map subscript. For maps where the result is meant to be
|
|
421
|
+
// `Option<V>` (the TS `Record<K,V>[k]` shape), transform should have
|
|
422
|
+
// wrapped this in an Option-coercion; here we just emit the subscript.
|
|
423
|
+
return `${obj}[${idx}]`;
|
|
424
|
+
}
|
|
302
425
|
case "record": {
|
|
303
426
|
if (e.spread) {
|
|
427
|
+
if (e.fields.length === 0) {
|
|
428
|
+
return emitExpr(e.spread);
|
|
429
|
+
}
|
|
304
430
|
const updates = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
|
|
305
431
|
return `${emitExpr(e.spread)}.(${updates.join(", ")})`;
|
|
306
432
|
}
|
|
@@ -319,8 +445,9 @@ function emitExpr(e) {
|
|
|
319
445
|
}
|
|
320
446
|
if (ctorName) {
|
|
321
447
|
const structFields = _structureDecls.get(ctorName);
|
|
322
|
-
|
|
323
|
-
|
|
448
|
+
// Always reorder by struct field name — TS object literal order ≠ Dafny
|
|
449
|
+
// positional order. Pad missing optional fields with None.
|
|
450
|
+
if (structFields) {
|
|
324
451
|
const provided = new Map(e.fields.map(f => [f.name, f]));
|
|
325
452
|
const vals = structFields.map(sf => {
|
|
326
453
|
const f = provided.get(sf.name);
|
|
@@ -401,7 +528,7 @@ function emitStmt(s, indent) {
|
|
|
401
528
|
case "ghostAssign":
|
|
402
529
|
return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
|
|
403
530
|
case "assert":
|
|
404
|
-
return `${pad}assert ${emitExpr(s.expr)};`;
|
|
531
|
+
return `${pad}${s.assumed ? "assume {:axiom}" : "assert"} ${emitExpr(s.expr)};`;
|
|
405
532
|
case "bind":
|
|
406
533
|
// Monadic bind shouldn't appear in Dafny mode, emit as regular assign
|
|
407
534
|
return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
|
|
@@ -556,6 +683,18 @@ function emitDecl(d) {
|
|
|
556
683
|
case "const": {
|
|
557
684
|
return `const ${escapeName(d.name)}: ${tyToDafny(d.type)} := ${emitExpr(d.value)}`;
|
|
558
685
|
}
|
|
686
|
+
case "extern": {
|
|
687
|
+
// Body-less Dafny function — `:axiom` makes Dafny accept the missing body
|
|
688
|
+
// and treats it as an uninterpreted symbol. Any `requires`/`ensures` were
|
|
689
|
+
// lifted from the source declaration's annotations.
|
|
690
|
+
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
691
|
+
const lines = [`function {:axiom} ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
692
|
+
for (const r of d.requires)
|
|
693
|
+
lines.push(` requires ${emitExpr(r)}`);
|
|
694
|
+
for (const e of d.ensures)
|
|
695
|
+
lines.push(` ensures ${emitExpr(e)}`);
|
|
696
|
+
return lines.join("\n");
|
|
697
|
+
}
|
|
559
698
|
case "namespace": {
|
|
560
699
|
// Dafny doesn't need namespaces — flatten declarations
|
|
561
700
|
return d.decls.map(emitDecl).join("\n\n");
|
|
@@ -567,6 +706,11 @@ function emitDecl(d) {
|
|
|
567
706
|
/** Preamble tracking — emitters add keys via `needPreamble(key)`, emitDafnyFile emits them. */
|
|
568
707
|
const _neededPreambles = new Set();
|
|
569
708
|
function needPreamble(key) { _neededPreambles.add(key); }
|
|
709
|
+
/** File-level opt-in for JS-clamp semantics on `arr.slice(lo, hi)`. Set by
|
|
710
|
+
* `emitDafnyFile` from the `//@ safe-slice` directive; consulted by the
|
|
711
|
+
* array-method emit. Off by default — case studies that wrote their `.slice`
|
|
712
|
+
* calls with provable bounds get direct `s[lo..hi]` emission. */
|
|
713
|
+
let _useSafeSlice = false;
|
|
570
714
|
const POW2 = `function Pow2(n: int): int
|
|
571
715
|
requires n >= 0
|
|
572
716
|
decreases n
|
|
@@ -599,6 +743,31 @@ const CEIL_REAL = `function CeilReal(x: real): int
|
|
|
599
743
|
if x == (x.Floor as real) then x.Floor
|
|
600
744
|
else x.Floor + 1
|
|
601
745
|
}`;
|
|
746
|
+
const SEQ_FIND_INDEX = `function SeqFindIndex<T>(s: seq<T>, p: T -> bool): int
|
|
747
|
+
ensures -1 <= SeqFindIndex(s, p) < |s|
|
|
748
|
+
ensures SeqFindIndex(s, p) >= 0 ==> p(s[SeqFindIndex(s, p)])
|
|
749
|
+
ensures SeqFindIndex(s, p) >= 0 ==>
|
|
750
|
+
(forall i: nat :: i < SeqFindIndex(s, p) ==> !p(s[i]))
|
|
751
|
+
ensures SeqFindIndex(s, p) == -1 ==> (forall i: nat :: i < |s| ==> !p(s[i]))
|
|
752
|
+
{
|
|
753
|
+
SeqFindIndexFrom(s, p, 0)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function SeqFindIndexFrom<T>(s: seq<T>, p: T -> bool, from: nat): int
|
|
757
|
+
requires from <= |s|
|
|
758
|
+
ensures -1 <= SeqFindIndexFrom(s, p, from) < |s|
|
|
759
|
+
ensures SeqFindIndexFrom(s, p, from) >= 0 ==>
|
|
760
|
+
from <= SeqFindIndexFrom(s, p, from) && p(s[SeqFindIndexFrom(s, p, from)])
|
|
761
|
+
ensures SeqFindIndexFrom(s, p, from) >= 0 ==>
|
|
762
|
+
(forall i: nat :: from <= i < SeqFindIndexFrom(s, p, from) ==> !p(s[i]))
|
|
763
|
+
ensures SeqFindIndexFrom(s, p, from) == -1 ==>
|
|
764
|
+
(forall i: nat :: from <= i < |s| ==> !p(s[i]))
|
|
765
|
+
decreases |s| - from
|
|
766
|
+
{
|
|
767
|
+
if from >= |s| then -1
|
|
768
|
+
else if p(s[from]) then from as int
|
|
769
|
+
else SeqFindIndexFrom(s, p, from + 1)
|
|
770
|
+
}`;
|
|
602
771
|
const SEQ_INDEX_OF = `function SeqIndexOf<T(==)>(s: seq<T>, x: T): int
|
|
603
772
|
ensures -1 <= SeqIndexOf(s, x) < |s|
|
|
604
773
|
ensures SeqIndexOf(s, x) >= 0 ==> s[SeqIndexOf(s, x)] == x
|
|
@@ -618,18 +787,75 @@ function SeqIndexOfFrom<T(==)>(s: seq<T>, x: T, from: nat): int
|
|
|
618
787
|
else if s[from] == x then from as int
|
|
619
788
|
else SeqIndexOfFrom(s, x, from + 1)
|
|
620
789
|
}`;
|
|
790
|
+
const SEQ_FIND_LAST = `function SeqFindLast<T>(s: seq<T>, p: T -> bool): Option<T>
|
|
791
|
+
ensures SeqFindLast(s, p).Some? ==> p(SeqFindLast(s, p).value)
|
|
792
|
+
ensures SeqFindLast(s, p).Some? ==> SeqFindLast(s, p).value in s
|
|
793
|
+
ensures SeqFindLast(s, p).Some? ==>
|
|
794
|
+
exists i: nat :: i < |s| && s[i] == SeqFindLast(s, p).value && p(s[i]) &&
|
|
795
|
+
(forall j: nat :: i < j < |s| ==> !p(s[j]))
|
|
796
|
+
ensures SeqFindLast(s, p).None? ==> forall i :: 0 <= i < |s| ==> !p(s[i])
|
|
797
|
+
decreases |s|
|
|
798
|
+
{
|
|
799
|
+
if |s| == 0 then None
|
|
800
|
+
else if p(s[|s|-1]) then Some(s[|s|-1])
|
|
801
|
+
else SeqFindLast(s[..|s|-1], p)
|
|
802
|
+
}`;
|
|
803
|
+
const SEQ_FLATTEN = `function SeqFlatten<T>(s: seq<seq<T>>): seq<T>
|
|
804
|
+
decreases |s|
|
|
805
|
+
{
|
|
806
|
+
if |s| == 0 then []
|
|
807
|
+
else s[0] + SeqFlatten(s[1..])
|
|
808
|
+
}`;
|
|
809
|
+
const SEQ_JOIN = `function SeqJoin(s: seq<string>, sep: string): string
|
|
810
|
+
decreases |s|
|
|
811
|
+
{
|
|
812
|
+
if |s| == 0 then ""
|
|
813
|
+
else if |s| == 1 then s[0]
|
|
814
|
+
else s[0] + sep + SeqJoin(s[1..], sep)
|
|
815
|
+
}`;
|
|
816
|
+
const SAFE_SLICE = `function SafeSlice<T>(s: seq<T>, lo: int, hi: int): seq<T>
|
|
817
|
+
ensures |SafeSlice(s, lo, hi)| <= |s|
|
|
818
|
+
{
|
|
819
|
+
var lo' := if lo < 0 then 0 else if lo > |s| as int then |s| else lo;
|
|
820
|
+
var hi' := if hi > |s| as int then |s| else if hi < lo' then lo' else hi;
|
|
821
|
+
s[lo'..hi']
|
|
822
|
+
}`;
|
|
621
823
|
const STRING_INDEX_OF = `function StringIndexOf(s: string, sub: string): int
|
|
824
|
+
ensures StringIndexOf(s, sub) == -1
|
|
825
|
+
|| (0 <= StringIndexOf(s, sub) <= |s| - |sub| && s[StringIndexOf(s, sub)..StringIndexOf(s, sub) + |sub|] == sub)
|
|
622
826
|
{
|
|
623
827
|
StringIndexOfFrom(s, sub, 0)
|
|
624
828
|
}
|
|
625
829
|
|
|
626
|
-
function StringIndexOfFrom(s: string, sub: string, from:
|
|
830
|
+
function StringIndexOfFrom(s: string, sub: string, from: int): int
|
|
831
|
+
ensures StringIndexOfFrom(s, sub, from) == -1
|
|
832
|
+
|| (0 <= StringIndexOfFrom(s, sub, from) <= |s| - |sub|
|
|
833
|
+
&& s[StringIndexOfFrom(s, sub, from)..StringIndexOfFrom(s, sub, from) + |sub|] == sub
|
|
834
|
+
&& StringIndexOfFrom(s, sub, from) >= from)
|
|
835
|
+
{
|
|
836
|
+
StringIndexOfFromN(s, sub, if from < 0 then 0 else from)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function StringIndexOfFromN(s: string, sub: string, from: nat): int
|
|
627
840
|
decreases |s| - from
|
|
841
|
+
ensures StringIndexOfFromN(s, sub, from) == -1
|
|
842
|
+
|| (from <= StringIndexOfFromN(s, sub, from) <= |s| - |sub|
|
|
843
|
+
&& s[StringIndexOfFromN(s, sub, from)..StringIndexOfFromN(s, sub, from) + |sub|] == sub)
|
|
628
844
|
{
|
|
629
845
|
if from + |sub| > |s| then -1
|
|
630
846
|
else if s[from..from + |sub|] == sub then from as int
|
|
631
|
-
else
|
|
847
|
+
else StringIndexOfFromN(s, sub, from + 1)
|
|
632
848
|
}`;
|
|
849
|
+
// `s.split(d)` in TS returns a non-empty sequence of segments. Modeled here as
|
|
850
|
+
// an axiom — defining it recursively would force StringIndexOf to grow ensures
|
|
851
|
+
// clauses that callers don't need. The two ensures cover what verification
|
|
852
|
+
// usually wants: result has at least one element, and every element fits
|
|
853
|
+
// within the source length.
|
|
854
|
+
const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<string>
|
|
855
|
+
requires |d| > 0
|
|
856
|
+
ensures |StringSplit(s, d)| >= 1
|
|
857
|
+
ensures |StringSplit(s, d)| <= |s| + 1
|
|
858
|
+
ensures forall k :: 0 <= k < |StringSplit(s, d)| ==> |StringSplit(s, d)[k]| <= |s|`;
|
|
633
859
|
const STRING_TRIM = `function StringTrimLeft(s: string): string
|
|
634
860
|
ensures |StringTrimLeft(s)| <= |s|
|
|
635
861
|
ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
|
|
@@ -675,6 +901,53 @@ const STRING_TO_UPPER = `function StringToUpper(s: string): string
|
|
|
675
901
|
}`;
|
|
676
902
|
const MATH_MIN = `function MathMin(a: int, b: int): int { if a <= b then a else b }`;
|
|
677
903
|
const MATH_MAX = `function MathMax(a: int, b: int): int { if a >= b then a else b }`;
|
|
904
|
+
const MAX_OF_SEQ = `function MaxOfSeq(s: seq<int>): int
|
|
905
|
+
requires |s| > 0
|
|
906
|
+
ensures forall i: nat :: i < |s| ==> s[i] <= MaxOfSeq(s)
|
|
907
|
+
ensures exists i: nat :: i < |s| && s[i] == MaxOfSeq(s)
|
|
908
|
+
decreases |s|
|
|
909
|
+
{
|
|
910
|
+
if |s| == 1 then s[0]
|
|
911
|
+
else MathMax(s[0], MaxOfSeq(s[1..]))
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Helper for proofs about MaxOfSeq applied to concatenations. Users invoke
|
|
915
|
+
// this in _ensures lemma bodies when Dafny doesn't automatically connect
|
|
916
|
+
// indices through (a + b)[i].
|
|
917
|
+
lemma MaxOfSeqConcat(a: seq<int>, b: seq<int>)
|
|
918
|
+
requires |a| + |b| > 0
|
|
919
|
+
ensures forall i: nat :: i < |a| ==> a[i] <= MaxOfSeq(a + b)
|
|
920
|
+
ensures forall i: nat :: i < |b| ==> b[i] <= MaxOfSeq(a + b)
|
|
921
|
+
{
|
|
922
|
+
forall i: nat | i < |a| ensures a[i] <= MaxOfSeq(a + b) {
|
|
923
|
+
assert (a + b)[i] == a[i];
|
|
924
|
+
}
|
|
925
|
+
forall i: nat | i < |b| ensures b[i] <= MaxOfSeq(a + b) {
|
|
926
|
+
assert (a + b)[|a| + i] == b[i];
|
|
927
|
+
}
|
|
928
|
+
}`;
|
|
929
|
+
const MIN_OF_SEQ = `function MinOfSeq(s: seq<int>): int
|
|
930
|
+
requires |s| > 0
|
|
931
|
+
ensures forall i: nat :: i < |s| ==> MinOfSeq(s) <= s[i]
|
|
932
|
+
ensures exists i: nat :: i < |s| && s[i] == MinOfSeq(s)
|
|
933
|
+
decreases |s|
|
|
934
|
+
{
|
|
935
|
+
if |s| == 1 then s[0]
|
|
936
|
+
else MathMin(s[0], MinOfSeq(s[1..]))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
lemma MinOfSeqConcat(a: seq<int>, b: seq<int>)
|
|
940
|
+
requires |a| + |b| > 0
|
|
941
|
+
ensures forall i: nat :: i < |a| ==> MinOfSeq(a + b) <= a[i]
|
|
942
|
+
ensures forall i: nat :: i < |b| ==> MinOfSeq(a + b) <= b[i]
|
|
943
|
+
{
|
|
944
|
+
forall i: nat | i < |a| ensures MinOfSeq(a + b) <= a[i] {
|
|
945
|
+
assert (a + b)[i] == a[i];
|
|
946
|
+
}
|
|
947
|
+
forall i: nat | i < |b| ensures MinOfSeq(a + b) <= b[i] {
|
|
948
|
+
assert (a + b)[|a| + i] == b[i];
|
|
949
|
+
}
|
|
950
|
+
}`;
|
|
678
951
|
const NAT_TO_STRING = `function NatToString(n: nat): string
|
|
679
952
|
decreases n
|
|
680
953
|
{
|
|
@@ -712,7 +985,13 @@ const PREAMBLE_CODE = [
|
|
|
712
985
|
["CeilReal", CEIL_REAL],
|
|
713
986
|
["FloorReal", FLOOR_REAL],
|
|
714
987
|
["SeqIndexOf", SEQ_INDEX_OF],
|
|
988
|
+
["SeqFindIndex", SEQ_FIND_INDEX],
|
|
989
|
+
["SeqFindLast", SEQ_FIND_LAST],
|
|
990
|
+
["SeqFlatten", SEQ_FLATTEN],
|
|
991
|
+
["SeqJoin", SEQ_JOIN],
|
|
992
|
+
["SafeSlice", SAFE_SLICE],
|
|
715
993
|
["StringIndexOf", STRING_INDEX_OF],
|
|
994
|
+
["StringSplit", STRING_SPLIT],
|
|
716
995
|
["StringTrim", STRING_TRIM],
|
|
717
996
|
["StringToLower", STRING_TO_LOWER],
|
|
718
997
|
["StringToUpper", STRING_TO_UPPER],
|
|
@@ -720,6 +999,8 @@ const PREAMBLE_CODE = [
|
|
|
720
999
|
["MathAbs", MATH_ABS],
|
|
721
1000
|
["MathMin", MATH_MIN],
|
|
722
1001
|
["MathMax", MATH_MAX],
|
|
1002
|
+
["MaxOfSeq", MAX_OF_SEQ],
|
|
1003
|
+
["MinOfSeq", MIN_OF_SEQ],
|
|
723
1004
|
];
|
|
724
1005
|
// ── Constructor and record helpers ───────────────────────────
|
|
725
1006
|
let _recordCtors = new Map();
|
|
@@ -789,7 +1070,8 @@ function translatePattern(pattern) {
|
|
|
789
1070
|
const fieldNames = fields.split(/\s+/).map(escapeName);
|
|
790
1071
|
return `${ctorName}(${fieldNames.join(", ")})`;
|
|
791
1072
|
}
|
|
792
|
-
export function emitDafnyFile(file, tsFileName) {
|
|
1073
|
+
export function emitDafnyFile(file, tsFileName, opts) {
|
|
1074
|
+
_useSafeSlice = !!opts?.safeSlice;
|
|
793
1075
|
buildRecordCtorMap(file.decls);
|
|
794
1076
|
_neededPreambles.clear();
|
|
795
1077
|
// Track successfully emitted pure defs — method wrappers are only
|