lemmascript 0.5.15 → 0.5.16
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 +1 -0
- package/package.json +1 -1
- package/tools/dist/dafny-commands.js +4 -4
- package/tools/dist/dafny-emit.js +6 -1
- package/tools/dist/ir.js +2 -0
- package/tools/dist/lean-emit.js +19 -0
- package/tools/dist/lsc.js +13 -3
- package/tools/dist/narrow.js +77 -2
- package/tools/dist/peephole.js +4 -0
- package/tools/dist/resolve.js +39 -2
- package/tools/dist/transform.js +71 -75
- 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.
|
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": {
|
|
@@ -694,7 +697,7 @@ function emitStmt(s, indent) {
|
|
|
694
697
|
case "break":
|
|
695
698
|
return `${pad}break;`;
|
|
696
699
|
case "continue":
|
|
697
|
-
|
|
700
|
+
return `${pad}continue;`;
|
|
698
701
|
case "if": {
|
|
699
702
|
let out = `${pad}if ${emitExpr(s.cond)} {\n${emitStmts(s.then, indent + 1)}\n${pad}}`;
|
|
700
703
|
if (s.else.length > 0) {
|
|
@@ -1303,6 +1306,8 @@ function resolveTy(ty) {
|
|
|
1303
1306
|
return { kind: "optional", inner: resolveTy(ty.inner) };
|
|
1304
1307
|
if (ty.kind === "array")
|
|
1305
1308
|
return { kind: "array", elem: resolveTy(ty.elem) };
|
|
1309
|
+
if (ty.kind === "tuple")
|
|
1310
|
+
return { kind: "tuple", elems: ty.elems.map(resolveTy) };
|
|
1306
1311
|
if (ty.kind === "map")
|
|
1307
1312
|
return { kind: "map", key: resolveTy(ty.key), value: resolveTy(ty.value) };
|
|
1308
1313
|
if (ty.kind === "set")
|
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;
|
|
@@ -318,6 +328,15 @@ function emitExpr(e, parentPrec) {
|
|
|
318
328
|
if (e.elems.length === 0)
|
|
319
329
|
return `#[]`;
|
|
320
330
|
return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
|
|
331
|
+
case "tupleLiteral":
|
|
332
|
+
return `(${e.elems.map(el => emitExpr(el)).join(", ")})`;
|
|
333
|
+
case "tupleProj": {
|
|
334
|
+
// Right-nested Prod projection: element i is `.2`×i then `.1`, except the
|
|
335
|
+
// last (i = arity-1) which is `.2`×i with no trailing `.1`.
|
|
336
|
+
const obj = emitExpr(e.obj);
|
|
337
|
+
const twos = ".2".repeat(e.index);
|
|
338
|
+
return e.index === e.arity - 1 ? `${obj}${twos}` : `${obj}${twos}.1`;
|
|
339
|
+
}
|
|
321
340
|
case "emptyMap": return `Std.HashMap.empty`;
|
|
322
341
|
case "emptySet": return `Std.HashSet.empty`;
|
|
323
342
|
case "default": return `(default : ${tyToLean(e.type)})`;
|
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;
|
|
@@ -308,6 +308,55 @@ function ruleEarlyReturnOrChain(s, rest) {
|
|
|
308
308
|
}
|
|
309
309
|
return inner[0];
|
|
310
310
|
}
|
|
311
|
+
/** Rule: `if (opt?.chain !== lit) terminate; rest` where `opt` is optional.
|
|
312
|
+
* `opt?.chain` is `undefined` when `opt` is None, and `undefined !== lit` is
|
|
313
|
+
* true, so the None case takes the terminating branch — falling through to
|
|
314
|
+
* `rest` proves `opt` is Some. Rewrite to
|
|
315
|
+
* someMatch opt { Some(v) => [if (v.chain !== lit) terminate; rest]; None => terminate }
|
|
316
|
+
* narrowing `opt` to `v` across `rest` (transform substitutes the scrutinee) and
|
|
317
|
+
* handing the now-non-optional inner guard to the ordinary rules (e.g.
|
|
318
|
+
* discriminant narrowing). Bound-optional companion to ruleEarlyReturnConsume,
|
|
319
|
+
* which handles only a bare presence check (`opt !== undefined`). Restricted to
|
|
320
|
+
* `!==` so the None case is guaranteed to terminate. */
|
|
321
|
+
function ruleEarlyReturnOptChainCompare(s, rest) {
|
|
322
|
+
if (s.kind !== "if")
|
|
323
|
+
return null;
|
|
324
|
+
if (rest.length === 0)
|
|
325
|
+
return null;
|
|
326
|
+
if (s.else.length !== 0 || !isTerminating(s.then))
|
|
327
|
+
return null;
|
|
328
|
+
const c = s.cond;
|
|
329
|
+
if (c.kind !== "binop" || c.op !== "!==")
|
|
330
|
+
return null;
|
|
331
|
+
const oc = c.left.kind === "optChain" ? c.left : c.right.kind === "optChain" ? c.right : null;
|
|
332
|
+
if (!oc || oc.kind !== "optChain" || oc.obj.ty.kind !== "optional")
|
|
333
|
+
return null;
|
|
334
|
+
const lit = c.left === oc ? c.right : c.left;
|
|
335
|
+
const innerTy = oc.obj.ty.inner;
|
|
336
|
+
const hint = binderHintFor(oc.obj);
|
|
337
|
+
if (hint === null)
|
|
338
|
+
return null;
|
|
339
|
+
const binder = freshName(hint);
|
|
340
|
+
const binderVar = { kind: "var", name: binder, ty: innerTy };
|
|
341
|
+
const unwrapped = applyChain(binderVar, oc.chain);
|
|
342
|
+
// applyChain rebuilds the field without the `isDiscriminant` flag resolve sets
|
|
343
|
+
// on a direct `x.disc`; restore it when the unwrapped access is the binder
|
|
344
|
+
// union's discriminant, so the inner guard feeds discriminant narrowing.
|
|
345
|
+
if (unwrapped.kind === "field" && unwrapped.obj.ty.kind === "user") {
|
|
346
|
+
const base = unwrapped.obj.ty.name.replace(/<.*/, "");
|
|
347
|
+
const decl = _typeDecls.find(d => d.name === base);
|
|
348
|
+
if (decl?.kind === "discriminated-union" && decl.discriminant === unwrapped.field) {
|
|
349
|
+
unwrapped.isDiscriminant = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const innerGuard = { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } };
|
|
353
|
+
// Keep `rest` as trailing statements (not an else branch) — `s.then` terminates,
|
|
354
|
+
// so `if (g) terminate; rest` ≡ `if (g) terminate else rest`, and the trailing
|
|
355
|
+
// form lets the ordinary early-exit rules (e.g. discriminant narrowing) fire on
|
|
356
|
+
// the now-non-optional inner guard when `chain` is a union discriminant.
|
|
357
|
+
const someBody = [{ kind: "if", cond: innerGuard, then: s.then, else: [] }, ...rest];
|
|
358
|
+
return { kind: "someMatch", scrutinee: oc.obj, binder, binderTy: innerTy, someBody, noneBody: s.then };
|
|
359
|
+
}
|
|
311
360
|
/** Rule (expression): `e !== undefined ? a : b`. */
|
|
312
361
|
function ruleConditionalOptionalSimple(e) {
|
|
313
362
|
if (e.kind !== "conditional")
|
|
@@ -491,6 +540,32 @@ function ruleOptChainIndex(e) {
|
|
|
491
540
|
const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
|
|
492
541
|
return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
|
|
493
542
|
}
|
|
543
|
+
/** Rule (statement): reconcile a `const e = arr[i]` whose binding is optional
|
|
544
|
+
* (`e: T | undefined`) but whose array-index initializer is total (`T`). Model
|
|
545
|
+
* the index as its JS semantics — `e := (0 <= i && i < arr.length) ? arr[i] :
|
|
546
|
+
* undefined` — so `e` is a real `Option<T>` and a later `e?.f` someMatch is
|
|
547
|
+
* well-typed; an in-bounds proof makes the None branch dead, so safely-indexed
|
|
548
|
+
* code verifies as if total. Bound-form sibling of ruleOptChainIndex /
|
|
549
|
+
* ruleNullishIndex. Fires purely on the optional-binding/total-index shape (the
|
|
550
|
+
* usual source is `noUncheckedIndexedAccess`, but the flag itself is never
|
|
551
|
+
* checked). Skipped when the element type is already optional: no mismatch. */
|
|
552
|
+
function ruleOptionalIndexBinding(s) {
|
|
553
|
+
if (s.kind !== "let")
|
|
554
|
+
return null;
|
|
555
|
+
if (s.ty.kind !== "optional")
|
|
556
|
+
return null;
|
|
557
|
+
const init = s.init;
|
|
558
|
+
if (init.kind !== "index")
|
|
559
|
+
return null;
|
|
560
|
+
if (init.obj.ty.kind !== "array")
|
|
561
|
+
return null;
|
|
562
|
+
if (init.ty.kind === "optional")
|
|
563
|
+
return null; // array-of-optionals: not a flag artifact
|
|
564
|
+
const cond = arrayBoundsCond(init.obj, init.idx);
|
|
565
|
+
const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
|
|
566
|
+
const guarded = { kind: "conditional", cond, then: init, else: undef, ty: s.ty };
|
|
567
|
+
return { ...s, init: guarded };
|
|
568
|
+
}
|
|
494
569
|
/** Rule (expression): `obj?.<chain>` — single-eval optional chain.
|
|
495
570
|
* → `someMatch obj { Some(_oc{N}_val) => apply(chain, _oc{N}_val), None => undefined }`.
|
|
496
571
|
* 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")
|
|
@@ -926,6 +930,16 @@ function resolveExpr(e, ctx) {
|
|
|
926
930
|
if (obj.ty.kind === "array") {
|
|
927
931
|
idxTy = obj.ty.elem;
|
|
928
932
|
}
|
|
933
|
+
else if (obj.ty.kind === "tuple") {
|
|
934
|
+
// Tuple projection: the position must be a literal — a tuple has a
|
|
935
|
+
// distinct type per slot, so a runtime index has no single result type
|
|
936
|
+
// and no backend projection (`t.0`/`t.1`) exists for it.
|
|
937
|
+
const elems = obj.ty.elems;
|
|
938
|
+
if (idx.kind !== "num" || !Number.isInteger(idx.value) || idx.value < 0 || idx.value >= elems.length) {
|
|
939
|
+
throw new Error(`tuple index must be an integer literal in [0, ${elems.length}); got ${tyToCanonical(idx.ty)} index into ${tyToCanonical(obj.ty)}`);
|
|
940
|
+
}
|
|
941
|
+
idxTy = elems[idx.value];
|
|
942
|
+
}
|
|
929
943
|
else if (obj.ty.kind === "map") {
|
|
930
944
|
const objPath = asTExprAccessPath(obj);
|
|
931
945
|
const idxPath = asTExprAccessPath(idx);
|
|
@@ -1090,6 +1104,17 @@ function resolveExpr(e, ctx) {
|
|
|
1090
1104
|
return { kind: "exists", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
|
|
1091
1105
|
}
|
|
1092
1106
|
case "arrayLiteral": {
|
|
1107
|
+
// Expected-tuple context: type each element against its own slot type and
|
|
1108
|
+
// produce a tuple literal (`[1, "a"]: [number, string]`).
|
|
1109
|
+
if (ctx.returnTy.kind === "tuple") {
|
|
1110
|
+
const slots = ctx.returnTy.elems;
|
|
1111
|
+
const elems = e.elems.map((el, i) => {
|
|
1112
|
+
const slot = slots[i];
|
|
1113
|
+
const r = resolveExpr(el, slot ? { ...ctx, returnTy: slot } : ctx);
|
|
1114
|
+
return slot ? coerceStr(r, slot) : r;
|
|
1115
|
+
});
|
|
1116
|
+
return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
|
|
1117
|
+
}
|
|
1093
1118
|
// Thread the expected element type into each element, so a record/union
|
|
1094
1119
|
// literal in an array resolves to its named datatype rather than an
|
|
1095
1120
|
// anonymous tuple (mirrors return-position and call-argument records, which
|
|
@@ -1104,6 +1129,12 @@ function resolveExpr(e, ctx) {
|
|
|
1104
1129
|
return expectedElem ? coerceStr(r, expectedElem) : r;
|
|
1105
1130
|
});
|
|
1106
1131
|
const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
|
|
1132
|
+
// No expected collection type: infer array vs tuple from the elements —
|
|
1133
|
+
// heterogeneous element types can't be a homogeneous seq, so they form a
|
|
1134
|
+
// tuple (`[1, "a"]` → `(int, string)`); otherwise a seq.
|
|
1135
|
+
if (!expectedElem && elems.length >= 2 && !elems.every(x => tyEqual(x.ty, elemTy))) {
|
|
1136
|
+
return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
|
|
1137
|
+
}
|
|
1107
1138
|
return { kind: "arrayLiteral", elems, ty: { kind: "array", elem: elemTy } };
|
|
1108
1139
|
}
|
|
1109
1140
|
case "lambda": {
|
|
@@ -1369,6 +1400,12 @@ function resolveStmt(s, ctx) {
|
|
|
1369
1400
|
// Map destructuring: [key, value]
|
|
1370
1401
|
nameTypes.push(iterable.ty.key, iterable.ty.value);
|
|
1371
1402
|
}
|
|
1403
|
+
else if (s.names.length >= 2 && iterable.ty.kind === "array" && iterable.ty.elem.kind === "tuple") {
|
|
1404
|
+
// Array of tuples: bind each name to its slot type.
|
|
1405
|
+
const elems = iterable.ty.elem.elems;
|
|
1406
|
+
for (let i = 0; i < s.names.length; i++)
|
|
1407
|
+
nameTypes.push(elems[i] ?? { kind: "unknown" });
|
|
1408
|
+
}
|
|
1372
1409
|
else {
|
|
1373
1410
|
// General tuple destructuring: all unknown
|
|
1374
1411
|
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) };
|
|
@@ -439,8 +441,9 @@ function lowerExpr(e, binds) {
|
|
|
439
441
|
// Number truthiness: !n → n == 0
|
|
440
442
|
if (e.op === "!" && (e.expr.ty.kind === "int" || e.expr.ty.kind === "nat"))
|
|
441
443
|
return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "num", value: 0 } };
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
+
// Object truthiness: arrays, objects, maps, and sets are always truthy in
|
|
445
|
+
// JS, so `!x` is always false.
|
|
446
|
+
if (e.op === "!" && ["array", "user", "map", "set", "tuple"].includes(e.expr.ty.kind)) {
|
|
444
447
|
lowerExpr(e.expr, binds); // preserve any lifted side effects; value is the constant false
|
|
445
448
|
return { kind: "bool", value: false };
|
|
446
449
|
}
|
|
@@ -697,6 +700,10 @@ function lowerExpr(e, binds) {
|
|
|
697
700
|
return { kind: "field", obj: transformExpr(e.obj), field: e.field, datatypeField: isRecordType(e.obj.ty) };
|
|
698
701
|
case "index": {
|
|
699
702
|
const idx = transformExpr(e.idx);
|
|
703
|
+
if (e.obj.ty.kind === "tuple") {
|
|
704
|
+
// resolve guarantees a numeric-literal index here; project at that slot.
|
|
705
|
+
return { kind: "tupleProj", obj: transformExpr(e.obj), index: e.idx.value, arity: e.obj.ty.elems.length };
|
|
706
|
+
}
|
|
700
707
|
if (e.obj.ty.kind === "map") {
|
|
701
708
|
// Mirrors the .get() → .getDirect switch at line ~453: when resolve has
|
|
702
709
|
// narrowed the index type to non-optional (via `k in m` atoms in scope),
|
|
@@ -918,6 +925,8 @@ function lowerExpr(e, binds) {
|
|
|
918
925
|
// Set with initial elements: new Set([a, b]) → {a, b}
|
|
919
926
|
if (e.ty.kind === "set")
|
|
920
927
|
return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
|
|
928
|
+
if (e.ty.kind === "tuple")
|
|
929
|
+
return { kind: "tupleLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
|
|
921
930
|
return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
|
|
922
931
|
case "lambda": {
|
|
923
932
|
// Pass the module typeDecls (not []), so type lookups inside the lambda
|
|
@@ -1397,86 +1406,73 @@ function transformStmts(stmts, typeDecls) {
|
|
|
1397
1406
|
const s = stmts[i];
|
|
1398
1407
|
// Transform for-of → for-in over range
|
|
1399
1408
|
if (s.kind === "forof") {
|
|
1400
|
-
const
|
|
1401
|
-
const
|
|
1402
|
-
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1405
|
-
|
|
1409
|
+
const headName = s.names[0];
|
|
1410
|
+
const headTy = s.nameTypes[0] ?? { kind: "unknown" };
|
|
1411
|
+
const iterExpr = transformExpr(s.iterable);
|
|
1412
|
+
// All for-of shapes lower to the same range loop `for idx in 0..seq.size`;
|
|
1413
|
+
// they differ only in (a) which indexable `seq` is iterated and (b) the
|
|
1414
|
+
// per-iteration `let`s that bind the loop names. Compute the shared loop-
|
|
1415
|
+
// variable naming once, then let each shape fill in `seq` + `perIter`.
|
|
1416
|
+
const count = _forofCounters.get(headName) ?? 0;
|
|
1417
|
+
_forofCounters.set(headName, count + 1);
|
|
1418
|
+
const suffix = count === 0 ? "" : `${count + 1}`;
|
|
1419
|
+
const idxName = freshName(`_${headName}_idx${suffix}`);
|
|
1420
|
+
const idxVar = { kind: "var", name: idxName };
|
|
1421
|
+
let seq;
|
|
1422
|
+
let perIter;
|
|
1423
|
+
if (s.iterable.ty.kind === "map") {
|
|
1424
|
+
// for (const k of map) / for (const [k, v] of map): materialize the keys
|
|
1425
|
+
// as a seq and index into it; a value name looks up via getDirect.
|
|
1406
1426
|
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;
|
|
1427
|
+
const keysSeqName = freshName(`_${headName}_keys${suffix}`);
|
|
1428
|
+
result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false,
|
|
1429
|
+
value: { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] } });
|
|
1430
|
+
seq = { kind: "var", name: keysSeqName };
|
|
1431
|
+
const letKey = { kind: "let", name: headName, type: keyTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
|
|
1432
|
+
if (s.names.length >= 2) {
|
|
1433
|
+
const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
|
|
1434
|
+
const letVal = { kind: "let", name: s.names[1], type: valueTy, mutable: false,
|
|
1435
|
+
value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: headName }], monadic: false } };
|
|
1436
|
+
perIter = [letKey, letVal];
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
perIter = [letKey];
|
|
1440
|
+
}
|
|
1427
1441
|
}
|
|
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;
|
|
1442
|
+
else if (s.names.length >= 2 && s.iterable.ty.kind === "array" && s.iterable.ty.elem.kind === "tuple") {
|
|
1443
|
+
// for (const [a, b] of tupleArr): bind the element once, then destructure
|
|
1444
|
+
// it into each name via a tuple projection.
|
|
1445
|
+
const tupleTy = s.iterable.ty.elem;
|
|
1446
|
+
seq = iterExpr;
|
|
1447
|
+
const elemName = freshName(`_${headName}_elem${suffix}`);
|
|
1448
|
+
const letElem = { kind: "let", name: elemName, type: tupleTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
|
|
1449
|
+
perIter = [letElem, ...s.names.map((n, k) => ({
|
|
1450
|
+
kind: "let", name: n, type: tupleTy.elems[k] ?? { kind: "unknown" }, mutable: false,
|
|
1451
|
+
value: { kind: "tupleProj", obj: { kind: "var", name: elemName }, index: k, arity: tupleTy.elems.length },
|
|
1452
|
+
}))];
|
|
1455
1453
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1454
|
+
else {
|
|
1455
|
+
// Single element by index. Sets aren't indexable, so materialize first.
|
|
1456
|
+
if (s.iterable.ty.kind === "set") {
|
|
1457
|
+
const seqName = freshName(`_${headName}_seq${suffix}`);
|
|
1458
|
+
const elemTy = headTy.kind !== "unknown" ? headTy : { kind: "string" };
|
|
1459
|
+
result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false,
|
|
1460
|
+
value: { kind: "app", fn: "SetToSeq", args: [iterExpr] } });
|
|
1461
|
+
seq = { kind: "var", name: seqName };
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
seq = iterExpr;
|
|
1465
|
+
}
|
|
1466
|
+
perIter = [{ kind: "let", name: headName, type: headTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } }];
|
|
1463
1467
|
}
|
|
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 } };
|
|
1468
|
+
const arrSize = { kind: "field", obj: seq, field: "size" };
|
|
1472
1469
|
// Auto-add bound invariant: idx ≤ bound (always true for range loops)
|
|
1473
|
-
const boundInv = { kind: "binop", op: "≤", left:
|
|
1470
|
+
const boundInv = { kind: "binop", op: "≤", left: idxVar, right: arrSize };
|
|
1471
|
+
const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
|
|
1474
1472
|
result.push({
|
|
1475
|
-
kind: "forin",
|
|
1476
|
-
idx: idxName,
|
|
1477
|
-
bound: arrSize,
|
|
1473
|
+
kind: "forin", idx: idxName, bound: arrSize,
|
|
1478
1474
|
invariants: [boundInv, ...s.invariants.map(transformExpr)],
|
|
1479
|
-
body: [
|
|
1475
|
+
body: [...perIter, ...bodyStmts],
|
|
1480
1476
|
});
|
|
1481
1477
|
i++;
|
|
1482
1478
|
continue;
|
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)}>`;
|