lemmascript 0.6.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.6.1",
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. */
@@ -1025,10 +1026,8 @@ function emitDecl(d) {
1025
1026
  /** Preamble tracking — emitters add keys via `needPreamble(key)`, emitDafnyFile emits them. */
1026
1027
  const _neededPreambles = new Set();
1027
1028
  function needPreamble(key) { _neededPreambles.add(key); }
1028
- /** File-level opt-in for JS-clamp semantics on `arr.slice(lo, hi)`. Set by
1029
- * `emitDafnyFile` from the `//@ safe-slice` directive; consulted by the
1030
- * array-method emit. Off by default — case studies that wrote their `.slice`
1031
- * 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. */
1032
1031
  let _useSafeSlice = false;
1033
1032
  const POW2 = `function Pow2(n: int): int
1034
1033
  requires n >= 0
@@ -1582,8 +1581,8 @@ function translatePattern(p) {
1582
1581
  return ctorName;
1583
1582
  return `${ctorName}(${p.binders.map(escapeName).join(", ")})`;
1584
1583
  }
1585
- export function emitDafnyFile(file, tsFileName, opts) {
1586
- _useSafeSlice = !!opts?.safeSlice;
1584
+ export function emitDafnyFile(file, tsFileName, options = DEFAULT_OPTIONS) {
1585
+ _useSafeSlice = options["safe-slice"];
1587
1586
  resetDafnyNameCache();
1588
1587
  buildRecordCtorMap(file.decls);
1589
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;
@@ -33,7 +34,8 @@ function withHavocKey(key, fn) {
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
36
  * 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`.
37
+ * when effectively pure, or as a body-less method when effectively impure
38
+ * after project defaults and source overrides.
37
39
  * Cleared at the start of every `extractModule`. */
38
40
  const _externs = new Map();
39
41
  /** Signature types of *kept* externs, for the imported-type resolver: a type
@@ -51,6 +53,8 @@ let _currentSourceFile = null;
51
53
  * that no verified function actually calls — and whose TS return types
52
54
  * often don't translate to valid Dafny. */
53
55
  let _inFunctionExtraction = false;
56
+ /** Effective options for the current extraction. Reset at extractModule entry. */
57
+ let _extractOptions = DEFAULT_OPTIONS;
54
58
  /** Counter for synthetic names used by let-statement array destructuring
55
59
  * when the initializer isn't a bare variable (single-eval temp). */
56
60
  let _destrCounter = 0;
@@ -164,7 +168,7 @@ function detectCrossFileExtern(callee, sourceFile, sigTypesOut) {
164
168
  const annots = collectFunctionAnnotations(externalDecl);
165
169
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
166
170
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
167
- const impure = hasBareFunctionAnnotation(externalDecl, "impure");
171
+ const impure = externIsImpure(externalDecl, qualified);
168
172
  return { qualified, flat, typeParams, params, returnType, requires, ensures, impure };
169
173
  }
170
174
  /** Build a concat-tree from a mixed list of literal and SpreadElement nodes.
@@ -784,6 +788,40 @@ function hasBareFunctionAnnotation(node, keyword, body) {
784
788
  function hasPureAnnotation(node, body) {
785
789
  return hasBareFunctionAnnotation(node, "pure", body);
786
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
+ }
787
825
  // ── Type declaration extraction ──────────────────────────────
788
826
  function extractTypeDecl(decl, extraDecls) {
789
827
  const name = decl.getName();
@@ -2028,7 +2066,8 @@ function extractFunctionInner(fn, parentAnnotations) {
2028
2066
  };
2029
2067
  }
2030
2068
  // ── Module extraction ────────────────────────────────────────
2031
- export function extractModule(sourceFile) {
2069
+ export function extractModule(sourceFile, options = DEFAULT_OPTIONS) {
2070
+ _extractOptions = options;
2032
2071
  // Seed the fresh-name check (names.ts) before anything mints: every
2033
2072
  // Identifier token in the module, a deliberate over-approximation.
2034
2073
  setUserNames(new Set(sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).map(i => i.getText())));
@@ -2247,12 +2286,6 @@ export function extractModule(sourceFile) {
2247
2286
  }
2248
2287
  return false;
2249
2288
  }
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
- }
2256
2289
  // `//@ extern NS.method` registers the extern under a *dotted* qualified name,
2257
2290
  // so a real `NS.method(args)` call dispatches to it (resolve.ts) with no
2258
2291
  // wrapper — e.g. `//@ extern fs.readFileSync` lets you call `fs.readFileSync`
@@ -2297,7 +2330,7 @@ export function extractModule(sourceFile) {
2297
2330
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
2298
2331
  _externs.set(qualified, {
2299
2332
  qualified, flat, typeParams, params, returnType, requires, ensures,
2300
- impure: hasImpure(f),
2333
+ impure: externIsImpure(f.node, qualified, f.parentStmt),
2301
2334
  });
2302
2335
  }
2303
2336
  // If any function has //@ verify, only extract those (brownfield mode).
@@ -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,
@@ -839,7 +839,7 @@ function emitDecl(d) {
839
839
  return `def ${escapeName(d.name)} : ${tyToLean(d.type)} := ${emitExpr(d.value)}`;
840
840
  case "extern": {
841
841
  if (d.impure) {
842
- throw new Error("//@ impure extern is not supported in the Lean backend");
842
+ throw new Error("impure extern is not supported in the Lean backend; add //@ pure to this extern or use extern-default: pure");
843
843
  }
844
844
  // Mirror Dafny's `function {:axiom}`: an uninterpreted total function.
845
845
  // In Lean that is an `opaque` declaration (sound — it commits to no body,
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,8 +453,8 @@ 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 externs are pure by default. `//@ impure` externs are method
457
- // calls: lift them to statement-level binds so repeated invocations remain
456
+ // Effectively pure bare-name externs are function calls. Impure externs are
457
+ // lifted to statement-level method binds so repeated invocations remain
458
458
  // independent. A method call cannot occur in a spec or lambda expression.
459
459
  if (fn.kind === "var") {
460
460
  const ext = ctx.externs.get(fn.name);
@@ -1645,7 +1645,7 @@ function rawCalleeName(e) {
1645
1645
  }
1646
1646
  return null;
1647
1647
  }
1648
- /** Whether a raw function body invokes any extern marked `//@ impure`. */
1648
+ /** Whether a raw function body invokes any extern resolved as impure. */
1649
1649
  function containsImpureExternCall(v, names) {
1650
1650
  if (Array.isArray(v))
1651
1651
  return v.some(x => containsImpureExternCall(x, names));
@@ -2508,8 +2508,8 @@ 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: pure declarations become uninterpreted functions; `//@ impure`
2512
- // declarations become body-less methods so calls have independent results.
2511
+ // Externs: effectively pure declarations become uninterpreted functions;
2512
+ // impure declarations become body-less methods with independent results.
2513
2513
  // Contracts come along in either case. A pure extern's `\result` denotes its
2514
2514
  // application; an impure extern keeps `\result` for the method out-parameter.
2515
2515
  const externDecls = (mod.externs ?? []).map(ext => {