lemmascript 0.5.12 → 0.5.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -60,11 +60,14 @@ export function dafnyVerify(dfyPath, dir, timeLimit, extraFlags) {
60
60
  execFileSync("dafny", args, { cwd: dir, stdio: "inherit" });
61
61
  return true;
62
62
  }
63
- catch {
63
+ catch (e) {
64
+ if (e?.code === "ENOENT") {
65
+ console.error("ERROR: `dafny` not found on PATH — verification never ran. Install Dafny 4.x: https://dafny.org/");
66
+ }
64
67
  return false;
65
68
  }
66
69
  }
67
- export function dafnyRegen(genPath, dfyPath, basePath, text, dir) {
70
+ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags) {
68
71
  // 1. Read old gen before overwriting (needed for base seeding)
69
72
  const oldGen = existsSync(genPath) ? readFileSync(genPath, "utf-8") : "";
70
73
  // 2. Always write new gen so user can inspect latest output
@@ -73,7 +76,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir) {
73
76
  if (!existsSync(dfyPath)) {
74
77
  writeFileSync(dfyPath, text);
75
78
  console.log(`Created: ${path.basename(dfyPath)}`);
76
- if (!dafnyVerify(dfyPath, dir)) {
79
+ if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
77
80
  console.error(`FAILED: ${path.basename(dfyPath)} verification failed on first run.`);
78
81
  process.exit(1);
79
82
  }
@@ -108,7 +111,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir) {
108
111
  process.exit(1);
109
112
  }
110
113
  // 7. Verify
111
- if (!dafnyVerify(dfyPath, dir)) {
114
+ if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
112
115
  console.error(`FAILED: ${path.basename(dfyPath)} verification failed.`);
113
116
  process.exit(1);
114
117
  }
@@ -1,6 +1,31 @@
1
1
  /**
2
2
  * Dafny emitter — IR → Dafny text.
3
3
  */
4
+ import { usesName, usesNameInDecl } from "./ir.js";
5
+ import { freshName } from "./names.js";
6
+ import { renameFreeVar } from "./transform.js";
7
+ /** Fresh binder for a comprehension wrapping the given subexpressions: `base`
8
+ * verbatim unless one of them references it, then primed until free. A *local*
9
+ * check — a same-named name elsewhere in the module keeps the plain binder. */
10
+ function freshBinder(base, ...wrapped) {
11
+ return freshName(base, name => wrapped.some(w => usesName(w, name)));
12
+ }
13
+ /** Binder + body for lowering a single-return lambda to a comprehension whose
14
+ * receiver is emitted inside the binder's scope. TS scoping keeps the receiver
15
+ * outside the lambda, so a lambda param sharing a name with anything free in
16
+ * the receiver would capture it — e.g. `mk(n).some(n => …)` naively emitting
17
+ * `exists n :: n in mk(n) && …`. Alpha-rename the param out of the way (and
18
+ * its free uses in the body); the zero-param default must also dodge free
19
+ * names in the body. */
20
+ function comprehensionBinder(lam, value, receiver) {
21
+ const rawName = lam.params[0]?.name;
22
+ if (rawName === undefined)
23
+ return { binder: escapeName(freshBinder("x", receiver, value)), body: value };
24
+ if (!usesName(receiver, rawName))
25
+ return { binder: escapeName(rawName), body: value };
26
+ const fresh = freshBinder(rawName, receiver, value);
27
+ return { binder: escapeName(fresh), body: renameFreeVar(value, rawName, fresh) };
28
+ }
4
29
  // ── Ty → Dafny type string ─────────────────────────────────
5
30
  function tyToDafny(ty) {
6
31
  switch (ty.kind) {
@@ -59,12 +84,19 @@ function escapeName(name) {
59
84
  // as the current method's out-parameter name.
60
85
  if (name === "\\result")
61
86
  return _resultName;
87
+ let out = name;
62
88
  if (DAFNY_KEYWORDS.has(name))
63
- return `${name}_`;
89
+ out = `${name}_`;
64
90
  // Dafny doesn't allow identifiers starting with _
65
- if (name.startsWith("_"))
66
- return `i${name}`;
67
- return name;
91
+ else if (name.startsWith("_"))
92
+ out = `i${name}`;
93
+ else
94
+ return name;
95
+ // Mangling must stay injective: the mangled form may itself be a name the
96
+ // user wrote (`match` → `match_` beside a real `match_`, `_x` → `i_x` beside
97
+ // a real `i_x`), silently merging two distinct variables. `freshName` primes
98
+ // it clear of user-name space (a prime can't occur in a TS identifier).
99
+ return freshName(out);
68
100
  }
69
101
  /** Format a typed parameter list for Dafny: "x: int, y: seq<int>" */
70
102
  function paramList(params) {
@@ -73,17 +105,18 @@ function paramList(params) {
73
105
  /** Format a method signature header, omitting `returns` for void methods.
74
106
  * Dafny's definite-assignment rule rejects unassigned out-parameters, so a
75
107
  * `returns (res: ())` on a void method fails verification. */
76
- function methodHeader(prefix, params, returnType) {
108
+ function methodHeader(prefix, params, returnType, scope) {
77
109
  const sig = `${prefix}(${paramList(params)})`;
78
110
  if (returnType.kind === "void")
79
111
  return sig;
80
- // The out-parameter is `res` by default, but a parameter named `res` (e.g. an
81
- // Express handler's `(req, res)`) would collide; pick a fresh name and record
82
- // it so `\result` references in the ensures/body resolve to the same name.
83
- const taken = new Set(params.map(p => escapeName(p.name)));
84
- let resName = "res";
85
- while (taken.has(resName))
86
- resName += "_";
112
+ // The out-parameter is `res` by default, but a param (an Express handler's
113
+ // `(req, res)`), body local, or callee named `res` would shadow it. Check only
114
+ // *this method's own* signature and body `res` is common module-wide (fields,
115
+ // unrelated params), so a module-wide check would prime spuriously. The primed
116
+ // name is recorded so `\result` references resolve to it.
117
+ const taken = (n) => params.some(p => escapeName(p.name) === n) ||
118
+ (scope !== undefined && usesNameInDecl(scope.requires, scope.ensures, scope.body, n));
119
+ const resName = freshName("res", taken);
87
120
  _resultName = resName;
88
121
  return `${sig} returns (${resName}: ${tyToDafny(returnType)})`;
89
122
  }
@@ -222,8 +255,8 @@ function emitExpr(e) {
222
255
  const ret = lam.body[0];
223
256
  if (ret.kind !== "return")
224
257
  throw new Error("unreachable");
225
- const p = escapeName(lam.params[0]?.name ?? "x");
226
- const body = emitExpr(ret.value);
258
+ const { binder: p, body: v } = comprehensionBinder(lam, ret.value, e.obj);
259
+ const body = emitExpr(v);
227
260
  return `(exists ${p} :: ${p} in ${obj} && ${body})`;
228
261
  }
229
262
  }
@@ -300,8 +333,13 @@ function emitExpr(e) {
300
333
  return `${obj}[${args[0]} := ${args[1]}]`;
301
334
  if (e.method === "has")
302
335
  return `(${args[0]} in ${obj})`;
303
- if (e.method === "delete")
304
- return `(map k | k in ${obj} && k != ${args[0]} :: ${obj}[k])`;
336
+ if (e.method === "delete") {
337
+ // Minted comprehension binder: freshen so a user variable `k` in the
338
+ // receiver or key isn't captured (`k != k` would delete nothing).
339
+ // Local check — only this comprehension's own operands can collide.
340
+ const k = freshBinder("k", e.obj, e.args[0]);
341
+ return `(map ${k} | ${k} in ${obj} && ${k} != ${args[0]} :: ${obj}[${k}])`;
342
+ }
305
343
  }
306
344
  // Set methods
307
345
  if (ty === "set") {
@@ -321,8 +359,8 @@ function emitExpr(e) {
321
359
  const ret = lam.body[0];
322
360
  if (ret.kind !== "return")
323
361
  throw new Error("unreachable");
324
- const p = escapeName(lam.params[0]?.name ?? "x");
325
- const body = emitExpr(ret.value);
362
+ const { binder: p, body: v } = comprehensionBinder(lam, ret.value, e.obj);
363
+ const body = emitExpr(v);
326
364
  return `(set ${p} | ${p} in ${obj} && ${body})`;
327
365
  }
328
366
  }
@@ -736,7 +774,7 @@ function emitDecl(d) {
736
774
  }
737
775
  case "method": {
738
776
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
739
- const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType)];
777
+ const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType, d)];
740
778
  for (const r of d.requires)
741
779
  lines.push(` requires ${emitExpr(r)}`);
742
780
  for (const e of d.ensures)
@@ -756,7 +794,7 @@ function emitDecl(d) {
756
794
  if (d.fields.length > 0 && d.methods.length > 0)
757
795
  lines.push("");
758
796
  for (const m of d.methods) {
759
- lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
797
+ lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType, m)}`);
760
798
  for (const r of m.requires)
761
799
  lines.push(` requires ${emitExpr(r)}`);
762
800
  for (const e of m.ensures)
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
8
8
  import { initTypeParser } from "./types.js";
9
+ import { setUserNames, freshName } from "./names.js";
9
10
  // ── Expression extraction ────────────────────────────────────
10
11
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
11
12
  let _havocKey = null;
@@ -1215,7 +1216,7 @@ function extractStmts(stmts) {
1215
1216
  let initExpr = extractExpr(initializer);
1216
1217
  let initVar = initExpr;
1217
1218
  if (initExpr.kind !== "var") {
1218
- const tempName = `_destr${_destrCounter++}`;
1219
+ const tempName = freshName(`_destr${_destrCounter++}`);
1219
1220
  const initTs = _eraseGenerics(typeToString(initializer.getType()));
1220
1221
  result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
1221
1222
  initVar = { kind: "var", name: tempName };
@@ -1259,7 +1260,7 @@ function extractStmts(stmts) {
1259
1260
  let initExpr = extractExpr(initializer);
1260
1261
  let initVar = initExpr;
1261
1262
  if (initExpr.kind !== "var") {
1262
- const tempName = `_destr${_destrCounter++}`;
1263
+ const tempName = freshName(`_destr${_destrCounter++}`);
1263
1264
  const initTs = _eraseGenerics(typeToString(initializer.getType()));
1264
1265
  result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
1265
1266
  initVar = { kind: "var", name: tempName };
@@ -1859,6 +1860,9 @@ function extractFunctionInner(fn, parentAnnotations) {
1859
1860
  }
1860
1861
  // ── Module extraction ────────────────────────────────────────
1861
1862
  export function extractModule(sourceFile) {
1863
+ // Seed the fresh-name check (names.ts) before anything mints: every
1864
+ // Identifier token in the module, a deliberate over-approximation.
1865
+ setUserNames(new Set(sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).map(i => i.getText())));
1862
1866
  const typeDecls = [];
1863
1867
  // Cross-file calls are auto-externed: ts-morph resolves the call's symbol;
1864
1868
  // if it's defined in a different source file we treat the symbol as opaque
package/tools/dist/ir.js CHANGED
@@ -60,3 +60,23 @@ export function anyExprInStmt(s, pred) {
60
60
  export function anyExprInStmts(stmts, pred) {
61
61
  return stmts.some(s => anyExprInStmt(s, pred));
62
62
  }
63
+ // A name is "used" — such that a synthesized binder of the same name would
64
+ // capture or shadow it — iff it appears as a variable reference or a called
65
+ // function. These drive the *local* freshness checks for user-facing binders
66
+ // (the result out-parameter, comprehension binders): a binder is checked only
67
+ // against the expressions/scope it actually wraps, not the whole module.
68
+ const _refsName = (name) => e => (e.kind === "var" && e.name === name) ||
69
+ (e.kind === "app" && e.fn === name) ||
70
+ (e.kind === "constructor" && e.name === name) ||
71
+ (e.kind === "match" && typeof e.scrutinee === "string" && e.scrutinee === name);
72
+ export function usesName(e, name) {
73
+ return anyExpr(e, _refsName(name));
74
+ }
75
+ export function usesNameInStmts(stmts, name) {
76
+ return anyExprInStmts(stmts, _refsName(name));
77
+ }
78
+ /** Does a declaration's spec (requires/ensures) or body reference `name`? The
79
+ * scope a method's out-parameter binder must dodge, shared by both emitters. */
80
+ export function usesNameInDecl(requires, ensures, body, name) {
81
+ return requires.some(e => usesName(e, name)) || ensures.some(e => usesName(e, name)) || usesNameInStmts(body, name);
82
+ }
@@ -2,7 +2,8 @@
2
2
  * Lean emitter — IR → Lean text.
3
3
  * No logic, no type decisions — just serialization.
4
4
  */
5
- import { anyExpr } from "./ir.js";
5
+ import { anyExpr, usesNameInDecl } from "./ir.js";
6
+ import { freshName } from "./names.js";
6
7
  // ── Ty → Lean type string ──────────────────────────────────
7
8
  function tyToLean(ty) {
8
9
  switch (ty.kind) {
@@ -76,11 +77,15 @@ const LEAN_KEYWORDS = new Set([
76
77
  "partial", "unsafe", "macro", "syntax", "by", "fun", "have", "show",
77
78
  "at", "from", "to", "deriving", "extends", "true", "false",
78
79
  ]);
80
+ // The return-value identifier for the method currently being emitted. Default
81
+ // `res`, but primed (e.g. `res'`) when a module identifier is named `res` — set
82
+ // by the method case. `\result` in an ensures/body must use the same name.
83
+ let _resultName = "res";
79
84
  function escapeName(name) {
80
85
  // \result is carried through the IR as the var name "\\result"; render it
81
- // as Lean's canonical return-value identifier (matches `return (res : T)`).
86
+ // as the method's return-value identifier (matches `return (res : T)`).
82
87
  if (name === "\\result")
83
- return "res";
88
+ return _resultName;
84
89
  return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
85
90
  }
86
91
  // ── Operator precedence (for parenthesization) ──────────────
@@ -258,6 +263,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
258
263
  return `${obj}.contains ${args[0]}`;
259
264
  if (method === "set")
260
265
  return `${obj}.insert ${args[0]} ${args[1]}`;
266
+ if (method === "delete")
267
+ return `${obj}.erase ${args[0]}`;
261
268
  }
262
269
  // Set methods
263
270
  if (tyKind === "set") {
@@ -680,7 +687,12 @@ function emitDecl(d) {
680
687
  // Spec clauses are Prop; the `do` body is computational (Bool).
681
688
  const prevBoolCtx = _boolCtx;
682
689
  _boolCtx = false;
683
- const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
690
+ // Prime the return binder only on a collision within *this method's own*
691
+ // signature/body — `res` is a common identifier module-wide (record
692
+ // fields, unrelated params), so a module-wide check would prime spuriously.
693
+ _resultName = freshName("res", n => d.params.some(p => escapeName(p.name) === n) ||
694
+ usesNameInDecl(d.requires, d.ensures, d.body, n));
695
+ const lines = [`method ${d.name} ${params} return (${_resultName} : ${tyToLean(d.returnType)})`];
684
696
  for (const r of d.requires)
685
697
  lines.push(` require ${emitExpr(r)}`);
686
698
  for (const e of d.ensures)
package/tools/dist/lsc.js CHANGED
@@ -76,7 +76,12 @@ function main() {
76
76
  const timeLimitIdx = args.findIndex(a => a.startsWith("--time-limit="));
77
77
  let timeLimit;
78
78
  if (timeLimitIdx >= 0) {
79
- timeLimit = parseInt(args[timeLimitIdx].split("=")[1]);
79
+ const val = args[timeLimitIdx].split("=")[1];
80
+ if (!/^[1-9]\d*$/.test(val)) {
81
+ console.error(`Invalid --time-limit: ${val} (expected seconds as a positive integer)`);
82
+ process.exit(1);
83
+ }
84
+ timeLimit = parseInt(val);
80
85
  args.splice(timeLimitIdx, 1);
81
86
  }
82
87
  const extraFlagsIdx = args.findIndex(a => a.startsWith("--extra-flags="));
@@ -93,6 +98,14 @@ function main() {
93
98
  slow = true;
94
99
  args.splice(slowIdx, 1);
95
100
  }
101
+ // Anything flag-shaped left over is a typo or a space-separated form
102
+ // (`--backend lean`): reject it rather than let it become a positional arg
103
+ // or be silently ignored (which would e.g. verify with the wrong backend).
104
+ const stray = args.find(a => a.startsWith("-"));
105
+ if (stray) {
106
+ console.error(`Unknown flag: ${stray} (flags take the form --flag=value, e.g. --backend=dafny)`);
107
+ process.exit(1);
108
+ }
96
109
  const [cmd, filePath] = args;
97
110
  if (!cmd) {
98
111
  console.error("Usage: lsc <gen|check|regen|extract|info> [--backend=lean|dafny] <file.ts>");
@@ -234,7 +247,7 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
234
247
  return;
235
248
  }
236
249
  if (cmd === "regen") {
237
- dafnyRegen(genPath, dfyPath, basePath, text, dir);
250
+ dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags);
238
251
  return;
239
252
  }
240
253
  console.error(`Unknown command: ${cmd}`);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Fresh names for toolchain-minted identifiers.
3
+ *
4
+ * Several passes synthesize names — loop counters (`_x_idx`), lift temps
5
+ * (`_t0`), someMatch binders (`_task_val`), quantifier binders. Any such name
6
+ * can collide with a user-written identifier and either capture it (silently
7
+ * changing semantics — the `.delete()` binder bug) or shadow it (a
8
+ * loud duplicate-variable error in the backend).
9
+ *
10
+ * The rule, chosen for zero churn to existing output: a minted name is used
11
+ * verbatim unless the user wrote the same identifier somewhere in the module,
12
+ * in which case a prime (`'`) is appended. TypeScript identifiers cannot
13
+ * contain a prime, while Dafny and Lean both accept them, so one prime always
14
+ * suffices; and priming preserves distinctness among minted names, so the
15
+ * existing counter/suffix schemes keep working unchanged.
16
+ *
17
+ * The module-wide check deliberately over-approximates scope — a colliding
18
+ * name anywhere in the file primes the mint. False positives only affect
19
+ * internal names nobody reads. Names with a user-facing meaning stay exact by
20
+ * different, local means: comprehension binders check the expressions they
21
+ * actually wrap (`usesName` in dafny-emit), and result binders check the
22
+ * signature/body in hand (`methodHeader`). Spec text (`//@` comments) is NOT
23
+ * scanned: specs may legitimately reference minted names (e.g. `_x_idx` loop
24
+ * counters in invariants), so a spec mention is a reference, not a collision
25
+ * — when a source collision does force a prime, spec references are expected
26
+ * to follow it.
27
+ */
28
+ let _userNames = new Set();
29
+ /** Seeded once per module by extract with every Identifier token in the
30
+ * source — params, locals, fields, callees alike. */
31
+ export function setUserNames(names) {
32
+ _userNames = names;
33
+ }
34
+ export function isUserName(name) {
35
+ return _userNames.has(name);
36
+ }
37
+ /** A toolchain-internal name: `base` verbatim, primed on collision. The one
38
+ * place the priming rule lives. `taken` says what counts as a collision —
39
+ * by default a user-written name anywhere in the module; callers that know
40
+ * the exact scope (e.g. a comprehension binder checking only the expressions
41
+ * it wraps) pass their own predicate. */
42
+ export function freshName(base, taken = isUserName) {
43
+ let name = base;
44
+ while (taken(name))
45
+ name += "'";
46
+ return name;
47
+ }
@@ -34,6 +34,7 @@
34
34
  * (early-return, let-cond) run in `walkStmts` so they can consume the rest
35
35
  * of the block.
36
36
  */
37
+ import { freshName } from "./names.js";
37
38
  // ── Optional-check detection ────────────────────────────────
38
39
  /** Counter for naming optChain binders. Reset per module. */
39
40
  let _ocCounter = 0;
@@ -52,7 +53,7 @@ function parseOptionalCheck(cond) {
52
53
  const hint = binderHintFor(e);
53
54
  if (hint === null)
54
55
  return null;
55
- return { scrutinee: e, innerTy, negated: true, binderHint: hint, truthiness: true };
56
+ return { scrutinee: e, innerTy, negated: true, binderHint: freshName(hint), truthiness: true };
56
57
  }
57
58
  if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "===")) {
58
59
  // Bare optional truthiness: `if (e)` where e: T | undefined — true iff e is
@@ -61,7 +62,7 @@ function parseOptionalCheck(cond) {
61
62
  const hint = binderHintFor(cond);
62
63
  if (hint === null)
63
64
  return null;
64
- return { scrutinee: cond, innerTy: cond.ty.inner, negated: false, binderHint: hint, truthiness: true };
65
+ return { scrutinee: cond, innerTy: cond.ty.inner, negated: false, binderHint: freshName(hint), truthiness: true };
65
66
  }
66
67
  return null;
67
68
  }
@@ -77,7 +78,7 @@ function parseOptionalCheck(cond) {
77
78
  const hint = binderHintFor(e);
78
79
  if (hint === null)
79
80
  return null;
80
- return { scrutinee: e, innerTy: e.ty.inner, negated: cond.op === "===", binderHint: hint, truthiness: false };
81
+ return { scrutinee: e, innerTy: e.ty.inner, negated: cond.op === "===", binderHint: freshName(hint), truthiness: false };
81
82
  }
82
83
  function binderHintFor(e) {
83
84
  // Pure access paths: var(x) or field(purePath, name).
@@ -425,7 +426,7 @@ function ruleNullish(e) {
425
426
  if (e.left.ty.kind !== "optional")
426
427
  return null;
427
428
  const innerTy = e.left.ty.inner;
428
- const binder = `_oc${_ocCounter++}_val`;
429
+ const binder = freshName(`_oc${_ocCounter++}_val`);
429
430
  return {
430
431
  kind: "someMatch",
431
432
  scrutinee: e.left, binder, binderTy: innerTy,
@@ -499,7 +500,7 @@ function ruleOptChain(e) {
499
500
  if (e.obj.ty.kind !== "optional")
500
501
  return null;
501
502
  const innerTy = e.obj.ty.inner;
502
- const binder = `_oc${_ocCounter++}_val`;
503
+ const binder = freshName(`_oc${_ocCounter++}_val`);
503
504
  let body = { kind: "var", name: binder, ty: innerTy };
504
505
  for (const step of e.chain) {
505
506
  if (step.kind === "field") {
@@ -543,9 +544,9 @@ function binderHintForMapAccess(m, k) {
543
544
  // mHint is `_m_val`, kHint is `_k_val` — stitch into `_m_k_val`.
544
545
  const mStem = mHint.replace(/_val$/, "");
545
546
  const kStem = kHint.replace(/^_/, "").replace(/_val$/, "");
546
- return `${mStem}_${kStem}_val`;
547
+ return freshName(`${mStem}_${kStem}_val`);
547
548
  }
548
- return `_oc${_ocCounter++}_val`;
549
+ return freshName(`_oc${_ocCounter++}_val`);
549
550
  }
550
551
  /** Rule (expression): `k in m ? m[k] : default` where m is map-typed.
551
552
  * The then-branch must be exactly `m[k]` (same obj, same key). This mirrors
@@ -592,9 +593,10 @@ function ruleConditionalOptionalTruthy(e) {
592
593
  return null;
593
594
  if (e.cond.ty.kind !== "optional")
594
595
  return null;
595
- const binder = binderHintFor(e.cond);
596
- if (binder === null)
596
+ const hint = binderHintFor(e.cond);
597
+ if (hint === null)
597
598
  return null;
599
+ const binder = freshName(hint);
598
600
  return {
599
601
  kind: "someMatch",
600
602
  scrutinee: e.cond, binderTy: e.cond.ty.inner,
@@ -7,6 +7,7 @@
7
7
  import { isBigInt } from "./typedir.js";
8
8
  import { parseTsType, tyToCanonical } from "./types.js";
9
9
  import { parseExpr } from "./specparser.js";
10
+ import { freshName } from "./names.js";
10
11
  function lookup(env, name) {
11
12
  if (!env)
12
13
  return undefined;
@@ -693,7 +694,7 @@ function resolveRecordMerge(base, override, ctx) {
693
694
  return { name: f.name, value: ovf }; // required: override always provides
694
695
  // optional: override field wins iff present, else base's field
695
696
  const bvf = { kind: "field", obj: bv, field: f.name, ty: ft };
696
- const binder = `_m${mergeBinder++}`;
697
+ const binder = freshName(`_m${mergeBinder++}`);
697
698
  // someBody is the unwrapped present value; transform re-wraps each arm in
698
699
  // the backend's Some constructor (Dafny `Some`, Lean `Option.some`).
699
700
  return { name: f.name, value: {
@@ -704,7 +705,7 @@ function resolveRecordMerge(base, override, ctx) {
704
705
  });
705
706
  if (tover.ty.kind === "optional") {
706
707
  // override may be absent (undefined spreads nothing) → base unchanged
707
- const binder = `_mo${mergeBinder++}`;
708
+ const binder = freshName(`_mo${mergeBinder++}`);
708
709
  return {
709
710
  kind: "someMatch", scrutinee: tover, binder, binderTy: userTy,
710
711
  someBody: merged(tbase, { kind: "var", name: binder, ty: userTy }), noneBody: tbase, ty: userTy,
@@ -1373,7 +1374,9 @@ function resolveStmt(s, ctx) {
1373
1374
  for (const _ of s.names)
1374
1375
  nameTypes.push({ kind: "unknown" });
1375
1376
  }
1376
- const idxName = `_${s.names[0]}_idx`;
1377
+ // Must match the freshened counter transform mints for this loop, so a
1378
+ // spec referencing the loop index resolves to the same name.
1379
+ const idxName = freshName(`_${s.names[0]}_idx`);
1377
1380
  env = extend(env, idxName, { kind: "nat" });
1378
1381
  for (let j = 0; j < s.names.length; j++) {
1379
1382
  env = extend(env, s.names[j], nameTypes[j] ?? { kind: "unknown" });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { anyExprInStmts } from "./ir.js";
8
8
  import { parseTsType } from "./types.js";
9
+ import { freshName } from "./names.js";
9
10
  // ── Generic IR walkers ──────────────────────────────────────
10
11
  /**
11
12
  * Map over all sub-expressions in an Expr. `f` is called on each node;
@@ -73,6 +74,54 @@ function mapStmt(s, f) {
73
74
  case "assert": return { ...s, expr: r(s.expr) };
74
75
  }
75
76
  }
77
+ /** Binders a match-arm pattern string (`.Ctor a b`, `_x_val`, `_`) introduces. */
78
+ function patternBinders(pattern) {
79
+ const toks = pattern.match(/[A-Za-z_][A-Za-z0-9_']*/g) ?? [];
80
+ return pattern.trimStart().startsWith(".") ? toks.slice(1) : toks;
81
+ }
82
+ /** Rename free occurrences of `from` to `to`, stopping at every construct that
83
+ * rebinds `from` — lambda params, `let`/`let-bind`/`ghostLet` (shadows the
84
+ * rest of the block), `match` arm patterns, `forall`/`exists`, and `for-in`
85
+ * indices. Capture-avoiding: a nested scope that reintroduces `from` keeps its
86
+ * own binding untouched. `mapExpr` doesn't descend into lambda bodies, so this
87
+ * walks them by hand. */
88
+ export function renameFreeVar(e, from, to) {
89
+ const f = (x) => {
90
+ if (x.kind === "var")
91
+ return x.name === from ? { ...x, name: to } : x;
92
+ // let-expression: `value` is in the outer scope (rename), `body` sees the
93
+ // rebound `from` (leave it), so handle the recursion here to stop descent.
94
+ if (x.kind === "let" && x.name === from)
95
+ return { ...x, value: mapExpr(x.value, f) };
96
+ if ((x.kind === "forall" || x.kind === "exists") && x.var === from)
97
+ return x;
98
+ if (x.kind === "match") {
99
+ const scr = typeof x.scrutinee === "string"
100
+ ? (x.scrutinee === from ? to : x.scrutinee) : mapExpr(x.scrutinee, f);
101
+ return { ...x, scrutinee: scr, arms: x.arms.map(a => patternBinders(a.pattern).includes(from) ? a : { ...a, body: mapExpr(a.body, f) }) };
102
+ }
103
+ if (x.kind === "lambda") {
104
+ if (x.params.some(p => p.name === from))
105
+ return x; // param shadows `from`
106
+ const body = [];
107
+ let shadowed = false;
108
+ for (const s of x.body) {
109
+ if (shadowed) {
110
+ body.push(s);
111
+ continue;
112
+ }
113
+ body.push(s.kind === "forin" && s.idx === from
114
+ ? { ...s, bound: mapExpr(s.bound, f) } // idx shadows in the loop body
115
+ : mapStmt(s, f));
116
+ if ((s.kind === "let" || s.kind === "let-bind" || s.kind === "ghostLet") && s.name === from)
117
+ shadowed = true;
118
+ }
119
+ return { ...x, body };
120
+ }
121
+ return null;
122
+ };
123
+ return mapExpr(e, f);
124
+ }
76
125
  /** Map over all sub-expressions in a TExpr (typed IR). */
77
126
  function mapTExpr(e, f) {
78
127
  const hit = f(e);
@@ -145,9 +194,12 @@ let _opts = DAFNY_OPTIONS;
145
194
  let _typeDecls = [];
146
195
  /** Prefix match-bound field names to avoid capturing user variables.
147
196
  * When prefix is given (the scrutinee name), include it to avoid
148
- * collisions in nested matches on different variables. */
197
+ * collisions in nested matches on different variables. `freshName` closes
198
+ * the residual gap: a user variable literally named `_value`/`_x_field` in an
199
+ * arm body would still be captured, so prime on any module-wide collision.
200
+ * Deterministic, so the pattern binder and its body substitutions agree. */
149
201
  function matchBinder(fieldName, prefix) {
150
- return prefix ? `_${prefix}_${fieldName}` : `_${fieldName}`;
202
+ return freshName(prefix ? `_${prefix}_${fieldName}` : `_${fieldName}`);
151
203
  }
152
204
  /** Build a match arm pattern like `.VariantName _v_field1 _v_field2` from variant info. */
153
205
  function buildMatchPattern(variantName, fields, scopePrefix) {
@@ -351,7 +403,7 @@ function lowerExpr(e, binds) {
351
403
  // callKind "unknown" and fall through to the regular case below where
352
404
  // they become `methodCall`.
353
405
  if (binds && e.kind === "call" && e.callKind === "method" && e.fn.kind === "var") {
354
- const name = `_t${_liftCounter++}`;
406
+ const name = freshName(`_t${_liftCounter++}`);
355
407
  const args = e.args.map(a => lowerExpr(a, binds));
356
408
  binds.push({ kind: "let-bind", name, value: { kind: "app", fn: e.fn.name, args } });
357
409
  return { kind: "var", name };
@@ -751,7 +803,7 @@ function lowerExpr(e, binds) {
751
803
  const result = { kind: "methodCall", obj: recv, objTy: e.fn.obj.ty, method, args, monadic: needsMonadic };
752
804
  // Monadic HOF call is itself monadic — lift via binds like a method call
753
805
  if (_opts.monadic && needsMonadic && binds) {
754
- const name = `_t${_liftCounter++}`;
806
+ const name = freshName(`_t${_liftCounter++}`);
755
807
  binds.push({ kind: "let-bind", name, value: result });
756
808
  return { kind: "var", name };
757
809
  }
@@ -911,7 +963,7 @@ function lowerExpr(e, binds) {
911
963
  case "havoc":
912
964
  // Dafny's * only works in var/assign positions — lift to own declaration
913
965
  if (binds) {
914
- const name = `_t${_liftCounter++}`;
966
+ const name = freshName(`_t${_liftCounter++}`);
915
967
  binds.push({ kind: "let", name, type: e.ty, mutable: false, value: { kind: "havoc", type: e.ty } });
916
968
  return { kind: "var", name };
917
969
  }
@@ -1357,11 +1409,11 @@ function transformStmts(stmts, typeDecls) {
1357
1409
  const count = _forofCounters.get(keyName) ?? 0;
1358
1410
  _forofCounters.set(keyName, count + 1);
1359
1411
  const suffix = count === 0 ? "" : `${count + 1}`;
1360
- const keysSeqName = `_${keyName}_keys${suffix}`;
1412
+ const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1361
1413
  const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1362
1414
  result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
1363
1415
  const keysVar = { kind: "var", name: keysSeqName };
1364
- const idxName = `_${keyName}_idx${suffix}`;
1416
+ const idxName = freshName(`_${keyName}_idx${suffix}`);
1365
1417
  const idx = { kind: "var", name: idxName };
1366
1418
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
1367
1419
  const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
@@ -1383,11 +1435,11 @@ function transformStmts(stmts, typeDecls) {
1383
1435
  const count = _forofCounters.get(keyName) ?? 0;
1384
1436
  _forofCounters.set(keyName, count + 1);
1385
1437
  const suffix = count === 0 ? "" : `${count + 1}`;
1386
- const keysSeqName = `_${keyName}_keys${suffix}`;
1438
+ const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1387
1439
  const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1388
1440
  result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
1389
1441
  const keysVar = { kind: "var", name: keysSeqName };
1390
- const idxName = `_${keyName}_idx${suffix}`;
1442
+ const idxName = freshName(`_${keyName}_idx${suffix}`);
1391
1443
  const idx = { kind: "var", name: idxName };
1392
1444
  const arrSize = { kind: "field", obj: keysVar, field: "size" };
1393
1445
  const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
@@ -1405,7 +1457,7 @@ function transformStmts(stmts, typeDecls) {
1405
1457
  }
1406
1458
  // Sets aren't indexable — bind SetToSeq to a variable for iteration
1407
1459
  if (s.iterable.ty.kind === "set") {
1408
- const seqName = `_${varName}_seq`;
1460
+ const seqName = freshName(`_${varName}_seq`);
1409
1461
  const convExpr = { kind: "app", fn: "SetToSeq", args: [iterExpr] };
1410
1462
  const elemTy = varTy.kind !== "unknown" ? varTy : { kind: "string" };
1411
1463
  result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false, value: convExpr });
@@ -1414,7 +1466,7 @@ function transformStmts(stmts, typeDecls) {
1414
1466
  const count = _forofCounters.get(varName) ?? 0;
1415
1467
  _forofCounters.set(varName, count + 1);
1416
1468
  const suffix = count === 0 ? "" : `${count + 1}`;
1417
- const idxName = `_${varName}_idx${suffix}`;
1469
+ const idxName = freshName(`_${varName}_idx${suffix}`);
1418
1470
  const idx = { kind: "var", name: idxName };
1419
1471
  const arrSize = { kind: "field", obj: iterExpr, field: "size" };
1420
1472
  const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
@@ -1489,15 +1541,17 @@ function transformStmt(s, typeDecls) {
1489
1541
  const arrIR = transformExpr(arrExpr);
1490
1542
  const arrTy = arrExpr.ty;
1491
1543
  const elemTy = arrTy.kind === "array" ? arrTy.elem : { kind: "unknown" };
1492
- const idxName = `_${param}_idx`;
1544
+ const idxName = freshName(`_${param}_idx`);
1493
1545
  const idx = { kind: "var", name: idxName };
1494
1546
  const arrSize = { kind: "field", obj: arrIR, field: "size" };
1495
1547
  const elemVar = { kind: "var", name: param };
1496
1548
  const keyIR = transformExpr(keyExpr);
1497
1549
  const valIR = transformExpr(valExpr);
1498
1550
  const mapSet = { kind: "methodCall", obj: { kind: "var", name: s.name }, objTy: s.ty, method: "set", args: [keyIR, valIR], monadic: false };
1499
- // Auto-invariant: all processed elements' keys are in the map
1500
- const kVar = { kind: "var", name: "ki" };
1551
+ // Auto-invariant: all processed elements' keys are in the map. The
1552
+ // quantifier wraps user expressions, so its binder must be fresh.
1553
+ const kiName = freshName("ki");
1554
+ const kVar = { kind: "var", name: kiName };
1501
1555
  const mapHasKey = {
1502
1556
  kind: "implies",
1503
1557
  premises: [
@@ -1506,7 +1560,7 @@ function transformStmt(s, typeDecls) {
1506
1560
  ],
1507
1561
  conclusion: { kind: "methodCall", obj: { kind: "var", name: s.name }, objTy: s.ty, method: "has", args: [keyIR.kind === "field" ? { kind: "field", obj: { kind: "index", arr: arrIR, idx: kVar }, field: keyIR.field } : keyIR], monadic: false },
1508
1562
  };
1509
- const autoInv = { kind: "forall", var: "ki", type: { kind: "int" }, body: mapHasKey };
1563
+ const autoInv = { kind: "forall", var: kiName, type: { kind: "int" }, body: mapHasKey };
1510
1564
  const stmts = [
1511
1565
  { kind: "let", name: s.name, type: s.ty, mutable: true, value: { kind: "emptyMap" } },
1512
1566
  { kind: "forin", idx: idxName, bound: arrSize,