lemmascript 0.5.22 → 0.6.1

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
@@ -14,7 +14,7 @@ Each example and case study is verified in Lean 4 and/or Dafny from the same ann
14
14
 
15
15
  See the internal [examples](examples).
16
16
 
17
- See the external case studies:
17
+ See the external [case studies](https://github.com/search?q=topic%3Alemmascript+topic%3Acase-study&type=repositories):
18
18
  - **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
19
19
  - **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
20
20
  - **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.22",
3
+ "version": "0.6.1",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -189,12 +189,12 @@ function escapeGeneratedName(name) {
189
189
  function paramList(params) {
190
190
  return params.map(p => `${escapeName(p.name)}: ${tyToDafny(p.type)}`).join(", ");
191
191
  }
192
- /** Format a method signature header, omitting `returns` for void methods.
193
- * Dafny's definite-assignment rule rejects unassigned out-parameters, so a
194
- * `returns (res: ())` on a void method fails verification. */
195
- function methodHeader(prefix, params, returnType, scope) {
192
+ /** Format a method signature header, normally omitting `returns` for void
193
+ * methods. A body-less impure extern opts into a Unit out-parameter so the
194
+ * shared method-call lifting can bind its call like any other expression. */
195
+ function methodHeader(prefix, params, returnType, scope, includeVoidReturn = false) {
196
196
  const sig = `${prefix}(${paramList(params)})`;
197
- if (returnType.kind === "void")
197
+ if (returnType.kind === "void" && !includeVoidReturn)
198
198
  return sig;
199
199
  // The out-parameter is `res` by default, but a param (an Express handler's
200
200
  // `(req, res)`), body local, or callee named `res` would shadow it. Check only
@@ -993,10 +993,20 @@ function emitDecl(d) {
993
993
  return `const ${escapeName(d.name)}: ${tyToDafny(d.type)} := ${emitExpr(d.value)}`;
994
994
  }
995
995
  case "extern": {
996
- // Body-less Dafny function — `:axiom` makes Dafny accept the missing body
997
- // and treats it as an uninterpreted symbol. Any `requires`/`ensures` were
998
- // lifted from the source declaration's annotations.
996
+ // `:axiom` makes Dafny accept the missing body. Pure externs are
997
+ // uninterpreted functions (deterministic/extensional); impure externs are
998
+ // methods, so every invocation gets an independent arbitrary result
999
+ // constrained only by its per-call contract.
999
1000
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
1001
+ if (d.impure) {
1002
+ const scope = { requires: d.requires, ensures: d.ensures, body: [] };
1003
+ const lines = [methodHeader(`method {:axiom} ${escapeName(d.name)}${tp}`, d.params, d.returnType, scope, true)];
1004
+ for (const r of d.requires)
1005
+ lines.push(` requires ${emitExpr(r)}`);
1006
+ for (const e of d.ensures)
1007
+ lines.push(` ensures ${emitExpr(e)}`);
1008
+ return lines.join("\n");
1009
+ }
1000
1010
  const lines = [`function {:axiom} ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
1001
1011
  for (const r of d.requires)
1002
1012
  lines.push(` requires ${emitExpr(r)}`);
@@ -1583,10 +1593,8 @@ export function emitDafnyFile(file, tsFileName, opts) {
1583
1593
  // skipped when the corresponding pure def was actually emitted.
1584
1594
  const emittedPureDefs = new Set();
1585
1595
  // Emit a decl, rolling back any preamble requirements it registered if it
1586
- // throws. A skipped decl must contribute neither text nor preambles — else a
1587
- // side-effecting `needPreamble` from a half-emitted decl leaves an unused
1588
- // preamble (e.g. `type Unknown` from a skipped const whose head is
1589
- // `unknown`-typed but whose value expr is unsupported).
1596
+ // throws, so callers that catch an emission error never observe partial
1597
+ // emitter state.
1590
1598
  const emitDeclTx = (d) => {
1591
1599
  const saved = new Set(_neededPreambles);
1592
1600
  try {
@@ -1601,13 +1609,12 @@ export function emitDafnyFile(file, tsFileName, opts) {
1601
1609
  };
1602
1610
  // Emit declarations
1603
1611
  const declLines = [];
1604
- const skipped = [];
1605
1612
  for (const decl of file.decls) {
1606
1613
  if (decl.kind === "method" && emittedPureDefs.has(decl.name))
1607
1614
  continue;
1608
1615
  if (decl.kind === "namespace") {
1609
- // Emit each inner decl individually if one fails, the rest survive
1610
- // and failed defs fall back to their method wrappers
1616
+ // Emit each inner declaration separately so failures name the exact
1617
+ // pure declaration that could not be translated.
1611
1618
  for (const inner of decl.decls) {
1612
1619
  try {
1613
1620
  declLines.push("");
@@ -1617,10 +1624,9 @@ export function emitDafnyFile(file, tsFileName, opts) {
1617
1624
  }
1618
1625
  catch (e) {
1619
1626
  const name = "name" in inner ? inner.name : "unknown";
1620
- const msg = e.message;
1621
- console.error(`WARNING: skipping pure '${name}': ${msg}`);
1622
- declLines.push(`\n// LemmaScript: skipped pure ${name}`);
1623
- skipped.push(name);
1627
+ const reason = e instanceof Error ? e.message : String(e);
1628
+ const source = tsFileName ? ` in ${tsFileName}` : "";
1629
+ throw new Error(`Dafny emission failed for '${name}'${source}: ${reason}`);
1624
1630
  }
1625
1631
  }
1626
1632
  continue;
@@ -1633,15 +1639,11 @@ export function emitDafnyFile(file, tsFileName, opts) {
1633
1639
  }
1634
1640
  catch (e) {
1635
1641
  const name = "name" in decl ? decl.name : "unknown";
1636
- const msg = e.message;
1637
- console.error(`WARNING: skipping '${name}': ${msg}`);
1638
- declLines.push(`// LemmaScript: skipped ${name}`);
1639
- skipped.push(name);
1642
+ const reason = e instanceof Error ? e.message : String(e);
1643
+ const source = tsFileName ? ` in ${tsFileName}` : "";
1644
+ throw new Error(`Dafny emission failed for '${name}'${source}: ${reason}`);
1640
1645
  }
1641
1646
  }
1642
- if (skipped.length > 0) {
1643
- console.error(`WARNING: ${skipped.length} declaration(s) skipped: ${skipped.join(", ")}`);
1644
- }
1645
1647
  // Build output with needed preambles
1646
1648
  const lines = [];
1647
1649
  if (tsFileName)
@@ -32,7 +32,8 @@ function withHavocKey(key, fn) {
32
32
  }
33
33
  /** Auto-detected cross-file calls. Populated by `extractExpr` whenever it sees
34
34
  * a call `Obj.method(...)` or `foo(...)` whose ts-morph symbol resolves to a
35
- * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`.
35
+ * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`
36
+ * by default, or as a body-less method when the source has `//@ impure`.
36
37
  * Cleared at the start of every `extractModule`. */
37
38
  const _externs = new Map();
38
39
  /** Signature types of *kept* externs, for the imported-type resolver: a type
@@ -163,7 +164,8 @@ function detectCrossFileExtern(callee, sourceFile, sigTypesOut) {
163
164
  const annots = collectFunctionAnnotations(externalDecl);
164
165
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
165
166
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
166
- return { qualified, flat, typeParams, params, returnType, requires, ensures };
167
+ const impure = hasBareFunctionAnnotation(externalDecl, "impure");
168
+ return { qualified, flat, typeParams, params, returnType, requires, ensures, impure };
167
169
  }
168
170
  /** Build a concat-tree from a mixed list of literal and SpreadElement nodes.
169
171
  * Literals collapse into arrayLiteral segments; spreads become bare expressions;
@@ -761,17 +763,27 @@ function collectFunctionAnnotations(fn) {
761
763
  }
762
764
  return collectAnnotations(fn);
763
765
  }
764
- /** Check for bare `//@ pure` annotation (no expression). */
765
- function hasPureAnnotation(node, body) {
766
+ /** Check for a bare function annotation such as `//@ pure` or `//@ impure`.
767
+ * Function annotations may precede the declaration or its first statement. */
768
+ function hasBareFunctionAnnotation(node, keyword, body) {
769
+ if (!body) {
770
+ const fnBody = node.getBody?.();
771
+ if (fnBody && Node.isBlock(fnBody))
772
+ body = fnBody.getStatements();
773
+ }
766
774
  const nodes = body && body.length > 0 ? [node, body[0]] : [node];
767
775
  for (const n of nodes) {
768
776
  for (const range of n.getLeadingCommentRanges()) {
769
- if (range.getText().trim() === "//@ pure")
777
+ if (range.getText().trim() === `//@ ${keyword}`)
770
778
  return true;
771
779
  }
772
780
  }
773
781
  return false;
774
782
  }
783
+ /** Check for bare `//@ pure` annotation (no expression). */
784
+ function hasPureAnnotation(node, body) {
785
+ return hasBareFunctionAnnotation(node, "pure", body);
786
+ }
775
787
  // ── Type declaration extraction ──────────────────────────────
776
788
  function extractTypeDecl(decl, extraDecls) {
777
789
  const name = decl.getName();
@@ -2158,7 +2170,8 @@ export function extractModule(sourceFile) {
2158
2170
  });
2159
2171
  }
2160
2172
  catch (e) {
2161
- console.error(`WARNING: skipping const '${decl.getName()}': ${e.message}`);
2173
+ const reason = e instanceof Error ? e.message : String(e);
2174
+ throw new Error(`Failed to extract const '${decl.getName()}' at ${sourceFile.getBaseName()}:${decl.getStartLineNumber()}: ${reason}`);
2162
2175
  }
2163
2176
  }
2164
2177
  }
@@ -2234,6 +2247,12 @@ export function extractModule(sourceFile) {
2234
2247
  }
2235
2248
  return false;
2236
2249
  }
