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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.6",
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",
@@ -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
+ }