lemmascript 0.6.0 → 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.6.0",
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)}`);
@@ -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();
@@ -2235,6 +2247,12 @@ export function extractModule(sourceFile) {
2235
2247
  }
2236
2248
  return false;
2237
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
+ }
2238
2256
  // `//@ extern NS.method` registers the extern under a *dotted* qualified name,
2239
2257
  // so a real `NS.method(args)` call dispatches to it (resolve.ts) with no
2240
2258
  // wrapper — e.g. `//@ extern fs.readFileSync` lets you call `fs.readFileSync`
@@ -2277,7 +2295,10 @@ export function extractModule(sourceFile) {
2277
2295
  const annots = collectFunctionAnnotations(f.node);
2278
2296
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
2279
2297
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
2280
- _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
+ });
2281
2302
  }
2282
2303
  // If any function has //@ verify, only extract those (brownfield mode).
2283
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
@@ -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: [] };
@@ -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