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 +1 -1
- package/tools/dist/config.js +236 -0
- package/tools/dist/dafny-emit.js +5 -6
- package/tools/dist/extract.js +43 -10
- package/tools/dist/info-command.js +2 -1
- package/tools/dist/lean-emit.js +1 -1
- package/tools/dist/lsc.js +77 -32
- package/tools/dist/resolve.js +3 -3
- package/tools/dist/transform.js +2 -2
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -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
|
-
/**
|
|
1029
|
-
*
|
|
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,
|
|
1586
|
-
_useSafeSlice =
|
|
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();
|
package/tools/dist/extract.js
CHANGED
|
@@ -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
|
-
*
|
|
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 =
|
|
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:
|
|
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,
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -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("
|
|
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
|
|
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
|
-
|
|
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),
|
|
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),
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
const
|
|
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,
|
|
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,
|
|
353
|
+
dafnyRegen(genPath, dfyPath, basePath, text, artifactDir, timeLimit, extraFlags, noVerify);
|
|
309
354
|
return;
|
|
310
355
|
}
|
|
311
356
|
console.error(`Unknown command: ${cmd}`);
|
package/tools/dist/resolve.js
CHANGED
|
@@ -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
|
-
//
|
|
457
|
-
//
|
|
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
|
|
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));
|
package/tools/dist/transform.js
CHANGED
|
@@ -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;
|
|
2512
|
-
// declarations become body-less methods
|
|
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 => {
|