lemmascript 0.5.15 → 0.5.17
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 +47 -0
- package/package.json +1 -1
- package/tools/dist/dafny-commands.js +4 -4
- package/tools/dist/dafny-emit.js +62 -12
- package/tools/dist/extract.js +2 -1
- package/tools/dist/ir.js +2 -0
- package/tools/dist/lean-emit.js +86 -5
- package/tools/dist/lsc.js +13 -3
- package/tools/dist/narrow.js +146 -36
- package/tools/dist/peephole.js +4 -0
- package/tools/dist/resolve.js +54 -4
- package/tools/dist/transform.js +94 -76
- package/tools/dist/typedir.js +36 -0
- package/tools/dist/types.js +8 -2
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ See the external case studies:
|
|
|
30
30
|
- **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
|
|
31
31
|
- **[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.
|
|
32
32
|
- **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. **Dafny + Lean**: the no-orphan theorem, the changelog semver core, and both tool-output truncators also carry Lean 4 (Velvet/Loom) proofs from the same annotated source, zero `sorry`; the Lean port drove the backend's brownfield batch (cross-file externs, union destructor lowering, Bool-vs-Prop contexts, `return`-in-loop elimination).
|
|
33
|
+
- **[flue-lemmascript](https://github.com/midspiral/flue-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of pure logic in **Flue** ([withastro/flue](https://github.com/withastro/flue)), Astro's agent harness — crash-recovery and context-compaction functions proven byte-identical. What it drove into the toolchain: native Dafny `continue`, bounds-guarded `noUncheckedIndexedAccess` optional indexing, optional narrowing past an `opt?.disc` guard composing with discriminated-union matching, and object truthiness. Dafny only.
|
|
33
34
|
- **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
|
|
34
35
|
- **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
|
|
35
36
|
- **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
|
|
@@ -85,6 +86,52 @@ lsc gen --backend=lean src/myModule.ts
|
|
|
85
86
|
lake build
|
|
86
87
|
```
|
|
87
88
|
|
|
89
|
+
## Continuous Integration
|
|
90
|
+
|
|
91
|
+
LemmaScript ships a **reusable GitHub Actions workflow** that regenerates your artifacts, verifies them, and fails the build if any committed generated file is out of date. Call it from your own repo's workflow:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
# .github/workflows/lemmascript.yml
|
|
95
|
+
name: LemmaScript
|
|
96
|
+
|
|
97
|
+
on:
|
|
98
|
+
push:
|
|
99
|
+
branches: [main]
|
|
100
|
+
pull_request:
|
|
101
|
+
branches: [main]
|
|
102
|
+
|
|
103
|
+
jobs:
|
|
104
|
+
verify:
|
|
105
|
+
uses: midspiral/LemmaScript/.github/workflows/verify.yml@main
|
|
106
|
+
with:
|
|
107
|
+
backend: dafny # dafny | lean | dafny-slow
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The workflow installs the toolchain and (per `backend`) the Dafny or Lean stack, then runs `tools/check.sh`, which batches over a **`LemmaScript-files.txt`** at your repo root. This file is the list of sources CI verifies — you create and maintain it. One entry per line, `filepath [timeout] [extra dafny flags…]`:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
src/domain.ts
|
|
114
|
+
src/patch.ts 120
|
|
115
|
+
src/heavy.ts 300 --isolate-assertions
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The optional second column is a per-file timeout in seconds; anything after it is passed verbatim to Dafny. The same list drives `lsc check` locally when you run it with no file argument, so CI and your local runs verify exactly the same set. To verify additional Dafny files outside that list, add an executable `check-extra.sh` at the root and it runs automatically (Dafny backends only).
|
|
119
|
+
|
|
120
|
+
Inputs (all optional):
|
|
121
|
+
|
|
122
|
+
| Input | Default | Purpose |
|
|
123
|
+
|-------|---------|---------|
|
|
124
|
+
| `backend` | `dafny` | `dafny`, `lean`, or `dafny-slow` (isolate-assertions / long-running proofs) |
|
|
125
|
+
| `node-version` | `24` | Node.js version |
|
|
126
|
+
| `ls-ref` | `main` | LemmaScript ref to verify against |
|
|
127
|
+
| `typecheck` | `true` | Run `npm ci && npm run typecheck` in the calling repo first |
|
|
128
|
+
|
|
129
|
+
To verify against **both** backends, add a second job with `backend: lean`.
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
- **[talktimer-lemmascript](https://github.com/midspiral/talktimer-lemmascript/blob/main/.github/workflows/lemmascript.yml)** — Dafny-only.
|
|
133
|
+
- **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/.github/workflows/lemmascript.yml)** — Dafny + Lean (two jobs).
|
|
134
|
+
|
|
88
135
|
## Annotations
|
|
89
136
|
|
|
90
137
|
```typescript
|
package/package.json
CHANGED
|
@@ -67,7 +67,7 @@ export function dafnyVerify(dfyPath, dir, timeLimit, extraFlags) {
|
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags) {
|
|
70
|
+
export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags, noVerify = false) {
|
|
71
71
|
// 1. Read old gen before overwriting (needed for base seeding)
|
|
72
72
|
const oldGen = existsSync(genPath) ? readFileSync(genPath, "utf-8") : "";
|
|
73
73
|
// 2. Always write new gen so user can inspect latest output
|
|
@@ -76,7 +76,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, ext
|
|
|
76
76
|
if (!existsSync(dfyPath)) {
|
|
77
77
|
writeFileSync(dfyPath, text);
|
|
78
78
|
console.log(`Created: ${path.basename(dfyPath)}`);
|
|
79
|
-
if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
|
|
79
|
+
if (!noVerify && !dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
|
|
80
80
|
console.error(`FAILED: ${path.basename(dfyPath)} verification failed on first run.`);
|
|
81
81
|
process.exit(1);
|
|
82
82
|
}
|
|
@@ -110,8 +110,8 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, ext
|
|
|
110
110
|
console.error(`FAILED: ${path.basename(dfyPath)} has modifications to generated lines.`);
|
|
111
111
|
process.exit(1);
|
|
112
112
|
}
|
|
113
|
-
// 7. Verify
|
|
114
|
-
if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
|
|
113
|
+
// 7. Verify (skipped under --no-verify: caller verifies separately)
|
|
114
|
+
if (!noVerify && !dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
|
|
115
115
|
console.error(`FAILED: ${path.basename(dfyPath)} verification failed.`);
|
|
116
116
|
process.exit(1);
|
|
117
117
|
}
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -36,6 +36,7 @@ function tyToDafny(ty) {
|
|
|
36
36
|
case "string": return "string";
|
|
37
37
|
case "void": return "()";
|
|
38
38
|
case "array": return `seq<${tyToDafny(ty.elem)}>`;
|
|
39
|
+
case "tuple": return `(${ty.elems.map(tyToDafny).join(", ")})`;
|
|
39
40
|
case "map": return `map<${tyToDafny(ty.key)}, ${tyToDafny(ty.value)}>`;
|
|
40
41
|
case "set": return `set<${tyToDafny(ty.elem)}>`;
|
|
41
42
|
case "optional": {
|
|
@@ -228,6 +229,8 @@ function emitExpr(e) {
|
|
|
228
229
|
if (e.elems.length === 0)
|
|
229
230
|
return `[]`;
|
|
230
231
|
return `[${e.elems.map(emitExpr).join(", ")}]`;
|
|
232
|
+
case "tupleLiteral": return `(${e.elems.map(emitExpr).join(", ")})`;
|
|
233
|
+
case "tupleProj": return `${emitExpr(e.obj)}.${e.index}`;
|
|
231
234
|
case "emptyMap": return `map[]`;
|
|
232
235
|
case "emptySet": return `{}`;
|
|
233
236
|
case "mapLiteral": {
|
|
@@ -255,6 +258,10 @@ function emitExpr(e) {
|
|
|
255
258
|
if (e.method === "concat")
|
|
256
259
|
return `(${obj} + [${args.join(", ")}])`;
|
|
257
260
|
if (e.method === "sort") {
|
|
261
|
+
if (args.length === 0) {
|
|
262
|
+
needPreamble("SeqSort");
|
|
263
|
+
return `SeqSort(${obj})`;
|
|
264
|
+
}
|
|
258
265
|
needPreamble("SeqSortBy");
|
|
259
266
|
return `SeqSortBy(${obj}, ${args[0]})`;
|
|
260
267
|
}
|
|
@@ -295,6 +302,10 @@ function emitExpr(e) {
|
|
|
295
302
|
needPreamble("SeqFindIndex");
|
|
296
303
|
return `SeqFindIndex(${obj}, ${args[0]})`;
|
|
297
304
|
}
|
|
305
|
+
if (e.method === "findLastIndex") {
|
|
306
|
+
needPreamble("SeqFindLastIndex");
|
|
307
|
+
return `SeqFindLastIndex(${obj}, ${args[0]})`;
|
|
308
|
+
}
|
|
298
309
|
if (e.method === "flat" && args.length === 0) {
|
|
299
310
|
needPreamble("SeqFlatten");
|
|
300
311
|
return `SeqFlatten(${obj})`;
|
|
@@ -303,16 +314,26 @@ function emitExpr(e) {
|
|
|
303
314
|
needPreamble("SeqJoin");
|
|
304
315
|
return `SeqJoin(${obj}, ${args[0]})`;
|
|
305
316
|
}
|
|
306
|
-
|
|
307
|
-
|
|
317
|
+
// `.some(pred)`: inline a single-return lambda's body, else apply the
|
|
318
|
+
// predicate (e.g. a function reference).
|
|
319
|
+
if (e.method === "some") {
|
|
308
320
|
const lam = e.args[0];
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
321
|
+
let p, body;
|
|
322
|
+
if (lam.kind === "lambda" && lam.body.length === 1 && lam.body[0].kind === "return") {
|
|
323
|
+
const cb = comprehensionBinder(lam, lam.body[0].value, e.obj);
|
|
324
|
+
p = cb.binder;
|
|
325
|
+
body = emitExpr(cb.body);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
p = escapeName(freshBinder("x", e.obj, e.args[0]));
|
|
329
|
+
body = `${args[0]}(${p})`;
|
|
330
|
+
}
|
|
314
331
|
return `(exists ${p} :: ${p} in ${obj} && ${body})`;
|
|
315
332
|
}
|
|
333
|
+
// `.reduce(f, init)` → Std's FoldLeft(f, init, xs) (same arg order).
|
|
334
|
+
if (e.method === "reduce" && args.length === 2) {
|
|
335
|
+
return `Std.Collections.Seq.FoldLeft(${args[0]}, ${args[1]}, ${obj})`;
|
|
336
|
+
}
|
|
316
337
|
}
|
|
317
338
|
// String methods
|
|
318
339
|
if (ty === "string") {
|
|
@@ -694,7 +715,7 @@ function emitStmt(s, indent) {
|
|
|
694
715
|
case "break":
|
|
695
716
|
return `${pad}break;`;
|
|
696
717
|
case "continue":
|
|
697
|
-
|
|
718
|
+
return `${pad}continue;`;
|
|
698
719
|
case "if": {
|
|
699
720
|
let out = `${pad}if ${emitExpr(s.cond)} {\n${emitStmts(s.then, indent + 1)}\n${pad}}`;
|
|
700
721
|
if (s.else.length > 0) {
|
|
@@ -1003,6 +1024,18 @@ function SeqIndexOfFrom<T(==)>(s: seq<T>, x: T, from: nat): int
|
|
|
1003
1024
|
else if s[from] == x then from as int
|
|
1004
1025
|
else SeqIndexOfFrom(s, x, from + 1)
|
|
1005
1026
|
}`;
|
|
1027
|
+
const SEQ_FIND_LAST_INDEX = `function SeqFindLastIndex<T>(s: seq<T>, p: T -> bool): int
|
|
1028
|
+
ensures -1 <= SeqFindLastIndex(s, p) < |s|
|
|
1029
|
+
ensures SeqFindLastIndex(s, p) >= 0 ==> p(s[SeqFindLastIndex(s, p)])
|
|
1030
|
+
ensures SeqFindLastIndex(s, p) >= 0 ==>
|
|
1031
|
+
(forall j: int :: SeqFindLastIndex(s, p) < j < |s| ==> !p(s[j]))
|
|
1032
|
+
ensures SeqFindLastIndex(s, p) == -1 ==> (forall i: nat :: i < |s| ==> !p(s[i]))
|
|
1033
|
+
decreases |s|
|
|
1034
|
+
{
|
|
1035
|
+
if |s| == 0 then -1
|
|
1036
|
+
else if p(s[|s|-1]) then |s| - 1
|
|
1037
|
+
else SeqFindLastIndex(s[..|s|-1], p)
|
|
1038
|
+
}`;
|
|
1006
1039
|
const SEQ_FIND_LAST = `function SeqFindLast<T>(s: seq<T>, p: T -> bool): Option<T>
|
|
1007
1040
|
ensures SeqFindLast(s, p).Some? ==> p(SeqFindLast(s, p).value)
|
|
1008
1041
|
ensures SeqFindLast(s, p).Some? ==> SeqFindLast(s, p).value in s
|
|
@@ -1029,12 +1062,21 @@ const SEQ_JOIN = `function SeqJoin(s: seq<string>, sep: string): string
|
|
|
1029
1062
|
else if |s| == 1 then s[0]
|
|
1030
1063
|
else s[0] + sep + SeqJoin(s[1..], sep)
|
|
1031
1064
|
}`;
|
|
1032
|
-
const SAFE_SLICE = `function
|
|
1065
|
+
const SAFE_SLICE = `function NormalizeSliceIndex(n: nat, i: int): int
|
|
1066
|
+
ensures 0 <= NormalizeSliceIndex(n, i) <= n as int
|
|
1067
|
+
{
|
|
1068
|
+
if i < 0 then
|
|
1069
|
+
if n as int + i < 0 then 0 else n as int + i
|
|
1070
|
+
else if i > n as int then n as int
|
|
1071
|
+
else i
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function SafeSlice<T>(s: seq<T>, lo: int, hi: int): seq<T>
|
|
1033
1075
|
ensures |SafeSlice(s, lo, hi)| <= |s|
|
|
1034
1076
|
{
|
|
1035
|
-
var lo' :=
|
|
1036
|
-
var hi' :=
|
|
1037
|
-
s[lo'..hi']
|
|
1077
|
+
var lo' := NormalizeSliceIndex(|s|, lo);
|
|
1078
|
+
var hi' := NormalizeSliceIndex(|s|, hi);
|
|
1079
|
+
if hi' < lo' then [] else s[lo'..hi']
|
|
1038
1080
|
}`;
|
|
1039
1081
|
const STRING_INDEX_OF = `function StringIndexOf(s: string, sub: string): int
|
|
1040
1082
|
ensures StringIndexOf(s, sub) == -1
|
|
@@ -1077,6 +1119,10 @@ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<s
|
|
|
1077
1119
|
// is the soundness condition — cmp must be a total preorder, otherwise no sorted
|
|
1078
1120
|
// permutation exists and the axiom would be vacuous. Callers discharge it (e.g.
|
|
1079
1121
|
// `(a,b) => a.k - b.k` is total + transitive by linear arithmetic).
|
|
1122
|
+
// Bare `.sort()`: permutation only, no sortedness (JS default order is type-dependent).
|
|
1123
|
+
const SEQ_SORT = `function {:axiom} SeqSort<T(==,!new)>(s: seq<T>): seq<T>
|
|
1124
|
+
ensures multiset(SeqSort(s)) == multiset(s)
|
|
1125
|
+
ensures |SeqSort(s)| == |s|`;
|
|
1080
1126
|
const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T, T) -> int): seq<T>
|
|
1081
1127
|
requires forall a: T, b: T :: cmp(a, b) <= 0 || cmp(b, a) <= 0
|
|
1082
1128
|
requires forall a: T, b: T, c: T :: cmp(a, b) <= 0 && cmp(b, c) <= 0 ==> cmp(a, c) <= 0
|
|
@@ -1246,6 +1292,7 @@ const PREAMBLE_CODE = [
|
|
|
1246
1292
|
["FloorReal", FLOOR_REAL],
|
|
1247
1293
|
["SeqIndexOf", SEQ_INDEX_OF],
|
|
1248
1294
|
["SeqFindIndex", SEQ_FIND_INDEX],
|
|
1295
|
+
["SeqFindLastIndex", SEQ_FIND_LAST_INDEX],
|
|
1249
1296
|
["SeqFilterSome", SEQ_FILTER_SOME],
|
|
1250
1297
|
["SeqFindLast", SEQ_FIND_LAST],
|
|
1251
1298
|
["SeqFlatten", SEQ_FLATTEN],
|
|
@@ -1253,6 +1300,7 @@ const PREAMBLE_CODE = [
|
|
|
1253
1300
|
["SafeSlice", SAFE_SLICE],
|
|
1254
1301
|
["StringIndexOf", STRING_INDEX_OF],
|
|
1255
1302
|
["StringSplit", STRING_SPLIT],
|
|
1303
|
+
["SeqSort", SEQ_SORT],
|
|
1256
1304
|
["SeqSortBy", SEQ_SORT_BY],
|
|
1257
1305
|
["StringTrim", STRING_TRIM],
|
|
1258
1306
|
["StringToLower", STRING_TO_LOWER],
|
|
@@ -1303,6 +1351,8 @@ function resolveTy(ty) {
|
|
|
1303
1351
|
return { kind: "optional", inner: resolveTy(ty.inner) };
|
|
1304
1352
|
if (ty.kind === "array")
|
|
1305
1353
|
return { kind: "array", elem: resolveTy(ty.elem) };
|
|
1354
|
+
if (ty.kind === "tuple")
|
|
1355
|
+
return { kind: "tuple", elems: ty.elems.map(resolveTy) };
|
|
1306
1356
|
if (ty.kind === "map")
|
|
1307
1357
|
return { kind: "map", key: resolveTy(ty.key), value: resolveTy(ty.value) };
|
|
1308
1358
|
if (ty.kind === "set")
|
package/tools/dist/extract.js
CHANGED
|
@@ -1887,7 +1887,8 @@ export function extractModule(sourceFile) {
|
|
|
1887
1887
|
const name = recordMatch[1];
|
|
1888
1888
|
// `<T extends B>` → bare `T`: a Dafny type param, like the def path.
|
|
1889
1889
|
const typeParams = recordMatch[2]?.split(",").map(s => s.trim().split(/\s+extends\s+/)[0].trim()).filter(Boolean);
|
|
1890
|
-
|
|
1890
|
+
// Split on `,` OR `;` — TS object types allow both.
|
|
1891
|
+
const fields = recordMatch[3].split(/[,;]/).map(f => f.trim()).filter(Boolean).map(f => {
|
|
1891
1892
|
const [fname, ftype] = f.split(":").map(s => s.trim());
|
|
1892
1893
|
const synth = _synthFromTsTypeString(ftype);
|
|
1893
1894
|
return { name: fname, tsType: synth ?? ftype };
|
package/tools/dist/ir.js
CHANGED
|
@@ -37,6 +37,8 @@ export function anyExpr(e, pred) {
|
|
|
37
37
|
case "app": return e.args.some(a => anyExpr(a, pred));
|
|
38
38
|
case "field": return anyExpr(e.obj, pred);
|
|
39
39
|
case "index": return anyExpr(e.arr, pred) || anyExpr(e.idx, pred);
|
|
40
|
+
case "tupleLiteral": return e.elems.some(x => anyExpr(x, pred));
|
|
41
|
+
case "tupleProj": return anyExpr(e.obj, pred);
|
|
40
42
|
case "implies": return e.premises.some(p => anyExpr(p, pred)) || anyExpr(e.conclusion, pred);
|
|
41
43
|
case "record": return (e.spread ? anyExpr(e.spread, pred) : false) || e.fields.some(f => anyExpr(f.value, pred));
|
|
42
44
|
case "arrayLiteral": return e.elems.some(x => anyExpr(x, pred));
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -34,6 +34,13 @@ function tyToLean(ty) {
|
|
|
34
34
|
const elem = tyToLean(ty.elem);
|
|
35
35
|
return elem.includes(" ") ? `Array (${elem})` : `Array ${elem}`;
|
|
36
36
|
}
|
|
37
|
+
case "tuple":
|
|
38
|
+
// Right-nested Prod: `A × B × C` = `A × (B × C)`. Parenthesize any element
|
|
39
|
+
// whose rendering has a space so it binds tighter than `×` (e.g. `A → B`).
|
|
40
|
+
return ty.elems.map(el => {
|
|
41
|
+
const s = tyToLean(el);
|
|
42
|
+
return s.includes(" ") ? `(${s})` : s;
|
|
43
|
+
}).join(" × ");
|
|
37
44
|
case "map": {
|
|
38
45
|
const k = tyToLean(ty.key);
|
|
39
46
|
const v = tyToLean(ty.value);
|
|
@@ -112,6 +119,9 @@ function collectUserRefs(ty, into) {
|
|
|
112
119
|
case "set":
|
|
113
120
|
collectUserRefs(ty.elem, into);
|
|
114
121
|
break;
|
|
122
|
+
case "tuple":
|
|
123
|
+
ty.elems.forEach(el => collectUserRefs(el, into));
|
|
124
|
+
break;
|
|
115
125
|
case "optional":
|
|
116
126
|
collectUserRefs(ty.inner, into);
|
|
117
127
|
break;
|
|
@@ -226,6 +236,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
|
|
|
226
236
|
return `${obj}.${monadic ? "allM" : "all"} ${args[0]}`;
|
|
227
237
|
if (method === "some")
|
|
228
238
|
return `${obj}.${monadic ? "anyM" : "any"} ${args[0]}`;
|
|
239
|
+
if (method === "reduce" && args.length === 2)
|
|
240
|
+
return `(${obj}.foldl ${args[0]} ${args[1]})`;
|
|
229
241
|
if (method === "includes")
|
|
230
242
|
return args.length > 1 ? `(${obj}.extract ${args[1]} ${obj}.size).contains ${args[0]}` : `${obj}.contains ${args[0]}`;
|
|
231
243
|
if (method === "find")
|
|
@@ -318,6 +330,15 @@ function emitExpr(e, parentPrec) {
|
|
|
318
330
|
if (e.elems.length === 0)
|
|
319
331
|
return `#[]`;
|
|
320
332
|
return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
|
|
333
|
+
case "tupleLiteral":
|
|
334
|
+
return `(${e.elems.map(el => emitExpr(el)).join(", ")})`;
|
|
335
|
+
case "tupleProj": {
|
|
336
|
+
// Right-nested Prod projection: element i is `.2`×i then `.1`, except the
|
|
337
|
+
// last (i = arity-1) which is `.2`×i with no trailing `.1`.
|
|
338
|
+
const obj = emitExpr(e.obj);
|
|
339
|
+
const twos = ".2".repeat(e.index);
|
|
340
|
+
return e.index === e.arity - 1 ? `${obj}${twos}` : `${obj}${twos}.1`;
|
|
341
|
+
}
|
|
321
342
|
case "emptyMap": return `Std.HashMap.empty`;
|
|
322
343
|
case "emptySet": return `Std.HashSet.empty`;
|
|
323
344
|
case "default": return `(default : ${tyToLean(e.type)})`;
|
|
@@ -457,8 +478,13 @@ function emitExpr(e, parentPrec) {
|
|
|
457
478
|
// A real value reached the Lean backend via coercion (e.g. number `/`).
|
|
458
479
|
// Same unsupported-real story as the `real` type case in tyToLean.
|
|
459
480
|
throw new Error("real arithmetic is not supported by the Lean backend (needs noncomputable ℝ / Mathlib).");
|
|
460
|
-
case "index":
|
|
461
|
-
|
|
481
|
+
case "index": {
|
|
482
|
+
const arr = emitExpr(e.arr);
|
|
483
|
+
// Parenthesize a low-precedence array expr (e.g. a function application)
|
|
484
|
+
// so the index binds to the whole thing, not its last token.
|
|
485
|
+
const wrap = e.arr.kind === "app" || e.arr.kind === "binop" || e.arr.kind === "methodCall" || e.arr.kind === "if" || e.arr.kind === "let" || e.arr.kind === "unop";
|
|
486
|
+
return `${wrap ? `(${arr})` : arr}[${emitExpr(e.idx)}]!`;
|
|
487
|
+
}
|
|
462
488
|
case "record": {
|
|
463
489
|
const fields = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
|
|
464
490
|
if (e.spread)
|
|
@@ -633,6 +659,59 @@ function emitStmt(s, indent) {
|
|
|
633
659
|
}
|
|
634
660
|
}
|
|
635
661
|
// ── Declaration emission ─────────────────────────────────────
|
|
662
|
+
/** Collect every function/variable name an IR tree references (stripping any
|
|
663
|
+
* `Pure.` qualifier), via a generic walk over `app`/`var` nodes. */
|
|
664
|
+
function collectRefNames(node, into) {
|
|
665
|
+
if (node === null || typeof node !== "object")
|
|
666
|
+
return;
|
|
667
|
+
if (Array.isArray(node)) {
|
|
668
|
+
for (const x of node)
|
|
669
|
+
collectRefNames(x, into);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const n = node;
|
|
673
|
+
if (n.kind === "app" && typeof n.fn === "string")
|
|
674
|
+
into.add(n.fn.replace(/^Pure\./, ""));
|
|
675
|
+
if (n.kind === "var" && typeof n.name === "string")
|
|
676
|
+
into.add(n.name.replace(/^Pure\./, ""));
|
|
677
|
+
for (const k of Object.keys(node))
|
|
678
|
+
collectRefNames(node[k], into);
|
|
679
|
+
}
|
|
680
|
+
/** Lean requires definition-before-use: order sibling decls so one that
|
|
681
|
+
* references another is emitted after it. Cycles are left in place (they would
|
|
682
|
+
* need a `mutual` block). Bails to the original order if any decl is unnamed. */
|
|
683
|
+
function orderDeclsByDeps(decls) {
|
|
684
|
+
const named = decls.filter((d) => typeof d.name === "string");
|
|
685
|
+
if (named.length !== decls.length)
|
|
686
|
+
return decls;
|
|
687
|
+
const names = new Set(named.map(d => d.name));
|
|
688
|
+
const byName = new Map(named.map(d => [d.name, d]));
|
|
689
|
+
const deps = new Map();
|
|
690
|
+
for (const d of named) {
|
|
691
|
+
const refs = new Set();
|
|
692
|
+
collectRefNames(d, refs);
|
|
693
|
+
refs.delete(d.name);
|
|
694
|
+
deps.set(d.name, [...refs].filter(r => names.has(r)));
|
|
695
|
+
}
|
|
696
|
+
const sorted = [];
|
|
697
|
+
const done = new Set();
|
|
698
|
+
const onStack = new Set();
|
|
699
|
+
const visit = (name) => {
|
|
700
|
+
if (done.has(name) || onStack.has(name))
|
|
701
|
+
return;
|
|
702
|
+
onStack.add(name);
|
|
703
|
+
for (const dep of deps.get(name) ?? [])
|
|
704
|
+
visit(dep);
|
|
705
|
+
onStack.delete(name);
|
|
706
|
+
done.add(name);
|
|
707
|
+
const def = byName.get(name);
|
|
708
|
+
if (def)
|
|
709
|
+
sorted.push(def);
|
|
710
|
+
};
|
|
711
|
+
for (const d of named)
|
|
712
|
+
visit(d.name);
|
|
713
|
+
return sorted;
|
|
714
|
+
}
|
|
636
715
|
function emitDecl(d) {
|
|
637
716
|
switch (d.kind) {
|
|
638
717
|
case "inductive": {
|
|
@@ -711,7 +790,7 @@ function emitDecl(d) {
|
|
|
711
790
|
}
|
|
712
791
|
case "namespace": {
|
|
713
792
|
const lines = [`namespace ${d.name}`];
|
|
714
|
-
for (const inner of d.decls)
|
|
793
|
+
for (const inner of orderDeclsByDeps(d.decls))
|
|
715
794
|
lines.push("", emitDecl(inner));
|
|
716
795
|
lines.push("", `end ${d.name}`);
|
|
717
796
|
return lines.join("\n");
|
|
@@ -733,8 +812,10 @@ function emitDecl(d) {
|
|
|
733
812
|
return sig;
|
|
734
813
|
// Spec axiom: ∀ params, req1 → … → (ens1 ∧ … ∧ ensN). Tagged `@[grind]` so
|
|
735
814
|
// the proof automation can use it, matching the ghost-function convention.
|
|
736
|
-
|
|
737
|
-
|
|
815
|
+
// Parenthesize each clause: an unwrapped `∀ k, P` would otherwise swallow
|
|
816
|
+
// the following ` ∧ …` conjuncts into its body.
|
|
817
|
+
const hyps = d.requires.map(e => `(${emitExpr(e)})`);
|
|
818
|
+
const concl = d.ensures.map(e => `(${emitExpr(e)})`).join(" ∧ ");
|
|
738
819
|
const axBody = [...hyps, concl].join(" → ");
|
|
739
820
|
const axiom = `@[grind] axiom ${escapeName(d.name)}_spec${params ? ` ${params}` : ""} : ${axBody}`;
|
|
740
821
|
return `${sig}\n${axiom}`;
|
package/tools/dist/lsc.js
CHANGED
|
@@ -98,6 +98,16 @@ function main() {
|
|
|
98
98
|
slow = true;
|
|
99
99
|
args.splice(slowIdx, 1);
|
|
100
100
|
}
|
|
101
|
+
// --no-verify (regen only): do regen + three-way merge + additions-only check
|
|
102
|
+
// but skip `dafny verify`. CI's `tools` job passes this to regen-dafny.sh so
|
|
103
|
+
// regen only enforces the drift + additions-only invariants; the separate
|
|
104
|
+
// `lsc check` pass over the same files does the one and only verification.
|
|
105
|
+
let noVerify = false;
|
|
106
|
+
const noVerifyIdx = args.indexOf("--no-verify");
|
|
107
|
+
if (noVerifyIdx >= 0) {
|
|
108
|
+
noVerify = true;
|
|
109
|
+
args.splice(noVerifyIdx, 1);
|
|
110
|
+
}
|
|
101
111
|
// Anything flag-shaped left over is a typo or a space-separated form
|
|
102
112
|
// (`--backend lean`): reject it rather than let it become a positional arg
|
|
103
113
|
// or be silently ignored (which would e.g. verify with the wrong backend).
|
|
@@ -117,7 +127,7 @@ function main() {
|
|
|
117
127
|
runBatch(cmd, backend, slow);
|
|
118
128
|
return;
|
|
119
129
|
}
|
|
120
|
-
runFile(cmd, filePath, backend, timeLimit, extraFlags);
|
|
130
|
+
runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify);
|
|
121
131
|
}
|
|
122
132
|
// LemmaScript-files.txt, parsed: `filepath [timeout_in_seconds] [extra dafny
|
|
123
133
|
// flags…]` per line; no timeout = Dafny default. Exits if the file is absent.
|
|
@@ -154,7 +164,7 @@ function runBatch(cmd, backend, slow) {
|
|
|
154
164
|
}
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
|
-
function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
|
|
167
|
+
function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false) {
|
|
158
168
|
const absPath = path.resolve(filePath);
|
|
159
169
|
if (!existsSync(absPath)) {
|
|
160
170
|
console.error(`File not found: ${absPath}`);
|
|
@@ -247,7 +257,7 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
|
|
|
247
257
|
return;
|
|
248
258
|
}
|
|
249
259
|
if (cmd === "regen") {
|
|
250
|
-
dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags);
|
|
260
|
+
dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags, noVerify);
|
|
251
261
|
return;
|
|
252
262
|
}
|
|
253
263
|
console.error(`Unknown command: ${cmd}`);
|
package/tools/dist/narrow.js
CHANGED
|
@@ -145,7 +145,7 @@ function walkStmt(s) {
|
|
|
145
145
|
// array rule fires; independent narrows commute, so the order is harmless.)
|
|
146
146
|
// && rules fire before the simple rule because they produce nested ifs whose
|
|
147
147
|
// inner shape doesn't match the simple rule directly.
|
|
148
|
-
return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? ruleExprStmtAndOptional(r) ?? r;
|
|
148
|
+
return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? ruleExprStmtAndOptional(r) ?? ruleOptionalIndexBinding(r) ?? r;
|
|
149
149
|
}
|
|
150
150
|
function walkStmts(stmts) {
|
|
151
151
|
const result = [];
|
|
@@ -159,7 +159,7 @@ function walkStmts(stmts) {
|
|
|
159
159
|
i += tagged.consumed - 1;
|
|
160
160
|
continue;
|
|
161
161
|
}
|
|
162
|
-
const consumed = ruleEarlyReturnOrChain(s, rest) ?? ruleEarlyReturnConsume(s, rest);
|
|
162
|
+
const consumed = ruleEarlyReturnOrChain(s, rest) ?? ruleEarlyReturnConsume(s, rest) ?? ruleEarlyReturnOptChainCompare(s, rest);
|
|
163
163
|
if (consumed) {
|
|
164
164
|
result.push(walkStmt(consumed));
|
|
165
165
|
return result;
|
|
@@ -260,54 +260,138 @@ function ruleEarlyReturnConsume(s, rest) {
|
|
|
260
260
|
noneBody: noneBranch,
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
|
-
/**
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
263
|
+
/** Flatten a nested `||` chain into its leaf conditions. */
|
|
264
|
+
function flattenOr(e) {
|
|
265
|
+
if (e.kind === "binop" && e.op === "||")
|
|
266
|
+
return [...flattenOr(e.left), ...flattenOr(e.right)];
|
|
267
|
+
return [e];
|
|
268
|
+
}
|
|
269
|
+
function classifyDisjunct(leaf) {
|
|
270
|
+
// `x?.chain !== lit` — `undefined !== lit` is true when x is None.
|
|
271
|
+
if (leaf.kind === "binop" && leaf.op === "!==") {
|
|
272
|
+
const oc = leaf.left.kind === "optChain" ? leaf.left : leaf.right.kind === "optChain" ? leaf.right : null;
|
|
273
|
+
if (oc && oc.kind === "optChain" && oc.obj.ty.kind === "optional") {
|
|
274
|
+
const hint = binderHintFor(oc.obj);
|
|
275
|
+
if (hint === null)
|
|
276
|
+
return null;
|
|
277
|
+
const binder = freshName(hint);
|
|
278
|
+
const unwrapped = applyChain({ kind: "var", name: binder, ty: oc.obj.ty.inner }, oc.chain);
|
|
279
|
+
if (unwrapped.kind === "field" && unwrapped.obj.ty.kind === "user") {
|
|
280
|
+
const base = unwrapped.obj.ty.name.replace(/<.*/, "");
|
|
281
|
+
const decl = _typeDecls.find(d => d.name === base);
|
|
282
|
+
if (decl?.kind === "discriminated-union" && decl.discriminant === unwrapped.field)
|
|
283
|
+
unwrapped.isDiscriminant = true;
|
|
284
|
+
}
|
|
285
|
+
const lit = leaf.left === oc ? leaf.right : leaf.left;
|
|
286
|
+
return { scrutinee: oc.obj, innerTy: oc.obj.ty.inner, binder, residual: { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } } };
|
|
287
|
+
}
|
|
272
288
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
289
|
+
// `!x` / `x === undefined`.
|
|
290
|
+
const chk = parseOptionalCheck(leaf);
|
|
291
|
+
if (chk && chk.negated) {
|
|
292
|
+
const residual = canBeFalsy(chk)
|
|
293
|
+
? { kind: "unop", op: "!", expr: { kind: "var", name: chk.binderHint, ty: chk.innerTy }, ty: { kind: "bool" } }
|
|
294
|
+
: null;
|
|
295
|
+
return { scrutinee: chk.scrutinee, innerTy: chk.innerTy, binder: chk.binderHint, residual };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
277
298
|
}
|
|
278
|
-
/** Rule: `if (
|
|
279
|
-
*
|
|
280
|
-
*
|
|
299
|
+
/** Rule: `if (D1 || … || Dn) terminate; rest`. Each `Di` that detects some optional
|
|
300
|
+
* `x` is None (`!x`, `x === undefined`, `x?.chain !== lit`) narrows that `x` to Some
|
|
301
|
+
* across `rest`; the rest — value guards reading a narrowed `x` directly, plus the
|
|
302
|
+
* detectors' Some-case residuals — become a trailing early-return. Sound: reaching
|
|
303
|
+
* `rest` means every disjunct was false, so every detected optional is present.
|
|
304
|
+
* Covers `if (!x || x.f !== v) continue` / `if (x?.t !== 'm' || x.g) break`.
|
|
281
305
|
* Closes the resolve.ts:602 TODO ("|| narrowing"). */
|
|
282
306
|
function ruleEarlyReturnOrChain(s, rest) {
|
|
283
307
|
if (s.kind !== "if")
|
|
284
308
|
return null;
|
|
285
309
|
if (rest.length === 0)
|
|
286
310
|
return null;
|
|
287
|
-
if (s.then.length === 0 || s.else.length !== 0)
|
|
311
|
+
if (s.then.length === 0 || s.else.length !== 0 || !isTerminating(s.then))
|
|
288
312
|
return null;
|
|
289
313
|
if (s.cond.kind !== "binop" || s.cond.op !== "||")
|
|
290
314
|
return null; // single check is the simpler rule
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
return null;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
const leaves = flattenOr(s.cond);
|
|
316
|
+
if (leaves.length < 2)
|
|
317
|
+
return null;
|
|
318
|
+
const detectors = [];
|
|
319
|
+
const residualLeaves = [];
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
for (const leaf of leaves) {
|
|
322
|
+
const d = classifyDisjunct(leaf);
|
|
323
|
+
if (!d) {
|
|
324
|
+
residualLeaves.push(leaf);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const key = binderHintFor(d.scrutinee);
|
|
328
|
+
if (seen.has(key))
|
|
329
|
+
return null; // two detectors on one optional: rare; leave to other rules
|
|
330
|
+
seen.add(key);
|
|
331
|
+
detectors.push(d);
|
|
332
|
+
if (d.residual)
|
|
333
|
+
residualLeaves.push(d.residual);
|
|
334
|
+
}
|
|
335
|
+
if (detectors.length === 0)
|
|
336
|
+
return null;
|
|
337
|
+
let inner = residualLeaves.length === 0
|
|
338
|
+
? rest
|
|
339
|
+
: [{ kind: "if", cond: residualLeaves.reduce((a, b) => ({ kind: "binop", op: "||", left: a, right: b, ty: { kind: "bool" } })), then: s.then, else: [] }, ...rest];
|
|
340
|
+
for (let i = detectors.length - 1; i >= 0; i--) {
|
|
341
|
+
const d = detectors[i];
|
|
342
|
+
inner = [{ kind: "someMatch", scrutinee: d.scrutinee, binderTy: d.innerTy, binder: d.binder, someBody: inner, noneBody: s.then }];
|
|
308
343
|
}
|
|
309
344
|
return inner[0];
|
|
310
345
|
}
|
|
346
|
+
/** Rule: `if (opt?.chain !== lit) terminate; rest` where `opt` is optional.
|
|
347
|
+
* `opt?.chain` is `undefined` when `opt` is None, and `undefined !== lit` is
|
|
348
|
+
* true, so the None case takes the terminating branch — falling through to
|
|
349
|
+
* `rest` proves `opt` is Some. Rewrite to
|
|
350
|
+
* someMatch opt { Some(v) => [if (v.chain !== lit) terminate; rest]; None => terminate }
|
|
351
|
+
* narrowing `opt` to `v` across `rest` (transform substitutes the scrutinee) and
|
|
352
|
+
* handing the now-non-optional inner guard to the ordinary rules (e.g.
|
|
353
|
+
* discriminant narrowing). Bound-optional companion to ruleEarlyReturnConsume,
|
|
354
|
+
* which handles only a bare presence check (`opt !== undefined`). Restricted to
|
|
355
|
+
* `!==` so the None case is guaranteed to terminate. */
|
|
356
|
+
function ruleEarlyReturnOptChainCompare(s, rest) {
|
|
357
|
+
if (s.kind !== "if")
|
|
358
|
+
return null;
|
|
359
|
+
if (rest.length === 0)
|
|
360
|
+
return null;
|
|
361
|
+
if (s.else.length !== 0 || !isTerminating(s.then))
|
|
362
|
+
return null;
|
|
363
|
+
const c = s.cond;
|
|
364
|
+
if (c.kind !== "binop" || c.op !== "!==")
|
|
365
|
+
return null;
|
|
366
|
+
const oc = c.left.kind === "optChain" ? c.left : c.right.kind === "optChain" ? c.right : null;
|
|
367
|
+
if (!oc || oc.kind !== "optChain" || oc.obj.ty.kind !== "optional")
|
|
368
|
+
return null;
|
|
369
|
+
const lit = c.left === oc ? c.right : c.left;
|
|
370
|
+
const innerTy = oc.obj.ty.inner;
|
|
371
|
+
const hint = binderHintFor(oc.obj);
|
|
372
|
+
if (hint === null)
|
|
373
|
+
return null;
|
|
374
|
+
const binder = freshName(hint);
|
|
375
|
+
const binderVar = { kind: "var", name: binder, ty: innerTy };
|
|
376
|
+
const unwrapped = applyChain(binderVar, oc.chain);
|
|
377
|
+
// applyChain rebuilds the field without the `isDiscriminant` flag resolve sets
|
|
378
|
+
// on a direct `x.disc`; restore it when the unwrapped access is the binder
|
|
379
|
+
// union's discriminant, so the inner guard feeds discriminant narrowing.
|
|
380
|
+
if (unwrapped.kind === "field" && unwrapped.obj.ty.kind === "user") {
|
|
381
|
+
const base = unwrapped.obj.ty.name.replace(/<.*/, "");
|
|
382
|
+
const decl = _typeDecls.find(d => d.name === base);
|
|
383
|
+
if (decl?.kind === "discriminated-union" && decl.discriminant === unwrapped.field) {
|
|
384
|
+
unwrapped.isDiscriminant = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const innerGuard = { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } };
|
|
388
|
+
// Keep `rest` as trailing statements (not an else branch) — `s.then` terminates,
|
|
389
|
+
// so `if (g) terminate; rest` ≡ `if (g) terminate else rest`, and the trailing
|
|
390
|
+
// form lets the ordinary early-exit rules (e.g. discriminant narrowing) fire on
|
|
391
|
+
// the now-non-optional inner guard when `chain` is a union discriminant.
|
|
392
|
+
const someBody = [{ kind: "if", cond: innerGuard, then: s.then, else: [] }, ...rest];
|
|
393
|
+
return { kind: "someMatch", scrutinee: oc.obj, binder, binderTy: innerTy, someBody, noneBody: s.then };
|
|
394
|
+
}
|
|
311
395
|
/** Rule (expression): `e !== undefined ? a : b`. */
|
|
312
396
|
function ruleConditionalOptionalSimple(e) {
|
|
313
397
|
if (e.kind !== "conditional")
|
|
@@ -491,6 +575,32 @@ function ruleOptChainIndex(e) {
|
|
|
491
575
|
const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
|
|
492
576
|
return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
|
|
493
577
|
}
|
|
578
|
+
/** Rule (statement): reconcile a `const e = arr[i]` whose binding is optional
|
|
579
|
+
* (`e: T | undefined`) but whose array-index initializer is total (`T`). Model
|
|
580
|
+
* the index as its JS semantics — `e := (0 <= i && i < arr.length) ? arr[i] :
|
|
581
|
+
* undefined` — so `e` is a real `Option<T>` and a later `e?.f` someMatch is
|
|
582
|
+
* well-typed; an in-bounds proof makes the None branch dead, so safely-indexed
|
|
583
|
+
* code verifies as if total. Bound-form sibling of ruleOptChainIndex /
|
|
584
|
+
* ruleNullishIndex. Fires purely on the optional-binding/total-index shape (the
|
|
585
|
+
* usual source is `noUncheckedIndexedAccess`, but the flag itself is never
|
|
586
|
+
* checked). Skipped when the element type is already optional: no mismatch. */
|
|
587
|
+
function ruleOptionalIndexBinding(s) {
|
|
588
|
+
if (s.kind !== "let")
|
|
589
|
+
return null;
|
|
590
|
+
if (s.ty.kind !== "optional")
|
|
591
|
+
return null;
|
|
592
|
+
const init = s.init;
|
|
593
|
+
if (init.kind !== "index")
|
|
594
|
+
return null;
|
|
595
|
+
if (init.obj.ty.kind !== "array")
|
|
596
|
+
return null;
|
|
597
|
+
if (init.ty.kind === "optional")
|
|
598
|
+
return null; // array-of-optionals: not a flag artifact
|
|
599
|
+
const cond = arrayBoundsCond(init.obj, init.idx);
|
|
600
|
+
const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
|
|
601
|
+
const guarded = { kind: "conditional", cond, then: init, else: undef, ty: s.ty };
|
|
602
|
+
return { ...s, init: guarded };
|
|
603
|
+
}
|
|
494
604
|
/** Rule (expression): `obj?.<chain>` — single-eval optional chain.
|
|
495
605
|
* → `someMatch obj { Some(_oc{N}_val) => apply(chain, _oc{N}_val), None => undefined }`.
|
|
496
606
|
* The someBody applies the chain to the binder directly (field/call/index),
|
package/tools/dist/peephole.js
CHANGED
|
@@ -24,6 +24,8 @@ function mapExpr(e, f) {
|
|
|
24
24
|
case "toNat": return { ...e, expr: r(e.expr) };
|
|
25
25
|
case "toReal": return { ...e, expr: r(e.expr) };
|
|
26
26
|
case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
|
|
27
|
+
case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
|
|
28
|
+
case "tupleProj": return { ...e, obj: r(e.obj) };
|
|
27
29
|
case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
|
|
28
30
|
fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
|
|
29
31
|
case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
|
|
@@ -368,6 +370,8 @@ function rewriteChildrenExpr(e) {
|
|
|
368
370
|
case "toNat": return { ...e, expr: r(e.expr) };
|
|
369
371
|
case "toReal": return { ...e, expr: r(e.expr) };
|
|
370
372
|
case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
|
|
373
|
+
case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
|
|
374
|
+
case "tupleProj": return { ...e, obj: r(e.obj) };
|
|
371
375
|
case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
|
|
372
376
|
fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
|
|
373
377
|
case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
|
package/tools/dist/resolve.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses linked environments (Scheme-style) for lexical scoping.
|
|
5
5
|
* No mutation — each let extends the chain, lookup walks it.
|
|
6
6
|
*/
|
|
7
|
-
import { isBigInt } from "./typedir.js";
|
|
7
|
+
import { isBigInt, tyEqual } from "./typedir.js";
|
|
8
8
|
import { parseTsType, tyToCanonical } from "./types.js";
|
|
9
9
|
import { parseExpr } from "./specparser.js";
|
|
10
10
|
import { freshName } from "./names.js";
|
|
@@ -283,6 +283,8 @@ function expandAlias(ty, typeDecls, seen = new Set()) {
|
|
|
283
283
|
return { kind: "optional", inner: expandAlias(ty.inner, typeDecls, seen) };
|
|
284
284
|
if (ty.kind === "array")
|
|
285
285
|
return { kind: "array", elem: expandAlias(ty.elem, typeDecls, seen) };
|
|
286
|
+
if (ty.kind === "tuple")
|
|
287
|
+
return { kind: "tuple", elems: ty.elems.map(e => expandAlias(e, typeDecls, seen)) };
|
|
286
288
|
if (ty.kind === "set")
|
|
287
289
|
return { kind: "set", elem: expandAlias(ty.elem, typeDecls, seen) };
|
|
288
290
|
if (ty.kind === "map")
|
|
@@ -300,7 +302,7 @@ function getDiscriminant(ctx, typeName) {
|
|
|
300
302
|
// unions, arrays, maps, sets, and unresolved generics are reference-compared at
|
|
301
303
|
// runtime, so a structural proof over them is unsound. Returns true for those.
|
|
302
304
|
function refEqHazard(ty, typeDecls) {
|
|
303
|
-
if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set")
|
|
305
|
+
if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set" || ty.kind === "tuple")
|
|
304
306
|
return true;
|
|
305
307
|
if (ty.kind === "user") {
|
|
306
308
|
let decl = typeDecls.find(d => d.name === ty.name);
|
|
@@ -340,6 +342,8 @@ function isUnmodeledTy(ty, typeDecls) {
|
|
|
340
342
|
return isUnmodeledTy(ty.inner, typeDecls);
|
|
341
343
|
if (ty.kind === "array")
|
|
342
344
|
return isUnmodeledTy(ty.elem, typeDecls);
|
|
345
|
+
if (ty.kind === "tuple")
|
|
346
|
+
return ty.elems.some(e => isUnmodeledTy(e, typeDecls));
|
|
343
347
|
if (ty.kind === "set")
|
|
344
348
|
return isUnmodeledTy(ty.elem, typeDecls);
|
|
345
349
|
if (ty.kind === "map")
|
|
@@ -476,8 +480,19 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
|
|
|
476
480
|
return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
|
|
477
481
|
}
|
|
478
482
|
}
|
|
483
|
+
// reduce's callback is (acc, elem): acc from the init arg's type, elem from the array.
|
|
484
|
+
if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "reduce" && ctx &&
|
|
485
|
+
rawArgs.length >= 2 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 2) {
|
|
486
|
+
const accTs = tyToTsStr(resolveExpr(rawArgs[1], ctx).ty);
|
|
487
|
+
const elemTs = tyToTsStr(fn.obj.ty.elem);
|
|
488
|
+
if (accTs && elemTs) {
|
|
489
|
+
const lam = rawArgs[0];
|
|
490
|
+
const updatedParams = lam.params.map((p, i) => p.tsType || i > 1 ? p : { ...p, tsType: i === 0 ? accTs : elemTs });
|
|
491
|
+
return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
479
494
|
if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
|
|
480
|
-
["map", "filter", "every", "some", "find", "findLast", "findIndex"].includes(fn.field) &&
|
|
495
|
+
["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex"].includes(fn.field) &&
|
|
481
496
|
rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
|
|
482
497
|
rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
|
|
483
498
|
const elemTy = fn.obj.ty.elem;
|
|
@@ -600,9 +615,11 @@ function inferMethodReturnTy(fn, args, ctx) {
|
|
|
600
615
|
return objTy;
|
|
601
616
|
if (fn.field === "every" || fn.field === "some")
|
|
602
617
|
return { kind: "bool" };
|
|
618
|
+
if (fn.field === "reduce" && args.length === 2)
|
|
619
|
+
return args[1].ty;
|
|
603
620
|
if (fn.field === "find" || fn.field === "findLast")
|
|
604
621
|
return { kind: "optional", inner: objTy.elem };
|
|
605
|
-
if (fn.field === "findIndex")
|
|
622
|
+
if (fn.field === "findIndex" || fn.field === "findLastIndex")
|
|
606
623
|
return { kind: "int" };
|
|
607
624
|
if (fn.field === "flat" && objTy.elem.kind === "array")
|
|
608
625
|
return { kind: "array", elem: objTy.elem.elem };
|
|
@@ -926,6 +943,16 @@ function resolveExpr(e, ctx) {
|
|
|
926
943
|
if (obj.ty.kind === "array") {
|
|
927
944
|
idxTy = obj.ty.elem;
|
|
928
945
|
}
|
|
946
|
+
else if (obj.ty.kind === "tuple") {
|
|
947
|
+
// Tuple projection: the position must be a literal — a tuple has a
|
|
948
|
+
// distinct type per slot, so a runtime index has no single result type
|
|
949
|
+
// and no backend projection (`t.0`/`t.1`) exists for it.
|
|
950
|
+
const elems = obj.ty.elems;
|
|
951
|
+
if (idx.kind !== "num" || !Number.isInteger(idx.value) || idx.value < 0 || idx.value >= elems.length) {
|
|
952
|
+
throw new Error(`tuple index must be an integer literal in [0, ${elems.length}); got ${tyToCanonical(idx.ty)} index into ${tyToCanonical(obj.ty)}`);
|
|
953
|
+
}
|
|
954
|
+
idxTy = elems[idx.value];
|
|
955
|
+
}
|
|
929
956
|
else if (obj.ty.kind === "map") {
|
|
930
957
|
const objPath = asTExprAccessPath(obj);
|
|
931
958
|
const idxPath = asTExprAccessPath(idx);
|
|
@@ -1090,6 +1117,17 @@ function resolveExpr(e, ctx) {
|
|
|
1090
1117
|
return { kind: "exists", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
|
|
1091
1118
|
}
|
|
1092
1119
|
case "arrayLiteral": {
|
|
1120
|
+
// Expected-tuple context: type each element against its own slot type and
|
|
1121
|
+
// produce a tuple literal (`[1, "a"]: [number, string]`).
|
|
1122
|
+
if (ctx.returnTy.kind === "tuple") {
|
|
1123
|
+
const slots = ctx.returnTy.elems;
|
|
1124
|
+
const elems = e.elems.map((el, i) => {
|
|
1125
|
+
const slot = slots[i];
|
|
1126
|
+
const r = resolveExpr(el, slot ? { ...ctx, returnTy: slot } : ctx);
|
|
1127
|
+
return slot ? coerceStr(r, slot) : r;
|
|
1128
|
+
});
|
|
1129
|
+
return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
|
|
1130
|
+
}
|
|
1093
1131
|
// Thread the expected element type into each element, so a record/union
|
|
1094
1132
|
// literal in an array resolves to its named datatype rather than an
|
|
1095
1133
|
// anonymous tuple (mirrors return-position and call-argument records, which
|
|
@@ -1104,6 +1142,12 @@ function resolveExpr(e, ctx) {
|
|
|
1104
1142
|
return expectedElem ? coerceStr(r, expectedElem) : r;
|
|
1105
1143
|
});
|
|
1106
1144
|
const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
|
|
1145
|
+
// No expected collection type: infer array vs tuple from the elements —
|
|
1146
|
+
// heterogeneous element types can't be a homogeneous seq, so they form a
|
|
1147
|
+
// tuple (`[1, "a"]` → `(int, string)`); otherwise a seq.
|
|
1148
|
+
if (!expectedElem && elems.length >= 2 && !elems.every(x => tyEqual(x.ty, elemTy))) {
|
|
1149
|
+
return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
|
|
1150
|
+
}
|
|
1107
1151
|
return { kind: "arrayLiteral", elems, ty: { kind: "array", elem: elemTy } };
|
|
1108
1152
|
}
|
|
1109
1153
|
case "lambda": {
|
|
@@ -1369,6 +1413,12 @@ function resolveStmt(s, ctx) {
|
|
|
1369
1413
|
// Map destructuring: [key, value]
|
|
1370
1414
|
nameTypes.push(iterable.ty.key, iterable.ty.value);
|
|
1371
1415
|
}
|
|
1416
|
+
else if (s.names.length >= 2 && iterable.ty.kind === "array" && iterable.ty.elem.kind === "tuple") {
|
|
1417
|
+
// Array of tuples: bind each name to its slot type.
|
|
1418
|
+
const elems = iterable.ty.elem.elems;
|
|
1419
|
+
for (let i = 0; i < s.names.length; i++)
|
|
1420
|
+
nameTypes.push(elems[i] ?? { kind: "unknown" });
|
|
1421
|
+
}
|
|
1372
1422
|
else {
|
|
1373
1423
|
// General tuple destructuring: all unknown
|
|
1374
1424
|
for (const _ of s.names)
|
package/tools/dist/transform.js
CHANGED
|
@@ -37,6 +37,8 @@ function mapExpr(e, f) {
|
|
|
37
37
|
case "toNat": return { ...e, expr: r(e.expr) };
|
|
38
38
|
case "toReal": return { ...e, expr: r(e.expr) };
|
|
39
39
|
case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
|
|
40
|
+
case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
|
|
41
|
+
case "tupleProj": return { ...e, obj: r(e.obj) };
|
|
40
42
|
case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
|
|
41
43
|
case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
|
|
42
44
|
case "if": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
|
|
@@ -187,6 +189,12 @@ export const DAFNY_OPTIONS = {
|
|
|
187
189
|
let _opts = DAFNY_OPTIONS;
|
|
188
190
|
/** Type declarations — set once per module transform for discriminated union handling. */
|
|
189
191
|
let _typeDecls = [];
|
|
192
|
+
/** Names of functions that get a `Pure.` mirror (Lean). A bare reference to one
|
|
193
|
+
* in a higher-order position resolves to the monadic method, so it must be
|
|
194
|
+
* redirected to the pure mirror. Set once per module transform. */
|
|
195
|
+
let _pureDefNames = new Set();
|
|
196
|
+
/** Array methods that take a function argument. */
|
|
197
|
+
const HOF_METHODS = new Set(["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex", "reduce"]);
|
|
190
198
|
/** Prefix match-bound field names to avoid capturing user variables.
|
|
191
199
|
* When prefix is given (the scrutinee name), include it to avoid
|
|
192
200
|
* collisions in nested matches on different variables. `freshName` closes
|
|
@@ -329,7 +337,8 @@ function coerceCondToBool(cond, ty) {
|
|
|
329
337
|
return { kind: "binop", op: "≠", left: cond, right: { kind: "num", value: 0 } };
|
|
330
338
|
if (ty.kind === "string")
|
|
331
339
|
return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "length" }, right: { kind: "num", value: 0 } };
|
|
332
|
-
|
|
340
|
+
// Arrays, objects, maps, sets, tuples are always truthy in JS (even `[]`/`{}`).
|
|
341
|
+
if (["array", "user", "map", "set", "tuple"].includes(ty.kind))
|
|
333
342
|
return { kind: "bool", value: true };
|
|
334
343
|
return cond;
|
|
335
344
|
}
|
|
@@ -439,8 +448,9 @@ function lowerExpr(e, binds) {
|
|
|
439
448
|
// Number truthiness: !n → n == 0
|
|
440
449
|
if (e.op === "!" && (e.expr.ty.kind === "int" || e.expr.ty.kind === "nat"))
|
|
441
450
|
return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "num", value: 0 } };
|
|
442
|
-
//
|
|
443
|
-
|
|
451
|
+
// Object truthiness: arrays, objects, maps, and sets are always truthy in
|
|
452
|
+
// JS, so `!x` is always false.
|
|
453
|
+
if (e.op === "!" && ["array", "user", "map", "set", "tuple"].includes(e.expr.ty.kind)) {
|
|
444
454
|
lowerExpr(e.expr, binds); // preserve any lifted side effects; value is the constant false
|
|
445
455
|
return { kind: "bool", value: false };
|
|
446
456
|
}
|
|
@@ -605,6 +615,13 @@ function lowerExpr(e, binds) {
|
|
|
605
615
|
else: { kind: "var", name: "undefined" },
|
|
606
616
|
};
|
|
607
617
|
}
|
|
618
|
+
// || on a number → `if x != 0 then x else default` (0 the only falsy int).
|
|
619
|
+
if (e.op === "||" && (e.left.ty.kind === "int" || e.left.ty.kind === "nat")) {
|
|
620
|
+
const left = lowerExpr(e.left, binds);
|
|
621
|
+
const right = lowerExpr(e.right, binds);
|
|
622
|
+
const truthy = valueTruthyCond(left, e.left.ty);
|
|
623
|
+
return { kind: "if", cond: truthy, then: left, else: right };
|
|
624
|
+
}
|
|
608
625
|
// String concatenation: `+` with a string operand. Stringify int/nat
|
|
609
626
|
// operands (Dafny NatToString, Lean toString) and join with arrayConcat
|
|
610
627
|
// (rendered `+` in Dafny, `++` in Lean).
|
|
@@ -697,6 +714,10 @@ function lowerExpr(e, binds) {
|
|
|
697
714
|
return { kind: "field", obj: transformExpr(e.obj), field: e.field, datatypeField: isRecordType(e.obj.ty) };
|
|
698
715
|
case "index": {
|
|
699
716
|
const idx = transformExpr(e.idx);
|
|
717
|
+
if (e.obj.ty.kind === "tuple") {
|
|
718
|
+
// resolve guarantees a numeric-literal index here; project at that slot.
|
|
719
|
+
return { kind: "tupleProj", obj: transformExpr(e.obj), index: e.idx.value, arity: e.obj.ty.elems.length };
|
|
720
|
+
}
|
|
700
721
|
if (e.obj.ty.kind === "map") {
|
|
701
722
|
// Mirrors the .get() → .getDirect switch at line ~453: when resolve has
|
|
702
723
|
// narrowed the index type to non-optional (via `k in m` atoms in scope),
|
|
@@ -766,8 +787,15 @@ function lowerExpr(e, binds) {
|
|
|
766
787
|
if (e.fn.kind === "field") {
|
|
767
788
|
const recv = lowerExpr(e.fn.obj, binds);
|
|
768
789
|
let method = e.fn.field;
|
|
790
|
+
const isHOF = e.fn.obj.ty.kind === "array" && HOF_METHODS.has(method);
|
|
769
791
|
const args = e.args.map((a, i) => {
|
|
770
792
|
const lowered = lowerExpr(a, binds);
|
|
793
|
+
// Lean: a pure fn passed to a HOF by name resolves to the monadic
|
|
794
|
+
// method; redirect to its pure `Pure.` mirror.
|
|
795
|
+
if (_opts.backend === "lean" && isHOF &&
|
|
796
|
+
lowered.kind === "var" && _pureDefNames.has(lowered.name)) {
|
|
797
|
+
return { kind: "var", name: `Pure.${lowered.name}` };
|
|
798
|
+
}
|
|
771
799
|
// Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1).
|
|
772
800
|
const isArrIdxArg = e.fn.kind === "field" && e.fn.obj.ty.kind === "array" &&
|
|
773
801
|
((e.fn.field === "with" && i === 0) || ((e.fn.field === "includes" || e.fn.field === "indexOf") && i === 1));
|
|
@@ -918,6 +946,8 @@ function lowerExpr(e, binds) {
|
|
|
918
946
|
// Set with initial elements: new Set([a, b]) → {a, b}
|
|
919
947
|
if (e.ty.kind === "set")
|
|
920
948
|
return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
|
|
949
|
+
if (e.ty.kind === "tuple")
|
|
950
|
+
return { kind: "tupleLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
|
|
921
951
|
return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
|
|
922
952
|
case "lambda": {
|
|
923
953
|
// Pass the module typeDecls (not []), so type lookups inside the lambda
|
|
@@ -1397,86 +1427,73 @@ function transformStmts(stmts, typeDecls) {
|
|
|
1397
1427
|
const s = stmts[i];
|
|
1398
1428
|
// Transform for-of → for-in over range
|
|
1399
1429
|
if (s.kind === "forof") {
|
|
1400
|
-
const
|
|
1401
|
-
const
|
|
1402
|
-
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1405
|
-
|
|
1430
|
+
const headName = s.names[0];
|
|
1431
|
+
const headTy = s.nameTypes[0] ?? { kind: "unknown" };
|
|
1432
|
+
const iterExpr = transformExpr(s.iterable);
|
|
1433
|
+
// All for-of shapes lower to the same range loop `for idx in 0..seq.size`;
|
|
1434
|
+
// they differ only in (a) which indexable `seq` is iterated and (b) the
|
|
1435
|
+
// per-iteration `let`s that bind the loop names. Compute the shared loop-
|
|
1436
|
+
// variable naming once, then let each shape fill in `seq` + `perIter`.
|
|
1437
|
+
const count = _forofCounters.get(headName) ?? 0;
|
|
1438
|
+
_forofCounters.set(headName, count + 1);
|
|
1439
|
+
const suffix = count === 0 ? "" : `${count + 1}`;
|
|
1440
|
+
const idxName = freshName(`_${headName}_idx${suffix}`);
|
|
1441
|
+
const idxVar = { kind: "var", name: idxName };
|
|
1442
|
+
let seq;
|
|
1443
|
+
let perIter;
|
|
1444
|
+
if (s.iterable.ty.kind === "map") {
|
|
1445
|
+
// for (const k of map) / for (const [k, v] of map): materialize the keys
|
|
1446
|
+
// as a seq and index into it; a value name looks up via getDirect.
|
|
1406
1447
|
const keyTy = s.nameTypes[0] ?? s.iterable.ty.key ?? { kind: "unknown" };
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
kind: "forin", idx: idxName, bound: arrSize,
|
|
1422
|
-
invariants: [boundInv, ...s.invariants.map(transformExpr)],
|
|
1423
|
-
body: [letKey, ...bodyStmts],
|
|
1424
|
-
});
|
|
1425
|
-
i++;
|
|
1426
|
-
continue;
|
|
1448
|
+
const keysSeqName = freshName(`_${headName}_keys${suffix}`);
|
|
1449
|
+
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false,
|
|
1450
|
+
value: { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] } });
|
|
1451
|
+
seq = { kind: "var", name: keysSeqName };
|
|
1452
|
+
const letKey = { kind: "let", name: headName, type: keyTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
|
|
1453
|
+
if (s.names.length >= 2) {
|
|
1454
|
+
const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
|
|
1455
|
+
const letVal = { kind: "let", name: s.names[1], type: valueTy, mutable: false,
|
|
1456
|
+
value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: headName }], monadic: false } };
|
|
1457
|
+
perIter = [letKey, letVal];
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
perIter = [letKey];
|
|
1461
|
+
}
|
|
1427
1462
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
const
|
|
1432
|
-
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
const keysVar = { kind: "var", name: keysSeqName };
|
|
1440
|
-
const idxName = freshName(`_${keyName}_idx${suffix}`);
|
|
1441
|
-
const idx = { kind: "var", name: idxName };
|
|
1442
|
-
const arrSize = { kind: "field", obj: keysVar, field: "size" };
|
|
1443
|
-
const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
|
|
1444
|
-
const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
|
|
1445
|
-
const letVal = { kind: "let", name: valueName, type: valueTy, mutable: false,
|
|
1446
|
-
value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: keyName }], monadic: false } };
|
|
1447
|
-
const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
|
|
1448
|
-
result.push({
|
|
1449
|
-
kind: "forin", idx: idxName, bound: arrSize,
|
|
1450
|
-
invariants: [boundInv, ...s.invariants.map(transformExpr)],
|
|
1451
|
-
body: [letKey, letVal, ...bodyStmts],
|
|
1452
|
-
});
|
|
1453
|
-
i++;
|
|
1454
|
-
continue;
|
|
1463
|
+
else if (s.names.length >= 2 && s.iterable.ty.kind === "array" && s.iterable.ty.elem.kind === "tuple") {
|
|
1464
|
+
// for (const [a, b] of tupleArr): bind the element once, then destructure
|
|
1465
|
+
// it into each name via a tuple projection.
|
|
1466
|
+
const tupleTy = s.iterable.ty.elem;
|
|
1467
|
+
seq = iterExpr;
|
|
1468
|
+
const elemName = freshName(`_${headName}_elem${suffix}`);
|
|
1469
|
+
const letElem = { kind: "let", name: elemName, type: tupleTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
|
|
1470
|
+
perIter = [letElem, ...s.names.map((n, k) => ({
|
|
1471
|
+
kind: "let", name: n, type: tupleTy.elems[k] ?? { kind: "unknown" }, mutable: false,
|
|
1472
|
+
value: { kind: "tupleProj", obj: { kind: "var", name: elemName }, index: k, arity: tupleTy.elems.length },
|
|
1473
|
+
}))];
|
|
1455
1474
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1475
|
+
else {
|
|
1476
|
+
// Single element by index. Sets aren't indexable, so materialize first.
|
|
1477
|
+
if (s.iterable.ty.kind === "set") {
|
|
1478
|
+
const seqName = freshName(`_${headName}_seq${suffix}`);
|
|
1479
|
+
const elemTy = headTy.kind !== "unknown" ? headTy : { kind: "string" };
|
|
1480
|
+
result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false,
|
|
1481
|
+
value: { kind: "app", fn: "SetToSeq", args: [iterExpr] } });
|
|
1482
|
+
seq = { kind: "var", name: seqName };
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
seq = iterExpr;
|
|
1486
|
+
}
|
|
1487
|
+
perIter = [{ kind: "let", name: headName, type: headTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } }];
|
|
1463
1488
|
}
|
|
1464
|
-
const
|
|
1465
|
-
_forofCounters.set(varName, count + 1);
|
|
1466
|
-
const suffix = count === 0 ? "" : `${count + 1}`;
|
|
1467
|
-
const idxName = freshName(`_${varName}_idx${suffix}`);
|
|
1468
|
-
const idx = { kind: "var", name: idxName };
|
|
1469
|
-
const arrSize = { kind: "field", obj: iterExpr, field: "size" };
|
|
1470
|
-
const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
|
|
1471
|
-
const letElem = { kind: "let", name: varName, type: varTy, mutable: false, value: { kind: "index", arr: iterExpr, idx } };
|
|
1489
|
+
const arrSize = { kind: "field", obj: seq, field: "size" };
|
|
1472
1490
|
// Auto-add bound invariant: idx ≤ bound (always true for range loops)
|
|
1473
|
-
const boundInv = { kind: "binop", op: "≤", left:
|
|
1491
|
+
const boundInv = { kind: "binop", op: "≤", left: idxVar, right: arrSize };
|
|
1492
|
+
const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
|
|
1474
1493
|
result.push({
|
|
1475
|
-
kind: "forin",
|
|
1476
|
-
idx: idxName,
|
|
1477
|
-
bound: arrSize,
|
|
1494
|
+
kind: "forin", idx: idxName, bound: arrSize,
|
|
1478
1495
|
invariants: [boundInv, ...s.invariants.map(transformExpr)],
|
|
1479
|
-
body: [
|
|
1496
|
+
body: [...perIter, ...bodyStmts],
|
|
1480
1497
|
});
|
|
1481
1498
|
i++;
|
|
1482
1499
|
continue;
|
|
@@ -2186,6 +2203,7 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
|
|
|
2186
2203
|
_forofCounters.clear();
|
|
2187
2204
|
_liftCounter = 0;
|
|
2188
2205
|
_typeDecls = mod.typeDecls;
|
|
2206
|
+
_pureDefNames = new Set(mod.functions.filter(f => f.isPure).map(f => f.name));
|
|
2189
2207
|
const typeDecls = mod.typeDecls.map(transformTypeDecl);
|
|
2190
2208
|
// Module-level constants
|
|
2191
2209
|
const constDecls = (mod.constants ?? []).map(c => ({
|
package/tools/dist/typedir.js
CHANGED
|
@@ -8,3 +8,39 @@
|
|
|
8
8
|
export function isBigInt(ty) {
|
|
9
9
|
return (ty.kind === "int" || ty.kind === "nat") && !!ty.big;
|
|
10
10
|
}
|
|
11
|
+
/** Structural equality on Ty. Used to decide whether a tuple type is homogeneous
|
|
12
|
+
* (all elements equal ⇒ lower to `seq`) vs heterogeneous (⇒ keep as `tuple`). */
|
|
13
|
+
export function tyEqual(a, b) {
|
|
14
|
+
if (a.kind !== b.kind)
|
|
15
|
+
return false;
|
|
16
|
+
switch (a.kind) {
|
|
17
|
+
case "array": return tyEqual(a.elem, b.elem);
|
|
18
|
+
case "set": return tyEqual(a.elem, b.elem);
|
|
19
|
+
case "tuple": {
|
|
20
|
+
const bt = b;
|
|
21
|
+
return a.elems.length === bt.elems.length && a.elems.every((e, i) => tyEqual(e, bt.elems[i]));
|
|
22
|
+
}
|
|
23
|
+
case "map": {
|
|
24
|
+
const bm = b;
|
|
25
|
+
return tyEqual(a.key, bm.key) && tyEqual(a.value, bm.value);
|
|
26
|
+
}
|
|
27
|
+
case "optional": return tyEqual(a.inner, b.inner);
|
|
28
|
+
case "user": return a.name === b.name;
|
|
29
|
+
case "fn": {
|
|
30
|
+
const bf = b;
|
|
31
|
+
return a.params.length === bf.params.length
|
|
32
|
+
&& a.params.every((p, i) => tyEqual(p, bf.params[i]))
|
|
33
|
+
&& tyEqual(a.result, bf.result);
|
|
34
|
+
}
|
|
35
|
+
case "string": {
|
|
36
|
+
const bs = b;
|
|
37
|
+
return JSON.stringify(a.values ?? null) === JSON.stringify(bs.values ?? null);
|
|
38
|
+
}
|
|
39
|
+
case "int":
|
|
40
|
+
case "nat": return !!a.big === !!b.big;
|
|
41
|
+
case "bool":
|
|
42
|
+
case "real":
|
|
43
|
+
case "void":
|
|
44
|
+
case "unknown": return true; // no payload
|
|
45
|
+
}
|
|
46
|
+
}
|
package/tools/dist/types.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Single source of truth for type-related decisions.
|
|
5
5
|
* The transform phase imports this.
|
|
6
6
|
*/
|
|
7
|
+
import { tyEqual } from "./typedir.js";
|
|
7
8
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
8
9
|
/**
|
|
9
10
|
* Parses TS type strings via a real ts-morph parse — no regex cascade. Set
|
|
@@ -90,10 +91,14 @@ function tyFromTypeNode(tn) {
|
|
|
90
91
|
if (Node.isArrayTypeNode(tn))
|
|
91
92
|
return { kind: "array", elem: tyFromTypeNode(tn.getElementTypeNode()) };
|
|
92
93
|
if (Node.isTupleTypeNode(tn)) {
|
|
93
|
-
const elems = tn.getElements();
|
|
94
|
+
const elems = tn.getElements().map(tyFromTypeNode);
|
|
94
95
|
if (elems.length === 0)
|
|
95
96
|
return { kind: "array", elem: { kind: "unknown" } };
|
|
96
|
-
|
|
97
|
+
// Homogeneous tuple → seq; only heterogeneous tuples need the tuple
|
|
98
|
+
// representation.
|
|
99
|
+
if (elems.every(e => tyEqual(e, elems[0])))
|
|
100
|
+
return { kind: "array", elem: elems[0] };
|
|
101
|
+
return { kind: "tuple", elems };
|
|
97
102
|
}
|
|
98
103
|
if (Node.isFunctionTypeNode(tn)) {
|
|
99
104
|
const params = tn.getParameters().map(p => {
|
|
@@ -158,6 +163,7 @@ export function tyToCanonical(ty) {
|
|
|
158
163
|
case "void": return "void";
|
|
159
164
|
case "unknown": return "unknown";
|
|
160
165
|
case "array": return `seq<${tyToCanonical(ty.elem)}>`;
|
|
166
|
+
case "tuple": return `(${ty.elems.map(tyToCanonical).join(", ")})`;
|
|
161
167
|
case "map": return `map<${tyToCanonical(ty.key)}, ${tyToCanonical(ty.value)}>`;
|
|
162
168
|
case "set": return `set<${tyToCanonical(ty.elem)}>`;
|
|
163
169
|
case "optional": return `Option<${tyToCanonical(ty.inner)}>`;
|