2250
+ function hasImpure(f) {
2251
+ if (hasBareFunctionAnnotation(f.node, "impure"))
2252
+ return true;
2253
+ return !!f.parentStmt && f.parentStmt.getLeadingCommentRanges()
2254
+ .some(r => r.getText().trim() === "//@ impure");
2255
+ }
2237
2256
  // `//@ extern NS.method` registers the extern under a *dotted* qualified name,
2238
2257
  // so a real `NS.method(args)` call dispatches to it (resolve.ts) with no
2239
2258
  // wrapper — e.g. `//@ extern fs.readFileSync` lets you call `fs.readFileSync`
@@ -2276,7 +2295,10 @@ export function extractModule(sourceFile) {
2276
2295
  const annots = collectFunctionAnnotations(f.node);
2277
2296
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
2278
2297
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
2279
- _externs.set(qualified, { qualified, flat, typeParams, params, returnType, requires, ensures });
2298
+ _externs.set(qualified, {
2299
+ qualified, flat, typeParams, params, returnType, requires, ensures,
2300
+ impure: hasImpure(f),
2301
+ });
2280
2302
  }
2281
2303
  // If any function has //@ verify, only extract those (brownfield mode).
2282
2304
  // For expression-body arrows, //@ verify may be on the parent variable statement.
