lemmascript 0.5.12 → 0.5.14

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.14",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,7 +32,7 @@
32
32
  "type": "git",
33
33
  "url": "https://github.com/midspiral/LemmaScript"
34
34
  },
35
- "homepage": "https://lemmascript.com",
35
+ "homepage": "https://lemmascript.org",
36
36
  "keywords": [
37
37
  "lemmascript",
38
38
  "verification",
@@ -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, userNames } 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) {
@@ -17,7 +42,7 @@ function tyToDafny(ty) {
17
42
  needPreamble("OptionType");
18
43
  return `Option<${tyToDafny(ty.inner)}>`;
19
44
  }
20
- case "user": return ty.name;
45
+ case "user": return escapeName(ty.name);
21
46
  case "fn": return `(${ty.params.map(tyToDafny).join(", ")}) -> ${tyToDafny(ty.result)}`;
22
47
  // Out-of-subset (`any`/`unknown`); opaque so real ops on it fail loudly
23
48
  // rather than silently verify as `int`. Mirrors the Lean backend's `_`.
@@ -50,22 +75,83 @@ const DAFNY_KEYWORDS = new Set([
50
75
  "copredicate", "inductive",
51
76
  ]);
52
77
  // The Dafny out-parameter name for the method currently being emitted. Default
53
- // `res`, but bumped (e.g. `res_`) when a parameter is named `res` — set by
54
- // methodHeader and reset per decl. `\result` in an ensures must use the *same*
55
- // name, so escapeName routes it here.
78
+ // `res`, but bumped (e.g. `res'`) when the method's own scope uses `res` — set
79
+ // by methodHeader and reset per decl. `\result` in an ensures must use the
80
+ // *same* name, so escapeName routes it here.
56
81
  let _resultName = "res";
57
- function escapeName(name) {
58
- // \result is carried through the IR as the var name "\\result"; render it
59
- // as the current method's out-parameter name.
60
- if (name === "\\result")
61
- return _resultName;
82
+ // ── Dafny name allocation ──────────────────────────────────
83
+ //
84
+ // freshName (names.ts) freshens in the *raw TS* namespace — but that is not the
85
+ // namespace Dafny sees. Escaping maps `_x`→`i_x` and keyword `match`→`match_`,
86
+ // so two raw-distinct names can collapse *after* escaping: a raw-freshened temp
87
+ // `_t0'`→`i_t0'` colliding with a user `_t0` that escaped-and-primed to `i_t0'`.
88
+ // So Dafny hygiene is a second allocator, layered at emission: escape to a base,
89
+ // then freshen against the names already claimed in the Dafny namespace. User
90
+ // names are allocated up front (Dafny-safe ones kept exact); generated names are
91
+ // allocated on first sight and cached so a decl and its references agree. Reset
92
+ // per file. (The raw freshName layer stays — Lean has a different escaping story.)
93
+ function dafnyBaseName(name) {
62
94
  if (DAFNY_KEYWORDS.has(name))
63
95
  return `${name}_`;
64
- // Dafny doesn't allow identifiers starting with _
65
96
  if (name.startsWith("_"))
66
- return `i${name}`;
97
+ return `i${name}`; // Dafny forbids leading `_`
67
98
  return name;
68
99
  }
100
+ let _userDafnyNames = new Map();
101
+ let _generatedDafnyNames = new Map();
102
+ let _takenDafnyNames = new Set();
103
+ /** `base`, primed until free in the Dafny namespace. A prime can't occur in a
104
+ * TS identifier, so priming always leaves user-name space. */
105
+ function freshDafnyName(base) {
106
+ let out = base;
107
+ while (_takenDafnyNames.has(out))
108
+ out += "'";
109
+ return out;
110
+ }
111
+ function resetDafnyNameCache() {
112
+ _userDafnyNames = new Map();
113
+ _generatedDafnyNames = new Map();
114
+ _takenDafnyNames = new Set();
115
+ const raws = [...userNames()].sort();
116
+ // Dafny-safe source names keep their spelling; names that must mangle are then
117
+ // freshened in the emitted namespace (safe-first, sorted → deterministic).
118
+ for (const raw of raws)
119
+ if (dafnyBaseName(raw) === raw) {
120
+ _userDafnyNames.set(raw, raw);
121
+ _takenDafnyNames.add(raw);
122
+ }
123
+ for (const raw of raws)
124
+ if (dafnyBaseName(raw) !== raw) {
125
+ const emitted = freshDafnyName(dafnyBaseName(raw));
126
+ _userDafnyNames.set(raw, emitted);
127
+ _takenDafnyNames.add(emitted);
128
+ }
129
+ }
130
+ function escapeName(name) {
131
+ // \result is carried through the IR as var "\\result"; render it as the
132
+ // current method's out-parameter name (chosen locally by methodHeader).
133
+ if (name === "\\result")
134
+ return _resultName;
135
+ const user = _userDafnyNames.get(name);
136
+ if (user !== undefined)
137
+ return user;
138
+ return escapeGeneratedName(name);
139
+ }
140
+ /** Allocate a toolchain-generated name (an ANF temp, a comprehension binder, a
141
+ * companion `_ensures` lemma). Escapes to a base, then freshens in the Dafny
142
+ * namespace so it can't collapse onto an escaped user name. Bypasses the user
143
+ * map on purpose: the raw name is synthesized, so it must be freshened *away
144
+ * from* a same-spelled user name, not aliased onto it. Cached so a declaration
145
+ * and its references render identically. */
146
+ function escapeGeneratedName(name) {
147
+ const cached = _generatedDafnyNames.get(name);
148
+ if (cached !== undefined)
149
+ return cached;
150
+ const emitted = freshDafnyName(dafnyBaseName(name));
151
+ _generatedDafnyNames.set(name, emitted);
152
+ _takenDafnyNames.add(emitted);
153
+ return emitted;
154
+ }
69
155
  /** Format a typed parameter list for Dafny: "x: int, y: seq<int>" */
70
156
  function paramList(params) {
71
157
  return params.map(p => `${escapeName(p.name)}: ${tyToDafny(p.type)}`).join(", ");
@@ -73,17 +159,18 @@ function paramList(params) {
73
159
  /** Format a method signature header, omitting `returns` for void methods.
74
160
  * Dafny's definite-assignment rule rejects unassigned out-parameters, so a
75
161
  * `returns (res: ())` on a void method fails verification. */
76
- function methodHeader(prefix, params, returnType) {
162
+ function methodHeader(prefix, params, returnType, scope) {
77
163
  const sig = `${prefix}(${paramList(params)})`;
78
164
  if (returnType.kind === "void")
79
165
  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 += "_";
166
+ // The out-parameter is `res` by default, but a param (an Express handler's
167
+ // `(req, res)`), body local, or callee named `res` would shadow it. Check only
168
+ // *this method's own* signature and body `res` is common module-wide (fields,
169
+ // unrelated params), so a module-wide check would prime spuriously. The primed
170
+ // name is recorded so `\result` references resolve to it.
171
+ const taken = (n) => params.some(p => escapeName(p.name) === n) ||
172
+ (scope !== undefined && usesNameInDecl(scope.requires, scope.ensures, scope.body, n));
173
+ const resName = freshName("res", taken);
87
174
  _resultName = resName;
88
175
  return `${sig} returns (${resName}: ${tyToDafny(returnType)})`;
89
176
  }
@@ -106,7 +193,7 @@ function emitQuantifier(e, keyword) {
106
193
  while (body.kind === e.kind) {
107
194
  const dty = tyToDafny(body.type);
108
195
  const ann = dty === "string" ? "" : `: ${dty}`;
109
- vars.push(`${body.var}${ann}`);
196
+ vars.push(`${escapeName(body.var)}${ann}`);
110
197
  body = body.body;
111
198
  }
112
199
  return `${keyword} ${vars.join(", ")} :: ${emitExpr(body)}`;
@@ -222,8 +309,8 @@ function emitExpr(e) {
222
309
  const ret = lam.body[0];
223
310
  if (ret.kind !== "return")
224
311
  throw new Error("unreachable");
225
- const p = escapeName(lam.params[0]?.name ?? "x");
226
- const body = emitExpr(ret.value);
312
+ const { binder: p, body: v } = comprehensionBinder(lam, ret.value, e.obj);
313
+ const body = emitExpr(v);
227
314
  return `(exists ${p} :: ${p} in ${obj} && ${body})`;
228
315
  }
229
316
  }
@@ -300,8 +387,13 @@ function emitExpr(e) {
300
387
  return `${obj}[${args[0]} := ${args[1]}]`;
301
388
  if (e.method === "has")
302
389
  return `(${args[0]} in ${obj})`;
303
- if (e.method === "delete")
304
- return `(map k | k in ${obj} && k != ${args[0]} :: ${obj}[k])`;
390
+ if (e.method === "delete") {
391
+ // Minted comprehension binder: freshen so a user variable `k` in the
392
+ // receiver or key isn't captured (`k != k` would delete nothing).
393
+ // Local check — only this comprehension's own operands can collide.
394
+ const k = escapeName(freshBinder("k", e.obj, e.args[0]));
395
+ return `(map ${k} | ${k} in ${obj} && ${k} != ${args[0]} :: ${obj}[${k}])`;
396
+ }
305
397
  }
306
398
  // Set methods
307
399
  if (ty === "set") {
@@ -321,8 +413,8 @@ function emitExpr(e) {
321
413
  const ret = lam.body[0];
322
414
  if (ret.kind !== "return")
323
415
  throw new Error("unreachable");
324
- const p = escapeName(lam.params[0]?.name ?? "x");
325
- const body = emitExpr(ret.value);
416
+ const { binder: p, body: v } = comprehensionBinder(lam, ret.value, e.obj);
417
+ const body = emitExpr(v);
326
418
  return `(set ${p} | ${p} in ${obj} && ${body})`;
327
419
  }
328
420
  }
@@ -447,9 +539,11 @@ function emitExpr(e) {
447
539
  }
448
540
  case "field": {
449
541
  const obj = emitExpr(e.obj);
450
- if (e.field === "size" || e.field === "length" || e.field === "collectionSize")
542
+ // `size`/`length`/`keys` are collection intrinsics unless the transform
543
+ // proved this is a declared datatype field (then project it).
544
+ if (!e.datatypeField && (e.field === "size" || e.field === "length" || e.field === "collectionSize"))
451
545
  return `|${obj}|`;
452
- if (e.field === "keys")
546
+ if (!e.datatypeField && e.field === "keys")
453
547
  return `${obj}.Keys`;
454
548
  if (e.field === "toNat")
455
549
  return obj;
@@ -681,23 +775,23 @@ function emitDecl(d) {
681
775
  const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name}` } : f);
682
776
  return `${escapeName(c.name)}(${paramList(fields)})`;
683
777
  });
684
- return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
778
+ return `datatype ${escapeName(d.name)}${tp} = ${ctors.join(" | ")}`;
685
779
  }
686
780
  case "structure": {
687
781
  const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
688
- return `datatype ${d.name}${tp} = ${d.name}(${paramList(d.fields)})`;
782
+ return `datatype ${escapeName(d.name)}${tp} = ${escapeName(d.name)}(${paramList(d.fields)})`;
689
783
  }
690
784
  case "type-alias": {
691
- return `type ${d.name} = ${tyToDafny(d.target)}`;
785
+ return `type ${escapeName(d.name)} = ${tyToDafny(d.target)}`;
692
786
  }
693
787
  case "opaque-type": {
694
788
  // Abstract type — no definition. `(==)` so it can sit inside datatypes
695
789
  // that derive structural equality. Never constructed or destructured.
696
- return `type ${d.name}(==)`;
790
+ return `type ${escapeName(d.name)}(==)`;
697
791
  }
698
792
  case "def": {
699
793
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
700
- const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
794
+ const lines = [`function ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
701
795
  for (const r of d.requires)
702
796
  lines.push(` requires ${emitExpr(r)}`);
703
797
  if (d.decreases)
@@ -710,7 +804,7 @@ function emitDecl(d) {
710
804
  // Strip constraints like (==) from type params — ghost lemmas don't need them
711
805
  const lemmaTP = d.typeParams.length > 0 ? `<${d.typeParams.map(t => t.replace(/\(.*\)/, '')).join(", ")}>` : "";
712
806
  lines.push("");
713
- lines.push(`lemma ${d.name}_ensures${lemmaTP}(${paramList(d.params)})`);
807
+ lines.push(`lemma ${escapeGeneratedName(`${d.name}_ensures`)}${lemmaTP}(${paramList(d.params)})`);
714
808
  for (const r of d.requires)
715
809
  lines.push(` requires ${emitExpr(r)}`);
716
810
  for (const e of d.ensures)
@@ -722,7 +816,7 @@ function emitDecl(d) {
722
816
  }
723
817
  case "def-by-method": {
724
818
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
725
- const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
819
+ const lines = [`function ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
726
820
  for (const r of d.requires)
727
821
  lines.push(` requires ${emitExpr(r)}`);
728
822
  if (d.decreases)
@@ -736,7 +830,7 @@ function emitDecl(d) {
736
830
  }
737
831
  case "method": {
738
832
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
739
- const lines = [methodHeader(`method ${d.name}${tp}`, d.params, d.returnType)];
833
+ const lines = [methodHeader(`method ${escapeName(d.name)}${tp}`, d.params, d.returnType, d)];
740
834
  for (const r of d.requires)
741
835
  lines.push(` requires ${emitExpr(r)}`);
742
836
  for (const e of d.ensures)
@@ -749,14 +843,14 @@ function emitDecl(d) {
749
843
  return lines.join("\n");
750
844
  }
751
845
  case "class": {
752
- const lines = [`class ${d.name} {`];
846
+ const lines = [`class ${escapeName(d.name)} {`];
753
847
  for (const f of d.fields) {
754
848
  lines.push(` var ${escapeName(f.name)}: ${tyToDafny(f.type)}`);
755
849
  }
756
850
  if (d.fields.length > 0 && d.methods.length > 0)
757
851
  lines.push("");
758
852
  for (const m of d.methods) {
759
- lines.push(` ${methodHeader(`method ${m.name}`, m.params, m.returnType)}`);
853
+ lines.push(` ${methodHeader(`method ${escapeName(m.name)}`, m.params, m.returnType, m)}`);
760
854
  for (const r of m.requires)
761
855
  lines.push(` requires ${emitExpr(r)}`);
762
856
  for (const e of m.ensures)
@@ -1211,21 +1305,17 @@ function qualifyCtor(name, type) {
1211
1305
  * "_" → "_"
1212
1306
  */
1213
1307
  const CTOR_MAP = { "some": "Some", "none": "None" };
1214
- function translatePattern(pattern) {
1215
- if (pattern === "_")
1308
+ function translatePattern(p) {
1309
+ if (p.kind === "wild")
1216
1310
  return "_";
1217
- const m = pattern.match(/^\.(\w+)\s*(.*)$/);
1218
- if (!m)
1219
- return pattern;
1220
- const ctorName = CTOR_MAP[m[1]] ?? escapeName(m[1]);
1221
- const fields = m[2].trim();
1222
- if (!fields)
1311
+ const ctorName = CTOR_MAP[p.ctor] ?? escapeName(p.ctor);
1312
+ if (p.binders.length === 0)
1223
1313
  return ctorName;
1224
- const fieldNames = fields.split(/\s+/).map(escapeName);
1225
- return `${ctorName}(${fieldNames.join(", ")})`;
1314
+ return `${ctorName}(${p.binders.map(escapeName).join(", ")})`;
1226
1315
  }
1227
1316
  export function emitDafnyFile(file, tsFileName, opts) {
1228
1317
  _useSafeSlice = !!opts?.safeSlice;
1318
+ resetDafnyNameCache();
1229
1319
  buildRecordCtorMap(file.decls);
1230
1320
  _neededPreambles.clear();
1231
1321
  // Track successfully emitted pure defs — method wrappers are only
@@ -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
@@ -4,6 +4,19 @@
4
4
  * The transform phase produces these types.
5
5
  * The emit phase pretty-prints them to backend syntax (Lean or Dafny).
6
6
  */
7
+ export const pWild = () => ({ kind: "wild" });
8
+ export const pCtor = (ctor, ...binders) => ({ kind: "ctor", ctor, binders });
9
+ /** Binder identifiers a pattern introduces (`[]` for wildcard / nullary ctor). */
10
+ export function patternBinders(p) {
11
+ return p.kind === "ctor" ? p.binders : [];
12
+ }
13
+ export function patternCtor(p) {
14
+ return p.kind === "ctor" ? p.ctor : null;
15
+ }
16
+ /** Does the pattern bind `name`? */
17
+ export function patternBinds(p, name) {
18
+ return patternBinders(p).includes(name);
19
+ }
7
20
  export function anyExpr(e, pred) {
8
21
  if (pred(e))
9
22
  return true;
@@ -60,3 +73,47 @@ export function anyExprInStmt(s, pred) {
60
73
  export function anyExprInStmts(stmts, pred) {
61
74
  return stmts.some(s => anyExprInStmt(s, pred));
62
75
  }
76
+ // A name is "used" — such that a synthesized binder of the same name would
77
+ // capture or shadow it — iff it appears as a variable reference or a called
78
+ // function. These drive the *local* freshness checks for user-facing binders
79
+ // (the result out-parameter, comprehension binders): a binder is checked only
80
+ // against the expressions/scope it actually wraps, not the whole module.
81
+ const _refsName = (name) => e => (e.kind === "var" && e.name === name) ||
82
+ (e.kind === "app" && e.fn === name) ||
83
+ (e.kind === "constructor" && e.name === name) ||
84
+ (e.kind === "match" && typeof e.scrutinee === "string" && e.scrutinee === name);
85
+ export function usesName(e, name) {
86
+ return anyExpr(e, _refsName(name));
87
+ }
88
+ export function usesNameInStmts(stmts, name) {
89
+ return anyExprInStmts(stmts, _refsName(name));
90
+ }
91
+ /** Names a statement tree *binds, targets, or introduces* — `let`/`let-bind`/
92
+ * `ghostLet` names, `assign`/`bind`/`ghostAssign` targets, `for-in` indices,
93
+ * and `match`-arm pattern binders — recursing through nested blocks. Distinct
94
+ * from `usesNameInStmts` (expression references only): an unread or assign-only
95
+ * local still duplicate-declares against a method's out-parameter in Dafny. */
96
+ export function bindsNameInStmts(stmts, name) {
97
+ return stmts.some(s => {
98
+ switch (s.kind) {
99
+ case "let":
100
+ case "let-bind":
101
+ case "ghostLet": return s.name === name;
102
+ case "assign":
103
+ case "bind":
104
+ case "ghostAssign": return s.target === name;
105
+ case "forin": return s.idx === name || bindsNameInStmts(s.body, name);
106
+ case "if": return bindsNameInStmts(s.then, name) || bindsNameInStmts(s.else, name);
107
+ case "while": return bindsNameInStmts(s.body, name);
108
+ case "match": return s.arms.some(a => patternBinds(a.pattern, name) || bindsNameInStmts(a.body, name));
109
+ default: return false;
110
+ }
111
+ });
112
+ }
113
+ /** Every occurrence of `name` a method's out-parameter binder must dodge —
114
+ * referenced in a spec (requires/ensures) or body, *or* bound/targeted anywhere
115
+ * in the body (an unread local still duplicate-declares). Both emitters share it. */
116
+ export function usesNameInDecl(requires, ensures, body, name) {
117
+ return requires.some(e => usesName(e, name)) || ensures.some(e => usesName(e, name))
118
+ || usesNameInStmts(body, name) || bindsNameInStmts(body, name);
119
+ }
@@ -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, patternBinders } 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) ──────────────
@@ -173,6 +178,10 @@ let _unknownEmitted = false; // across files in one run — the def file imports
173
178
  // built only from decidable atoms (comparisons, Bool-returning calls) coerces fine
174
179
  // and stays in the more proof-friendly Prop form.
175
180
  let _boolCtx = false;
181
+ /** Render a match pattern to Lean syntax: `_`, `.none`, `.some x`, `.syn seq`. */
182
+ function renderLeanPattern(p) {
183
+ return p.kind === "wild" ? "_" : "." + [p.ctor, ...p.binders].join(" ");
184
+ }
176
185
  // A Bool-valued atom that does NOT coerce to Prop: an inlined union discriminator
177
186
  // (lowered to a match-bool `match x with | .C .. => true | _ => false`) or a raw
178
187
  // `match` used as a Bool — neither has a `Decidable` instance Lean can synthesize
@@ -258,6 +267,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
258
267
  return `${obj}.contains ${args[0]}`;
259
268
  if (method === "set")
260
269
  return `${obj}.insert ${args[0]} ${args[1]}`;
270
+ if (method === "delete")
271
+ return `${obj}.erase ${args[0]}`;
261
272
  }
262
273
  // Set methods
263
274
  if (tyKind === "set") {
@@ -458,7 +469,7 @@ function emitExpr(e, parentPrec) {
458
469
  // Always parenthesize inline matches — Lean parses alternatives greedily,
459
470
  // so any token after an arm body (`→`, another match's `|`, etc.) would
460
471
  // bleed into the last `.none` case without explicit bracketing.
461
- const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
472
+ const arms = e.arms.map(a => `| ${renderLeanPattern(a.pattern)} => ${emitExpr(a.body)}`);
462
473
  const scrut = typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee);
463
474
  return `(match ${scrut} with ${arms.join(" ")})`;
464
475
  }
@@ -550,10 +561,10 @@ function emitStmt(s, indent) {
550
561
  const scrut = typeof s.scrutinee === "string" ? s.scrutinee : emitExpr(s.scrutinee);
551
562
  // Option match (.some/.none) → emit as if/let for WPGen.if compatibility
552
563
  if (s.arms.length === 2) {
553
- const someArm = s.arms.find(a => a.pattern.startsWith(".some "));
554
- const noneArm = s.arms.find(a => a.pattern === ".none");
564
+ const someArm = s.arms.find(a => a.pattern.kind === "ctor" && a.pattern.ctor === "some");
565
+ const noneArm = s.arms.find(a => a.pattern.kind === "ctor" && a.pattern.ctor === "none");
555
566
  if (someArm && noneArm) {
556
- const boundVar = someArm.pattern.slice(6); // strip ".some "
567
+ const boundVar = patternBinders(someArm.pattern)[0]; // ".some x" ⇒ "x"
557
568
  const hName = `h_${scrut.replace(/[^a-zA-Z0-9_]/g, "_")}`;
558
569
  const lines = [
559
570
  `${pad}if ${hName} : (${scrut}).isSome = true then`,
@@ -578,7 +589,7 @@ function emitStmt(s, indent) {
578
589
  // General match
579
590
  const lines = [`${pad}match ${scrut} with`];
580
591
  for (const arm of s.arms) {
581
- lines.push(`${pad}| ${arm.pattern} =>`);
592
+ lines.push(`${pad}| ${renderLeanPattern(arm.pattern)} =>`);
582
593
  if (arm.body.length === 0) {
583
594
  lines.push(`${pad} pure ()`);
584
595
  }
@@ -680,7 +691,12 @@ function emitDecl(d) {
680
691
  // Spec clauses are Prop; the `do` body is computational (Bool).
681
692
  const prevBoolCtx = _boolCtx;
682
693
  _boolCtx = false;
683
- const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
694
+ // Prime the return binder only on a collision within *this method's own*
695
+ // signature/body — `res` is a common identifier module-wide (record
696
+ // fields, unrelated params), so a module-wide check would prime spuriously.
697
+ _resultName = freshName("res", n => d.params.some(p => escapeName(p.name) === n) ||
698
+ usesNameInDecl(d.requires, d.ensures, d.body, n));
699
+ const lines = [`method ${d.name} ${params} return (${_resultName} : ${tyToLean(d.returnType)})`];
684
700
  for (const r of d.requires)
685
701
  lines.push(` require ${emitExpr(r)}`);
686
702
  for (const e of d.ensures)
@@ -732,7 +748,7 @@ function emitPureExpr(e, indent) {
732
748
  case "match": {
733
749
  const lines = [`${pad}match ${typeof e.scrutinee === "string" ? e.scrutinee : emitExpr(e.scrutinee)} with`];
734
750
  for (const arm of e.arms) {
735
- lines.push(`${pad}| ${arm.pattern} =>`);
751
+ lines.push(`${pad}| ${renderLeanPattern(arm.pattern)} =>`);
736
752
  lines.push(emitPureExpr(arm.body, indent + 1));
737
753
  }
738
754
  return lines.join("\n");
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}`);