lemmascript 0.5.6 → 0.5.7
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 +2 -1
- package/tools/dist/extract.js +30 -1
- package/tools/dist/guard-command.js +238 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"type": "git",
|
|
32
32
|
"url": "https://github.com/midspiral/LemmaScript"
|
|
33
33
|
},
|
|
34
|
+
"homepage": "https://lemmascript.com",
|
|
34
35
|
"keywords": [
|
|
35
36
|
"lemmascript",
|
|
36
37
|
"verification",
|
package/tools/dist/extract.js
CHANGED
|
@@ -599,7 +599,7 @@ function extractExpr(node) {
|
|
|
599
599
|
}
|
|
600
600
|
// ── Annotation parsing ───────────────────────────────────────
|
|
601
601
|
const PREFIX = "//@ ";
|
|
602
|
-
const KEYWORDS = ["requires", "ensures", "invariant", "decreases", "done_with", "type"];
|
|
602
|
+
const KEYWORDS = ["requires", "ensures", "contract", "invariant", "decreases", "done_with", "type"];
|
|
603
603
|
function parseAnnotations(node) {
|
|
604
604
|
const result = [];
|
|
605
605
|
for (const range of node.getLeadingCommentRanges()) {
|
|
@@ -1730,7 +1730,30 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1730
1730
|
}
|
|
1731
1731
|
return {
|
|
1732
1732
|
name: fn.getName?.() ?? "<anonymous>",
|
|
1733
|
+
exported: false, // set in extractModule against the source file's export surface
|
|
1733
1734
|
typeParams: unboundedTypeParams,
|
|
1735
|
+
// Original TS parameter grouping, before the flatten below loses it. `defaults` carries
|
|
1736
|
+
// each bound name's default initializer text (omitted when none) for TS-targeting consumers.
|
|
1737
|
+
tsParams: fn.getParameters().map(p => {
|
|
1738
|
+
const nameNode = p.getNameNode();
|
|
1739
|
+
if (Node.isObjectBindingPattern(nameNode)) {
|
|
1740
|
+
const els = nameNode.getElements();
|
|
1741
|
+
const defaults = {};
|
|
1742
|
+
for (const el of els) {
|
|
1743
|
+
const init = el.getInitializer();
|
|
1744
|
+
if (init)
|
|
1745
|
+
defaults[el.getName()] = init.getText();
|
|
1746
|
+
}
|
|
1747
|
+
const binds = els.map(el => el.getName());
|
|
1748
|
+
return Object.keys(defaults).length ? { kind: "object", binds, defaults } : { kind: "object", binds };
|
|
1749
|
+
}
|
|
1750
|
+
if (p.isRestParameter())
|
|
1751
|
+
return { kind: "rest", binds: [p.getName()] };
|
|
1752
|
+
const init = p.getInitializer();
|
|
1753
|
+
return init
|
|
1754
|
+
? { kind: "simple", binds: [p.getName()], defaults: { [p.getName()]: init.getText() } }
|
|
1755
|
+
: { kind: "simple", binds: [p.getName()] };
|
|
1756
|
+
}),
|
|
1734
1757
|
params: fn.getParameters().flatMap(p => {
|
|
1735
1758
|
// Flatten destructured object params into individual params
|
|
1736
1759
|
const nameNode = p.getNameNode();
|
|
@@ -1790,6 +1813,7 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1790
1813
|
})(),
|
|
1791
1814
|
requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
|
|
1792
1815
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
1816
|
+
contract: annots.filter(a => a.kind === "contract").map(a => a.expr),
|
|
1793
1817
|
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
1794
1818
|
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
1795
1819
|
autohavoc: false, // set in extractModule (file-level directive or per-function)
|
|
@@ -2076,11 +2100,16 @@ export function extractModule(sourceFile) {
|
|
|
2076
2100
|
}
|
|
2077
2101
|
return false;
|
|
2078
2102
|
}
|
|
2103
|
+
// The module's export surface, by name — covers inline `export function`,
|
|
2104
|
+
// `export { a, b }`, re-exports, and `export const`. Consumers (e.g. the guard
|
|
2105
|
+
// plugin) use this to wrap only the boundary, not internal helpers.
|
|
2106
|
+
const exportedNames = new Set(sourceFile.getExportedDeclarations().keys());
|
|
2079
2107
|
const functions = fnsToExtract.map(f => {
|
|
2080
2108
|
// For expression-body arrows, annotations come from the parent variable statement
|
|
2081
2109
|
const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
|
|
2082
2110
|
const raw = extractFunction(f.node, parentAnnots);
|
|
2083
2111
|
raw.name = f.name; // use the const name, not "<anonymous>"
|
|
2112
|
+
raw.exported = exportedNames.has(f.name);
|
|
2084
2113
|
raw.autohavoc = hasAutohavoc(f);
|
|
2085
2114
|
return raw;
|
|
2086
2115
|
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsc guard` — emit a drop-in `<file>.guarded.ts` that enforces each verified
|
|
3
|
+
* function's `//@ requires` at runtime.
|
|
4
|
+
*
|
|
5
|
+
* Backend-neutral (like `extract`/`info`): reads the Raw IR and re-parses each
|
|
6
|
+
* `//@ requires` string with the specparser, then lowers the resulting RawExpr
|
|
7
|
+
* back to executable TypeScript. The generated module re-exports every function
|
|
8
|
+
* at its original signature, each guarded: on a violated clause it throws
|
|
9
|
+
* `PreconditionError(fn, clause, clauseId, args, detail)`; a `can.*` namespace
|
|
10
|
+
* exposes the same per-clause checks as booleans for render-time gating.
|
|
11
|
+
*
|
|
12
|
+
* Clauses naming a symbol that is not TS-resident (a ghost `.dfy` predicate, an
|
|
13
|
+
* unbounded quantifier) cannot be lowered — they are SKIPPED with a warning,
|
|
14
|
+
* never faked. Internal core-to-core calls stay raw (`__core.*`); the proof
|
|
15
|
+
* covers those.
|
|
16
|
+
*/
|
|
17
|
+
import { writeFileSync } from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import { parseExpr } from "./specparser.js";
|
|
20
|
+
class NotLowerable extends Error {
|
|
21
|
+
}
|
|
22
|
+
const GLOBALS = new Set(["Math", "Number", "undefined"]);
|
|
23
|
+
// ── RawExpr → executable TS (throws NotLowerable on a non-TS-resident symbol) ──
|
|
24
|
+
function lower(e, ctx) {
|
|
25
|
+
switch (e.kind) {
|
|
26
|
+
case "num": return String(e.value);
|
|
27
|
+
case "bool": return String(e.value);
|
|
28
|
+
case "str": return JSON.stringify(e.value);
|
|
29
|
+
case "var":
|
|
30
|
+
if (ctx.bound.has(e.name) || ctx.params.has(e.name) || GLOBALS.has(e.name))
|
|
31
|
+
return e.name;
|
|
32
|
+
if (ctx.fns.has(e.name))
|
|
33
|
+
return `__core.${e.name}`;
|
|
34
|
+
throw new NotLowerable(`unknown symbol '${e.name}' (not a param, bound var, or module function)`);
|
|
35
|
+
case "field": return `${lower(e.obj, ctx)}.${e.field}`;
|
|
36
|
+
case "index": return `${lower(e.obj, ctx)}[${lower(e.idx, ctx)}]`;
|
|
37
|
+
case "call": return `${lower(e.fn, ctx)}(${e.args.map((a) => lower(a, ctx)).join(", ")})`;
|
|
38
|
+
case "unop": return `(${e.op}${lower(e.expr, ctx)})`;
|
|
39
|
+
case "conditional":
|
|
40
|
+
return `(${lower(e.cond, ctx)} ? ${lower(e.then, ctx)} : ${lower(e.else, ctx)})`;
|
|
41
|
+
case "arrayLiteral": return `[${e.elems.map((x) => lower(x, ctx)).join(", ")}]`;
|
|
42
|
+
case "binop": {
|
|
43
|
+
const l = () => lower(e.left, ctx), r = () => lower(e.right, ctx);
|
|
44
|
+
if (e.op === "==>")
|
|
45
|
+
return `(!(${l()}) || (${r()}))`;
|
|
46
|
+
if (e.op === "<==>")
|
|
47
|
+
return `((${l()}) === (${r()}))`;
|
|
48
|
+
if (e.op === "in")
|
|
49
|
+
throw new NotLowerable("'in' membership not yet lowered");
|
|
50
|
+
return `(${l()} ${e.op} ${r()})`;
|
|
51
|
+
}
|
|
52
|
+
case "forall":
|
|
53
|
+
case "exists": {
|
|
54
|
+
const { lo, ubOp, ub } = quantRange(e, ctx);
|
|
55
|
+
const inner = { ...ctx, bound: new Set([...ctx.bound, e.var]) };
|
|
56
|
+
const body = lower(e.body, inner);
|
|
57
|
+
const hit = e.kind === "forall" ? `!(${body})` : `(${body})`;
|
|
58
|
+
const found = e.kind === "forall" ? "false" : "true";
|
|
59
|
+
const dflt = e.kind === "forall" ? "true" : "false";
|
|
60
|
+
return `(() => { for (let ${e.var} = ${lo}; ${e.var} ${ubOp} ${ub}; ${e.var}++) { if (${hit}) return ${found}; } return ${dflt}; })()`;
|
|
61
|
+
}
|
|
62
|
+
default: throw new NotLowerable(`unsupported expression kind '${e.kind}'`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Extract a sound finite iteration range [lo, ub) for the quantified var from a
|
|
66
|
+
// `lo <= v && v < ub ==> P` antecedent. Both bounds must be found (or var is a
|
|
67
|
+
// nat) — guessing would risk an unsound under-scan. Throws otherwise.
|
|
68
|
+
function quantRange(q, ctx) {
|
|
69
|
+
if (q.body.kind !== "binop" || q.body.op !== "==>")
|
|
70
|
+
throw new NotLowerable(`quantifier over '${q.var}' has no bounding antecedent`);
|
|
71
|
+
const inner = { ...ctx, bound: new Set([...ctx.bound, q.var]) };
|
|
72
|
+
const isVar = (x) => x.kind === "var" && x.name === q.var;
|
|
73
|
+
const conj = (e) => e.kind === "binop" && e.op === "&&" ? [...conj(e.left), ...conj(e.right)] : [e];
|
|
74
|
+
let upper = null;
|
|
75
|
+
let lowerB = null;
|
|
76
|
+
for (const c of conj(q.body.left)) {
|
|
77
|
+
if (c.kind !== "binop")
|
|
78
|
+
continue;
|
|
79
|
+
if (isVar(c.left) && (c.op === "<" || c.op === "<="))
|
|
80
|
+
upper = { ub: lower(c.right, inner), strict: c.op === "<" };
|
|
81
|
+
else if (isVar(c.right) && (c.op === ">" || c.op === ">="))
|
|
82
|
+
upper = { ub: lower(c.left, inner), strict: c.op === ">" };
|
|
83
|
+
else if (isVar(c.right) && (c.op === "<" || c.op === "<="))
|
|
84
|
+
lowerB = { expr: lower(c.left, inner), strict: c.op === "<" };
|
|
85
|
+
else if (isVar(c.left) && (c.op === ">" || c.op === ">="))
|
|
86
|
+
lowerB = { expr: lower(c.right, inner), strict: c.op === ">" };
|
|
87
|
+
}
|
|
88
|
+
if (!upper)
|
|
89
|
+
throw new NotLowerable(`no upper bound found for '${q.var}'`);
|
|
90
|
+
let lo;
|
|
91
|
+
if (lowerB)
|
|
92
|
+
lo = lowerB.strict ? `(${lowerB.expr}) + 1` : lowerB.expr;
|
|
93
|
+
else if (q.varType === "nat")
|
|
94
|
+
lo = "0";
|
|
95
|
+
else
|
|
96
|
+
throw new NotLowerable(`no lower bound found for '${q.var}'`);
|
|
97
|
+
return { lo, ubOp: upper.strict ? "<" : "<=", ub: upper.ub };
|
|
98
|
+
}
|
|
99
|
+
// ── human-readable label for a sub-expression (spec text, bare names) ──
|
|
100
|
+
function render(e) {
|
|
101
|
+
switch (e.kind) {
|
|
102
|
+
case "num": return String(e.value);
|
|
103
|
+
case "bool": return String(e.value);
|
|
104
|
+
case "str": return JSON.stringify(e.value);
|
|
105
|
+
case "var": return e.name;
|
|
106
|
+
case "field": return `${render(e.obj)}.${e.field}`;
|
|
107
|
+
case "index": return `${render(e.obj)}[${render(e.idx)}]`;
|
|
108
|
+
case "call": return `${render(e.fn)}(${e.args.map(render).join(", ")})`;
|
|
109
|
+
case "unop": return `${e.op}${render(e.expr)}`;
|
|
110
|
+
case "binop": return `${render(e.left)} ${e.op} ${render(e.right)}`;
|
|
111
|
+
case "conditional": return `${render(e.cond)} ? ${render(e.then)} : ${render(e.else)}`;
|
|
112
|
+
default: return "?";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Maximal "notable" sub-expressions (calls / field / index / param vars) whose
|
|
116
|
+
// runtime values explain a failure. Recurses through operators only.
|
|
117
|
+
function collectNotable(e, ctx, out) {
|
|
118
|
+
switch (e.kind) {
|
|
119
|
+
case "call":
|
|
120
|
+
case "field":
|
|
121
|
+
case "index": {
|
|
122
|
+
try {
|
|
123
|
+
out.set(render(e), lower(e, ctx));
|
|
124
|
+
}
|
|
125
|
+
catch { /* skip unlowerable leaf */ }
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case "var":
|
|
129
|
+
if (ctx.params.has(e.name))
|
|
130
|
+
out.set(e.name, e.name);
|
|
131
|
+
return;
|
|
132
|
+
case "binop":
|
|
133
|
+
collectNotable(e.left, ctx, out);
|
|
134
|
+
collectNotable(e.right, ctx, out);
|
|
135
|
+
return;
|
|
136
|
+
case "unop":
|
|
137
|
+
collectNotable(e.expr, ctx, out);
|
|
138
|
+
return;
|
|
139
|
+
case "conditional":
|
|
140
|
+
collectNotable(e.cond, ctx, out);
|
|
141
|
+
collectNotable(e.then, ctx, out);
|
|
142
|
+
collectNotable(e.else, ctx, out);
|
|
143
|
+
return;
|
|
144
|
+
default: return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ── per-function check table ──────────────────────────────────────────
|
|
148
|
+
function buildChecks(fn, ctx) {
|
|
149
|
+
const preamble = [], entries = [], skipped = [];
|
|
150
|
+
fn.requires.forEach((src, i) => {
|
|
151
|
+
const id = `${fn.name}#${i}`;
|
|
152
|
+
try {
|
|
153
|
+
const ast = parseExpr(src);
|
|
154
|
+
const clauseLit = JSON.stringify(src);
|
|
155
|
+
if (ast.kind === "forall") {
|
|
156
|
+
const { lo, ubOp, ub } = quantRange(ast, ctx);
|
|
157
|
+
const inner = { ...ctx, bound: new Set([...ctx.bound, ast.var]) };
|
|
158
|
+
const body = lower(ast.body, inner);
|
|
159
|
+
const w = `__w${i}`;
|
|
160
|
+
preamble.push(` const ${w} = ((): number => { for (let ${ast.var} = ${lo}; ${ast.var} ${ubOp} ${ub}; ${ast.var}++) { if (!(${body})) return ${ast.var}; } return -1; })();`);
|
|
161
|
+
entries.push(` __C(${JSON.stringify(id)}, ${clauseLit}, ${w} === -1, () => ({ ${JSON.stringify(ast.var)}: ${w} })),`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const ok = lower(ast, ctx);
|
|
165
|
+
const notes = new Map();
|
|
166
|
+
collectNotable(ast, ctx, notes);
|
|
167
|
+
const detail = `{ ${[...notes].map(([k, v]) => `${JSON.stringify(k)}: ${v}`).join(", ")} }`;
|
|
168
|
+
entries.push(` __C(${JSON.stringify(id)}, ${clauseLit}, (${ok}), () => (${detail})),`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!(err instanceof NotLowerable))
|
|
173
|
+
throw err;
|
|
174
|
+
skipped.push(` // SKIPPED ${id} (${err.message}): ${src}`);
|
|
175
|
+
console.warn(` warning: ${fn.name} — unlowerable clause skipped (${err.message}): ${src}`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return { preamble, entries, skipped };
|
|
179
|
+
}
|
|
180
|
+
function sig(fn) {
|
|
181
|
+
const tp = fn.typeParams.length ? `<${fn.typeParams.join(", ")}>` : "";
|
|
182
|
+
const params = fn.params.map((p) => `${p.name}: ${p.tsType}`).join(", ");
|
|
183
|
+
return `${tp}(${params}): ${fn.returnType}`;
|
|
184
|
+
}
|
|
185
|
+
const argList = (fn) => fn.params.map((p) => p.name).join(", ");
|
|
186
|
+
function emitFunction(fn, ctx) {
|
|
187
|
+
const { preamble, entries, skipped } = buildChecks(fn, ctx);
|
|
188
|
+
const checksBody = [...skipped, ...preamble, ` return [`, ...entries, ` ];`].join("\n");
|
|
189
|
+
const args = argList(fn);
|
|
190
|
+
return [
|
|
191
|
+
`function checks_${fn.name}(${fn.params.map((p) => `${p.name}: ${p.tsType}`).join(", ")}): __Check[] {`,
|
|
192
|
+
checksBody,
|
|
193
|
+
`}`,
|
|
194
|
+
`export function ${fn.name}${sig(fn)} {`,
|
|
195
|
+
` return __enforce(${JSON.stringify(fn.name)}, [${args}], checks_${fn.name}(${args}), () => __core.${fn.name}(${args}));`,
|
|
196
|
+
`}`,
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
export function runGuard(raw, outPath) {
|
|
200
|
+
const base = path.basename(raw.file, ".ts");
|
|
201
|
+
const fnNames = new Set(raw.functions.map((f) => f.name));
|
|
202
|
+
const blocks = raw.functions.map((fn) => {
|
|
203
|
+
const ctx = { params: new Set(fn.params.map((p) => p.name)), bound: new Set(), fns: fnNames };
|
|
204
|
+
return emitFunction(fn, ctx);
|
|
205
|
+
});
|
|
206
|
+
const canEntries = raw.functions.map((fn) => ` ${fn.name}: (${fn.params.map((p) => `${p.name}: ${p.tsType}`).join(", ")}): boolean => __holds(checks_${fn.name}(${argList(fn)})),`);
|
|
207
|
+
const header = [
|
|
208
|
+
`// ${base}.guarded.ts — GENERATED by \`lsc guard\`. Do not edit.`,
|
|
209
|
+
`// Drop-in for ${base}.ts: each function checks its //@ requires and throws`,
|
|
210
|
+
`// PreconditionError on violation; \`can.*\` runs the same checks as booleans.`,
|
|
211
|
+
``,
|
|
212
|
+
`import * as __core from "./${base}";`,
|
|
213
|
+
``,
|
|
214
|
+
`export class PreconditionError extends Error {`,
|
|
215
|
+
` constructor(`,
|
|
216
|
+
` readonly fn: string,`,
|
|
217
|
+
` readonly clause: string,`,
|
|
218
|
+
` readonly clauseId: string,`,
|
|
219
|
+
` readonly args: unknown[],`,
|
|
220
|
+
` readonly detail: unknown,`,
|
|
221
|
+
` ) {`,
|
|
222
|
+
` super(\`precondition failed in \${fn}: \${clause}\`);`,
|
|
223
|
+
` this.name = "PreconditionError";`,
|
|
224
|
+
` }`,
|
|
225
|
+
`}`,
|
|
226
|
+
``,
|
|
227
|
+
`type __Check = { id: string; clause: string; ok: boolean; detail: () => unknown };`,
|
|
228
|
+
`const __C = (id: string, clause: string, ok: boolean, detail: () => unknown): __Check => ({ id, clause, ok, detail });`,
|
|
229
|
+
`function __enforce<R>(fn: string, args: unknown[], checks: __Check[], call: () => R): R {`,
|
|
230
|
+
` for (const c of checks) if (!c.ok) throw new PreconditionError(fn, c.clause, c.id, args, c.detail());`,
|
|
231
|
+
` return call();`,
|
|
232
|
+
`}`,
|
|
233
|
+
`const __holds = (checks: __Check[]): boolean => checks.every((c) => c.ok);`,
|
|
234
|
+
].join("\n");
|
|
235
|
+
const text = [header, "", ...blocks, "", "export const can = {", ...canEntries, "};", ""].join("\n");
|
|
236
|
+
writeFileSync(outPath, text);
|
|
237
|
+
console.log(`Wrote ${outPath} (${raw.functions.length} functions guarded)`);
|
|
238
|
+
}
|