lemmascript 0.6.0 → 0.6.2

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.2",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,236 @@
1
+ /**
2
+ * LemmaScript project configuration.
3
+ *
4
+ * Config is discovered per source file, validated from one registry, then
5
+ * layered with eligible file directives before consumers see a resolved set.
6
+ * Filesystem policy stays here/lsc.ts; extractors and emitters receive options.
7
+ */
8
+ import { existsSync, readFileSync } from "fs";
9
+ import path from "path";
10
+ export const OPTION_SPECS = {
11
+ "extern-default": {
12
+ type: "enum",
13
+ values: ["pure", "impure"],
14
+ default: "pure",
15
+ fileOverride: true,
16
+ description: "Default model for externs without //@ pure or //@ impure.",
17
+ },
18
+ "safe-slice": {
19
+ type: "boolean",
20
+ default: false,
21
+ fileOverride: true,
22
+ directiveAliases: ["safe-slice"],
23
+ description: "Use JavaScript-clamping semantics for two-argument array slice.",
24
+ },
25
+ "proof-dir": {
26
+ type: "path",
27
+ default: null,
28
+ fileOverride: false,
29
+ description: "Directory for Dafny artifacts, relative to lemmascript.json.",
30
+ },
31
+ };
32
+ export const DEFAULT_OPTIONS = Object.freeze(Object.fromEntries(Object.entries(OPTION_SPECS).map(([key, spec]) => [key, spec.default])));
33
+ const KNOWN_KEYS = Object.keys(OPTION_SPECS);
34
+ const CONFIG_CACHE = new Map();
35
+ function fail(source, message) {
36
+ throw new Error(`${source}: ${message}`);
37
+ }
38
+ function isKnownKey(key) {
39
+ return Object.hasOwn(OPTION_SPECS, key);
40
+ }
41
+ function parseValue(key, raw, source) {
42
+ const spec = OPTION_SPECS[key];
43
+ if (spec.type === "boolean") {
44
+ if (typeof raw !== "boolean")
45
+ fail(source, `option '${key}' must be true or false`);
46
+ return raw;
47
+ }
48
+ if (spec.type === "enum") {
49
+ if (typeof raw !== "string" || !spec.values.includes(raw)) {
50
+ fail(source, `option '${key}' must be one of: ${spec.values.join(", ")}`);
51
+ }
52
+ return raw;
53
+ }
54
+ if (typeof raw !== "string" || raw.trim().length === 0) {
55
+ fail(source, `option '${key}' must be a non-empty relative path`);
56
+ }
57
+ if (path.isAbsolute(raw))
58
+ fail(source, `option '${key}' must be relative to lemmascript.json`);
59
+ return raw;
60
+ }
61
+ /** Validate a parsed lemmascript.json object, returning only explicitly set keys. */
62
+ export function validateOptions(raw, source) {
63
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
64
+ fail(source, "expected a JSON object");
65
+ }
66
+ const out = {};
67
+ for (const [key, value] of Object.entries(raw)) {
68
+ if (key === "$schema")
69
+ continue;
70
+ if (!isKnownKey(key)) {
71
+ fail(source, `unknown option '${key}' (known options: ${KNOWN_KEYS.join(", ")})`);
72
+ }
73
+ out[key] = parseValue(key, value, source);
74
+ }
75
+ return out;
76
+ }
77
+ /** Last line in the leading-comment region. Directives after code are errors. */
78
+ function leadingCommentLineCount(lines) {
79
+ let inBlock = false;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ let rest = lines[i];
82
+ if (i === 0)
83
+ rest = rest.replace(/^\uFEFF/, "");
84
+ if (i === 0 && rest.startsWith("#!"))
85
+ continue;
86
+ while (true) {
87
+ rest = rest.trimStart();
88
+ if (inBlock) {
89
+ const end = rest.indexOf("*/");
90
+ if (end < 0)
91
+ break;
92
+ inBlock = false;
93
+ rest = rest.slice(end + 2);
94
+ continue;
95
+ }
96
+ if (rest.length === 0 || rest.startsWith("//"))
97
+ break;
98
+ if (rest.startsWith("/*")) {
99
+ const end = rest.indexOf("*/", 2);
100
+ if (end < 0) {
101
+ inBlock = true;
102
+ break;
103
+ }
104
+ rest = rest.slice(end + 2);
105
+ continue;
106
+ }
107
+ return i;
108
+ }
109
+ }
110
+ return lines.length;
111
+ }
112
+ function parseDirectiveValue(key, text, source) {
113
+ const spec = OPTION_SPECS[key];
114
+ if (spec.type === "boolean") {
115
+ if (text !== "true" && text !== "false")
116
+ fail(source, `option '${key}' must be true or false`);
117
+ return (text === "true");
118
+ }
119
+ if (spec.type === "enum")
120
+ return parseValue(key, text, source);
121
+ // Config-only today, but keep the diagnostic precise if a future path is
122
+ // made file-overridable.
123
+ return parseValue(key, text, source);
124
+ }
125
+ /** Parse top-of-file `//@ option key value` directives and legacy aliases. */
126
+ export function parseFileOptions(sourceText, source) {
127
+ const lines = sourceText.split(/\r?\n/);
128
+ const leadingLines = leadingCommentLineCount(lines);
129
+ const seen = new Map();
130
+ const out = {};
131
+ const setOption = (key, value, line) => {
132
+ const previous = seen.get(key);
133
+ if (previous !== undefined) {
134
+ fail(`${source}:${line}`, `duplicate option '${key}' (first set on line ${previous})`);
135
+ }
136
+ seen.set(key, line);
137
+ out[key] = value;
138
+ };
139
+ const parseLine = (lineText, index, inLeadingRegion) => {
140
+ const line = index + 1;
141
+ const optionMatch = lineText.match(/^[ \t]*\/\/@[ \t]+option(?:[ \t]+(.*?))?[ \t]*$/);
142
+ if (optionMatch) {
143
+ if (!inLeadingRegion)
144
+ fail(`${source}:${line}`, "//@ option directives must appear before the first source statement");
145
+ const parts = (optionMatch[1] ?? "").trim().split(/\s+/).filter(Boolean);
146
+ if (parts.length !== 2)
147
+ fail(`${source}:${line}`, "expected //@ option <key> <value>");
148
+ const [rawKey, rawValue] = parts;
149
+ if (!isKnownKey(rawKey)) {
150
+ fail(`${source}:${line}`, `unknown option '${rawKey}' (known options: ${KNOWN_KEYS.join(", ")})`);
151
+ }
152
+ const spec = OPTION_SPECS[rawKey];
153
+ if (!spec.fileOverride)
154
+ fail(`${source}:${line}`, `option '${rawKey}' is config-only`);
155
+ setOption(rawKey, parseDirectiveValue(rawKey, rawValue, `${source}:${line}`), line);
156
+ return;
157
+ }
158
+ const aliasMatch = lineText.match(/^[ \t]*\/\/@[ \t]+([A-Za-z][A-Za-z0-9-]*)[ \t]*$/);
159
+ if (!aliasMatch)
160
+ return;
161
+ const alias = aliasMatch[1];
162
+ for (const key of KNOWN_KEYS) {
163
+ const aliases = "directiveAliases" in OPTION_SPECS[key]
164
+ ? OPTION_SPECS[key].directiveAliases
165
+ : [];
166
+ if (!aliases.includes(alias))
167
+ continue;
168
+ // Legacy aliases retain their pre-config placement behavior. New generic
169
+ // option directives are deliberately restricted to the file preamble.
170
+ setOption(key, true, line);
171
+ return;
172
+ }
173
+ };
174
+ for (let i = 0; i < lines.length; i++)
175
+ parseLine(lines[i], i, i < leadingLines);
176
+ return out;
177
+ }
178
+ /** Apply defaults and all cross-option rules after explicit layers are merged. */
179
+ export function resolveOptions(explicit, source) {
180
+ // There are no cross-option constraints in the initial registry. Keep this
181
+ // as the single resolution gate: future dependent defaults (UTF-16 → local
182
+ // Dafny library) and incompatibilities belong here, before any consumer runs.
183
+ void source;
184
+ return Object.freeze({ ...DEFAULT_OPTIONS, ...explicit });
185
+ }
186
+ /** Find `fileName` at or above `fromPath`. */
187
+ export function findUp(fileName, fromPath, fromIsDirectory = false) {
188
+ let dir = fromIsDirectory ? path.resolve(fromPath) : path.dirname(path.resolve(fromPath));
189
+ while (true) {
190
+ const candidate = path.join(dir, fileName);
191
+ if (existsSync(candidate))
192
+ return candidate;
193
+ const parent = path.dirname(dir);
194
+ if (parent === dir)
195
+ return null;
196
+ dir = parent;
197
+ }
198
+ }
199
+ /** Discover, parse, and validate a config without materializing defaults. */
200
+ export function loadConfigOptions(sourcePath, configPath) {
201
+ const configFile = configPath ? path.resolve(configPath) : findUp("lemmascript.json", sourcePath);
202
+ if (!configFile)
203
+ return { explicit: {}, configFile: null };
204
+ if (!existsSync(configFile))
205
+ fail(configFile, "config file not found");
206
+ const cached = CONFIG_CACHE.get(configFile);
207
+ if (cached)
208
+ return { explicit: { ...cached }, configFile };
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(readFileSync(configFile, "utf8"));
212
+ }
213
+ catch (err) {
214
+ const detail = err instanceof Error ? err.message : String(err);
215
+ fail(configFile, `invalid JSON (${detail})`);
216
+ }
217
+ const explicit = validateOptions(parsed, configFile);
218
+ CONFIG_CACHE.set(configFile, explicit);
219
+ return { explicit: { ...explicit }, configFile };
220
+ }
221
+ /** Resolve the directory containing one source file's Dafny companions. */
222
+ export function resolveDafnyArtifactDir(sourcePath, configFile, options) {
223
+ const source = path.resolve(sourcePath);
224
+ const proofDir = options["proof-dir"];
225
+ if (proofDir === null)
226
+ return path.dirname(source);
227
+ if (!configFile)
228
+ fail(source, "proof-dir requires a lemmascript.json file");
229
+ const configDir = path.dirname(path.resolve(configFile));
230
+ const relativeSource = path.relative(configDir, source);
231
+ if (relativeSource === ".." || relativeSource.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSource)) {
232
+ fail(source, `is outside the config directory ${configDir}; cannot map proof-dir`);
233
+ }
234
+ const proofRoot = path.resolve(configDir, proofDir);
235
+ return path.join(proofRoot, path.dirname(relativeSource));
236
+ }
@@ -4,6 +4,7 @@
4
4
  import { exactIntegerLiteral, usesName, usesNameInDecl, usesNameInStmts } from "./ir.js";
