lemmascript 0.5.7 → 0.5.9

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.
@@ -2,6 +2,7 @@
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
6
  // ── Ty → Lean type string ──────────────────────────────────
6
7
  function tyToLean(ty) {
7
8
  switch (ty.kind) {
@@ -55,7 +56,14 @@ function tyToLean(ty) {
55
56
  const retStr = ret.includes(" ") ? `(${ret})` : ret;
56
57
  return [...params, retStr].join(" → ");
57
58
  }
58
- case "unknown": return "_";
59
+ // Out-of-subset (`any`/`unknown`) an opaque carrier, so unmodeled payloads
60
+ // (e.g. a `details` field never inspected by the verified code) pass through
61
+ // but real ops on them fail loudly. Mirrors the Dafny backend's
62
+ // `type Unknown(==, 0)`. The decl is emitted once, in the first file that
63
+ // needs it (the def file imports the types file, so no duplicate).
64
+ case "unknown":
65
+ _needsUnknown = true;
66
+ return "Unknown";
59
67
  }
60
68
  }
61
69
  // ── Lean keyword escaping ────────────────────────────────────
@@ -83,8 +91,119 @@ const PREC = {
83
91
  "+": 5, "-": 5, "++": 5, "arrayConcat": 5, "*": 6, "/": 6, "%": 6,
84
92
  };
85
93
  function prec(op) { return PREC[op] ?? 10; }
