lemmascript 0.5.8 → 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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ See the external case studies:
29
29
  - **[xyflow-lemmascript](https://github.com/midspiral/xyflow-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [xyflow](https://github.com/xyflow/xyflow)'s core edge and geometry utilities. 9 functions verified: `addEdge` (dedup — never loses edges, adds at most one), `reconnectEdge` (semantic: under a unique-id precondition, the result is in-place — `|result| ≤ |edges|`, no insertion — *and* when a matching edge existed with non-empty new endpoints, the output contains an edge with those endpoints. Uses `//@ assume` to characterize destructuring, `find`, and the constructed edge), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Also adds a new verified feature — a **DAG connection gate**: `canReach` decides reachability soundly *and* completely, and `wouldCreateCycle` gates both new connections and edge reconnections so the graph is proven to stay acyclic, with `isAcyclic` (sound + complete) establishing the base case and a topological-rank witness giving a safe evaluation order (+29 obligations), shown live in a React Flow demo that refuses cycle-closing edges — extending the case study from verifying existing code to adding a verified feature. Dafny only.
30
30
  - **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
31
31
  - **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
32
- - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. Dafny only.
32
+ - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. **Dafny + Lean**: the no-orphan theorem, the changelog semver core, and both tool-output truncators also carry Lean 4 (Velvet/Loom) proofs from the same annotated source, zero `sorry`; the Lean port drove the backend's brownfield batch (cross-file externs, union destructor lowering, Bool-vs-Prop contexts, `return`-in-loop elimination).
33
33
  - **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
34
34
  - **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
35
35
  - **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -533,6 +533,9 @@ function emitExpr(e) {
533
533
  case "exists": return emitQuantifier(e, "exists");
534
534
  case "let": return `(var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)})`;
535
535
  case "havoc": return "*";
536
+ // No Dafny producer: the only `default` source is the Lean return-in-loop
537
+ // rewrite, which never runs on the Dafny path (native in-loop returns).
538
+ case "default": throw new Error("default-value expression not produced on the Dafny path");
536
539
  }
537
540
  }
538
541
  /** Emit a pure expression with indentation for if/match/let. */
package/tools/dist/ir.js CHANGED
@@ -4,4 +4,59 @@
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 {};
7
+ export function anyExpr(e, pred) {
8
+ if (pred(e))
9
+ return true;
10
+ switch (e.kind) {
11
+ case "var":
12
+ case "num":
13
+ case "bool":
14
+ case "str":
15
+ case "emptyMap":
16
+ case "emptySet":
17
+ case "havoc":
18
+ case "default": return false;
19
+ case "constructor": return (e.args ?? []).some(a => anyExpr(a, pred));
20
+ case "binop": return anyExpr(e.left, pred) || anyExpr(e.right, pred);
21
+ case "unop":
22
+ case "toNat":
23
+ case "toReal": return anyExpr(e.expr, pred);
24
+ case "app": return e.args.some(a => anyExpr(a, pred));
25
+ case "field": return anyExpr(e.obj, pred);
26
+ case "index": return anyExpr(e.arr, pred) || anyExpr(e.idx, pred);
27
+ case "implies": return e.premises.some(p => anyExpr(p, pred)) || anyExpr(e.conclusion, pred);
28
+ case "record": return (e.spread ? anyExpr(e.spread, pred) : false) || e.fields.some(f => anyExpr(f.value, pred));
29
+ case "arrayLiteral": return e.elems.some(x => anyExpr(x, pred));
30
+ case "mapLiteral": return e.entries.some(en => anyExpr(en.key, pred) || anyExpr(en.value, pred));
31
+ case "methodCall": return anyExpr(e.obj, pred) || e.args.some(a => anyExpr(a, pred));
32
+ case "lambda": return e.body.some(s => anyExprInStmt(s, pred));
33
+ case "if": return anyExpr(e.cond, pred) || anyExpr(e.then, pred) || anyExpr(e.else, pred);
34
+ case "match": return (typeof e.scrutinee !== "string" && anyExpr(e.scrutinee, pred)) || e.arms.some(a => anyExpr(a.body, pred));
35
+ case "forall":
36
+ case "exists": return anyExpr(e.body, pred);
37
+ case "let": return anyExpr(e.value, pred) || anyExpr(e.body, pred);
38
+ }
39
+ }
40
+ export function anyExprInStmt(s, pred) {
41
+ switch (s.kind) {
42
+ case "let":
43
+ case "assign":
44
+ case "bind":
45
+ case "let-bind":
46
+ case "return":
47
+ case "ghostLet":
48
+ case "ghostAssign": return anyExpr(s.value, pred);
49
+ case "assert": return anyExpr(s.expr, pred);
50
+ case "break":
51
+ case "continue": return false;
52
+ case "if": return anyExpr(s.cond, pred) || anyExprInStmts(s.then, pred) || anyExprInStmts(s.else, pred);
53
+ case "match": return (typeof s.scrutinee !== "string" && anyExpr(s.scrutinee, pred)) || s.arms.some(a => anyExprInStmts(a.body, pred));
54
+ case "while": return anyExpr(s.cond, pred) || s.invariants.some(i => anyExpr(i, pred))
55
+ || (s.decreasing ? anyExpr(s.decreasing, pred) : false) || (s.doneWith ? anyExpr(s.doneWith, pred) : false)
56
+ || anyExprInStmts(s.body, pred);
57
+ case "forin": return anyExpr(s.bound, pred) || s.invariants.some(i => anyExpr(i, pred)) || anyExprInStmts(s.body, pred);
58
+ }
59
+ }
60
+ export function anyExprInStmts(stmts, pred) {
61
+ return stmts.some(s => anyExprInStmt(s, pred));
62
+ }
@@ -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
  }
