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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.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
- return true;
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("---"));
@@ -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[0]}])`;
161
+ return `(${obj} + [${args.join(", ")}])`;
162
162
  if (e.method === "concat")
163
- return `(${obj} + [${args[0]}])`;
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();
@@ -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).getText(),
97
+ tsType: externTypeText(p.getTypeAtLocation(callee)),
93
98
  }));
94
- const returnType = sig.getReturnType().getText();
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
- const head = node.getHead().getLiteralText();
313
- if (head)
314
- parts.push({ kind: "str", value: head });
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
- let spread = null;
485
- const fields = [];
486
- const computedFields = [];
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
- spread = extractExpr(prop.getExpression());
501
+ acc = extractExpr(prop.getExpression());
490
502
  }
491
503
  else if (Node.isShorthandPropertyAssignment(prop)) {
492
504
  const name = prop.getName();
493
- fields.push({ name, value: { kind: "var", name } });
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 && Node.isComputedPropertyName(nameNode)) {
499
- computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
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 if (init) {
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
- fields.push({ name, value: extractExpr(init) });
521
+ update(name, extractExpr(init));
506
522
  }
507
523
  }
508
524
  }
509
- // Desugar computed keys: { ...base, [k]: v } → base.set(k, v)
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
- // new Set(existingSet) pass through
559
- return extractExpr(arg);
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 body = stripExitBreaks(extractStmts(clause.getStatements()));
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
+ }