lemmascript 0.5.5 → 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/dafny-commands.js +3 -1
- package/tools/dist/dafny-emit.js +47 -5
- package/tools/dist/extract.js +73 -31
- package/tools/dist/guard-command.js +238 -0
- package/tools/dist/lean-emit.js +29 -6
- package/tools/dist/narrow.js +30 -6
- package/tools/dist/resolve.js +13 -1
- package/tools/dist/specparser.js +37 -15
- package/tools/dist/transform.js +193 -47
- package/tools/dist/emit.js +0 -253
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",
|
|
@@ -28,7 +28,9 @@ export function dafnyCheckDiff(genPath, dfyPath) {
|
|
|
28
28
|
diff = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf-8");
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
|
-
|
|
31
|
+
// git couldn't be spawned: the check never ran, so fail loud, don't green-pass.
|
|
32
|
+
console.error(`ERROR: could not run \`git diff\` to verify ${path.basename(dfyPath)} is additions-only (is git installed?)`);
|
|
33
|
+
return false;
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
const deletions = diff.split("\n").filter(l => l.startsWith("-") && !l.startsWith("---"));
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -86,7 +86,7 @@ function methodHeader(prefix, params, returnType) {
|
|
|
86
86
|
// ── Lean op → Dafny op ─────────────────────────────────────
|
|
87
87
|
const OP_MAP = {
|
|
88
88
|
"=": "==", "≠": "!=", "≥": ">=", "≤": "<=",
|
|
89
|
-
"∧": "&&", "∨": "||", "¬": "!",
|
|
89
|
+
"∧": "&&", "∨": "||", "¬": "!", "↔": "<==>",
|
|
90
90
|
"arrayConcat": "+",
|
|
91
91
|
};
|
|
92
92
|
function mapOp(op) { return OP_MAP[op] ?? op; }
|
|
@@ -152,15 +152,15 @@ function emitExpr(e) {
|
|
|
152
152
|
if (e.method === "with")
|
|
153
153
|
return `${obj}[${args[0]} := ${args[1]}]`;
|
|
154
154
|
if (e.method === "includes")
|
|
155
|
-
return `(${args[0]} in ${obj})`;
|
|
155
|
+
return args.length > 1 ? `(${args[0]} in ${obj}[${args[1]}..])` : `(${args[0]} in ${obj})`;
|
|
156
156
|
if (e.method === "indexOf") {
|
|
157
157
|
needPreamble("SeqIndexOf");
|
|
158
|
-
return `SeqIndexOf(${obj}, ${args[0]})`;
|
|
158
|
+
return args.length > 1 ? `SeqIndexOfFrom(${obj}, ${args[0]}, ${args[1]})` : `SeqIndexOf(${obj}, ${args[0]})`;
|
|
159
159
|
}
|
|
160
160
|
if (e.method === "push")
|
|
161
|
-
return `(${obj} + [${args
|
|
161
|
+
return `(${obj} + [${args.join(", ")}])`;
|
|
162
162
|
if (e.method === "concat")
|
|
163
|
-
return `(${obj} + [${args
|
|
163
|
+
return `(${obj} + [${args.join(", ")}])`;
|
|
164
164
|
// No-arg slice is a full copy; Dafny seq is an immutable value type, so
|
|
165
165
|
// the copy is just the seq itself (the idiom for "copy then mutate").
|
|
166
166
|
if (e.method === "slice" && args.length === 0)
|
|
@@ -399,12 +399,22 @@ function emitExpr(e) {
|
|
|
399
399
|
return `{${args.join(", ")}}`;
|
|
400
400
|
if (e.fn === "JSFloorDiv")
|
|
401
401
|
needPreamble("JSFloorDiv");
|
|
402
|
+
if (e.fn === "JSRem")
|
|
403
|
+
needPreamble("JSRem");
|
|
404
|
+
if (e.fn === "JSTruncDiv")
|
|
405
|
+
needPreamble("JSTruncDiv");
|
|
406
|
+
if (e.fn === "JSStringLt")
|
|
407
|
+
needPreamble("JSStringLt");
|
|
402
408
|
if (e.fn === "CeilReal")
|
|
403
409
|
needPreamble("CeilReal");
|
|
404
410
|
if (e.fn === "FloorReal")
|
|
405
411
|
needPreamble("FloorReal");
|
|
406
412
|
if (e.fn === "NatToString")
|
|
407
413
|
needPreamble("NatToString");
|
|
414
|
+
if (e.fn === "IntToString") {
|
|
415
|
+
needPreamble("NatToString");
|
|
416
|
+
needPreamble("IntToString");
|
|
417
|
+
}
|
|
408
418
|
if (e.fn === "MathAbs")
|
|
409
419
|
needPreamble("MathAbs");
|
|
410
420
|
if (e.fn === "MathMin")
|
|
@@ -421,6 +431,8 @@ function emitExpr(e) {
|
|
|
421
431
|
}
|
|
422
432
|
if (e.fn === "Perm")
|
|
423
433
|
needPreamble("Perm");
|
|
434
|
+
if (e.fn === "SetFromSeq")
|
|
435
|
+
needPreamble("SetFromSeq");
|
|
424
436
|
return `${escapeName(e.fn)}(${args.join(", ")})`;
|
|
425
437
|
}
|
|
426
438
|
case "field": {
|
|
@@ -804,6 +816,26 @@ const JS_FLOOR_DIV = `function JSFloorDiv(a: int, b: int): int
|
|
|
804
816
|
if a <= 0 then (-a) / (-b)
|
|
805
817
|
else -((a - 1) / (-b)) - 1
|
|
806
818
|
}`;
|
|
819
|
+
const JS_REM = `function JSRem(a: int, b: int): int
|
|
820
|
+
requires b != 0
|
|
821
|
+
{
|
|
822
|
+
var r := (if a < 0 then -a else a) % (if b < 0 then -b else b);
|
|
823
|
+
if a < 0 then -r else r
|
|
824
|
+
}`;
|
|
825
|
+
const JS_TRUNC_DIV = `function JSTruncDiv(a: int, b: int): int
|
|
826
|
+
requires b != 0
|
|
827
|
+
{
|
|
828
|
+
var q := (if a < 0 then -a else a) / (if b < 0 then -b else b);
|
|
829
|
+
if (a < 0) != (b < 0) then -q else q
|
|
830
|
+
}`;
|
|
831
|
+
const JS_STRING_LT = `predicate JSStringLt(s: string, t: string)
|
|
832
|
+
decreases |s|
|
|
833
|
+
{
|
|
834
|
+
if |s| == 0 then |t| > 0
|
|
835
|
+
else if |t| == 0 then false
|
|
836
|
+
else if s[0] != t[0] then s[0] < t[0]
|
|
837
|
+
else JSStringLt(s[1..], t[1..])
|
|
838
|
+
}`;
|
|
807
839
|
const FLOOR_REAL = `function FloorReal(x: real): int
|
|
808
840
|
{
|
|
809
841
|
x.Floor
|
|
@@ -1031,11 +1063,16 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
|
|
|
1031
1063
|
if n < 10 then [digit]
|
|
1032
1064
|
else NatToString(n / 10) + [digit]
|
|
1033
1065
|
}`;
|
|
1066
|
+
const INT_TO_STRING = `function IntToString(n: int): string
|
|
1067
|
+
{
|
|
1068
|
+
if n < 0 then "-" + NatToString(-n) else NatToString(n)
|
|
1069
|
+
}`;
|
|
1034
1070
|
const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
|
|
1035
1071
|
// perm(a, b) — `a` and `b` are reorderings of each other (equal as multisets).
|
|
1036
1072
|
// Transparent (Dafny unfolds it), so hand-proofs can reason with `multiset`
|
|
1037
1073
|
// directly. The `(==)` bound requires the element type to support equality.
|
|
1038
1074
|
const PERM = `predicate Perm<T(==)>(a: seq<T>, b: seq<T>) { multiset(a) == multiset(b) }`;
|
|
1075
|
+
const SET_FROM_SEQ = `function SetFromSeq<T(==)>(s: seq<T>): set<T> { set x | x in s }`;
|
|
1039
1076
|
const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
1040
1077
|
ensures forall x :: x in s <==> x in res
|
|
1041
1078
|
ensures |res| == |s|
|
|
@@ -1063,6 +1100,9 @@ const PREAMBLE_CODE = [
|
|
|
1063
1100
|
["BitAnd", BIT_AND],
|
|
1064
1101
|
["BitOr", BIT_OR],
|
|
1065
1102
|
["JSFloorDiv", JS_FLOOR_DIV],
|
|
1103
|
+
["JSRem", JS_REM],
|
|
1104
|
+
["JSTruncDiv", JS_TRUNC_DIV],
|
|
1105
|
+
["JSStringLt", JS_STRING_LT],
|
|
1066
1106
|
["CeilReal", CEIL_REAL],
|
|
1067
1107
|
["FloorReal", FLOOR_REAL],
|
|
1068
1108
|
["SeqIndexOf", SEQ_INDEX_OF],
|
|
@@ -1078,12 +1118,14 @@ const PREAMBLE_CODE = [
|
|
|
1078
1118
|
["StringToLower", STRING_TO_LOWER],
|
|
1079
1119
|
["StringToUpper", STRING_TO_UPPER],
|
|
1080
1120
|
["NatToString", NAT_TO_STRING],
|
|
1121
|
+
["IntToString", INT_TO_STRING],
|
|
1081
1122
|
["MathAbs", MATH_ABS],
|
|
1082
1123
|
["MathMin", MATH_MIN],
|
|
1083
1124
|
["MathMax", MATH_MAX],
|
|
1084
1125
|
["MaxOfSeq", MAX_OF_SEQ],
|
|
1085
1126
|
["MinOfSeq", MIN_OF_SEQ],
|
|
1086
1127
|
["Perm", PERM],
|
|
1128
|
+
["SetFromSeq", SET_FROM_SEQ],
|
|
1087
1129
|
];
|
|
1088
1130
|
// ── Constructor and record helpers ───────────────────────────
|
|
1089
1131
|
let _recordCtors = new Map();
|
package/tools/dist/extract.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Produces structured AST nodes, not strings.
|
|
5
5
|
* The only strings are //@ annotation expressions (parsed later by specparser).
|
|
6
6
|
*/
|
|
7
|
-
import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
|
|
7
|
+
import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
|
|
8
8
|
import { initTypeParser } from "./types.js";
|
|
9
9
|
// ── Expression extraction ────────────────────────────────────
|
|
10
10
|
/** When set, calls whose function/method name matches this key are replaced with havoc. */
|
|
@@ -87,11 +87,16 @@ function detectCrossFileExtern(callee, sourceFile) {
|
|
|
87
87
|
// types in the callee's own type-parameter namespace, so these names match
|
|
88
88
|
// what `params`/`returnType` reference — declare them on the emitted axiom.
|
|
89
89
|
const typeParams = sig.getTypeParameters().map(tp => tp.getText());
|
|
90
|
+
// Print types relative to the call site (enclosingNode = callee, alias names
|
|
91
|
+
// kept): a bare `TMsg`, not `import("/abs/path/transcript").TMsg` — the
|
|
92
|
+
// importing module declares the datatype locally, so the axiom must use the
|
|
93
|
+
// local name.
|
|
94
|
+
const externTypeText = (t) => t.getText(callee, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
|
|
90
95
|
const params = sig.getParameters().map(p => ({
|
|
91
96
|
name: p.getName(),
|
|
92
|
-
tsType: p.getTypeAtLocation(callee)
|
|
97
|
+
tsType: externTypeText(p.getTypeAtLocation(callee)),
|
|
93
98
|
}));
|
|
94
|
-
const returnType = sig.getReturnType()
|
|
99
|
+
const returnType = externTypeText(sig.getReturnType());
|
|
95
100
|
let qualified;
|
|
96
101
|
if (Node.isPropertyAccessExpression(callee)) {
|
|
97
102
|
qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
|
|
@@ -309,9 +314,10 @@ function extractExpr(node) {
|
|
|
309
314
|
// Template literal: `foo${x}bar` → "foo" + x + "bar"
|
|
310
315
|
if (Node.isTemplateExpression(node)) {
|
|
311
316
|
const parts = [];
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
317
|
+
// Always push the head, even when empty: a leading string literal anchors the
|
|
318
|
+
// whole chain as string-typed so each interpolated value is stringified (not
|
|
319
|
+
// added numerically — `${a}${b}` is concatenation, not `a + b`).
|
|
320
|
+
parts.push({ kind: "str", value: node.getHead().getLiteralText() });
|
|
315
321
|
for (const span of node.getTemplateSpans()) {
|
|
316
322
|
parts.push(extractExpr(span.getExpression()));
|
|
317
323
|
const text = span.getLiteral().getLiteralText();
|
|
@@ -481,43 +487,42 @@ function extractExpr(node) {
|
|
|
481
487
|
}
|
|
482
488
|
// Object literal: { res: true, done: false } or { ...obj, res: true }
|
|
483
489
|
if (Node.isObjectLiteralExpression(node)) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
// Fold properties in source order: a later spread overrides everything before
|
|
491
|
+
// it, a named field is a record-update on the accumulator, a computed key is a
|
|
492
|
+
// map `.set`. Order matters: if `a` has `k`, `{ k: v, ...a }` is `a`, not `a.(k := v)`.
|
|
493
|
+
let acc = null;
|
|
494
|
+
const update = (name, value) => {
|
|
495
|
+
acc = acc && acc.kind === "record"
|
|
496
|
+
? { kind: "record", spread: acc.spread, fields: [...acc.fields, { name, value }] }
|
|
497
|
+
: { kind: "record", spread: acc, fields: [{ name, value }] };
|
|
498
|
+
};
|
|
487
499
|
for (const prop of node.getProperties()) {
|
|
488
500
|
if (Node.isSpreadAssignment(prop)) {
|
|
489
|
-
|
|
501
|
+
acc = extractExpr(prop.getExpression());
|
|
490
502
|
}
|
|
491
503
|
else if (Node.isShorthandPropertyAssignment(prop)) {
|
|
492
504
|
const name = prop.getName();
|
|
493
|
-
|
|
505
|
+
update(name, { kind: "var", name });
|
|
494
506
|
}
|
|
495
507
|
else if (Node.isPropertyAssignment(prop)) {
|
|
496
508
|
const nameNode = prop.getNameNode();
|
|
497
509
|
const init = prop.getInitializer();
|
|
498
|
-
if (init
|
|
499
|
-
|
|
510
|
+
if (!init)
|
|
511
|
+
continue;
|
|
512
|
+
if (Node.isComputedPropertyName(nameNode)) {
|
|
513
|
+
// { ...base, [k]: v } → base.set(k, v); { [k]: v } → {}.set(k, v)
|
|
514
|
+
const base = acc ?? { kind: "record", spread: null, fields: [] };
|
|
515
|
+
acc = { kind: "call", fn: { kind: "field", obj: base, field: "set" }, args: [extractExpr(nameNode.getExpression()), extractExpr(init)] };
|
|
500
516
|
}
|
|
501
|
-
else
|
|
517
|
+
else {
|
|
502
518
|
// String-literal keys (e.g. `"bun run": 3`) — use the unquoted literal
|
|
503
519
|
// value; otherwise `prop.getName()` may include surrounding quotes.
|
|
504
520
|
const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName();
|
|
505
|
-
|
|
521
|
+
update(name, extractExpr(init));
|
|
506
522
|
}
|
|
507
523
|
}
|
|
508
524
|
}
|
|
509
|
-
|
|
510
|
-
// No spread: { [k]: v } → {}.set(k, v) (empty map base)
|
|
511
|
-
if (computedFields.length > 0) {
|
|
512
|
-
let result = spread ?? { kind: "record", spread: null, fields: [] };
|
|
513
|
-
for (const cf of computedFields) {
|
|
514
|
-
result = { kind: "call",
|
|
515
|
-
fn: { kind: "field", obj: result, field: "set" },
|
|
516
|
-
args: [cf.key, cf.value] };
|
|
517
|
-
}
|
|
518
|
-
return result;
|
|
519
|
-
}
|
|
520
|
-
return { kind: "record", spread, fields };
|
|
525
|
+
return acc ?? { kind: "record", spread: null, fields: [] };
|
|
521
526
|
}
|
|
522
527
|
// Ternary: cond ? then : else
|
|
523
528
|
if (Node.isConditionalExpression(node)) {
|
|
@@ -555,8 +560,12 @@ function extractExpr(node) {
|
|
|
555
560
|
if (Node.isArrayLiteralExpression(arg)) {
|
|
556
561
|
return { kind: "emptyCollection", collectionType: "Set", tsType, initElems: arg.getElements().map(e => extractExpr(e)) };
|
|
557
562
|
}
|
|
558
|
-
|
|
559
|
-
|
|
563
|
+
const argSymbol = arg.getType().getSymbol()?.getName() ?? arg.getType().getAliasSymbol()?.getName();
|
|
564
|
+
// new Set(existingSet) — identity (sets are value types)
|
|
565
|
+
if (argSymbol === "Set")
|
|
566
|
+
return extractExpr(arg);
|
|
567
|
+
// new Set(arr) — build a deduplicated set from the array's elements
|
|
568
|
+
return { kind: "call", fn: { kind: "var", name: "__setFromArray" }, args: [extractExpr(arg)] };
|
|
560
569
|
}
|
|
561
570
|
return { kind: "emptyCollection", collectionType: name, tsType };
|
|
562
571
|
}
|
|
@@ -590,7 +599,7 @@ function extractExpr(node) {
|
|
|
590
599
|
}
|
|
591
600
|
// ── Annotation parsing ───────────────────────────────────────
|
|
592
601
|
const PREFIX = "//@ ";
|
|
593
|
-
const KEYWORDS = ["requires", "ensures", "invariant", "decreases", "done_with", "type"];
|
|
602
|
+
const KEYWORDS = ["requires", "ensures", "contract", "invariant", "decreases", "done_with", "type"];
|
|
594
603
|
function parseAnnotations(node) {
|
|
595
604
|
const result = [];
|
|
596
605
|
for (const range of node.getLeadingCommentRanges()) {
|
|
@@ -1557,6 +1566,7 @@ function extractStmts(stmts) {
|
|
|
1557
1566
|
// inside a `{ }` case block is stripped, while a `break` inside a
|
|
1558
1567
|
// nested loop stays put.
|
|
1559
1568
|
const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
|
|
1569
|
+
const isExit = (st) => !!st && ["break", "return", "throw", "continue"].includes(st.kind);
|
|
1560
1570
|
let fallthrough = [];
|
|
1561
1571
|
for (const clause of s.getClauses()) {
|
|
1562
1572
|
if (Node.isCaseClause(clause)) {
|
|
@@ -1565,7 +1575,10 @@ function extractStmts(stmts) {
|
|
|
1565
1575
|
fallthrough.push(label);
|
|
1566
1576
|
continue;
|
|
1567
1577
|
}
|
|
1568
|
-
const
|
|
1578
|
+
const raw = extractStmts(clause.getStatements());
|
|
1579
|
+
if (!isExit(raw[raw.length - 1]))
|
|
1580
|
+
throw new Error(`switch case "${label}" at line ${line}: a non-empty case must end with break/return/throw; fall-through into the next case is not supported`);
|
|
1581
|
+
const body = stripExitBreaks(raw);
|
|
1569
1582
|
for (const l of fallthrough)
|
|
1570
1583
|
cases.push({ label: l, body });
|
|
1571
1584
|
cases.push({ label, body });
|
|
@@ -1717,7 +1730,30 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1717
1730
|
}
|
|
1718
1731
|
return {
|
|
1719
1732
|
name: fn.getName?.() ?? "<anonymous>",
|
|
1733
|
+
exported: false, // set in extractModule against the source file's export surface
|
|
1720
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
|
+
}),
|
|
1721
1757
|
params: fn.getParameters().flatMap(p => {
|
|
1722
1758
|
// Flatten destructured object params into individual params
|
|
1723
1759
|
const nameNode = p.getNameNode();
|
|
@@ -1777,6 +1813,7 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1777
1813
|
})(),
|
|
1778
1814
|
requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
|
|
1779
1815
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
1816
|
+
contract: annots.filter(a => a.kind === "contract").map(a => a.expr),
|
|
1780
1817
|
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
1781
1818
|
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
1782
1819
|
autohavoc: false, // set in extractModule (file-level directive or per-function)
|
|
@@ -2063,11 +2100,16 @@ export function extractModule(sourceFile) {
|
|
|
2063
2100
|
}
|
|
2064
2101
|
return false;
|
|
2065
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());
|
|
2066
2107
|
const functions = fnsToExtract.map(f => {
|
|
2067
2108
|
// For expression-body arrows, annotations come from the parent variable statement
|
|
2068
2109
|
const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
|
|
2069
2110
|
const raw = extractFunction(f.node, parentAnnots);
|
|
2070
2111
|
raw.name = f.name; // use the const name, not "<anonymous>"
|
|
2112
|
+
raw.exported = exportedNames.has(f.name);
|
|
2071
2113
|
raw.autohavoc = hasAutohavoc(f);
|
|
2072
2114
|
return raw;
|
|
2073
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
|
+
}
|