@@ -838,6 +838,9 @@ function emitDecl(d) {
838
838
  case "const":
839
839
  return `def ${escapeName(d.name)} : ${tyToLean(d.type)} := ${emitExpr(d.value)}`;
840
840
  case "extern": {
841
+ if (d.impure) {
842
+ throw new Error("//@ impure extern is not supported in the Lean backend");
843
+ }
841
844
  // Mirror Dafny's `function {:axiom}`: an uninterpreted total function.
842
845
  // In Lean that is an `opaque` declaration (sound — it commits to no body,
843
846
  // only to the type being inhabited). Any `requires`/`ensures` the source
package/tools/dist/lsc.js CHANGED
@@ -337,4 +337,10 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
337
337
  console.error(`Unknown command: ${cmd}`);
338
338
  process.exit(1);
339
339
  }
340
- main();
340
+ try {
341
+ main();
342
+ }
343
+ catch (e) {
344
+ console.error(`ERROR: ${e instanceof Error ? e.message : String(e)}`);
345
+ process.exitCode = 1;
346
+ }
@@ -453,12 +453,21 @@ function classifyCall(fn, ctx) {
453
453
  return "pure";
454
454
  if (fn.kind === "var" && (ctx.inSpec || ctx.inLambda) && ctx.pureFns.has(fn.name))
455
455
  return "spec-pure";
456
- // Bare-name `//@ extern` declarations are emitted as `function {:axiom}`
457
- // pure from the verifier's perspective. Classify them as pure so callers
458
- // don't get lifted to statement-level binds (which would force lambdas to
459
- // become multi-statement, illegal in Dafny).
460
- if (fn.kind === "var" && ctx.externs.has(fn.name))
461
- return "pure";
456
+ // Bare-name externs are pure by default. `//@ impure` externs are method
457
+ // calls: lift them to statement-level binds so repeated invocations remain
458
+ // independent. A method call cannot occur in a spec or lambda expression.
459
+ if (fn.kind === "var") {
460
+ const ext = ctx.externs.get(fn.name);
461
+ if (ext) {
462
+ if (!ext.impure)
463
+ return "pure";
464
+ if (ctx.inSpec)
465
+ throw new Error(`impure extern ${fn.name} cannot be called from a specification`);
466
+ if (ctx.inLambda)
467
+ throw new Error(`impure extern ${fn.name} cannot be called from a lambda`);
468
+ return "method";
469
+ }
470
+ }
462
471
  if (fn.kind === "var" && lookup(ctx.env, fn.name)?.kind === "fn")
463
472
  return "pure";
464
473
  if (fn.kind === "var" && ctx.inSpec) {
@@ -848,15 +857,23 @@ function resolveExpr(e, ctx) {
848
857
  }
849
858
  // Extern dispatch: `NS.method(args)` where NS.method is declared via
850
859
  // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
851
- // rest of the pipeline sees an ordinary pure function. The extern's
852
- // declaration is emitted alongside the file as `function {:axiom} ...`.
860
+ // rest of the pipeline sees an ordinary named call. Pure externs remain
861
+ // expressions; `//@ impure` externs are lifted as method calls.
853
862
  if (e.fn.kind === "field" && e.fn.obj.kind === "var") {
854
863
  const qualified = `${e.fn.obj.name}.${e.fn.field}`;
855
864
  const ext = ctx.externs.get(qualified);
856
865
  if (ext) {
866
+ if (ext.impure && ctx.inSpec)
867
+ throw new Error(`impure extern ${qualified} cannot be called from a specification`);
868
+ if (ext.impure && ctx.inLambda)
869
+ throw new Error(`impure extern ${qualified} cannot be called from a lambda`);
857
870
  const args = e.args.map(a => resolveExpr(a, ctx));
858
871
  const fn = { kind: "var", name: ext.flat, ty: { kind: "unknown" } };
859
- return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure", paramTys: ext.params };
872
+ return {
873
+ kind: "call", fn, args, ty: ext.returnTy,
874
+ callKind: ext.impure ? "method" : "pure",
875
+ paramTys: ext.params,
876
+ };
860
877
  }
861
878
  }
862
879
  const fn = resolveExpr(e.fn, ctx);
@@ -1618,8 +1635,33 @@ function collectCallsStmts(stmts, fns, out) {
1618
1635
  }
1619
1636
  }
1620
1637
  }
1621
- function computePureFns(functions) {
1638
+ /** Dotted/bare spelling of a raw call target, when statically named. */
1639
+ function rawCalleeName(e) {
1640
+ if (e.kind === "var")
1641
+ return e.name;
1642
+ if (e.kind === "field") {
1643
+ const obj = rawCalleeName(e.obj);
1644
+ return obj ? `${obj}.${e.field}` : null;
1645
+ }
1646
+ return null;
1647
+ }
1648
+ /** Whether a raw function body invokes any extern marked `//@ impure`. */
1649
+ function containsImpureExternCall(v, names) {
1650
+ if (Array.isArray(v))
1651
+ return v.some(x => containsImpureExternCall(x, names));
1652
+ if (v === null || typeof v !== "object")
1653
+ return false;
1654
+ const node = v;
1655
+ if (node.kind === "call" && node.fn) {
1656
+ const callee = rawCalleeName(node.fn);
1657
+ if (callee && names.has(callee))
1658
+ return true;
1659
+ }
1660
+ return Object.values(v).some(x => containsImpureExternCall(x, names));
1661
+ }
1662
+ function computePureFns(functions, externDecls) {
1622
1663
  const allFnNames = new Set(functions.map(fn => fn.name));
1664
+ const impureExternNames = new Set(externDecls.filter(ext => ext.impure).flatMap(ext => [ext.qualified, ext.flat]));
1623
1665
  // //@ pure functions are always considered pure — never taint callers
1624
1666
  const forcePure = new Set(functions.filter(fn => fn.pure).map(fn => fn.name));
1625
1667
  // Build call graph: fn → set of same-file functions it calls
@@ -1630,7 +1672,9 @@ function computePureFns(functions) {
1630
1672
  callGraph.set(fn.name, calls);
1631
1673
  }
1632
1674
  // Seed: syntactically non-pure functions (skip //@ pure)
1633
- const nonPure = new Set(functions.filter(fn => !forcePure.has(fn.name) && !isSyntacticallyPure(fn.body)).map(fn => fn.name));
1675
+ const nonPure = new Set(functions.filter(fn => !forcePure.has(fn.name) &&
1676
+ (!isSyntacticallyPure(fn.body) || containsImpureExternCall(fn.body, impureExternNames)))
1677
+ .map(fn => fn.name));
1634
1678
  // Build reverse graph: fn → set of functions that call it
1635
1679
  const callers = new Map();
1636
1680
  for (const name of allFnNames)
@@ -1763,7 +1807,7 @@ function precomputeFieldTypesInner(typeDecls) {
1763
1807
  export function resolveModule(raw) {
1764
1808
  _warnedRefEq.clear();
1765
1809
  precomputeFieldTypes(raw.typeDecls);
1766
- const pureFns = computePureFns(raw.functions);
1810
+ const pureFns = computePureFns(raw.functions, raw.externs ?? []);
1767
1811
  // Pre-compute function parameter and return types
1768
1812
  const fnParams = new Map();
1769
1813
  const fnReturns = new Map();
@@ -1783,7 +1827,7 @@ export function resolveModule(raw) {
1783
1827
  for (const ext of raw.externs ?? []) {
1784
1828
  const params = ext.params.map(p => parseTsType(p.tsType));
1785
1829
  const returnTy = parseTsType(ext.returnType);
1786
- externs.set(ext.qualified, { flat: ext.flat, params, returnTy });
1830
+ externs.set(ext.qualified, { flat: ext.flat, params, returnTy, impure: ext.impure });
1787
1831
  if (!ext.qualified.includes("."))
1788
1832
  fnReturns.set(ext.qualified, returnTy);
1789
1833
  }
@@ -1821,6 +1865,7 @@ export function resolveModule(raw) {
1821
1865
  returnTy: sig.returnTy,
1822
1866
  requires,
1823
1867
  ensures,
1868
+ impure: ext.impure,
1824
1869
  };
1825
1870
  });
1826
1871
  const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, externs, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
@@ -4,11 +4,11 @@
4
4
  */
5
5
  import { normalizeBigIntLiteral } from "./rawir.js";
6
6
  const MULTI_OPS = ["<==>", "==>", "===", "!==", "==", "!=", ">=", "<=", "&&", "||"];
7
- /** Hex / binary / octal / decimal integer, with optional numeric separators and
8
- * an optional `n` (BigInt) suffix in capture group 1. Deliberately integer-only:
9
- * a trailing `.` is left for the tokenizer to emit as punctuation, exactly as
10
- * the previous digit-scanning loop did. */
11
- const INTEGER_LITERAL = /^(?:(?:0[xX][0-9a-fA-F](?:_?[0-9a-fA-F])*)|(?:0[bB][01](?:_?[01])*)|(?:0[oO][0-7](?:_?[0-7])*)|(?:[0-9](?:_?[0-9])*))(n)?/;
7
+ /** Numeric literals accepted in specs. BigInts are recognized first so their
8
+ * exact value never passes through Number; ordinary numbers additionally
9
+ * allow TypeScript's fractional and exponent forms. */
10
+ const BIGINT_LITERAL = /^(?:(?:0[xX][0-9a-fA-F](?:_?[0-9a-fA-F])*)|(?:0[bB][01](?:_?[01])*)|(?:0[oO][0-7](?:_?[0-7])*)|(?:[0-9](?:_?[0-9])*))n/;
11
+ const NUMBER_LITERAL = /^(?:(?:0[xX][0-9a-fA-F](?:_?[0-9a-fA-F])*)|(?:0[bB][01](?:_?[01])*)|(?:0[oO][0-7](?:_?[0-7])*)|(?:(?:[0-9](?:_?[0-9])*)(?:\.(?:[0-9](?:_?[0-9])*)?)?|\.(?:[0-9](?:_?[0-9])*))(?:[eE][+-]?(?:[0-9](?:_?[0-9])*))?)/;
12
12
  function tokenize(input) {
13
13
  const tokens = [];
14
14
  let i = 0;
@@ -47,15 +47,17 @@ function tokenize(input) {
47
47
  tokens.push({ type: "str", value: s });
48
48
  continue;
49
49
  }
50
- if (/[0-9]/.test(input[i])) {
51
- const match = input.slice(i).match(INTEGER_LITERAL);
50
+ if (/[0-9]/.test(input[i]) || (input[i] === "." && /[0-9]/.test(input[i + 1]))) {
51
+ const rest = input.slice(i);
52
+ const bigintMatch = rest.match(BIGINT_LITERAL);
53
+ const match = bigintMatch ?? rest.match(NUMBER_LITERAL);
52
54
  if (!match)
53
55
  throw new Error(`Invalid numeric literal at ${i} in: ${input}`);
54
56
  const text = match[0];
55
57
  i += text.length;
56
58
  // The `n` suffix is meaningful, not noise: a BigInt keeps its exact value
57
59
  // as a decimal string instead of being rounded into a double.
58
- if (match[1] === "n")
60
+ if (bigintMatch)
59
61
  tokens.push({ type: "bigint", value: normalizeBigIntLiteral(text) });
60
62
  else
61
63
  tokens.push({ type: "num", value: Number(text.replace(/_/g, "")) });
@@ -2508,10 +2508,10 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2508
2508
  // Lean module base — overridable via `//@ lean-module` (see lsc.ts). Only the
2509
2509
  // def→types import below reads it; Dafny never passes an override.
2510
2510
  const moduleBase = moduleBaseOverride ?? base;
2511
- // Externs: emit as top-of-file `function {:axiom}` (Dafny) declarations.
2512
- // Any `requires`/`ensures` from the source declaration come along so callers
2513
- // see the same spec the source itself verified. Substitute `\result` with the
2514
- // function call (same pattern as for in-file pure-function ensures).
2511
+ // Externs: pure declarations become uninterpreted functions; `//@ impure`
2512
+ // declarations become body-less methods so calls have independent results.
2513
+ // Contracts come along in either case. A pure extern's `\result` denotes its
2514
+ // application; an impure extern keeps `\result` for the method out-parameter.
2515
2515
  const externDecls = (mod.externs ?? []).map(ext => {
2516
2516
  const fnCall = { kind: "app", fn: ext.flat, args: ext.params.map(p => ({ kind: "var", name: p.name })) };
2517
2517
  return {
@@ -2521,7 +2521,10 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2521
2521
  params: ext.params.map(p => ({ name: p.name, type: p.ty })),
2522
2522
  returnType: ext.returnTy,
2523
2523
  requires: ext.requires.map(transformExpr),
2524
- ensures: ext.ensures.map(e => replaceVar(transformExpr(e), "\\result", fnCall)),
2524
+ ensures: ext.ensures.map(e => ext.impure
2525
+ ? transformExpr(e)
2526
+ : replaceVar(transformExpr(e), "\\result", fnCall)),
2527
+ impure: ext.impure,
2525
2528
  };
2526
2529
  });
2527
2530
  // Def file: Velvet methods