5
5
  import { freshNameWhere, userNames } from "./names.js";
6
6
  import { renameFreeVar } from "./transform.js";
7
+ import { DEFAULT_OPTIONS } from "./config.js";
7
8
  /** Fresh binder for a comprehension wrapping the given subexpressions: `base`
8
9
  * verbatim unless one of them references it, then primed until free. A *local*
9
10
  * check — a same-named name elsewhere in the module keeps the plain binder. */
@@ -189,12 +190,12 @@ function escapeGeneratedName(name) {
189
190
  function paramList(params) {
190
191
  return params.map(p => `${escapeName(p.name)}: ${tyToDafny(p.type)}`).join(", ");
191
192
  }
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) {
193
+ /** Format a method signature header, normally omitting `returns` for void
194
+ * methods. A body-less impure extern opts into a Unit out-parameter so the
195
+ * shared method-call lifting can bind its call like any other expression. */
196
+ function methodHeader(prefix, params, returnType, scope, includeVoidReturn = false) {
196
197
  const sig = `${prefix}(${paramList(params)})`;
197
- if (returnType.kind === "void")
198
+ if (returnType.kind === "void" && !includeVoidReturn)
198
199
  return sig;
199
200
  // The out-parameter is `res` by default, but a param (an Express handler's
200
201
  // `(req, res)`), body local, or callee named `res` would shadow it. Check only
@@ -993,10 +994,20 @@ function emitDecl(d) {
993
994
  return `const ${escapeName(d.name)}: ${tyToDafny(d.type)} := ${emitExpr(d.value)}`;
994
995
  }
995
996
  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.
997
+ // `:axiom` makes Dafny accept the missing body. Pure externs are
998
+ // uninterpreted functions (deterministic/extensional); impure externs are
999
+ // methods, so every invocation gets an independent arbitrary result
1000
+ // constrained only by its per-call contract.
999
1001
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
1002
+ if (d.impure) {
1003
+ const scope = { requires: d.requires, ensures: d.ensures, body: [] };
1004
+ const lines = [methodHeader(`method {:axiom} ${escapeName(d.name)}${tp}`, d.params, d.returnType, scope, true)];
1005
+ for (const r of d.requires)
1006
+ lines.push(` requires ${emitExpr(r)}`);
1007
+ for (const e of d.ensures)
1008
+ lines.push(` ensures ${emitExpr(e)}`);
1009
+ return lines.join("\n");
1010
+ }
1000
1011
  const lines = [`function {:axiom} ${escapeName(d.name)}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
1001
1012
  for (const r of d.requires)
1002
1013
  lines.push(` requires ${emitExpr(r)}`);
@@ -1015,10 +1026,8 @@ function emitDecl(d) {
1015
1026
  /** Preamble tracking — emitters add keys via `needPreamble(key)`, emitDafnyFile emits them. */
1016
1027
  const _neededPreambles = new Set();
1017
1028
  function needPreamble(key) { _neededPreambles.add(key); }
1018
- /** File-level opt-in for JS-clamp semantics on `arr.slice(lo, hi)`. Set by
1019
- * `emitDafnyFile` from the `//@ safe-slice` directive; consulted by the
1020
- * array-method emit. Off by default — case studies that wrote their `.slice`
1021
- * calls with provable bounds get direct `s[lo..hi]` emission. */
1029
+ /** Effective JS-clamp semantics for `arr.slice(lo, hi)`, resolved from project
1030
+ * config plus file directives before emission. */
1022
1031
  let _useSafeSlice = false;
1023
1032
  const POW2 = `function Pow2(n: int): int
1024
1033
  requires n >= 0
@@ -1572,8 +1581,8 @@ function translatePattern(p) {
1572
1581
  return ctorName;
1573
1582
  return `${ctorName}(${p.binders.map(escapeName).join(", ")})`;
1574
1583
  }
1575
- export function emitDafnyFile(file, tsFileName, opts) {
1576
- _useSafeSlice = !!opts?.safeSlice;
1584
+ export function emitDafnyFile(file, tsFileName, options = DEFAULT_OPTIONS) {
1585
+ _useSafeSlice = options["safe-slice"];
1577
1586
  resetDafnyNameCache();
1578
1587
  buildRecordCtorMap(file.decls);
1579
1588
  _neededPreambles.clear();
@@ -8,6 +8,7 @@ import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
8
8
  import { initTypeParser } from "./types.js";
9
9
  import { normalizeBigIntLiteral } from "./rawir.js";
10
10
  import { setUserNames, freshName } from "./names.js";
11
+ import { DEFAULT_OPTIONS } from "./config.js";
11
12
  // ── Expression extraction ────────────────────────────────────
12
13
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
13
14
  let _havocKey = null;
@@ -32,7 +33,9 @@ function withHavocKey(key, fn) {
32
33
  }
33
34
  /** Auto-detected cross-file calls. Populated by `extractExpr` whenever it sees
34
35
  * 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>`.
36
+ * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`
37
+ * when effectively pure, or as a body-less method when effectively impure
38
+ * after project defaults and source overrides.
36
39
  * Cleared at the start of every `extractModule`. */
37
40
  const _externs = new Map();
38
41
  /** Signature types of *kept* externs, for the imported-type resolver: a type
@@ -50,6 +53,8 @@ let _currentSourceFile = null;
50
53
  * that no verified function actually calls — and whose TS return types
51
54
  * often don't translate to valid Dafny. */
52
55
  let _inFunctionExtraction = false;
56
+ /** Effective options for the current extraction. Reset at extractModule entry. */
57
+ let _extractOptions = DEFAULT_OPTIONS;
53
58
  /** Counter for synthetic names used by let-statement array destructuring
54
59
  * when the initializer isn't a bare variable (single-eval temp). */
55
60
  let _destrCounter = 0;
@@ -163,7 +168,8 @@ function detectCrossFileExtern(callee, sourceFile, sigTypesOut) {
163
168
  const annots = collectFunctionAnnotations(externalDecl);
164
169
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
165
170
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
166
- return { qualified, flat, typeParams, params, returnType, requires, ensures };
171
+ const impure = externIsImpure(externalDecl, qualified);
172
+ return { qualified, flat, typeParams, params, returnType, requires, ensures, impure };
167
173
  }
168
174
  /** Build a concat-tree from a mixed list of literal and SpreadElement nodes.
169
175
  * Literals collapse into arrayLiteral segments; spreads become bare expressions;
@@ -761,17 +767,61 @@ function collectFunctionAnnotations(fn) {
761
767
  }
762
768
  return collectAnnotations(fn);
763
769
  }
764
- /** Check for bare `//@ pure` annotation (no expression). */
765
- function hasPureAnnotation(node, body) {
770
+ /** Check for a bare function annotation such as `//@ pure` or `//@ impure`.
771
+ * Function annotations may precede the declaration or its first statement. */
772
+ function hasBareFunctionAnnotation(node, keyword, body) {
773
+ if (!body) {
774
+ const fnBody = node.getBody?.();
775
+ if (fnBody && Node.isBlock(fnBody))
776
+ body = fnBody.getStatements();
777
+ }
766
778
  const nodes = body && body.length > 0 ? [node, body[0]] : [node];
767
779
  for (const n of nodes) {
768
780
  for (const range of n.getLeadingCommentRanges()) {
769
- if (range.getText().trim() === "//@ pure")
781
+ if (range.getText().trim() === `//@ ${keyword}`)
770
782
  return true;
771
783
  }
772
784
  }
773
785
  return false;
774
786
  }
787
+ /** Check for bare `//@ pure` annotation (no expression). */
788
+ function hasPureAnnotation(node, body) {
789
+ return hasBareFunctionAnnotation(node, "pure", body);
790
+ }
791
+ function enclosingVariableStatement(node) {
792
+ let current = node.getParent();
793
+ while (current && !Node.isSourceFile(current)) {
794
+ if (Node.isVariableStatement(current))
795
+ return current;
796
+ current = current.getParent();
797
+ }
798
+ return undefined;
799
+ }
800
+ /** `pure`/`impure` may be attached to a function, its first statement, or the
801
+ * variable statement that owns a const arrow. */
802
+ function hasExternModeAnnotation(node, keyword, parentStmt) {
803
+ if (hasBareFunctionAnnotation(node, keyword))
804
+ return true;
805
+ if (Node.isVariableDeclaration(node)) {
806
+ const init = node.getInitializer();
807
+ if (init && Node.isArrowFunction(init) && hasBareFunctionAnnotation(init, keyword))
808
+ return true;
809
+ }
810
+ const statement = parentStmt ?? enclosingVariableStatement(node);
811
+ return !!statement && statement.getLeadingCommentRanges()
812
+ .some(r => r.getText().trim() === `//@ ${keyword}`);
813
+ }
814
+ function externIsImpure(node, name, parentStmt) {
815
+ const pure = hasExternModeAnnotation(node, "pure", parentStmt);
816
+ const impure = hasExternModeAnnotation(node, "impure", parentStmt);
817
+ if (pure && impure)
818
+ throw new Error(`${name}: extern cannot be both //@ pure and //@ impure`);
819
+ if (impure)
820
+ return true;
821
+ if (pure)
822
+ return false;
823
+ return _extractOptions["extern-default"] === "impure";
824
+ }
775
825
  // ── Type declaration extraction ──────────────────────────────
776
826
  function extractTypeDecl(decl, extraDecls) {
777
827
  const name = decl.getName();
@@ -2016,7 +2066,8 @@ function extractFunctionInner(fn, parentAnnotations) {
2016
2066
  };
2017
2067
  }
2018
2068
  // ── Module extraction ────────────────────────────────────────
2019
- export function extractModule(sourceFile) {
2069
+ export function extractModule(sourceFile, options = DEFAULT_OPTIONS) {
2070
+ _extractOptions = options;
2020
2071
  // Seed the fresh-name check (names.ts) before anything mints: every
2021
2072
  // Identifier token in the module, a deliberate over-approximation.
2022
2073
  setUserNames(new Set(sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).map(i => i.getText())));
@@ -2277,7 +2328,10 @@ export function extractModule(sourceFile) {
2277
2328
  const annots = collectFunctionAnnotations(f.node);
2278
2329
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
2279
2330
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
2280
- _externs.set(qualified, { qualified, flat, typeParams, params, returnType, requires, ensures });
2331
+ _externs.set(qualified, {
2332
+ qualified, flat, typeParams, params, returnType, requires, ensures,
2333
+ impure: externIsImpure(f.node, qualified, f.parentStmt),
2334
+ });
2281
2335
  }
2282
2336
  // If any function has //@ verify, only extract those (brownfield mode).
2283
2337
  // For expression-body arrows, //@ verify may be on the parent variable statement.
@@ -83,7 +83,7 @@ function typedFn(fn, rawFn) {
83
83
  bodyKinds: bodyKinds(fn),
84
84
  };
85
85
  }
86
- export function runTypedInfo(raw, typed, version, backendDirective, dafny) {
86
+ export function runTypedInfo(raw, typed, version, backendDirective, options, dafny) {
87
87
  const rawByName = new Map(raw.functions.map(f => [f.name, f]));
88
88
  const rawMethods = new Map(raw.classes.flatMap(c => c.methods.map(m => [`${c.name}.${m.name}`, m])));
89
89
  const out = {
@@ -91,6 +91,7 @@ export function runTypedInfo(raw, typed, version, backendDirective, dafny) {
91
91
  lemmascript: version,
92
92
  file: typed.file,
93
93
  backendDirective,
94
+ options,
94
95
  typeDecls: typed.typeDecls,
95
96
  externs: typed.externs,
96
97
  constants: typed.constants,
@@ -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; add //@ pure to this extern or use extern-default: pure");
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
@@ -5,7 +5,7 @@
5
5
  * Pipeline: extract → resolve → narrow → transform → peephole → emit
6
6
  */
7
7
  import { Project, ScriptTarget } from "ts-morph";
8
- import { existsSync, readFileSync } from "fs";
8
+ import { existsSync, mkdirSync, readFileSync } from "fs";
9
9
  import { execFileSync } from "child_process";
10
10
  import { createRequire } from "module";
11
11
  import path from "path";
@@ -20,6 +20,7 @@ import { emitDafnyFile, emittedNameMap } from "./dafny-emit.js";
20
20
  import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
21
21
  import { leanGen, leanCheck } from "./lean-commands.js";
22
22
  import { runInfo, runTypedInfo } from "./info-command.js";
23
+ import { findUp, loadConfigOptions, parseFileOptions, resolveDafnyArtifactDir, resolveOptions, } from "./config.js";
23
24
  /** Version of the lemmascript package — the root package.json sits two levels
24
25
  * above this module from both tools/src/ (tsx) and tools/dist/ (installed). */
25
26
  function lscVersion() {
@@ -86,6 +87,16 @@ function main() {
86
87
  backend = val;
87
88
  args.splice(backendIdx, 1);
88
89
  }
90
+ const configIdx = args.findIndex(a => a.startsWith("--config="));
91
+ let configPath;
92
+ if (configIdx >= 0) {
93
+ configPath = args[configIdx].slice("--config=".length);
94
+ if (!configPath) {
95
+ console.error("Invalid --config: expected a path after '='");
96
+ process.exit(1);
97
+ }
98
+ args.splice(configIdx, 1);
99
+ }
89
100
  const timeLimitIdx = args.findIndex(a => a.startsWith("--time-limit="));
90
101
  let timeLimit;
91
102
  if (timeLimitIdx >= 0) {
@@ -139,7 +150,8 @@ function main() {
139
150
  }
140
151
  const [cmd, filePath] = args;
141
152
  if (!cmd) {
142
- console.error("Usage: lsc <gen|check|regen|extract|info> [--backend=lean|dafny] <file.ts>");
153
+ console.error("Usage: lsc <gen|check|regen|extract|info> [--backend=lean|dafny] [--config=path] <file.ts>");
154
+ console.error(" lsc config [--config=path] [<file.ts>]");
143
155
  console.error(" lsc info --typed <file.ts> (machine-readable Typed IR contract to stdout)");
144
156
  console.error(" lsc <gen|gen-check|check> [--backend=…] [--slow] (no file: batch over LemmaScript-files.txt)");
145
157
  console.error(" lsc claimcheck [<file.ts>] [flags…] (forwards to lemmascript-claimcheck)");
@@ -150,11 +162,15 @@ function main() {
150
162
  console.error(`--typed is only valid with the info command (got: ${cmd})`);
151
163
  process.exit(1);
152
164
  }
165
+ if (cmd === "config") {
166
+ runConfig(filePath, configPath);
167
+ return;
168
+ }
153
169
  if (!filePath) {
154
- runBatch(cmd, backend, slow);
170
+ runBatch(cmd, backend, slow, configPath);
155
171
  return;
156
172
  }
157
- runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify, typedInfo);
173
+ runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify, typedInfo, configPath);
158
174
  }
159
175
  // LemmaScript-files.txt, parsed: `filepath [timeout_in_seconds] [extra dafny
160
176
  // flags…]` per line; no timeout = Dafny default. Exits if the file is absent.
@@ -172,11 +188,36 @@ function readEntries() {
172
188
  return { file, timeout, flags };
173
189
  });
174
190
  }
191
+ function effectiveOptions(sourcePath, sourceText, configPath) {
192
+ const loaded = loadConfigOptions(sourcePath, configPath);
193
+ const fileOptions = parseFileOptions(sourceText, sourcePath);
194
+ const options = resolveOptions({ ...loaded.explicit, ...fileOptions }, sourcePath);
195
+ return { options, configFile: loaded.configFile };
196
+ }
197
+ /** `lsc config [file.ts]` — report discovery, effective values, and routing. */
198
+ function runConfig(filePath, configPath) {
199
+ if (!filePath) {
200
+ // loadConfigOptions starts discovery at a source file's parent, so use a
201
+ // synthetic path under cwd for the directory-oriented command form.
202
+ const probe = path.join(process.cwd(), ".lemmascript-config-probe.ts");
203
+ const loaded = loadConfigOptions(probe, configPath);
204
+ const options = resolveOptions(loaded.explicit, loaded.configFile ?? process.cwd());
205
+ console.log(JSON.stringify({ configFile: loaded.configFile, options }, null, 2));
206
+ return;
207
+ }
208
+ const sourcePath = path.resolve(filePath);
209
+ if (!existsSync(sourcePath))
210
+ throw new Error(`File not found: ${sourcePath}`);
211
+ const sourceText = readFileSync(sourcePath, "utf8");
212
+ const { options, configFile } = effectiveOptions(sourcePath, sourceText, configPath);
213
+ const artifactDir = resolveDafnyArtifactDir(sourcePath, configFile, options);
214
+ console.log(JSON.stringify({ configFile, options, artifactDir }, null, 2));
215
+ }
175
216
  // Batch over LemmaScript-files.txt. `check` entries with a timeout above 60s
176
217
  // (the CI limit) are gen-check only, unless --slow. Fail-fast: the first
177
218
  // failing entry exits. tools/check.sh drives this from source;
178
219
  // installed-package consumers run `lsc check`.
179
- function runBatch(cmd, backend, slow) {
220
+ function runBatch(cmd, backend, slow, configPath) {
180
221
  if (cmd !== "gen" && cmd !== "gen-check" && cmd !== "check") {
181
222
  console.error(`No file given, and batch mode supports gen|gen-check|check (not ${cmd}).`);
182
223
  process.exit(1);
@@ -184,39 +225,42 @@ function runBatch(cmd, backend, slow) {
184
225
  for (const e of readEntries()) {
185
226
  if (cmd === "check" && backend === "dafny" && !slow && e.timeout !== undefined && e.timeout > 60) {
186
227
  console.log(`=== ${path.basename(e.file)} (timeout ${e.timeout}s > 60s, gen-check only) ===`);
187
- runFile("gen-check", e.file, backend, undefined, undefined);
228
+ runFile("gen-check", e.file, backend, undefined, undefined, false, false, configPath);
188
229
  }
189
230
  else {
190
- runFile(cmd, e.file, backend, e.timeout, e.flags);
231
+ runFile(cmd, e.file, backend, e.timeout, e.flags, false, false, configPath);
191
232
  }
192
233
  }
193
234
  }
194
- function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false, typedInfo = false) {
235
+ function guardRelocatedDafnyProof(sourceDir, artifactDir, base, targetDfyPath) {
236
+ if (path.resolve(sourceDir) === path.resolve(artifactDir) || existsSync(targetDfyPath))
237
+ return;
238
+ const legacyPaths = [
239
+ path.join(sourceDir, `${base}.dfy`),
240
+ path.join(sourceDir, `${base}.dfy.base`),
241
+ path.join(sourceDir, `${base}.dfy.merged`),
242
+ ].filter(existsSync);
243
+ if (legacyPaths.length === 0)
244
+ return;
245
+ throw new Error(`proof-dir maps '${base}' to ${artifactDir}, but existing proof state would be left behind:\n` +
246
+ legacyPaths.map(p => ` ${p}`).join("\n") +
247
+ `\nMove the hand-written .dfy to ${targetDfyPath}, inspect or remove stale .dfy.base/.dfy.merged files, then rerun. The .dfy.gen file is regeneratable.`);
248
+ }
249
+ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false, typedInfo = false, configPath) {
195
250
  const absPath = path.resolve(filePath);
196
251
  if (!existsSync(absPath)) {
197
252
  console.error(`File not found: ${absPath}`);
198
253
  process.exit(1);
199
254
  }
200
255
  // Find nearest tsconfig.json for import resolution; fall back to bare options
201
- function findTsConfig(from) {
202
- let dir = path.dirname(from);
203
- while (true) {
204
- const candidate = path.join(dir, "tsconfig.json");
205
- if (existsSync(candidate))
206
- return candidate;
207
- const parent = path.dirname(dir);
208
- if (parent === dir)
209
- return undefined;
210
- dir = parent;
211
- }
212
- }
213
- const tsConfigFilePath = findTsConfig(absPath);
256
+ const tsConfigFilePath = findUp("tsconfig.json", absPath) ?? undefined;
214
257
  const project = tsConfigFilePath
215
258
  ? new Project({ tsConfigFilePath })
216
259
  : new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
217
260
  const sourceFile = project.addSourceFileAtPath(absPath);
218
261
  project.resolveSourceFileDependencies();
219
262
  const fullText = sourceFile.getFullText();
263
+ const { options, configFile } = effectiveOptions(absPath, fullText, configPath);
220
264
  // Check //@ backend directive — skip if backend doesn't match.
221
265
  // `extract` and `info` are backend-neutral and always run.
222
266
  const backendDirective = fullText.match(/\/\/@ backend (\w+)/);
@@ -224,8 +268,6 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
224
268
  console.log(`Skipped: ${path.basename(filePath)} (//@ backend ${backendDirective[1]}, current: ${backend})`);
225
269
  return;
226
270
  }
227
- // File-level directives consumed by the Dafny emitter.
228
- const safeSlice = /\/\/@ safe-slice\b/.test(fullText);
229
271
  // `//@ lean-module <name>` overrides the Lean module base (default: file
230
272
  // basename). Lean module names are flat/global, so two identically-named
231
273
  // `.ts` files (e.g. an in-place fork's duplicated `compaction.ts`) would emit
@@ -234,7 +276,7 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
234
276
  const leanModuleDirective = fullText.match(/\/\/@ lean-module ([A-Za-z0-9_.\-]+)/);
235
277
  const leanModuleOverride = leanModuleDirective ? leanModuleDirective[1] : undefined;
236
278
  // Extract: ts-morph → Raw IR
237
- const raw = extractModule(sourceFile);
279
+ const raw = extractModule(sourceFile, options);
238
280
  if (cmd === "extract") {
239
281
  console.log(JSON.stringify(raw, null, 2));
240
282
  return;
@@ -263,13 +305,13 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
263
305
  typesFile = peepholeModule(typesFile, "dafny");
264
306
  defFile = peepholeModule(defFile, "dafny");
265
307
  const merged = { ...defFile, decls: [...(typesFile?.decls ?? []), ...defFile.decls] };
266
- emitDafnyFile(merged, path.basename(filePath), { safeSlice });
308
+ emitDafnyFile(merged, path.basename(filePath), options);
267
309
  dafnyInfo = { emittedNames: Object.fromEntries(emittedNameMap()) };
268
310
  }
269
311
  catch (err) {
270
312
  dafnyInfo = { error: err instanceof Error ? err.message : String(err) };
271
313
  }
272
- runTypedInfo(raw, typed, lscVersion(), backendDirective ? backendDirective[1] : null, dafnyInfo);
314
+ runTypedInfo(raw, typed, lscVersion(), backendDirective ? backendDirective[1] : null, options, dafnyInfo);
273
315
  return;
274
316
  }
275
317
  const dir = path.dirname(absPath);
@@ -282,10 +324,13 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
282
324
  defFile = peepholeModule(defFile, "dafny");
283
325
  const allDecls = [...(typesFile?.decls ?? []), ...defFile.decls];
284
326
  const merged = { ...defFile, decls: allDecls };
285
- const text = emitDafnyFile(merged, path.basename(filePath), { safeSlice });
286
- const genPath = path.join(dir, `${base}.dfy.gen`);
287
- const dfyPath = path.join(dir, `${base}.dfy`);
288
- const basePath = path.join(dir, `${base}.dfy.base`);
327
+ const text = emitDafnyFile(merged, path.basename(filePath), options);
328
+ const artifactDir = resolveDafnyArtifactDir(absPath, configFile, options);
329
+ const genPath = path.join(artifactDir, `${base}.dfy.gen`);
330
+ const dfyPath = path.join(artifactDir, `${base}.dfy`);
331
+ const basePath = path.join(artifactDir, `${base}.dfy.base`);
332
+ guardRelocatedDafnyProof(dir, artifactDir, base, dfyPath);
333
+ mkdirSync(artifactDir, { recursive: true });
289
334
  if (cmd === "gen") {
290
335
  dafnyGen(genPath, dfyPath, text);
291
336
  return;
@@ -300,12 +345,12 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false
300
345
  dafnyGen(genPath, dfyPath, text);
301
346
  if (!dafnyCheckDiff(genPath, dfyPath))
302
347
  process.exit(1);
303
- if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags))
348
+ if (!dafnyVerify(dfyPath, artifactDir, timeLimit, extraFlags))
304
349
  process.exit(1);
305
350
  return;
306
351
  }
307
352
  if (cmd === "regen") {
308
- dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags, noVerify);
353
+ dafnyRegen(genPath, dfyPath, basePath, text, artifactDir, timeLimit, extraFlags, noVerify);
309
354
  return;
310
355
  }
311
356
  console.error(`Unknown command: ${cmd}`);
@@ -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
+ // Effectively pure bare-name externs are function calls. Impure externs are
457
+ // lifted to statement-level method 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 resolved as 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: effectively pure declarations become uninterpreted functions;
2512
+ // impure declarations become body-less methods with 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