lemmascript 0.4.0 → 0.5.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 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) and [DESIGN.md](DESIGN.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`. **753 verification conditions, 0 errors, 0 `assume`s, 0 axioms** under `--isolate-assertions --verification-time-limit 180`. Dafny only.
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. 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.
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` (replacebounded 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.
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
- | `foo.ts` | — | TypeScript source with `//@ ` annotations |
88
- | `foo.dfy.gen` | Yes | Generated Dafny (merge base, always regeneratable) |
89
- | `foo.dfy` | Yes (initial) | Annotated Dafny (gen + proof additions) |
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
- | `foo.ts` | — | TypeScript source with `//@ ` annotations |
96
- | `foo.types.lean` | Yes | Lean types, `namespace Pure` defs |
97
- | `foo.spec.lean` | No | Ghost definitions, helper lemmas |
98
- | `foo.def.lean` | Yes | Velvet method definitions |
99
- | `foo.proof.lean` | No | `prove_correct` with proof tactics |
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.4.0",
3
+ "version": "0.5.0",
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
  },
@@ -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
  }
@@ -36,6 +37,10 @@ const DAFNY_KEYWORDS = new Set([
36
37
  "by", "calc", "reveal",
37
38
  ]);
38
39
  function escapeName(name) {
40
+ // \result is carried through the IR as the var name "\\result"; render it
41
+ // as Dafny's canonical return-value identifier.
42
+ if (name === "\\result")
43
+ return "res";
39
44
  if (DAFNY_KEYWORDS.has(name))
40
45
  return `${name}_`;
41
46
  // Dafny doesn't allow identifiers starting with _
@@ -78,6 +83,15 @@ function emitQuantifier(e, keyword) {
78
83
  }
79
84
  return `${keyword} ${vars.join(", ")} :: ${emitExpr(body)}`;
80
85
  }
