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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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
  }
@@ -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
- throw new Error("Unsupported Dafny construct: 'continue' statement");
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));
@@ -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}`);
@@ -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),
@@ -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) };
@@ -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)
@@ -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
- // Array truthiness: every array is truthy in JS, so !xs is always false.
443
- if (e.op === "!" && e.expr.ty.kind === "array") {
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 varName = s.names[0];
1401
- const varTy = s.nameTypes[0] ?? { kind: "unknown" };
1402
- let iterExpr = transformExpr(s.iterable);
1403
- // Map key-only iteration: for (const k in record) iterate keys only
1404
- if (s.names.length === 1 && s.iterable.ty.kind === "map") {
1405
- const keyName = s.names[0];
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 count = _forofCounters.get(keyName) ?? 0;
1408
- _forofCounters.set(keyName, count + 1);
1409
- const suffix = count === 0 ? "" : `${count + 1}`;
1410
- const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1411
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1412
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
1413
- const keysVar = { kind: "var", name: keysSeqName };
1414
- const idxName = freshName(`_${keyName}_idx${suffix}`);
1415
- const idx = { kind: "var", name: idxName };
1416
- const arrSize = { kind: "field", obj: keysVar, field: "size" };
1417
- const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1418
- const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
1419
- const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
1420
- result.push({
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
- // Map iteration: for (const [k, v] of map) iterate keys, look up values
1429
- if (s.names.length >= 2 && s.iterable.ty.kind === "map") {
1430
- const keyName = s.names[0], valueName = s.names[1];
1431
- const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
1432
- const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
1433
- const count = _forofCounters.get(keyName) ?? 0;
1434
- _forofCounters.set(keyName, count + 1);
1435
- const suffix = count === 0 ? "" : `${count + 1}`;
1436
- const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1437
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1438
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
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
- // Sets aren't indexable — bind SetToSeq to a variable for iteration
1457
- if (s.iterable.ty.kind === "set") {
1458
- const seqName = freshName(`_${varName}_seq`);
1459
- const convExpr = { kind: "app", fn: "SetToSeq", args: [iterExpr] };
1460
- const elemTy = varTy.kind !== "unknown" ? varTy : { kind: "string" };
1461
- result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false, value: convExpr });
1462
- iterExpr = { kind: "var", name: seqName };
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 count = _forofCounters.get(varName) ?? 0;
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: idx, right: arrSize };
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: [letElem, ...bodyStmts],
1475
+ body: [...perIter, ...bodyStmts],
1480
1476
  });
1481
1477
  i++;
1482
1478
  continue;
@@ -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
+ }
@@ -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
- return { kind: "array", elem: tyFromTypeNode(elems[0]) };
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)}>`;