86
- // ── Method call → Lean syntax ───────────────────────────────
94
+ const _unionCtors = new Map();
95
+ // Types that transitively reference an `opaque` type can't derive `Repr` or
96
+ // `DecidableEq` (the opaque type provides neither). `Inhabited` still derives
97
+ // (via the empty array / first constructor), so only those two are dropped.
98
+ // "Unknown" (the `any`/`unknown` carrier) is opaque by construction.
99
+ const _opaqueNames = new Set(["Unknown"]);
100
+ const _typeRefs = new Map();
101
+ const _taintedTypes = new Set();
102
+ function collectUserRefs(ty, into) {
103
+ switch (ty.kind) {
104
+ case "array":
105
+ case "set":
106
+ collectUserRefs(ty.elem, into);
107
+ break;
108
+ case "optional":
109
+ collectUserRefs(ty.inner, into);
110
+ break;
111
+ case "map":
112
+ collectUserRefs(ty.key, into);
113
+ collectUserRefs(ty.value, into);
114
+ break;
115
+ case "fn":
116
+ ty.params.forEach(p => collectUserRefs(p, into));
117
+ collectUserRefs(ty.result, into);
118
+ break;
119
+ case "user":
120
+ into.add(ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name);
121
+ break;
122
+ case "unknown":
123
+ into.add("Unknown");
124
+ break;
125
+ }
126
+ }
127
+ function registerInductives(decls) {
128
+ for (const d of decls) {
129
+ if (d.kind === "inductive") {
130
+ _unionCtors.set(d.name, d.constructors);
131
+ const refs = new Set();
132
+ for (const c of d.constructors)
133
+ for (const f of c.fields)
134
+ collectUserRefs(f.type, refs);
135
+ _typeRefs.set(d.name, refs);
136
+ }
137
+ else if (d.kind === "structure") {
138
+ const refs = new Set();
139
+ for (const f of d.fields)
140
+ collectUserRefs(f.type, refs);
141
+ _typeRefs.set(d.name, refs);
142
+ }
143
+ else if (d.kind === "opaque-type") {
144
+ _opaqueNames.add(d.name);
145
+ }
146
+ }
147
+ // Fixpoint: a type is tainted if it references an opaque or already-tainted type.
148
+ let changed = true;
149
+ while (changed) {
150
+ changed = false;
151
+ for (const [name, refs] of _typeRefs) {
152
+ if (_taintedTypes.has(name))
153
+ continue;
154
+ if ([...refs].some(r => _opaqueNames.has(r) || _taintedTypes.has(r))) {
155
+ _taintedTypes.add(name);
156
+ changed = true;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ /** Deriving clause, dropping `Repr`/`DecidableEq` for opaque-tainted types. */
162
+ function emitDeriving(name, deriving) {
163
+ const der = _taintedTypes.has(name) ? deriving.filter(x => x !== "Repr" && x !== "DecidableEq") : deriving;
164
+ return der.length > 0 ? `\nderiving ${der.join(", ")}` : "";
165
+ }
87
166
  let _needsJSString = false;
167
+ let _needsUnknown = false;
168
+ let _unknownEmitted = false; // across files in one run — the def file imports the types file
169
+ // Bool-vs-Prop context. Lean keeps `Bool` and `Prop` distinct; the IR uses the
170
+ // Prop connectives (∧/∨/¬) uniformly. In a computational position the connectives
171
+ // may need to be the Bool ones (&&/||/!). They are only *required* when a connective
172
+ // has an operand that does not coerce Bool→Prop — see `needsBoolConnectives`. A body
173
+ // built only from decidable atoms (comparisons, Bool-returning calls) coerces fine
174
+ // and stays in the more proof-friendly Prop form.
175
+ let _boolCtx = false;
176
+ // A Bool-valued atom that does NOT coerce to Prop: an inlined union discriminator
177
+ // (lowered to a match-bool `match x with | .C .. => true | _ => false`) or a raw
178
+ // `match` used as a Bool — neither has a `Decidable` instance Lean can synthesize
179
+ // through the `match`. Under a Prop connective (∧/∨/¬) such an operand is a type
180
+ // error, so its presence forces the whole body to Bool connectives.
181
+ function isNonCoercibleBoolAtom(e) {
182
+ if (e.kind === "match")
183
+ return true;
184
+ if (e.kind === "binop" && (e.op === "=" || e.op === "≠") && e.right.kind === "constructor") {
185
+ const rhs = e.right; // capture the narrowed node so it survives the closure below
186
+ const ctor = rhs.type ? _unionCtors.get(rhs.type)?.find(c => c.name === rhs.name) : undefined;
187
+ return !!ctor && ctor.fields.length > 0;
188
+ }
189
+ return false;
190
+ }
191
+ // A ∧/∨/¬ connective with a non-coercible operand — the specific node that would
192
+ // fail to elaborate if emitted in Prop form.
193
+ function connectiveHasNonCoercibleOperand(e) {
194
+ if (e.kind === "binop" && (e.op === "∧" || e.op === "∨"))
195
+ return isNonCoercibleBoolAtom(e.left) || isNonCoercibleBoolAtom(e.right);
196
+ if (e.kind === "unop" && e.op === "¬")
197
+ return isNonCoercibleBoolAtom(e.expr);
198
+ return false;
199
+ }
200
+ // True iff some ∧/∨/¬ connective in `e` has a non-coercible operand, i.e. emitting
201
+ // the body with Prop connectives would fail to elaborate. (A decidable atom coerces,
202
+ // so a body with no such operand stays Prop — this keeps arithmetic predicates like
203
+ // `x ≥ 0 ∧ x < n` in `∧` form rather than forcing `&&`.)
204
+ function needsBoolConnectives(e) {
205
+ return anyExpr(e, connectiveHasNonCoercibleOperand);
206
+ }
88
207
  function emitMethodCall(tyKind, method, monadic, obj, args) {
89
208
  // Array methods
90
209
  if (tyKind === "array") {
@@ -100,10 +219,14 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
100
219
  return args.length > 1 ? `(${obj}.extract ${args[1]} ${obj}.size).contains ${args[0]}` : `${obj}.contains ${args[0]}`;
101
220
  if (method === "find")
102
221
  return `${obj}.find? ${args[0]}`;
222
+ if (method === "join")
223
+ return `(String.intercalate ${args[0]} ${obj}.toList)`;
103
224
  if (method === "with")
104
225
  return `${obj}.set! ${args[0]} ${args[1]}`;
105
226
  if (method === "push")
106
227
  return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
228
+ if (method === "unshift")
229
+ return `(#[${args.join(", ")}] ++ ${obj})`;
107
230
  if (method === "concat")
108
231
  return args.length === 1 ? `Array.push ${obj} ${args[0]}` : `${obj} ++ #[${args.join(", ")}]`;
109
232
  // arr.slice → Array.extract. No-arg slice is a full copy (Array is a value
@@ -162,7 +285,8 @@ function wrapOperand(sub, parentPrec) {
162
285
  }
163
286
  function emitExpr(e, parentPrec) {
164
287
  switch (e.kind) {
165
- case "var": return escapeName(e.name);
288
+ // `undefined` is the IR's spelling of the absent optional (mirrors dafny-emit's None)
289
+ case "var": return e.name === "undefined" ? "none" : escapeName(e.name);
166
290
  case "num": return `${e.value}`;
167
291
  case "bool": return e.value ? "true" : "false";
168
292
  case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
@@ -183,6 +307,7 @@ function emitExpr(e, parentPrec) {
183
307
  return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
184
308
  case "emptyMap": return `Std.HashMap.empty`;
185
309
  case "emptySet": return `Std.HashSet.empty`;
310
+ case "default": return `(default : ${tyToLean(e.type)})`;
186
311
  case "methodCall": {
187
312
  const obj = emitExpr(e.obj);
188
313
  const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "methodCall" || e.obj.kind === "if" || e.obj.kind === "let";
@@ -201,11 +326,25 @@ function emitExpr(e, parentPrec) {
201
326
  }
202
327
  case "unop":
203
328
  if (e.op === "¬")
204
- return `¬(${emitExpr(e.expr)})`;
329
+ return _boolCtx ? `!(${emitExpr(e.expr)})` : `¬(${emitExpr(e.expr)})`;
205
330
  if (e.op === "-" && e.expr.kind === "num")
206
331
  return `-${e.expr.value}`;
207
332
  return `(-${emitExpr(e.expr)})`;
208
333
  case "binop": {
334
+ // Discriminator test against a constructor that carries fields:
335
+ // `x = .Ctor` → `(match x with | .Ctor .. => true | _ => false)`. A
336
+ // multi-field constructor is a function, not a value, so a bare
337
+ // `x = Type.Ctor` is ill-typed. The Bool result coerces to Prop in spec
338
+ // positions, mirroring how the backend already treats decidable atoms.
339
+ // Nullary constructors (enums) keep the cheap `DecidableEq` comparison.
340
+ if ((e.op === "=" || e.op === "≠") && e.right.kind === "constructor") {
341
+ const rhs = e.right; // capture the narrowed node so it survives into the closure below
342
+ const ctor = rhs.type ? _unionCtors.get(rhs.type)?.find(c => c.name === rhs.name) : undefined;
343
+ if (ctor && ctor.fields.length > 0) {
344
+ const [yes, no] = e.op === "=" ? ["true", "false"] : ["false", "true"];
345
+ return `(match ${emitExpr(e.left)} with | .${escapeName(rhs.name)} .. => ${yes} | _ => ${no})`;
346
+ }
347
+ }
209
348
  // `k in m` (map/set membership) → `m.contains k` in Lean. Dafny has
210
349
  // native `in`; Lean uses the method form for HashMap/HashSet.
211
350
  if (e.op === "in") {
@@ -213,7 +352,10 @@ function emitExpr(e, parentPrec) {
213
352
  const wrap = e.right.kind === "binop" || e.right.kind === "app" || e.right.kind === "methodCall";
214
353
  return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
215
354
  }
216
- const op = e.op === "arrayConcat" ? "++" : e.op;
355
+ const op = e.op === "arrayConcat" ? "++"
356
+ : _boolCtx && e.op === "∧" ? "&&"
357
+ : _boolCtx && e.op === "∨" ? "||"
358
+ : e.op;
217
359
  // ↔ does not chain in Lean — a nested iff operand needs parens.
218
360
  const childPrec = e.op === "↔" ? prec(e.op) + 1 : prec(e.op);
219
361
  // `-`, `/`, `%` are left-associative and non-associative, so an equal-
@@ -241,6 +383,12 @@ function emitExpr(e, parentPrec) {
241
383
  // the bare form, so its output is unaffected.)
242
384
  if (e.ctorOf)
243
385
  return args.length ? `${e.ctorOf}.${e.fn} ${args.join(" ")}` : `${e.ctorOf}.${e.fn}`;
386
+ // Option constructors arrive Dafny-spelled from transform (`app "Some"`);
387
+ // Lean core exports the lowercase forms as top-level names.
388
+ if (e.fn === "Some" && args.length === 1)
389
+ return `some ${args[0]}`;
390
+ if (e.fn === "None" && args.length === 0)
391
+ return `none`;
244
392
  // SetToSeq → .toArray for Lean (HashSet has native toArray)
245
393
  if (e.fn === "SetToSeq" && args.length === 1)
246
394
  return `${args[0]}.toArray`;
@@ -263,6 +411,24 @@ function emitExpr(e, parentPrec) {
263
411
  return `${e.fn} ${args.join(" ")}`;
264
412
  }
265
413
  case "field": {
414
+ // Union destructor (tagged by transform): `x.field` where x is a
415
+ // multi-constructor inductive. Lean has no projection there, so match the
416
+ // owning constructor, bind the field positionally, and ignore the rest.
417
+ // Other constructors fall to `default` — the source guards every such
418
+ // access with a discriminator test, so that branch is never reached.
419
+ if (e.fromUnion) {
420
+ const ctors = _unionCtors.get(e.fromUnion);
421
+ // Pin the owning ctor when given (field names repeat across variants);
422
+ // otherwise fall back to the sole variant carrying this field name.
423
+ const owner = (e.ctor ? ctors?.find(c => c.name === e.ctor) : undefined)
424
+ ?? ctors?.find(c => c.fields.some(f => f.name === e.field));
425
+ if (owner) {
426
+ const idx = owner.fields.findIndex(f => f.name === e.field);
427
+ const pats = owner.fields.map((_, i) => (i === idx ? "_v" : "_")).join(" ");
428
+ const fty = tyToLean(owner.fields[idx].type);
429
+ return `(match ${emitExpr(e.obj)} with | .${escapeName(owner.name)} ${pats} => _v | _ => (default : ${fty}))`;
430
+ }
431
+ }
266
432
  const obj = emitExpr(e.obj);
267
433
  if (e.field === "collectionSize")
268
434
  return `${obj}.size`;
@@ -328,10 +494,19 @@ function emitStmts(stmts, indent) {
328
494
  function emitStmt(s, indent) {
329
495
  const pad = " ".repeat(indent);
330
496
  switch (s.kind) {
331
- case "let":
332
- return s.mutable
333
- ? `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`
334
- : `${pad}let ${escapeName(s.name)} := ${emitExpr(s.value)}`;
497
+ case "let": {
498
+ // `mut` lets always carry their type (assignments must re-elaborate at it).
499
+ // Immutable lets are ascribed only when the initializer is a `match` (the
500
+ // `??` / Option-unwrap lowerings): Velvet's WP elaboration runs *backwards*,
501
+ // so without the ascription the binding's type is a metavariable that a
502
+ // later use pins first — e.g. `lines.size ≤ maxLines` pins `maxLines : ℕ`
503
+ // and the ℤ-valued unwrap arms then fail to elaborate. Other initializer
504
+ // shapes determine their own type, and leaving them bare keeps previously
505
+ // generated (and proven) artifacts byte-stable.
506
+ const ascribe = s.mutable || s.value.kind === "match";
507
+ const ty = ascribe && s.type.kind !== "unknown" ? ` : ${tyToLean(s.type)}` : "";
508
+ return `${pad}let ${s.mutable ? "mut " : ""}${escapeName(s.name)}${ty} := ${emitExpr(s.value)}`;
509
+ }
335
510
  case "assign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
336
511
  case "ghostLet":
337
512
  return `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`;
@@ -344,7 +519,10 @@ function emitStmt(s, indent) {
344
519
  // to WPGen.default, which drops the assertion. Coerce to Prop via `= true`.
345
520
  // Top-level Prop constructs (`=`, `<`, `∧`, `¬`, `∀`, `∃`, `→`) already
346
521
  // land in Prop — Lean auto-coerces inner Bools there.
522
+ const prevBoolCtx = _boolCtx;
523
+ _boolCtx = false; // assertions are Prop
347
524
  const inner = emitExpr(s.expr);
525
+ _boolCtx = prevBoolCtx;
348
526
  const wrapped = isPropValued(s.expr) ? inner : `(${inner}) = true`;
349
527
  return `${pad}assertGadget (${wrapped})`;
350
528
  }
@@ -411,21 +589,30 @@ function emitStmt(s, indent) {
411
589
  return lines.join("\n");
412
590
  }
413
591
  case "while": {
592
+ // Guard is computational (Bool); invariants / done_with are Prop.
414
593
  const lines = [`${pad}while ${emitExpr(s.cond)}`];
594
+ const prevBoolCtx = _boolCtx;
595
+ _boolCtx = false;
415
596
  for (const inv of s.invariants)
416
597
  lines.push(`${pad} invariant ${emitExpr(inv)}`);
598
+ // `done_with True` (the auto-supplied fact for breaking loops) is a Prop;
599
+ // the bool literal would need a coercion, so emit the Prop `True` directly.
417
600
  if (s.doneWith)
418
- lines.push(`${pad} done_with ${emitExpr(s.doneWith)}`);
601
+ lines.push(`${pad} done_with ${s.doneWith.kind === "bool" && s.doneWith.value ? "True" : emitExpr(s.doneWith)}`);
419
602
  if (s.decreasing)
420
603
  lines.push(`${pad} decreasing ${emitExpr(s.decreasing)}`);
604
+ _boolCtx = prevBoolCtx;
421
605
  lines.push(`${pad}do`);
422
606
  lines.push(emitStmts(s.body, indent + 1));
423
607
  return lines.join("\n");
424
608
  }
425
609
  case "forin": {
426
610
  const lines = [`${pad}for ${s.idx} in [:${emitExpr(s.bound)}]`];
611
+ const prevBoolCtx = _boolCtx;
612
+ _boolCtx = false; // invariants are Prop
427
613
  for (const inv of s.invariants)
428
614
  lines.push(`${pad} invariant ${emitExpr(inv)}`);
615
+ _boolCtx = prevBoolCtx;
429
616
  lines.push(`${pad}do`);
430
617
  lines.push(emitStmts(s.body, indent + 1));
431
618
  return lines.join("\n");
@@ -446,17 +633,13 @@ function emitDecl(d) {
446
633
  lines.push(` | ${c.name} ${params} : ${d.name}`);
447
634
  }
448
635
  }
449
- if (d.deriving.length > 0)
450
- lines.push(`deriving ${d.deriving.join(", ")}`);
451
- return lines.join("\n");
636
+ return lines.join("\n") + emitDeriving(d.name, d.deriving);
452
637
  }
453
638
  case "structure": {
454
639
  const lines = [`structure ${d.name} where`];
455
640
  for (const f of d.fields)
456
641
  lines.push(` ${escapeName(f.name)} : ${tyToLean(f.type)}`);
457
- if (d.deriving.length > 0)
458
- lines.push(`deriving ${d.deriving.join(", ")}`);
459
- return lines.join("\n");
642
+ return lines.join("\n") + emitDeriving(d.name, d.deriving);
460
643
  }
461
644
  case "type-alias": {
462
645
  return `abbrev ${d.name} := ${tyToLean(d.target)}`;
@@ -467,7 +650,17 @@ function emitDecl(d) {
467
650
  }
468
651
  case "def": {
469
652
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
470
- let out = `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
653
+ // A Bool-returning pure function is a computation, not a proposition, so its
654
+ // connectives *may* need the Bool operators (&&/||/!) — but only when a
655
+ // connective has a non-coercible operand (e.g. an inlined union discriminator).
656
+ // A predicate built from decidable atoms (`x ≥ 0 ∧ x < n`) coerces to Bool as
657
+ // a whole and stays in the more proof-friendly Prop form. Other return types
658
+ // only have connectives inside (Decidable) conditions, where Prop is fine.
659
+ const prevBoolCtx = _boolCtx;
660
+ _boolCtx = tyToLean(d.returnType) === "Bool" && needsBoolConnectives(d.body);
661
+ const body = emitPureExpr(d.body, 1);
662
+ _boolCtx = prevBoolCtx;
663
+ let out = `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${body}`;
471
664
  // A `//@ decreases` on a pure function marks it recursive and names its
472
665
  // termination measure — emit it as Lean's `termination_by`. This is
473
666
  // required when the recursion is on `arr.slice(...)` (→ `Array.extract`,
@@ -484,13 +677,18 @@ function emitDecl(d) {
484
677
  throw new Error("function by method is not supported for Lean backend");
485
678
  case "method": {
486
679
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
680
+ // Spec clauses are Prop; the `do` body is computational (Bool).
681
+ const prevBoolCtx = _boolCtx;
682
+ _boolCtx = false;
487
683
  const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
488
684
  for (const r of d.requires)
489
685
  lines.push(` require ${emitExpr(r)}`);
490
686
  for (const e of d.ensures)
491
687
  lines.push(` ensures ${emitExpr(e)}`);
492
688
  lines.push(" do");
689
+ _boolCtx = true;
493
690
  lines.push(emitStmts(d.body, 2));
691
+ _boolCtx = prevBoolCtx;
494
692
  return lines.join("\n");
495
693
  }
496
694
  case "namespace": {
@@ -504,10 +702,25 @@ function emitDecl(d) {
504
702
  throw new Error(`Lean class support not yet implemented: ${d.name}`);
505
703
  case "const":
506
704
  return `def ${escapeName(d.name)} : ${tyToLean(d.type)} := ${emitExpr(d.value)}`;
507
- case "extern":
508
- // Lean: emit an opaque function declaration. The user is expected to
509
- // provide an axiomatic body or a stub in the companion spec file.
510
- throw new Error(`Lean extern support not yet implemented: ${d.name}`);
705
+ case "extern": {
706
+ // Mirror Dafny's `function {:axiom}`: an uninterpreted total function.
707
+ // In Lean that is an `opaque` declaration (sound it commits to no body,
708
+ // only to the type being inhabited). Any `requires`/`ensures` the source
709
+ // carried become a characterizing `axiom`; `\result` was already replaced
710
+ // by the call expression in the transform, so ensures reference `name args`.
711
+ const tp = d.typeParams.length > 0 ? ` {${d.typeParams.join(" ")} : Type}` : "";
712
+ const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
713
+ const sig = `opaque ${escapeName(d.name)}${tp}${params ? ` ${params}` : ""} : ${tyToLean(d.returnType)}`;
714
+ if (d.requires.length === 0 && d.ensures.length === 0)
715
+ return sig;
716
+ // Spec axiom: ∀ params, req1 → … → (ens1 ∧ … ∧ ensN). Tagged `@[grind]` so
717
+ // the proof automation can use it, matching the ghost-function convention.
718
+ const hyps = d.requires.map(emitExpr);
719
+ const concl = d.ensures.map(emitExpr).join(" ∧ ");
720
+ const axBody = [...hyps, concl].join(" → ");
721
+ const axiom = `@[grind] axiom ${escapeName(d.name)}_spec${params ? ` ${params}` : ""} : ${axBody}`;
722
+ return `${sig}\n${axiom}`;
723
+ }
511
724
  }
512
725
  }
513
726
  /** Emit a pure expression with indented if/match blocks. */
@@ -533,12 +746,19 @@ function emitPureExpr(e, indent) {
533
746
  // ── File emission ────────────────────────────────────────────
534
747
  export function emitLeanFile(file) {
535
748
  _needsJSString = false;
536
- // Emit declarations first so _needsJSString is set
749
+ _needsUnknown = false;
750
+ registerInductives(file.decls);
751
+ // Emit declarations first so _needsJSString / _needsUnknown are set
537
752
  const declLines = [];
538
753
  for (const decl of file.decls) {
539
754
  declLines.push("");
540
755
  declLines.push(emitDecl(decl));
541
756
  }
757
+ // The `unknown` carrier must precede every use (Lean is definition-before-use).
758
+ if (_needsUnknown && !_unknownEmitted) {
759
+ declLines.unshift("", "/-- Opaque carrier for `unknown`-typed values (mirrors Dafny's `type Unknown(==, 0)`). -/", "opaque Unknown : Type");
760
+ _unknownEmitted = true;
761
+ }
542
762
  const lines = [];
543
763
  if (file.comment) {
544
764
  lines.push("/-");
package/tools/dist/lsc.js CHANGED
@@ -82,6 +82,13 @@ function main() {
82
82
  }
83
83
  // File-level directives consumed by the Dafny emitter.
84
84
  const safeSlice = /\/\/@ safe-slice\b/.test(fullText);
85
+ // `//@ lean-module <name>` overrides the Lean module base (default: file
86
+ // basename). Lean module names are flat/global, so two identically-named
87
+ // `.ts` files (e.g. an in-place fork's duplicated `compaction.ts`) would emit
88
+ // colliding `foo.types`/`foo.def` modules; this gives one a distinct base so
89
+ // both can be separate Lean libraries. Lean-only — Dafny is unaffected.
90
+ const leanModuleDirective = fullText.match(/\/\/@ lean-module ([A-Za-z0-9_.\-]+)/);
91
+ const leanModuleOverride = leanModuleDirective ? leanModuleDirective[1] : undefined;
85
92
  // Extract: ts-morph → Raw IR
86
93
  const raw = extractModule(sourceFile);
87
94
  if (cmd === "extract") {
@@ -140,15 +147,16 @@ function main() {
140
147
  process.exit(1);
141
148
  }
142
149
  // ── Lean backend ──────────────────────────────────────────
143
- const specPath = path.join(dir, `${base}.spec.lean`);
144
- const specImport = existsSync(specPath) ? `«${base}.spec»` : undefined;
145
- let { typesFile, defFile } = transformModuleLean(typed, specImport);
150
+ const leanBase = leanModuleOverride ?? base;
151
+ const specPath = path.join(dir, `${leanBase}.spec.lean`);
152
+ const specImport = existsSync(specPath) ? `«${leanBase}.spec»` : undefined;
153
+ let { typesFile, defFile } = transformModuleLean(typed, specImport, leanModuleOverride);
146
154
  if (typesFile)
147
155
  typesFile = peepholeModule(typesFile, "lean");
148
156
  defFile = peepholeModule(defFile, "lean");
149
- const typesPath = typesFile ? path.join(dir, `${base}.types.lean`) : null;
157
+ const typesPath = typesFile ? path.join(dir, `${leanBase}.types.lean`) : null;
150
158
  const typesText = typesFile ? emitLeanFile(typesFile) : null;
151
- const defPath = path.join(dir, `${base}.def.lean`);
159
+ const defPath = path.join(dir, `${leanBase}.def.lean`);
152
160
  const defText = emitLeanFile(defFile);
153
161
  if (cmd === "gen") {
154
162
  leanGen(typesPath, defPath, typesText, defText);
@@ -156,7 +164,7 @@ function main() {
156
164
  }
157
165
  if (cmd === "check") {
158
166
  leanGen(typesPath, defPath, typesText, defText);
159
- if (!leanCheck(dir, base))
167
+ if (!leanCheck(dir, leanBase))
160
168
  process.exit(1);
161
169
  return;
162
170
  }
@@ -100,7 +100,7 @@ const parseSimpleOptionalCheck = parseOptionalCheck;
100
100
  // ── Walkers ──────────────────────────────────────────────────
101
101
  function walkExpr(e) {
102
102
  const r = recurseExpr(e);
103
- return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
103
+ return ruleNullish(r) ?? ruleNullishIndex(r) ?? ruleOptChainIndex(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
104
104
  }
105
105
  function recurseExpr(e) {
106
106
  const re = walkExpr;
@@ -144,7 +144,7 @@ function walkStmt(s) {
144
144
  // array rule fires; independent narrows commute, so the order is harmless.)
145
145
  // && rules fire before the simple rule because they produce nested ifs whose
146
146
  // inner shape doesn't match the simple rule directly.
147
- return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? r;
147
+ return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? ruleExprStmtAndOptional(r) ?? r;
148
148
  }
149
149
  function walkStmts(stmts) {
150
150
  const result = [];
@@ -434,6 +434,61 @@ function ruleNullish(e) {
434
434
  ty: e.ty,
435
435
  };
436
436
  }
437
+ /** Rule (expression): `arr[i] ?? right` — nullish coalescing on an array index.
438
+ * Under noUncheckedIndexedAccess `arr[i]` is `T | undefined`, undefined exactly
439
+ * when out of bounds, so → `(0 <= i && i < arr.length) ? arr[i] : right`. The
440
+ * guarded `then` keeps the seq index in bounds for the backend. (Map index is
441
+ * already optional-typed and handled by ruleNullish above; this is the array
442
+ * case, whose element type stays non-optional in expression position.) */
443
+ function ruleNullishIndex(e) {
444
+ if (e.kind !== "nullish")
445
+ return null;
446
+ if (e.left.kind !== "index")
447
+ return null;
448
+ if (e.left.obj.ty.kind !== "array")
449
+ return null;
450
+ const idx = e.left.idx;
451
+ const len = { kind: "field", obj: e.left.obj, field: "length", ty: { kind: "int" } };
452
+ const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
453
+ const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
454
+ const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
455
+ return { kind: "conditional", cond, then: e.left, else: e.right, ty: e.ty };
456
+ }
457
+ /** Rule (expression): `arr[i]?.<chain>` — optional chaining on an array index,
458
+ * the optChain sibling of ruleNullishIndex. `arr[i]` is `T | undefined`,
459
+ * undefined exactly out of bounds, so → `(0 <= i && i < arr.length) ? <chain on
460
+ * arr[i]> : undefined`. The conditional's optional type makes transform wrap the
461
+ * in-bounds chain result in Some and the OOB branch in None — the same Option<…>
462
+ * a directly-optional scrutinee yields via ruleOptChain, just bounds-guarded.
463
+ * (ruleOptChain itself bails here: an array index is typed as the non-optional
464
+ * element type, so its `?.` never reaches that rule.) */
465
+ function ruleOptChainIndex(e) {
466
+ if (e.kind !== "optChain")
467
+ return null;
468
+ if (e.obj.kind !== "index")
469
+ return null;
470
+ if (e.obj.obj.ty.kind !== "array")
471
+ return null;
472
+ const idx = e.obj.idx;
473
+ const len = { kind: "field", obj: e.obj.obj, field: "length", ty: { kind: "int" } };
474
+ const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
475
+ const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
476
+ const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
477
+ let body = e.obj; // arr[i] — in bounds under `cond`
478
+ for (const step of e.chain) {
479
+ if (step.kind === "field") {
480
+ body = { kind: "field", obj: body, field: step.name, ty: step.ty };
481
+ }
482
+ else if (step.kind === "index") {
483
+ body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
484
+ }
485
+ else {
486
+ body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind };
487
+ }
488
+ }
489
+ const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
490
+ return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
491
+ }
437
492
  /** Rule (expression): `obj?.<chain>` — single-eval optional chain.
438
493
  * → `someMatch obj { Some(_oc{N}_val) => apply(chain, _oc{N}_val), None => undefined }`.
439
494
  * The someBody applies the chain to the binder directly (field/call/index),
@@ -598,6 +653,38 @@ function ruleIfAndOptional(s) {
598
653
  noneBody: [],
599
654
  };
600
655
  }
656
+ /** Rule: a bare expression statement `x !== undefined && rest` (the `if`-less
657
+ * guard idiom, TS-equivalent to `if (x !== undefined) rest;`) where `x` is a
658
+ * pure access path.
659
+ * → `someMatch x { Some(_x_val) => rest;, None => {} }`.
660
+ * Runs `rest` for effect inside the narrowed scope. Wrapping `rest` as an
661
+ * expr-statement and walking it lets chained checks
662
+ * (`a !== undefined && a.b !== undefined && a.b.f()`) nest into someMatches.
663
+ * Unlike the ternary rule (`ruleConditionalAndOptional`), a method call in
664
+ * `rest` is fine here: a statement-level someMatch arm keeps it in statement
665
+ * position, so transform never ANF-lifts it out of the arm (which would drop
666
+ * the guard and reference the un-narrowed optional). */
667
+ function ruleExprStmtAndOptional(s) {
668
+ if (s.kind !== "expr")
669
+ return null;
670
+ if (s.expr.kind !== "binop" || s.expr.op !== "&&")
671
+ return null;
672
+ const extracted = extractLeftmostOptionalCheck(s.expr);
673
+ if (!extracted)
674
+ return null;
675
+ const { check, restCond } = extracted;
676
+ const innerStmt = { kind: "expr", expr: restCond };
677
+ const someBody = canBeFalsy(check)
678
+ ? [{ kind: "if", cond: bound(check), then: [walkStmt(innerStmt)], else: [] }]
679
+ : [walkStmt(innerStmt)];
680
+ return {
681
+ kind: "someMatch",
682
+ scrutinee: check.scrutinee, binderTy: check.innerTy,
683
+ binder: check.binderHint,
684
+ someBody,
685
+ noneBody: [],
686
+ };
687
+ }
601
688
  // ── Discriminant narrowing ──────────────────────────────────
602
689
  /** Detect `Array.isArray(<path>)` where `<path>` is a var or a chain of
603
690
  * field accesses rooted at a var, and the path's type is a synthesized
@@ -13,6 +13,7 @@ function mapExpr(e, f) {
13
13
  case "emptyMap":
14
14
  case "emptySet":
15
15
  case "havoc":
16
+ case "default":
16
17
  case "mapLiteral": return e;
17
18
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
18
19
  case "unop": return { ...e, expr: r(e.expr) };
@@ -358,6 +359,7 @@ function rewriteChildrenExpr(e) {
358
359
  case "emptyMap":
359
360
  case "emptySet":
360
361
  case "havoc":
362
+ case "default":
361
363
  case "mapLiteral": return e;
362
364
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
363
365
  case "unop": return { ...e, expr: r(e.expr) };