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 +1 -1
- package/package.json +1 -1
- package/tools/dist/config.js +236 -0
- package/tools/dist/dafny-emit.js +23 -14
- package/tools/dist/extract.js +61 -7
- package/tools/dist/info-command.js +2 -1
- package/tools/dist/lean-emit.js +3 -0
- package/tools/dist/lsc.js +77 -32
- package/tools/dist/resolve.js +58 -13
- package/tools/dist/transform.js +8 -5
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
|
@@ -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. */
|
|
@@ -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
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
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
|
-
/**
|
|
1019
|
-
*
|
|
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,
|
|
1576
|
-
_useSafeSlice =
|
|
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();
|
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;
|
|
@@ -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
|
-
|
|
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`
|
|
765
|
-
|
|
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() ===
|
|
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, {
|
|
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,
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -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
|
|
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,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
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
852
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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) &&
|
|
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: [] };
|
package/tools/dist/transform.js
CHANGED
|
@@ -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:
|
|
2512
|
-
//
|
|
2513
|
-
//
|
|
2514
|
-
//
|
|
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 =>
|
|
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
|