86
+ // Dafny's `forall`/`exists ::` body extends as far as possible. So
87
+ // `(forall i :: P(i)) <op> Q` (or `... ==> Q`) would parse with the operator
88
+ // absorbed into the body. Wrap a quantifier in parens to terminate its body
89
+ // before the operator. Only the LEFT operand needs this — a quantifier in
90
+ // right-operand position is fine because its body correctly spans the rest.
91
+ function wrapQuantifier(sub) {
92
+ const inner = emitExpr(sub);
93
+ return (sub.kind === "forall" || sub.kind === "exists") ? `(${inner})` : inner;
94
+ }
81
95
  function emitExpr(e) {
82
96
  switch (e.kind) {
83
97
  case "var": return e.name === "undefined" ? "None" : escapeName(e.name);
@@ -85,6 +99,11 @@ function emitExpr(e) {
85
99
  case "bool": return e.value ? "true" : "false";
86
100
  case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
87
101
  case "constructor": {
102
+ // Option constructors (Some/None) may appear in inferred positions
103
+ // (e.g. lambda result) without an explicit `optional<T>` in any
104
+ // signature, so request the preamble here.
105
+ if (e.type === "Option")
106
+ needPreamble("OptionType");
88
107
  const head = qualifyCtor(e.name, e.type);
89
108
  if (!e.args || e.args.length === 0)
90
109
  return head;
@@ -96,6 +115,10 @@ function emitExpr(e) {
96
115
  return `[${e.elems.map(emitExpr).join(", ")}]`;
97
116
  case "emptyMap": return `map[]`;
98
117
  case "emptySet": return `{}`;
118
+ case "mapLiteral": {
119
+ const entries = e.entries.map(en => `${emitExpr(en.key)} := ${emitExpr(en.value)}`);
120
+ return `map[${entries.join(", ")}]`;
121
+ }
99
122
  case "methodCall": {
100
123
  const obj = emitExpr(e.obj);
101
124
  const args = e.args.map(emitExpr);
@@ -114,16 +137,45 @@ function emitExpr(e) {
114
137
  return `(${obj} + [${args[0]}])`;
115
138
  if (e.method === "concat")
116
139
  return `(${obj} + [${args[0]}])`;
140
+ // No-arg slice is a full copy; Dafny seq is an immutable value type, so
141
+ // the copy is just the seq itself (the idiom for "copy then mutate").
142
+ if (e.method === "slice" && args.length === 0)
143
+ return obj;
117
144
  if (e.method === "slice" && args.length === 1)
118
145
  return `${obj}[${args[0]}..]`;
119
- if (e.method === "slice" && args.length === 2)
146
+ if (e.method === "slice" && args.length === 2) {
147
+ // JS slice clamps both bounds; Dafny requires `0 <= lo <= hi <= |s|`.
148
+ // Direct slice is default (matches existing case studies that wrote
149
+ // bounded calls). Files needing JS clamping opt in via `//@ safe-slice`.
150
+ if (_useSafeSlice) {
151
+ needPreamble("SafeSlice");
152
+ return `SafeSlice(${obj}, ${args[0]}, ${args[1]})`;
153
+ }
120
154
  return `${obj}[${args[0]}..${args[1]}]`;
155
+ }
121
156
  if (e.method === "map")
122
157
  return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
123
158
  if (e.method === "filter")
124
159
  return `Std.Collections.Seq.Filter(${args[0]}, ${obj})`;
125
160
  if (e.method === "every")
126
161
  return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
162
+ if (e.method === "findLast") {
163
+ needPreamble("OptionType");
164
+ needPreamble("SeqFindLast");
165
+ return `SeqFindLast(${obj}, ${args[0]})`;
166
+ }
167
+ if (e.method === "findIndex") {
168
+ needPreamble("SeqFindIndex");
169
+ return `SeqFindIndex(${obj}, ${args[0]})`;
170
+ }
171
+ if (e.method === "flat" && args.length === 0) {
172
+ needPreamble("SeqFlatten");
173
+ return `SeqFlatten(${obj})`;
174
+ }
175
+ if (e.method === "join") {
176
+ needPreamble("SeqJoin");
177
+ return `SeqJoin(${obj}, ${args[0]})`;
178
+ }
127
179
  if (e.method === "some" && e.args[0].kind === "lambda" &&
128
180
  e.args[0].body.length === 1 && e.args[0].body[0].kind === "return") {
129
181
  const lam = e.args[0];
@@ -139,14 +191,46 @@ function emitExpr(e) {
139
191
  if (ty === "string") {
140
192
  if (e.method === "indexOf") {
141
193
  needPreamble("StringIndexOf");
194
+ if (args.length === 2)
195
+ return `StringIndexOfFrom(${obj}, ${args[0]}, ${args[1]})`;
142
196
  return `StringIndexOf(${obj}, ${args[0]})`;
143
197
  }
144
- if (e.method === "slice")
198
+ if (e.method === "split") {
199
+ needPreamble("StringSplit");
200
+ return `StringSplit(${obj}, ${args[0]})`;
201
+ }
202
+ if (e.method === "slice") {
203
+ // JS negative index: arr.slice(0, -N) → arr[0..|arr|-N]. After
204
+ // transform, unary minus on a numeric literal is folded to a
205
+ // negative `num` IR node, so check for that here.
206
+ const negVal = (a) => a.kind === "num" && a.value < 0 ? -a.value : null;
207
+ const loN = negVal(e.args[0]);
208
+ const loEx = loN !== null ? `|${obj}|-${loN}` : args[0];
209
+ if (args.length === 1)
210
+ return `${obj}[${loEx}..]`;
211
+ const hiN = negVal(e.args[1]);
212
+ const hiEx = hiN !== null ? `|${obj}|-${hiN}` : args[1];
213
+ return `${obj}[${loEx}..${hiEx}]`;
214
+ }
215
+ if (e.method === "substring") {
216
+ if (args.length === 1)
217
+ return `${obj}[${args[0]}..]`;
145
218
  return `${obj}[${args[0]}..${args[1]}]`;
219
+ }
220
+ if (e.method === "endsWith")
221
+ return `(|${obj}| >= |${args[0]}| && ${obj}[|${obj}|-|${args[0]}|..] == ${args[0]})`;
146
222
  if (e.method === "trim") {
147
223
  needPreamble("StringTrim");
148
224
  return `StringTrim(${obj})`;
149
225
  }
226
+ if (e.method === "trimEnd") {
227
+ needPreamble("StringTrim");
228
+ return `StringTrimRight(${obj})`;
229
+ }
230
+ if (e.method === "trimStart") {
231
+ needPreamble("StringTrim");
232
+ return `StringTrimLeft(${obj})`;
233
+ }
150
234
  if (e.method === "toLowerCase") {
151
235
  needPreamble("StringToLower");
152
236
  return `StringToLower(${obj})`;
@@ -159,6 +243,8 @@ function emitExpr(e) {
159
243
  needPreamble("StringIndexOf");
160
244
  return `(StringIndexOf(${obj}, ${args[0]}) >= 0)`;
161
245
  }
246
+ if (e.method === "startsWith")
247
+ return `(|${obj}| >= |${args[0]}| && ${obj}[..|${args[0]}|] == ${args[0]})`;
162
248
  if (e.method === "charCodeAt")
163
249
  return `(${obj}[${args[0]}] as int)`;
164
250
  }
@@ -185,6 +271,20 @@ function emitExpr(e) {
185
271
  return `(${obj} + {${args[0]}})`;
186
272
  if (e.method === "delete")
187
273
  return `(${obj} - {${args[0]}})`;
274
+ // `.filter(pred)` on a set: extract collapses the JS idiom
275
+ // `new Set([...s].filter(p))` into `s.filter(p)` with set receiver
276
+ // (the spread → array → set round-trip is identity over set
277
+ // semantics). Lower to Dafny set-builder: `set x | x in s && p(x)`.
278
+ if (e.method === "filter" && e.args.length === 1 && e.args[0].kind === "lambda" &&
279
+ e.args[0].body.length === 1 && e.args[0].body[0].kind === "return") {
280
+ const lam = e.args[0];
281
+ const ret = lam.body[0];
282
+ if (ret.kind !== "return")
283
+ throw new Error("unreachable");
284
+ const p = escapeName(lam.params[0]?.name ?? "x");
285
+ const body = emitExpr(ret.value);
286
+ return `(set ${p} | ${p} in ${obj} && ${body})`;
287
+ }
188
288
  }
189
289
  throw new Error(`Unsupported Dafny method call: .${e.method}() on ${ty}`);
190
290
  }
@@ -251,10 +351,10 @@ function emitExpr(e) {
251
351
  return `(${left} ${op} ${right})`;
252
352
  }
253
353
  }
254
- return `(${emitExpr(e.left)} ${op} ${emitExpr(e.right)})`;
354
+ return `(${wrapQuantifier(e.left)} ${op} ${emitExpr(e.right)})`;
255
355
  }
256
356
  case "implies": {
257
- const parts = [...e.premises.map(emitExpr), emitExpr(e.conclusion)];
357
+ const parts = [...e.premises.map(wrapQuantifier), emitExpr(e.conclusion)];
258
358
  return `(${parts.join(" ==> ")})`;
259
359
  }
260
360
  case "app": {
@@ -282,6 +382,14 @@ function emitExpr(e) {
282
382
  needPreamble("MathMin");
283
383
  if (e.fn === "MathMax")
284
384
  needPreamble("MathMax");
385
+ if (e.fn === "MaxOfSeq") {
386
+ needPreamble("MathMax");
387
+ needPreamble("MaxOfSeq");
388
+ }
389
+ if (e.fn === "MinOfSeq") {
390
+ needPreamble("MathMin");
391
+ needPreamble("MinOfSeq");
392
+ }
285
393
  return `${escapeName(e.fn)}(${args.join(", ")})`;
286
394
  }
287
395
  case "field": {
@@ -297,10 +405,19 @@ function emitExpr(e) {
297
405
  case "toNat":
298
406
  // Dafny doesn't need toNat — just emit the inner expression
299
407
  return emitExpr(e.expr);
300
- case "index":
301
- return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]`;
408
+ case "index": {
409
+ const obj = emitExpr(e.arr);
410
+ const idx = emitExpr(e.idx);
411
+ // Plain seq/map subscript. For maps where the result is meant to be
412
+ // `Option<V>` (the TS `Record<K,V>[k]` shape), transform should have
413
+ // wrapped this in an Option-coercion; here we just emit the subscript.
414
+ return `${obj}[${idx}]`;
415
+ }
302
416
  case "record": {
303
417
  if (e.spread) {
418
+ if (e.fields.length === 0) {
419
+ return emitExpr(e.spread);
420
+ }
304
421
  const updates = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
305
422
  return `${emitExpr(e.spread)}.(${updates.join(", ")})`;
306
423
  }
@@ -319,8 +436,9 @@ function emitExpr(e) {
319
436
  }
320
437
  if (ctorName) {
321
438
  const structFields = _structureDecls.get(ctorName);
322
- if (structFields && e.fields.length < structFields.length) {
323
- // Pad missing fields: match by name, fill None for optional
439
+ // Always reorder by struct field name — TS object literal order ≠ Dafny
440
+ // positional order. Pad missing optional fields with None.
441
+ if (structFields) {
324
442
  const provided = new Map(e.fields.map(f => [f.name, f]));
325
443
  const vals = structFields.map(sf => {
326
444
  const f = provided.get(sf.name);
@@ -401,7 +519,7 @@ function emitStmt(s, indent) {
401
519
  case "ghostAssign":
402
520
  return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
403
521
  case "assert":
404
- return `${pad}assert ${emitExpr(s.expr)};`;
522
+ return `${pad}${s.assumed ? "assume {:axiom}" : "assert"} ${emitExpr(s.expr)};`;
405
523
  case "bind":
406
524
  // Monadic bind shouldn't appear in Dafny mode, emit as regular assign
407
525
  return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
@@ -556,6 +674,18 @@ function emitDecl(d) {
556
674
  case "const": {
557
675
  return `const ${escapeName(d.name)}: ${tyToDafny(d.type)} := ${emitExpr(d.value)}`;
558
676
  }
677
+ case "extern": {
678
+ // Body-less Dafny function — `:axiom` makes Dafny accept the missing body
679
+ // and treats it as an uninterpreted symbol. Any `requires`/`ensures` were
680
+ // lifted from the source declaration's annotations.
681
+ const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
682
+ const lines = [`function {:axiom} ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
683
+ for (const r of d.requires)
684
+ lines.push(` requires ${emitExpr(r)}`);
685
+ for (const e of d.ensures)
686
+ lines.push(` ensures ${emitExpr(e)}`);
687
+ return lines.join("\n");
688
+ }
559
689
  case "namespace": {
560
690
  // Dafny doesn't need namespaces — flatten declarations
561
691
  return d.decls.map(emitDecl).join("\n\n");
@@ -567,6 +697,11 @@ function emitDecl(d) {
567
697
  /** Preamble tracking — emitters add keys via `needPreamble(key)`, emitDafnyFile emits them. */
568
698
  const _neededPreambles = new Set();
569
699
  function needPreamble(key) { _neededPreambles.add(key); }
700
+ /** File-level opt-in for JS-clamp semantics on `arr.slice(lo, hi)`. Set by
701
+ * `emitDafnyFile` from the `//@ safe-slice` directive; consulted by the
702
+ * array-method emit. Off by default — case studies that wrote their `.slice`
703
+ * calls with provable bounds get direct `s[lo..hi]` emission. */
704
+ let _useSafeSlice = false;
570
705
  const POW2 = `function Pow2(n: int): int
571
706
  requires n >= 0
572
707
  decreases n
@@ -599,6 +734,31 @@ const CEIL_REAL = `function CeilReal(x: real): int
599
734
  if x == (x.Floor as real) then x.Floor
600
735
  else x.Floor + 1
601
736
  }`;
737
+ const SEQ_FIND_INDEX = `function SeqFindIndex<T>(s: seq<T>, p: T -> bool): int
738
+ ensures -1 <= SeqFindIndex(s, p) < |s|
739
+ ensures SeqFindIndex(s, p) >= 0 ==> p(s[SeqFindIndex(s, p)])
740
+ ensures SeqFindIndex(s, p) >= 0 ==>
741
+ (forall i: nat :: i < SeqFindIndex(s, p) ==> !p(s[i]))
742
+ ensures SeqFindIndex(s, p) == -1 ==> (forall i: nat :: i < |s| ==> !p(s[i]))
743
+ {
744
+ SeqFindIndexFrom(s, p, 0)
745
+ }
746
+
747
+ function SeqFindIndexFrom<T>(s: seq<T>, p: T -> bool, from: nat): int
748
+ requires from <= |s|
749
+ ensures -1 <= SeqFindIndexFrom(s, p, from) < |s|
750
+ ensures SeqFindIndexFrom(s, p, from) >= 0 ==>
751
+ from <= SeqFindIndexFrom(s, p, from) && p(s[SeqFindIndexFrom(s, p, from)])
752
+ ensures SeqFindIndexFrom(s, p, from) >= 0 ==>
753
+ (forall i: nat :: from <= i < SeqFindIndexFrom(s, p, from) ==> !p(s[i]))
754
+ ensures SeqFindIndexFrom(s, p, from) == -1 ==>
755
+ (forall i: nat :: from <= i < |s| ==> !p(s[i]))
756
+ decreases |s| - from
757
+ {
758
+ if from >= |s| then -1
759
+ else if p(s[from]) then from as int
760
+ else SeqFindIndexFrom(s, p, from + 1)
761
+ }`;
602
762
  const SEQ_INDEX_OF = `function SeqIndexOf<T(==)>(s: seq<T>, x: T): int
603
763
  ensures -1 <= SeqIndexOf(s, x) < |s|
604
764
  ensures SeqIndexOf(s, x) >= 0 ==> s[SeqIndexOf(s, x)] == x
@@ -618,18 +778,75 @@ function SeqIndexOfFrom<T(==)>(s: seq<T>, x: T, from: nat): int
618
778
  else if s[from] == x then from as int
619
779
  else SeqIndexOfFrom(s, x, from + 1)
620
780
  }`;
781
+ const SEQ_FIND_LAST = `function SeqFindLast<T>(s: seq<T>, p: T -> bool): Option<T>
782
+ ensures SeqFindLast(s, p).Some? ==> p(SeqFindLast(s, p).value)
783
+ ensures SeqFindLast(s, p).Some? ==> SeqFindLast(s, p).value in s
784
+ ensures SeqFindLast(s, p).Some? ==>
785
+ exists i: nat :: i < |s| && s[i] == SeqFindLast(s, p).value && p(s[i]) &&
786
+ (forall j: nat :: i < j < |s| ==> !p(s[j]))
787
+ ensures SeqFindLast(s, p).None? ==> forall i :: 0 <= i < |s| ==> !p(s[i])
788
+ decreases |s|
789
+ {
790
+ if |s| == 0 then None
791
+ else if p(s[|s|-1]) then Some(s[|s|-1])
792
+ else SeqFindLast(s[..|s|-1], p)
793
+ }`;
794
+ const SEQ_FLATTEN = `function SeqFlatten<T>(s: seq<seq<T>>): seq<T>
795
+ decreases |s|
796
+ {
797
+ if |s| == 0 then []
798
+ else s[0] + SeqFlatten(s[1..])
799
+ }`;
800
+ const SEQ_JOIN = `function SeqJoin(s: seq<string>, sep: string): string
801
+ decreases |s|
802
+ {
803
+ if |s| == 0 then ""
804
+ else if |s| == 1 then s[0]
805
+ else s[0] + sep + SeqJoin(s[1..], sep)
806
+ }`;
807
+ const SAFE_SLICE = `function SafeSlice<T>(s: seq<T>, lo: int, hi: int): seq<T>
808
+ ensures |SafeSlice(s, lo, hi)| <= |s|
809
+ {
810
+ var lo' := if lo < 0 then 0 else if lo > |s| as int then |s| else lo;
811
+ var hi' := if hi > |s| as int then |s| else if hi < lo' then lo' else hi;
812
+ s[lo'..hi']
813
+ }`;
621
814
  const STRING_INDEX_OF = `function StringIndexOf(s: string, sub: string): int
815
+ ensures StringIndexOf(s, sub) == -1
816
+ || (0 <= StringIndexOf(s, sub) <= |s| - |sub| && s[StringIndexOf(s, sub)..StringIndexOf(s, sub) + |sub|] == sub)
622
817
  {
623
818
  StringIndexOfFrom(s, sub, 0)
624
819
  }
625
820
 
626
- function StringIndexOfFrom(s: string, sub: string, from: nat): int
821
+ function StringIndexOfFrom(s: string, sub: string, from: int): int
822
+ ensures StringIndexOfFrom(s, sub, from) == -1
823
+ || (0 <= StringIndexOfFrom(s, sub, from) <= |s| - |sub|
824
+ && s[StringIndexOfFrom(s, sub, from)..StringIndexOfFrom(s, sub, from) + |sub|] == sub
825
+ && StringIndexOfFrom(s, sub, from) >= from)
826
+ {
827
+ StringIndexOfFromN(s, sub, if from < 0 then 0 else from)
828
+ }
829
+
830
+ function StringIndexOfFromN(s: string, sub: string, from: nat): int
627
831
  decreases |s| - from
832
+ ensures StringIndexOfFromN(s, sub, from) == -1
833
+ || (from <= StringIndexOfFromN(s, sub, from) <= |s| - |sub|
834
+ && s[StringIndexOfFromN(s, sub, from)..StringIndexOfFromN(s, sub, from) + |sub|] == sub)
628
835
  {
629
836
  if from + |sub| > |s| then -1
630
837
  else if s[from..from + |sub|] == sub then from as int
631
- else StringIndexOfFrom(s, sub, from + 1)
838
+ else StringIndexOfFromN(s, sub, from + 1)
632
839
  }`;
840
+ // `s.split(d)` in TS returns a non-empty sequence of segments. Modeled here as
841
+ // an axiom — defining it recursively would force StringIndexOf to grow ensures
842
+ // clauses that callers don't need. The two ensures cover what verification
843
+ // usually wants: result has at least one element, and every element fits
844
+ // within the source length.
845
+ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<string>
846
+ requires |d| > 0
847
+ ensures |StringSplit(s, d)| >= 1
848
+ ensures |StringSplit(s, d)| <= |s| + 1
849
+ ensures forall k :: 0 <= k < |StringSplit(s, d)| ==> |StringSplit(s, d)[k]| <= |s|`;
633
850
  const STRING_TRIM = `function StringTrimLeft(s: string): string
634
851
  ensures |StringTrimLeft(s)| <= |s|
635
852
  ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
@@ -675,6 +892,53 @@ const STRING_TO_UPPER = `function StringToUpper(s: string): string
675
892
  }`;
676
893
  const MATH_MIN = `function MathMin(a: int, b: int): int { if a <= b then a else b }`;
677
894
  const MATH_MAX = `function MathMax(a: int, b: int): int { if a >= b then a else b }`;
895
+ const MAX_OF_SEQ = `function MaxOfSeq(s: seq<int>): int
896
+ requires |s| > 0
897
+ ensures forall i: nat :: i < |s| ==> s[i] <= MaxOfSeq(s)
898
+ ensures exists i: nat :: i < |s| && s[i] == MaxOfSeq(s)
899
+ decreases |s|
900
+ {
901
+ if |s| == 1 then s[0]
902
+ else MathMax(s[0], MaxOfSeq(s[1..]))
903
+ }
904
+
905
+ // Helper for proofs about MaxOfSeq applied to concatenations. Users invoke
906
+ // this in _ensures lemma bodies when Dafny doesn't automatically connect
907
+ // indices through (a + b)[i].
908
+ lemma MaxOfSeqConcat(a: seq<int>, b: seq<int>)
909
+ requires |a| + |b| > 0
910
+ ensures forall i: nat :: i < |a| ==> a[i] <= MaxOfSeq(a + b)
911
+ ensures forall i: nat :: i < |b| ==> b[i] <= MaxOfSeq(a + b)
912
+ {
913
+ forall i: nat | i < |a| ensures a[i] <= MaxOfSeq(a + b) {
914
+ assert (a + b)[i] == a[i];
915
+ }
916
+ forall i: nat | i < |b| ensures b[i] <= MaxOfSeq(a + b) {
917
+ assert (a + b)[|a| + i] == b[i];
918
+ }
919
+ }`;
920
+ const MIN_OF_SEQ = `function MinOfSeq(s: seq<int>): int
921
+ requires |s| > 0
922
+ ensures forall i: nat :: i < |s| ==> MinOfSeq(s) <= s[i]
923
+ ensures exists i: nat :: i < |s| && s[i] == MinOfSeq(s)
924
+ decreases |s|
925
+ {
926
+ if |s| == 1 then s[0]
927
+ else MathMin(s[0], MinOfSeq(s[1..]))
928
+ }
929
+
930
+ lemma MinOfSeqConcat(a: seq<int>, b: seq<int>)
931
+ requires |a| + |b| > 0
932
+ ensures forall i: nat :: i < |a| ==> MinOfSeq(a + b) <= a[i]
933
+ ensures forall i: nat :: i < |b| ==> MinOfSeq(a + b) <= b[i]
934
+ {
935
+ forall i: nat | i < |a| ensures MinOfSeq(a + b) <= a[i] {
936
+ assert (a + b)[i] == a[i];
937
+ }
938
+ forall i: nat | i < |b| ensures MinOfSeq(a + b) <= b[i] {
939
+ assert (a + b)[|a| + i] == b[i];
940
+ }
941
+ }`;
678
942
  const NAT_TO_STRING = `function NatToString(n: nat): string
679
943
  decreases n
680
944
  {
@@ -712,7 +976,13 @@ const PREAMBLE_CODE = [
712
976
  ["CeilReal", CEIL_REAL],
713
977
  ["FloorReal", FLOOR_REAL],
714
978
  ["SeqIndexOf", SEQ_INDEX_OF],
979
+ ["SeqFindIndex", SEQ_FIND_INDEX],
980
+ ["SeqFindLast", SEQ_FIND_LAST],
981
+ ["SeqFlatten", SEQ_FLATTEN],
982
+ ["SeqJoin", SEQ_JOIN],
983
+ ["SafeSlice", SAFE_SLICE],
715
984
  ["StringIndexOf", STRING_INDEX_OF],
985
+ ["StringSplit", STRING_SPLIT],
716
986
  ["StringTrim", STRING_TRIM],
717
987
  ["StringToLower", STRING_TO_LOWER],
718
988
  ["StringToUpper", STRING_TO_UPPER],
@@ -720,6 +990,8 @@ const PREAMBLE_CODE = [
720
990
  ["MathAbs", MATH_ABS],
721
991
  ["MathMin", MATH_MIN],
722
992
  ["MathMax", MATH_MAX],
993
+ ["MaxOfSeq", MAX_OF_SEQ],
994
+ ["MinOfSeq", MIN_OF_SEQ],
723
995
  ];
724
996
  // ── Constructor and record helpers ───────────────────────────
725
997
  let _recordCtors = new Map();
@@ -789,7 +1061,8 @@ function translatePattern(pattern) {
789
1061
  const fieldNames = fields.split(/\s+/).map(escapeName);
790
1062
  return `${ctorName}(${fieldNames.join(", ")})`;
791
1063
  }
792
- export function emitDafnyFile(file, tsFileName) {
1064
+ export function emitDafnyFile(file, tsFileName, opts) {
1065
+ _useSafeSlice = !!opts?.safeSlice;
793
1066
  buildRecordCtorMap(file.decls);
794
1067
  _neededPreambles.clear();
795
1068
  // Track successfully emitted pure defs — method wrappers are only