@@ -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 = [];
@@ -653,6 +653,38 @@ function ruleIfAndOptional(s) {
653
653
  noneBody: [],
654
654
  };
655
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
+ }
656
688
  // ── Discriminant narrowing ──────────────────────────────────
657
689
  /** Detect `Array.isArray(<path>)` where `<path>` is a var or a chain of
658
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) };
@@ -4,6 +4,7 @@
4
4
  * Consumes resolved types and classifications.
5
5
  * No type lookups, no string parsing, no re-inference.
6
6
  */
7
+ import { anyExprInStmts } from "./ir.js";
7
8
  import { parseTsType } from "./types.js";
8
9
  // ── Generic IR walkers ──────────────────────────────────────
9
10
  /**
@@ -23,7 +24,8 @@ function mapExpr(e, f) {
23
24
  case "str":
24
25
  case "emptyMap":
25
26
  case "emptySet":
26
- case "havoc": return e;
27
+ case "havoc":
28
+ case "default": return e;
27
29
  case "mapLiteral": return { ...e, entries: e.entries.map(en => ({ key: r(en.key), value: r(en.value) })) };
28
30
  case "constructor": return e.args ? { ...e, args: e.args.map(r) } : e;
29
31
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
@@ -630,6 +632,17 @@ function lowerExpr(e, binds) {
630
632
  return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
631
633
  }
632
634
  }
635
+ // Union destructor: `x.field` where x is a discriminated union and `field`
636
+ // is a data field of one of its variants. Dafny reads the destructor
637
+ // directly; Lean has no field projection on a multi-ctor inductive, so tag
638
+ // the node with the union's base name and let the Lean emitter `match`.
639
+ if (e.obj.ty.kind === "user") {
640
+ const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
641
+ const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
642
+ if (decl?.variants?.some(v => v.fields.some(f => f.name === e.field))) {
643
+ return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName };
644
+ }
645
+ }
633
646
  return { kind: "field", obj: transformExpr(e.obj), field: e.field };
634
647
  case "index": {
635
648
  const idx = transformExpr(e.idx);
@@ -1106,6 +1119,188 @@ function buildPopLowering(arrName, arrTy) {
1106
1119
  const guardedTrunc = { kind: "if", cond: isNonEmpty, then: truncated, else: arrVar };
1107
1120
  return { optValue, guardedTrunc };
1108
1121
  }
1122
+ /**
1123
+ * Velvet (Lean backend) rejects `return` inside a loop. For a function shaped as
1124
+ * `…; while (…) { … early returns … }; return fallthrough`, hoist a mutable
1125
+ * result variable: each in-loop `return e` becomes `_loopRet := e; break`, then
1126
+ * `return _loopRet` after the loop. The trailing fallthrough return seeds
1127
+ * `_loopRet`, so a normal loop exit returns it unchanged. Dafny is untouched —
1128
+ * it keeps the native early returns.
1129
+ */
1130
+ function stmtsContainReturn(stmts) {
1131
+ for (const s of stmts) {
1132
+ if (s.kind === "return")
1133
+ return true;
1134
+ if (s.kind === "if" && (stmtsContainReturn(s.then) || stmtsContainReturn(s.else)))
1135
+ return true;
1136
+ if (s.kind === "match" && s.arms.some(a => stmtsContainReturn(a.body)))
1137
+ return true;
1138
+ // Don't descend into a nested while — its returns would target that loop.
1139
+ }
1140
+ return false;
1141
+ }
1142
+ function replaceReturnsWithBreak(stmts, retVar) {
1143
+ return stmts.flatMap((s) => {
1144
+ if (s.kind === "return")
1145
+ return [{ kind: "assign", target: retVar, value: s.value }, { kind: "break" }];
1146
+ if (s.kind === "if")
1147
+ return [{ ...s, then: replaceReturnsWithBreak(s.then, retVar), else: replaceReturnsWithBreak(s.else, retVar) }];
1148
+ if (s.kind === "match")
1149
+ return [{ ...s, arms: s.arms.map(a => ({ ...a, body: replaceReturnsWithBreak(a.body, retVar) })) }];
1150
+ return [s];
1151
+ });
1152
+ }
1153
+ // Seed value for `_loopRet` when a return-in-loop function has no trailing
1154
+ // fallthrough return. Readable literals for the primitives; everything else
1155
+ // (user types, arrays, maps, optionals) gets a typed `default : T` rather than a
1156
+ // type-incorrect `0` — these types derive `Inhabited`, so the default exists.
1157
+ function defaultExprForTy(ty) {
1158
+ switch (ty.kind) {
1159
+ case "bool": return { kind: "bool", value: false };
1160
+ case "string": return { kind: "str", value: "" };
1161
+ case "nat":
1162
+ case "int":
1163
+ case "real": return { kind: "num", value: 0 };
1164
+ default: return { kind: "default", type: ty };
1165
+ }
1166
+ }
1167
+ function eliminateReturnInLoops(stmts, retTy, resultInvariants) {
1168
+ const out = [];
1169
+ for (let i = 0; i < stmts.length; i++) {
1170
+ const s = stmts[i];
1171
+ if (s.kind === "while" && stmtsContainReturn(s.body)) {
1172
+ const retVar = "_loopRet";
1173
+ const next = stmts[i + 1];
1174
+ const init = next && next.kind === "return" ? next.value : defaultExprForTy(retTy);
1175
+ out.push({ kind: "let", name: retVar, type: retTy, mutable: true, value: init });
1176
+ // The result variable carries the postcondition (the function's `ensures`
1177
+ // with `\result` → `_loopRet`) as a loop invariant: it holds initially (the
1178
+ // fallthrough seed) and after each `_loopRet := e; break`, so the
1179
+ // postcondition is re-established from the invariant when the loop exits.
1180
+ // Where Dafny discharged the postcondition at each `return` site, the
1181
+ // break-rewrite discharges it once, after the loop.
1182
+ out.push({ ...s, invariants: [...s.invariants, ...resultInvariants], body: replaceReturnsWithBreak(s.body, retVar) });
1183
+ out.push({ kind: "return", value: { kind: "var", name: retVar } });
1184
+ if (next && next.kind === "return")
1185
+ i++; // consume the seeded fallthrough return
1186
+ }
1187
+ else {
1188
+ out.push(s);
1189
+ }
1190
+ }
1191
+ return out;
1192
+ }
1193
+ /** True if `name` is referenced as a variable anywhere in `stmts`. Conservative:
1194
+ * counts every occurrence and ignores shadowing — a spurious hit only costs an
1195
+ * unused `let`, whereas a miss would leave a binder unbound. */
1196
+ function stmtsUseVar(stmts, name) {
1197
+ return anyExprInStmts(stmts, e => e.kind === "var" && e.name === name);
1198
+ }
1199
+ /**
1200
+ * Velvet (Lean backend) cannot synthesize a `WPGen` for a monadic statement
1201
+ * `match` on a user inductive in a method body (loom's matcher-WPGen drops a
1202
+ * `sorry`). loom *can* handle method-body `if`s (cf. examples/arrayEquals), so
1203
+ * lower such matches to discriminator `if`-chains: `match x with | .C f.. => B`
1204
+ * becomes `if x.C? then (let f := x.f-destructor; B) else …`. Constructor field
1205
+ * binders become `let`s bound to the (already-supported) union destructor. Option
1206
+ * matches and other non-user matches are left alone (loom handles those). Dafny
1207
+ * is untouched — it keeps the native match.
1208
+ */
1209
+ function matchToIfChains(stmts) {
1210
+ return stmts.flatMap((s) => {
1211
+ if (s.kind === "if")
1212
+ return [{ ...s, then: matchToIfChains(s.then), else: matchToIfChains(s.else) }];
1213
+ if (s.kind === "while")
1214
+ return [{ ...s, body: matchToIfChains(s.body) }];
1215
+ if (s.kind === "forin")
1216
+ return [{ ...s, body: matchToIfChains(s.body) }];
1217
+ if (s.kind !== "match")
1218
+ return [s];
1219
+ const arms = s.arms.map(a => ({ ...a, body: matchToIfChains(a.body) }));
1220
+ const ctorArms = arms.filter(a => a.pattern.trim() !== "_");
1221
+ const firstCtor = ctorArms[0]?.pattern.trim().split(/\s+/)[0].replace(/^\./, "");
1222
+ const decl = firstCtor
1223
+ ? _typeDecls.find(d => (d.kind === "discriminated-union" || d.kind === "string-union") &&
1224
+ ((d.variants?.some(v => v.name === firstCtor)) || (d.values?.includes(firstCtor))))
1225
+ : undefined;
1226
+ if (!decl)
1227
+ return [{ ...s, arms }]; // not a user union (e.g. Option) — leave as match
1228
+ const scrutExpr = typeof s.scrutinee === "string" ? { kind: "var", name: s.scrutinee } : s.scrutinee;
1229
+ const defaultArm = arms.find(a => a.pattern.trim() === "_");
1230
+ let elseBranch = defaultArm ? defaultArm.body : [];
1231
+ for (let k = ctorArms.length - 1; k >= 0; k--) {
1232
+ const armBody = ctorArms[k].body;
1233
+ if (armBody.length === 0)
1234
+ continue; // empty arm (no-op) — let it fall through to `else`
1235
+ const toks = ctorArms[k].pattern.trim().split(/\s+/);
1236
+ const ctor = toks[0].replace(/^\./, "");
1237
+ const binders = toks.slice(1);
1238
+ const variant = decl.variants?.find(v => v.name === ctor);
1239
+ // Discriminator condition. A nullary discriminated-union constructor would
1240
+ // need `DecidableEq` for `x = .Ctor` (which such unions don't derive), so
1241
+ // use a match-bool instead. Multi-field ctors already lower to a match-bool;
1242
+ // string-unions derive DecidableEq, so `=` is fine there.
1243
+ const cond = decl.kind === "discriminated-union" && binders.length === 0
1244
+ ? { kind: "match", scrutinee: scrutExpr, arms: [
1245
+ { pattern: `.${ctor}`, body: { kind: "bool", value: true } },
1246
+ { pattern: "_", body: { kind: "bool", value: false } }
1247
+ ] }
1248
+ : { kind: "binop", op: "=", left: scrutExpr, right: { kind: "constructor", name: ctor, type: decl.name } };
1249
+ // Bind only the constructor-field binders the body actually uses, pinning the
1250
+ // owning ctor so the destructor doesn't guess (variants share field names).
1251
+ const lets = [];
1252
+ binders.forEach((b, i) => {
1253
+ const f = variant?.fields[i];
1254
+ if (b !== "_" && f && stmtsUseVar(armBody, b))
1255
+ lets.push({
1256
+ kind: "let", name: b, type: f.type ?? { kind: "unknown" }, mutable: false,
1257
+ value: { kind: "field", obj: scrutExpr, field: f.name, fromUnion: decl.name, ctor },
1258
+ });
1259
+ });
1260
+ elseBranch = [{ kind: "if", cond, then: [...lets, ...armBody], else: elseBranch }];
1261
+ }
1262
+ return elseBranch;
1263
+ });
1264
+ }
1265
+ /** True if `stmts` contain a `break` not nested inside another while. */
1266
+ function stmtsContainBreak(stmts) {
1267
+ for (const s of stmts) {
1268
+ if (s.kind === "break")
1269
+ return true;
1270
+ if (s.kind === "if" && (stmtsContainBreak(s.then) || stmtsContainBreak(s.else)))
1271
+ return true;
1272
+ if (s.kind === "match" && s.arms.some(a => stmtsContainBreak(a.body)))
1273
+ return true;
1274
+ }
1275
+ return false;
1276
+ }
1277
+ /**
1278
+ * loom's default loop-exit fact is `¬guard`, which does not hold for a loop that
1279
+ * `break`s (a break exits with the guard still true). Such loops need an explicit
1280
+ * `//@ done_with` in the TS source (`//@ done_with true` when the loop invariant
1281
+ * alone carries the exit facts). Rather than silently supplying one, reject —
1282
+ * the exit fact is part of the spec and belongs beside the invariants.
1283
+ * Lean-only; Dafny derives loop-exit facts from the break sites themselves.
1284
+ */
1285
+ function requireDoneWithForBreaks(stmts, fnName) {
1286
+ for (const s of stmts) {
1287
+ if (s.kind === "if") {
1288
+ requireDoneWithForBreaks(s.then, fnName);
1289
+ requireDoneWithForBreaks(s.else, fnName);
1290
+ }
1291
+ if (s.kind === "match")
1292
+ for (const a of s.arms)
1293
+ requireDoneWithForBreaks(a.body, fnName);
1294
+ if (s.kind === "while") {
1295
+ if (!s.doneWith && stmtsContainBreak(s.body)) {
1296
+ throw new Error(`${fnName}: loop with break needs //@ done_with on the Lean backend ` +
1297
+ `(an early return in a loop also lowers to a break). ` +
1298
+ `Use //@ done_with true when the loop invariants carry the exit facts.`);
1299
+ }
1300
+ requireDoneWithForBreaks(s.body, fnName);
1301
+ }
1302
+ }
1303
+ }
1109
1304
  function eliminateTopLevelContinue(stmts) {
1110
1305
  const out = [];
1111
1306
  for (let i = 0; i < stmts.length; i++) {
@@ -1914,11 +2109,11 @@ function replaceVar(e, name, replacement, narrowing) {
1914
2109
  }
1915
2110
  // ── Top-level transform ──────────────────────────────────────
1916
2111
  /** Transform for Lean backend — same logic, Lean options. */
1917
- export function transformModuleLean(mod, specImport) {
2112
+ export function transformModuleLean(mod, specImport, moduleBase) {
1918
2113
  const prev = _opts;
1919
2114
  _opts = LEAN_OPTIONS;
1920
2115
  try {
1921
- return transformModule(mod, specImport);
2116
+ return transformModule(mod, specImport, moduleBase);
1922
2117
  }
1923
2118
  finally {
1924
2119
  _opts = prev;
@@ -1935,7 +2130,7 @@ export function transformModuleDafny(mod) {
1935
2130
  _opts = prev;
1936
2131
  }
1937
2132
  }
1938
- export function transformModule(mod, specImport) {
2133
+ export function transformModule(mod, specImport, moduleBaseOverride) {
1939
2134
  _forofCounters.clear();
1940
2135
  _liftCounter = 0;
1941
2136
  _typeDecls = mod.typeDecls;
@@ -1988,6 +2183,9 @@ export function transformModule(mod, specImport) {
1988
2183
  }
1989
2184
  }
1990
2185
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
2186
+ // Lean module base — overridable via `//@ lean-module` (see lsc.ts). Only the
2187
+ // def→types import below reads it; Dafny never passes an override.
2188
+ const moduleBase = moduleBaseOverride ?? base;
1991
2189
  // Externs: emit as top-of-file `function {:axiom}` (Dafny) declarations.
1992
2190
  // Any `requires`/`ensures` from the source declaration come along so callers
1993
2191
  // see the same spec the source itself verified. Substitute `\result` with the
@@ -2011,12 +2209,18 @@ export function transformModule(mod, specImport) {
2011
2209
  ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
2012
2210
  : [];
2013
2211
  if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0) {
2212
+ // Declaration order differs by backend. Dafny allows forward references, so
2213
+ // externs go first to be in scope everywhere. Lean requires definition-before-use:
2214
+ // an extern's signature may reference a declared type (e.g. `estimateTokens(m: AgentMessage)`),
2215
+ // so types must precede externs, which in turn precede the pure mirrors that may call them.
2216
+ const decls = _opts.backend === "lean"
2217
+ ? [...typeDecls, ...externDecls, ...pureNamespace]
2218
+ : [...externDecls, ...typeDecls, ...pureNamespace];
2014
2219
  typesFile = {
2015
2220
  comment: " Generated by lsc — Lean types and pure function mirrors.",
2016
2221
  imports: typesImports,
2017
2222
  options: [],
2018
- // Externs come first so they're in scope for every later declaration.
2019
- decls: [...externDecls, ...typeDecls, ...pureNamespace],
2223
+ decls,
2020
2224
  };
2021
2225
  }
2022
2226
  // Def file: Velvet methods
@@ -2036,6 +2240,19 @@ export function transformModule(mod, specImport) {
2036
2240
  let body = pureDefNames.has(fn.name)
2037
2241
  ? [{ kind: "return", value: { kind: "app", fn: `Pure.${fn.name}`, args: fn.params.map(p => ({ kind: "var", name: p.name })) } }]
2038
2242
  : promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
2243
+ // Lean-only method-body rewrites (Velvet can't WP-synthesize monadic matches
2244
+ // and forbids `return` in loops):
2245
+ // 1. monadic statement-`match` on a user union → discriminator `if`-chains
2246
+ // 2. `return` inside a loop → result var + break (postcondition as invariant)
2247
+ // Dafny keeps the native forms. Breaking loops (including those synthesized
2248
+ // by rewrite 2) must carry a `//@ done_with` in the TS source — enforced here,
2249
+ // since loom's default loop-exit fact `¬guard` does not hold across a break.
2250
+ if (_opts.backend === "lean" && !pureDefNames.has(fn.name)) {
2251
+ body = matchToIfChains(body);
2252
+ const resultInvariants = fn.ensures.map(e => replaceVar(transformExpr(e), "\\result", { kind: "var", name: "_loopRet" }));
2253
+ body = eliminateReturnInLoops(body, fn.returnTy, resultInvariants);
2254
+ requireDoneWithForBreaks(body, fn.name);
2255
+ }
2039
2256
  // Shadow reassigned parameters with mutable locals
2040
2257
  const paramNames = new Set(fn.params.map(p => p.name));
2041
2258
  const reassigned = findReassignedNames(fn.body, paramNames);
@@ -2082,7 +2299,7 @@ export function transformModule(mod, specImport) {
2082
2299
  methods: classMethods,
2083
2300
  };
2084
2301
  });
2085
- const defImport = specImport ?? (typesFile ? `«${base}.types»` : null);
2302
+ const defImport = specImport ?? (typesFile ? `«${moduleBase}.types»` : null);
2086
2303
  const defBaseImports = defImport ? [defImport] : ["LemmaScript"];
2087
2304
  const defFile = {
2088
2305
  comment: " Generated by lsc from " + (mod.file.split("/").pop() ?? "") + "\n Do not edit — re-run `lsc gen` to regenerate.",
@@ -1,253 +0,0 @@
1
- /**
2
- * Lean IR → text. Trivial pretty-printer.
3
- * No logic, no type decisions — just serialization.
4
- */
5
- // ── Lean keyword escaping ────────────────────────────────────
6
- const LEAN_KEYWORDS = new Set([
7
- "def", "theorem", "lemma", "example", "structure", "class", "instance",
8
- "inductive", "where", "match", "with", "if", "then", "else", "do",
9
- "let", "mut", "return", "for", "in", "while", "break", "continue",
10
- "import", "open", "section", "namespace", "end", "set_option",
11
- "variable", "axiom", "constant", "private", "protected", "noncomputable",
12
- "partial", "unsafe", "macro", "syntax", "by", "fun", "have", "show",
13
- "at", "from", "to", "deriving", "extends", "true", "false",
14
- ]);
15
- function escapeName(name) {
16
- return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
17
- }
18
- // ── Operator precedence (for parenthesization) ──────────────
19
- const PREC = {
20
- "→": 1, "∨": 2, "∧": 3,
21
- "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
22
- "+": 5, "-": 5, "*": 6, "/": 6, "%": 6,
23
- };
24
- function prec(op) { return PREC[op] ?? 10; }
25
- // ── Expression emission ─────────────────────────────────────
26
- function emitExpr(e, parentPrec) {
27
- switch (e.kind) {
28
- case "var": return escapeName(e.name);
29
- case "num": return `${e.value}`;
30
- case "bool": return e.value ? "true" : "false";
31
- case "str": return `"${e.value}"`;
32
- case "constructor": return `.${e.name}`;
33
- case "arrayLiteral":
34
- if (e.elems.length === 0)
35
- return `#[]`;
36
- return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
37
- case "dotCall": {
38
- const obj = emitExpr(e.obj);
39
- const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "dotCall";
40
- const receiver = wrap ? `(${obj})` : obj;
41
- const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
42
- return args.length > 0 ? `${receiver}.${e.method} ${args.join(" ")}` : `${receiver}.${e.method}`;
43
- }
44
- case "lambda": {
45
- const params = e.params.map(p => p.name).join(" ");
46
- // Single return statement → expression lambda
47
- if (e.body.length === 1 && e.body[0].kind === "return") {
48
- return `(fun ${params} => ${emitExpr(e.body[0].value)})`;
49
- }
50
- // Multi-statement → do block
51
- return `(fun ${params} => do\n${emitStmts(e.body, 2)})`;
52
- }
53
- case "unop":
54
- if (e.op === "¬")
55
- return `¬(${emitExpr(e.expr)})`;
56
- if (e.op === "-" && e.expr.kind === "num")
57
- return `-${e.expr.value}`;
58
- return `(-${emitExpr(e.expr)})`;
59
- case "binop": {
60
- const s = `${emitExpr(e.left, prec(e.op))} ${e.op} ${emitExpr(e.right, prec(e.op))}`;
61
- return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
62
- }
63
- case "implies": {
64
- const parts = [...e.premises.map(p => emitExpr(p)), emitExpr(e.conclusion)];
65
- const s = parts.join(" → ");
66
- return parentPrec !== undefined ? `(${s})` : s;
67
- }
68
- case "app": {
69
- const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
70
- return `${e.fn} ${args.join(" ")}`;
71
- }
72
- case "field": {
73
- const obj = emitExpr(e.obj);
74
- const wrap = e.obj.kind !== "var" && e.obj.kind !== "num" && e.obj.kind !== "bool";
75
- return wrap ? `(${obj}).${escapeName(e.field)}` : `${obj}.${escapeName(e.field)}`;
76
- }
77
- case "toNat": {
78
- const inner = emitExpr(e.expr);
79
- const wrap = e.expr.kind !== "var" && e.expr.kind !== "num";
80
- return wrap ? `(${inner}).toNat` : `${inner}.toNat`;
81
- }
82
- case "index":
83
- return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]!`;
84
- case "record": {
85
- const fields = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
86
- if (e.spread)
87
- return `{ ${emitExpr(e.spread)} with ${fields.join(", ")} }`;
88
- return `{ ${fields.join(", ")} }`;
89
- }
90
- case "if":
91
- return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
92
- case "match": {
93
- const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
94
- return `match ${e.scrutinee} with ${arms.join(" ")}`;
95
- }
96
- case "forall": return `∀ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
97
- case "exists": return `∃ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
98
- case "let": return `let ${e.name} := ${emitExpr(e.value)}\n${emitExpr(e.body)}`;
99
- }
100
- }
101
- // ── Statement emission ──────────────────────────────────────
102
- function emitStmts(stmts, indent) {
103
- const pad = " ".repeat(indent);
104
- return stmts.map(s => emitStmt(s, indent)).join("\n");
105
- }
106
- function emitStmt(s, indent) {
107
- const pad = " ".repeat(indent);
108
- switch (s.kind) {
109
- case "let":
110
- return s.mutable
111
- ? `${pad}let mut ${escapeName(s.name)} : ${s.type} := ${emitExpr(s.value)}`
112
- : `${pad}let ${escapeName(s.name)} := ${emitExpr(s.value)}`;
113
- case "assign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
114
- case "bind": return `${pad}${escapeName(s.target)} ← ${emitExpr(s.value)}`;
115
- case "let-bind": return `${pad}let ${s.name} ← ${emitExpr(s.value)}`;
116
- case "return": return `${pad}return ${emitExpr(s.value)}`;
117
- case "break": return `${pad}break`;
118
- case "continue": return `${pad}continue`;
119
- case "if": {
120
- let out = `${pad}if ${emitExpr(s.cond)} then\n${emitStmts(s.then, indent + 1)}`;
121
- if (s.else.length > 0) {
122
- if (s.else.length === 1 && s.else[0].kind === "if") {
123
- const ei = s.else[0];
124
- out += `\n${pad}else if ${emitExpr(ei.cond)} then\n${emitStmts(ei.then, indent + 1)}`;
125
- if (ei.else.length > 0)
126
- out += `\n${pad}else\n${emitStmts(ei.else, indent + 1)}`;
127
- }
128
- else {
129
- out += `\n${pad}else\n${emitStmts(s.else, indent + 1)}`;
130
- }
131
- }
132
- return out;
133
- }
134
- case "match": {
135
- const lines = [`${pad}match ${s.scrutinee} with`];
136
- for (const arm of s.arms) {
137
- lines.push(`${pad}| ${arm.pattern} =>`);
138
- lines.push(emitStmts(arm.body, indent + 1));
139
- }
140
- return lines.join("\n");
141
- }
142
- case "while": {
143
- const lines = [`${pad}while ${emitExpr(s.cond)}`];
144
- for (const inv of s.invariants)
145
- lines.push(`${pad} invariant ${emitExpr(inv)}`);
146
- if (s.doneWith)
147
- lines.push(`${pad} done_with ${emitExpr(s.doneWith)}`);
148
- if (s.decreasing)
149
- lines.push(`${pad} decreasing ${emitExpr(s.decreasing)}`);
150
- lines.push(`${pad}do`);
151
- lines.push(emitStmts(s.body, indent + 1));
152
- return lines.join("\n");
153
- }
154
- case "forin": {
155
- const lines = [`${pad}for ${s.idx} in [:${emitExpr(s.bound)}]`];
156
- for (const inv of s.invariants)
157
- lines.push(`${pad} invariant ${emitExpr(inv)}`);
158
- lines.push(`${pad}do`);
159
- lines.push(emitStmts(s.body, indent + 1));
160
- return lines.join("\n");
161
- }
162
- }
163
- }
164
- // ── Declaration emission ─────────────────────────────────────
165
- function emitDecl(d) {
166
- switch (d.kind) {
167
- case "inductive": {
168
- const lines = [`inductive ${d.name} where`];
169
- for (const c of d.constructors) {
170
- if (c.fields.length === 0) {
171
- lines.push(` | ${c.name} : ${d.name}`);
172
- }
173
- else {
174
- const params = c.fields.map(f => `(${escapeName(f.name)} : ${f.type})`).join(" ");
175
- lines.push(` | ${c.name} ${params} : ${d.name}`);
176
- }
177
- }
178
- if (d.deriving.length > 0)
179
- lines.push(`deriving ${d.deriving.join(", ")}`);
180
- return lines.join("\n");
181
- }
182
- case "structure": {
183
- const lines = [`structure ${d.name} where`];
184
- for (const f of d.fields)
185
- lines.push(` ${escapeName(f.name)} : ${f.type}`);
186
- if (d.deriving.length > 0)
187
- lines.push(`deriving ${d.deriving.join(", ")}`);
188
- return lines.join("\n");
189
- }
190
- case "def": {
191
- const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
192
- return `def ${d.name} ${params} : ${d.returnType} :=\n${emitPureExpr(d.body, 1)}`;
193
- }
194
- case "method": {
195
- const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
196
- const lines = [`method ${d.name} ${params} return (res : ${d.returnType})`];
197
- for (const r of d.requires)
198
- lines.push(` require ${emitExpr(r)}`);
199
- for (const e of d.ensures)
200
- lines.push(` ensures ${emitExpr(e)}`);
201
- lines.push(" do");
202
- lines.push(emitStmts(d.body, 2));
203
- return lines.join("\n");
204
- }
205
- case "namespace": {
206
- const lines = [`namespace ${d.name}`];
207
- for (const inner of d.decls)
208
- lines.push("", emitDecl(inner));
209
- lines.push("", `end ${d.name}`);
210
- return lines.join("\n");
211
- }
212
- }
213
- }
214
- /** Emit a pure expression with indented if/match blocks. */
215
- function emitPureExpr(e, indent) {
216
- const pad = " ".repeat(indent);
217
- switch (e.kind) {
218
- case "if":
219
- return `${pad}if ${emitExpr(e.cond)} then\n${emitPureExpr(e.then, indent + 1)}\n${pad}else\n${emitPureExpr(e.else, indent + 1)}`;
220
- case "match": {
221
- const lines = [`${pad}match ${e.scrutinee} with`];
222
- for (const arm of e.arms) {
223
- lines.push(`${pad}| ${arm.pattern} =>`);
224
- lines.push(emitPureExpr(arm.body, indent + 1));
225
- }
226
- return lines.join("\n");
227
- }
228
- case "let":
229
- return `${pad}let ${e.name} := ${emitExpr(e.value)}\n${emitPureExpr(e.body, indent)}`;
230
- default:
231
- return `${pad}${emitExpr(e)}`;
232
- }
233
- }
234
- // ── File emission ────────────────────────────────────────────
235
- export function emitFile(file) {
236
- const lines = [];
237
- if (file.comment) {
238
- lines.push("/-");
239
- lines.push(file.comment);
240
- lines.push("-/");
241
- }
242
- for (const imp of file.imports)
243
- lines.push(`import ${imp}`);
244
- if (file.options.length > 0)
245
- lines.push("");
246
- for (const opt of file.options)
247
- lines.push(`set_option ${opt.key} ${opt.value}`);
248
- for (const decl of file.decls) {
249
- lines.push("");
250
- lines.push(emitDecl(decl));
251
- }
252
- return lines.join("\n") + "\n";
253
- }