lemmascript 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/tools/dist/dafny-emit.js +50 -4
- package/tools/dist/extract.js +106 -39
- package/tools/dist/ir.js +56 -1
- package/tools/dist/lean-emit.js +242 -22
- package/tools/dist/lsc.js +14 -6
- package/tools/dist/narrow.js +89 -2
- package/tools/dist/peephole.js +2 -0
- package/tools/dist/resolve.js +147 -5
- package/tools/dist/transform.js +227 -9
- package/tools/dist/types.js +14 -0
- package/tools/dist/guard-command.js +0 -238
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ See the external case studies:
|
|
|
29
29
|
- **[xyflow-lemmascript](https://github.com/midspiral/xyflow-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [xyflow](https://github.com/xyflow/xyflow)'s core edge and geometry utilities. 9 functions verified: `addEdge` (dedup — never loses edges, adds at most one), `reconnectEdge` (semantic: under a unique-id precondition, the result is in-place — `|result| ≤ |edges|`, no insertion — *and* when a matching edge existed with non-empty new endpoints, the output contains an edge with those endpoints. Uses `//@ assume` to characterize destructuring, `find`, and the constructed edge), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Also adds a new verified feature — a **DAG connection gate**: `canReach` decides reachability soundly *and* completely, and `wouldCreateCycle` gates both new connections and edge reconnections so the graph is proven to stay acyclic, with `isAcyclic` (sound + complete) establishing the base case and a topological-rank witness giving a safe evaluation order (+29 obligations), shown live in a React Flow demo that refuses cycle-closing edges — extending the case study from verifying existing code to adding a verified feature. Dafny only.
|
|
30
30
|
- **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
|
|
31
31
|
- **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
|
|
32
|
-
- **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. Dafny
|
|
32
|
+
- **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. **Dafny + Lean**: the no-orphan theorem, the changelog semver core, and both tool-output truncators also carry Lean 4 (Velvet/Loom) proofs from the same annotated source, zero `sorry`; the Lean port drove the backend's brownfield batch (cross-file externs, union destructor lowering, Bool-vs-Prop contexts, `return`-in-loop elimination).
|
|
33
33
|
- **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
|
|
34
34
|
- **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
|
|
35
35
|
- **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
|
package/package.json
CHANGED
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -19,7 +19,11 @@ function tyToDafny(ty) {
|
|
|
19
19
|
}
|
|
20
20
|
case "user": return ty.name;
|
|
21
21
|
case "fn": return `(${ty.params.map(tyToDafny).join(", ")}) -> ${tyToDafny(ty.result)}`;
|
|
22
|
-
|
|
22
|
+
// Out-of-subset (`any`/`unknown`); opaque so real ops on it fail loudly
|
|
23
|
+
// rather than silently verify as `int`. Mirrors the Lean backend's `_`.
|
|
24
|
+
case "unknown":
|
|
25
|
+
needPreamble("UnknownType");
|
|
26
|
+
return "Unknown";
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
// ── Dafny keyword escaping ──────────────────────────────────
|
|
@@ -159,8 +163,14 @@ function emitExpr(e) {
|
|
|
159
163
|
}
|
|
160
164
|
if (e.method === "push")
|
|
161
165
|
return `(${obj} + [${args.join(", ")}])`;
|
|
166
|
+
if (e.method === "unshift")
|
|
167
|
+
return `([${args.join(", ")}] + ${obj})`;
|
|
162
168
|
if (e.method === "concat")
|
|
163
169
|
return `(${obj} + [${args.join(", ")}])`;
|
|
170
|
+
if (e.method === "sort") {
|
|
171
|
+
needPreamble("SeqSortBy");
|
|
172
|
+
return `SeqSortBy(${obj}, ${args[0]})`;
|
|
173
|
+
}
|
|
164
174
|
// No-arg slice is a full copy; Dafny seq is an immutable value type, so
|
|
165
175
|
// the copy is just the seq itself (the idiom for "copy then mutate").
|
|
166
176
|
if (e.method === "slice" && args.length === 0)
|
|
@@ -523,6 +533,9 @@ function emitExpr(e) {
|
|
|
523
533
|
case "exists": return emitQuantifier(e, "exists");
|
|
524
534
|
case "let": return `(var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)})`;
|
|
525
535
|
case "havoc": return "*";
|
|
536
|
+
// No Dafny producer: the only `default` source is the Lean return-in-loop
|
|
537
|
+
// rewrite, which never runs on the Dafny path (native in-loop returns).
|
|
538
|
+
case "default": throw new Error("default-value expression not produced on the Dafny path");
|
|
526
539
|
}
|
|
527
540
|
}
|
|
528
541
|
/** Emit a pure expression with indentation for if/match/let. */
|
|
@@ -671,7 +684,8 @@ function emitDecl(d) {
|
|
|
671
684
|
return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
|
|
672
685
|
}
|
|
673
686
|
case "structure": {
|
|
674
|
-
|
|
687
|
+
const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
|
|
688
|
+
return `datatype ${d.name}${tp} = ${d.name}(${paramList(d.fields)})`;
|
|
675
689
|
}
|
|
676
690
|
case "type-alias": {
|
|
677
691
|
return `type ${d.name} = ${tyToDafny(d.target)}`;
|
|
@@ -964,6 +978,17 @@ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<s
|
|
|
964
978
|
ensures |StringSplit(s, d)| >= 1
|
|
965
979
|
ensures |StringSplit(s, d)| <= |s| + 1
|
|
966
980
|
ensures forall k :: 0 <= k < |StringSplit(s, d)| ==> |StringSplit(s, d)[k]| <= |s|`;
|
|
981
|
+
// `xs.sort(cmp)` in TS sorts in place by a comparator (negative ⟺ a before b).
|
|
982
|
+
// Modeled here as an axiom returning a permutation sorted by cmp. The `requires`
|
|
983
|
+
// is the soundness condition — cmp must be a total preorder, otherwise no sorted
|
|
984
|
+
// permutation exists and the axiom would be vacuous. Callers discharge it (e.g.
|
|
985
|
+
// `(a,b) => a.k - b.k` is total + transitive by linear arithmetic).
|
|
986
|
+
const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T, T) -> int): seq<T>
|
|
987
|
+
requires forall a: T, b: T :: cmp(a, b) <= 0 || cmp(b, a) <= 0
|
|
988
|
+
requires forall a: T, b: T, c: T :: cmp(a, b) <= 0 && cmp(b, c) <= 0 ==> cmp(a, c) <= 0
|
|
989
|
+
ensures multiset(SeqSortBy(s, cmp)) == multiset(s)
|
|
990
|
+
ensures |SeqSortBy(s, cmp)| == |s|
|
|
991
|
+
ensures forall i: int, j: int :: 0 <= i <= j < |SeqSortBy(s, cmp)| ==> cmp(SeqSortBy(s, cmp)[i], SeqSortBy(s, cmp)[j]) <= 0`;
|
|
967
992
|
const STRING_TRIM = `function StringTrimLeft(s: string): string
|
|
968
993
|
ensures |StringTrimLeft(s)| <= |s|
|
|
969
994
|
ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
|
|
@@ -1095,6 +1120,9 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
|
1095
1120
|
/** Preamble code keyed by name. Emitted in this order when needed. */
|
|
1096
1121
|
const PREAMBLE_CODE = [
|
|
1097
1122
|
["OptionType", "datatype Option<T> = None | Some(value: T)"],
|
|
1123
|
+
// Opaque carrier for `unknown`-typed values. `(==)` for compare/map-key/match;
|
|
1124
|
+
// `(0)` (auto-init ⇒ nonempty) so `havoc` (`:= *`) is well-formed.
|
|
1125
|
+
["UnknownType", "type Unknown(==, 0)"],
|
|
1098
1126
|
["SetToSeq", SET_TO_SEQ],
|
|
1099
1127
|
["Pow2", POW2],
|
|
1100
1128
|
["BitAnd", BIT_AND],
|
|
@@ -1114,6 +1142,7 @@ const PREAMBLE_CODE = [
|
|
|
1114
1142
|
["SafeSlice", SAFE_SLICE],
|
|
1115
1143
|
["StringIndexOf", STRING_INDEX_OF],
|
|
1116
1144
|
["StringSplit", STRING_SPLIT],
|
|
1145
|
+
["SeqSortBy", SEQ_SORT_BY],
|
|
1117
1146
|
["StringTrim", STRING_TRIM],
|
|
1118
1147
|
["StringToLower", STRING_TO_LOWER],
|
|
1119
1148
|
["StringToUpper", STRING_TO_UPPER],
|
|
@@ -1202,6 +1231,23 @@ export function emitDafnyFile(file, tsFileName, opts) {
|
|
|
1202
1231
|
// Track successfully emitted pure defs — method wrappers are only
|
|
1203
1232
|
// skipped when the corresponding pure def was actually emitted.
|
|
1204
1233
|
const emittedPureDefs = new Set();
|
|
1234
|
+
// Emit a decl, rolling back any preamble requirements it registered if it
|
|
1235
|
+
// throws. A skipped decl must contribute neither text nor preambles — else a
|
|
1236
|
+
// side-effecting `needPreamble` from a half-emitted decl leaves an unused
|
|
1237
|
+
// preamble (e.g. `type Unknown` from a skipped const whose head is
|
|
1238
|
+
// `unknown`-typed but whose value expr is unsupported).
|
|
1239
|
+
const emitDeclTx = (d) => {
|
|
1240
|
+
const saved = new Set(_neededPreambles);
|
|
1241
|
+
try {
|
|
1242
|
+
return emitDecl(d);
|
|
1243
|
+
}
|
|
1244
|
+
catch (e) {
|
|
1245
|
+
_neededPreambles.clear();
|
|
1246
|
+
for (const k of saved)
|
|
1247
|
+
_neededPreambles.add(k);
|
|
1248
|
+
throw e;
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1205
1251
|
// Emit declarations
|
|
1206
1252
|
const declLines = [];
|
|
1207
1253
|
const skipped = [];
|
|
@@ -1214,7 +1260,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
|
|
|
1214
1260
|
for (const inner of decl.decls) {
|
|
1215
1261
|
try {
|
|
1216
1262
|
declLines.push("");
|
|
1217
|
-
declLines.push(
|
|
1263
|
+
declLines.push(emitDeclTx(inner));
|
|
1218
1264
|
if (inner.kind === "def")
|
|
1219
1265
|
emittedPureDefs.add(inner.name);
|
|
1220
1266
|
}
|
|
@@ -1230,7 +1276,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
|
|
|
1230
1276
|
}
|
|
1231
1277
|
try {
|
|
1232
1278
|
declLines.push("");
|
|
1233
|
-
declLines.push(
|
|
1279
|
+
declLines.push(emitDeclTx(decl));
|
|
1234
1280
|
if (decl.kind === "def-by-method")
|
|
1235
1281
|
emittedPureDefs.add(decl.name);
|
|
1236
1282
|
}
|
package/tools/dist/extract.js
CHANGED
|
@@ -487,9 +487,9 @@ function extractExpr(node) {
|
|
|
487
487
|
}
|
|
488
488
|
// Object literal: { res: true, done: false } or { ...obj, res: true }
|
|
489
489
|
if (Node.isObjectLiteralExpression(node)) {
|
|
490
|
-
// Fold properties in source order: a
|
|
491
|
-
//
|
|
492
|
-
//
|
|
490
|
+
// Fold properties in source order: a spread on top of existing content is a
|
|
491
|
+
// field-wise merge (resolve expands it against the result type), a named field
|
|
492
|
+
// is a record-update on the accumulator, a computed key is a map `.set`.
|
|
493
493
|
let acc = null;
|
|
494
494
|
const update = (name, value) => {
|
|
495
495
|
acc = acc && acc.kind === "record"
|
|
@@ -498,7 +498,8 @@ function extractExpr(node) {
|
|
|
498
498
|
};
|
|
499
499
|
for (const prop of node.getProperties()) {
|
|
500
500
|
if (Node.isSpreadAssignment(prop)) {
|
|
501
|
-
|
|
501
|
+
const s = extractExpr(prop.getExpression());
|
|
502
|
+
acc = acc === null ? s : { kind: "recordMerge", base: acc, override: s };
|
|
502
503
|
}
|
|
503
504
|
else if (Node.isShorthandPropertyAssignment(prop)) {
|
|
504
505
|
const name = prop.getName();
|
|
@@ -985,6 +986,14 @@ function forCounterRename(decl, forStmt) {
|
|
|
985
986
|
const scopeRoot = fnLike ?? forStmt.getSourceFile();
|
|
986
987
|
const isScope = (a) => Node.isBlock(a) || Node.isSourceFile(a) ||
|
|
987
988
|
Node.isForStatement(a) || Node.isForOfStatement(a) || Node.isForInStatement(a);
|
|
989
|
+
// A counting-`for` counter is hoisted out of its `for`, so its real scope is
|
|
990
|
+
// the nearest enclosing block, not the `for` itself.
|
|
991
|
+
const hoistScope = (d) => {
|
|
992
|
+
const owner = d.getParent()?.getParent();
|
|
993
|
+
return owner && Node.isForStatement(owner) && owner.getInitializer() === d.getParent()
|
|
994
|
+
? d.getFirstAncestor(a => Node.isBlock(a) || Node.isSourceFile(a))
|
|
995
|
+
: undefined;
|
|
996
|
+
};
|
|
988
997
|
// Scopes that enclose this loop — its counter, once hoisted, lives in one of
|
|
989
998
|
// these, so a same-named binding scoped here would collide.
|
|
990
999
|
const enclosing = new Set(forStmt.getAncestors());
|
|
@@ -992,6 +1001,11 @@ function forCounterRename(decl, forStmt) {
|
|
|
992
1001
|
const declClash = scopeRoot.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some(d => {
|
|
993
1002
|
if (d === decl || d.getName() !== name)
|
|
994
1003
|
return false;
|
|
1004
|
+
// Two sibling for-counters hoisting into the same block: rename only the
|
|
1005
|
+
// later loop, so the first keeps its name. Any other clash (e.g. a `let`) yields.
|
|
1006
|
+
const counterScope = hoistScope(d);
|
|
1007
|
+
if (counterScope)
|
|
1008
|
+
return enclosing.has(counterScope) && d.getStart() < decl.getStart();
|
|
995
1009
|
const scope = d.getFirstAncestor(isScope);
|
|
996
1010
|
return !!scope && enclosing.has(scope);
|
|
997
1011
|
});
|
|
@@ -1027,6 +1041,7 @@ function renameRawExpr(e, from, to) {
|
|
|
1027
1041
|
case "index": return { ...e, obj: r(e.obj), idx: r(e.idx) };
|
|
1028
1042
|
case "field": return { ...e, obj: r(e.obj) };
|
|
1029
1043
|
case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(f => ({ ...f, value: r(f.value) })) };
|
|
1044
|
+
case "recordMerge": return { ...e, base: r(e.base), override: r(e.override) };
|
|
1030
1045
|
case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
|
|
1031
1046
|
case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
|
|
1032
1047
|
case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
|
|
@@ -1567,32 +1582,38 @@ function extractStmts(stmts) {
|
|
|
1567
1582
|
// nested loop stays put.
|
|
1568
1583
|
const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
|
|
1569
1584
|
const isExit = (st) => !!st && ["break", "return", "throw", "continue"].includes(st.kind);
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1585
|
+
// Resolve JS fall-through positionally: a clause's effective body is its
|
|
1586
|
+
// own statements concatenated with each following clause's statements up
|
|
1587
|
+
// to and including the first clause that ends in break/return/throw (or
|
|
1588
|
+
// the switch end). This covers both empty stacked labels (`case A: case
|
|
1589
|
+
// B: body`) and a *non-empty* case that falls through (`case A: sA; case
|
|
1590
|
+
// B: ...`) — the stripped breaks are the switch exits.
|
|
1591
|
+
const clauseInfos = s.getClauses().map(clause => {
|
|
1592
|
+
const stmts = extractStmts(clause.getStatements());
|
|
1593
|
+
return {
|
|
1594
|
+
label: Node.isCaseClause(clause)
|
|
1595
|
+
? clause.getExpression().getText().replace(/^["']|["']$/g, "")
|
|
1596
|
+
: null,
|
|
1597
|
+
stmts,
|
|
1598
|
+
exits: isExit(stmts[stmts.length - 1]),
|
|
1599
|
+
};
|
|
1600
|
+
});
|
|
1601
|
+
const fallThroughBody = (start) => {
|
|
1602
|
+
let body = [];
|
|
1603
|
+
for (let j = start; j < clauseInfos.length; j++) {
|
|
1604
|
+
body = body.concat(clauseInfos[j].stmts);
|
|
1605
|
+
if (clauseInfos[j].exits)
|
|
1606
|
+
break;
|
|
1592
1607
|
}
|
|
1608
|
+
return stripExitBreaks(body);
|
|
1609
|
+
};
|
|
1610
|
+
for (let i = 0; i < clauseInfos.length; i++) {
|
|
1611
|
+
const c = clauseInfos[i];
|
|
1612
|
+
if (c.label === null)
|
|
1613
|
+
defaultBody = fallThroughBody(i);
|
|
1614
|
+
else
|
|
1615
|
+
cases.push({ label: c.label, body: fallThroughBody(i) });
|
|
1593
1616
|
}
|
|
1594
|
-
for (const l of fallthrough)
|
|
1595
|
-
cases.push({ label: l, body: [] });
|
|
1596
1617
|
result.push({ kind: "switch", expr: switchExpr, discriminant, cases, defaultBody, line });
|
|
1597
1618
|
continue;
|
|
1598
1619
|
}
|
|
@@ -1692,17 +1713,21 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
1692
1713
|
}
|
|
1693
1714
|
}
|
|
1694
1715
|
function extractFunctionInner(fn, parentAnnotations) {
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1716
|
+
// A `<T extends B>` bound is handled one of two ways. When B is a modelable
|
|
1717
|
+
// type (a record/nominal), substitute T with B — a body that reads T's fields
|
|
1718
|
+
// (e.g. `x.id`) then typechecks against B. When B is a union/intersection we
|
|
1719
|
+
// can't model as one Dafny type (e.g. `string & {}` tricks), keep T as a Dafny
|
|
1720
|
+
// type param instead; such a T must be phantom (any field read won't typecheck).
|
|
1697
1721
|
_typeParamMap = new Map();
|
|
1698
1722
|
_reservedForCounterNames = new Set();
|
|
1699
|
-
const
|
|
1723
|
+
const typeParams = [];
|
|
1700
1724
|
for (const tp of fn.getTypeParameters?.() ?? []) {
|
|
1701
1725
|
const constraint = tp.getConstraint();
|
|
1702
|
-
|
|
1726
|
+
const ct = constraint?.getType();
|
|
1727
|
+
if (constraint && !ct?.isUnion() && !ct?.isIntersection())
|
|
1703
1728
|
_typeParamMap.set(tp.getName(), constraint.getText());
|
|
1704
1729
|
else
|
|
1705
|
-
|
|
1730
|
+
typeParams.push(tp.getName());
|
|
1706
1731
|
}
|
|
1707
1732
|
const body = fn.getBody();
|
|
1708
1733
|
// Expression-body arrow: wrap in implicit return
|
|
@@ -1731,7 +1756,7 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1731
1756
|
return {
|
|
1732
1757
|
name: fn.getName?.() ?? "<anonymous>",
|
|
1733
1758
|
exported: false, // set in extractModule against the source file's export surface
|
|
1734
|
-
typeParams
|
|
1759
|
+
typeParams,
|
|
1735
1760
|
// Original TS parameter grouping, before the flatten below loses it. `defaults` carries
|
|
1736
1761
|
// each bound name's default initializer text (omitted when none) for TS-targeting consumers.
|
|
1737
1762
|
tsParams: fn.getParameters().map(p => {
|
|
@@ -1759,10 +1784,20 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1759
1784
|
const nameNode = p.getNameNode();
|
|
1760
1785
|
if (Node.isObjectBindingPattern(nameNode)) {
|
|
1761
1786
|
const type = p.getType();
|
|
1787
|
+
// `//@ declare-type` types are invisible to the TS checker, so
|
|
1788
|
+
// `type.getProperty` finds nothing for them and the bindings would
|
|
1789
|
+
// collapse to `unknown`. Fall back to the declared record's field types.
|
|
1790
|
+
const declTypeName = p.getTypeNode()?.getText();
|
|
1791
|
+
const declFields = declTypeName
|
|
1792
|
+
? _synthArrayUnions?.find(d => d.name === declTypeName && d.kind === "record")?.fields
|
|
1793
|
+
: undefined;
|
|
1762
1794
|
return nameNode.getElements().map(el => {
|
|
1763
1795
|
const name = el.getName();
|
|
1764
1796
|
const propType = type.getProperty(name)?.getTypeAtLocation(p);
|
|
1765
|
-
|
|
1797
|
+
if (propType)
|
|
1798
|
+
return { name, tsType: typeToString(propType) };
|
|
1799
|
+
const declTy = declFields?.find(f => f.name === name)?.tsType;
|
|
1800
|
+
return { name, tsType: declTy ?? "unknown" };
|
|
1766
1801
|
});
|
|
1767
1802
|
}
|
|
1768
1803
|
// Syntactic union nodes go through _tsTypeFromUnionNode so synth fires
|
|
@@ -1843,15 +1878,17 @@ export function extractModule(sourceFile) {
|
|
|
1843
1878
|
// `//@ declare-type Name { f1: T1, ... }` — record form.
|
|
1844
1879
|
// `//@ declare-type Name = TsType` — alias form (e.g. `Ruleset = Rule[]`).
|
|
1845
1880
|
function parseDeclareType(body) {
|
|
1846
|
-
const recordMatch = body.match(/^(\w+)\s*\{(.+)\}$/);
|
|
1881
|
+
const recordMatch = body.match(/^(\w+)\s*(?:<([^>]+)>)?\s*\{(.+)\}$/);
|
|
1847
1882
|
if (recordMatch) {
|
|
1848
1883
|
const name = recordMatch[1];
|
|
1849
|
-
|
|
1884
|
+
// `<T extends B>` → bare `T`: a Dafny type param, like the def path.
|
|
1885
|
+
const typeParams = recordMatch[2]?.split(",").map(s => s.trim().split(/\s+extends\s+/)[0].trim()).filter(Boolean);
|
|
1886
|
+
const fields = recordMatch[3].split(",").map(f => f.trim()).filter(Boolean).map(f => {
|
|
1850
1887
|
const [fname, ftype] = f.split(":").map(s => s.trim());
|
|
1851
1888
|
const synth = _synthFromTsTypeString(ftype);
|
|
1852
1889
|
return { name: fname, tsType: synth ?? ftype };
|
|
1853
1890
|
});
|
|
1854
|
-
typeDecls.push({ name, kind: "record", fields });
|
|
1891
|
+
typeDecls.push({ name, kind: "record", fields, ...(typeParams?.length ? { typeParams } : {}) });
|
|
1855
1892
|
return;
|
|
1856
1893
|
}
|
|
1857
1894
|
const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
|
|
@@ -2052,7 +2089,8 @@ export function extractModule(sourceFile) {
|
|
|
2052
2089
|
const sig = f.node.getType().getCallSignatures()[0];
|
|
2053
2090
|
if (!sig)
|
|
2054
2091
|
continue;
|
|
2055
|
-
|
|
2092
|
+
// Bare names, dropping `extends B` — same as the def path.
|
|
2093
|
+
const typeParams = sig.getTypeParameters().map(tp => tp.getText().split(/\s+extends\s+/)[0].trim());
|
|
2056
2094
|
// Normalize via typeToString (not raw getText): resolves declare-type
|
|
2057
2095
|
// shadows and yields bare names, so a param typed by an unreachable import
|
|
2058
2096
|
// becomes `AgentMessage`, not `import("/abs/path").AgentMessage`.
|
|
@@ -2297,6 +2335,10 @@ export function extractModule(sourceFile) {
|
|
|
2297
2335
|
collectNamesExpr(e.spread);
|
|
2298
2336
|
e.fields.forEach(f => collectNamesExpr(f.value));
|
|
2299
2337
|
}
|
|
2338
|
+
if (e.kind === "recordMerge") {
|
|
2339
|
+
collectNamesExpr(e.base);
|
|
2340
|
+
collectNamesExpr(e.override);
|
|
2341
|
+
}
|
|
2300
2342
|
if (e.kind === "arrayLiteral") {
|
|
2301
2343
|
e.elems.forEach(collectNamesExpr);
|
|
2302
2344
|
}
|
|
@@ -2305,6 +2347,31 @@ export function extractModule(sourceFile) {
|
|
|
2305
2347
|
collectNamesExpr(e.then);
|
|
2306
2348
|
collectNamesExpr(e.else);
|
|
2307
2349
|
}
|
|
2350
|
+
if (e.kind === "nullish") {
|
|
2351
|
+
collectNamesExpr(e.left);
|
|
2352
|
+
collectNamesExpr(e.right);
|
|
2353
|
+
}
|
|
2354
|
+
if (e.kind === "nonNull") {
|
|
2355
|
+
collectNamesExpr(e.expr);
|
|
2356
|
+
}
|
|
2357
|
+
if (e.kind === "optChain") {
|
|
2358
|
+
collectNamesExpr(e.obj);
|
|
2359
|
+
for (const c of e.chain) {
|
|
2360
|
+
if (c.kind === "call")
|
|
2361
|
+
c.args.forEach(collectNamesExpr);
|
|
2362
|
+
if (c.kind === "index")
|
|
2363
|
+
collectNamesExpr(c.idx);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
if (e.kind === "lambda") {
|
|
2367
|
+
if (Array.isArray(e.body))
|
|
2368
|
+
collectNames(e.body);
|
|
2369
|
+
else
|
|
2370
|
+
collectNamesExpr(e.body);
|
|
2371
|
+
}
|
|
2372
|
+
if (e.kind === "forall" || e.kind === "exists") {
|
|
2373
|
+
collectNamesExpr(e.body);
|
|
2374
|
+
}
|
|
2308
2375
|
}
|
|
2309
2376
|
// Signature types (params + return) get base-name stripping below; body /
|
|
2310
2377
|
// spec references stay exact-match (so a body `let xs: Hunk[]` doesn't pull
|
package/tools/dist/ir.js
CHANGED
|
@@ -4,4 +4,59 @@
|
|
|
4
4
|
* The transform phase produces these types.
|
|
5
5
|
* The emit phase pretty-prints them to backend syntax (Lean or Dafny).
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
7
|
+
export function anyExpr(e, pred) {
|
|
8
|
+
if (pred(e))
|
|
9
|
+
return true;
|
|
10
|
+
switch (e.kind) {
|
|
11
|
+
case "var":
|
|
12
|
+
case "num":
|
|
13
|
+
case "bool":
|
|
14
|
+
case "str":
|
|
15
|
+
case "emptyMap":
|
|
16
|
+
case "emptySet":
|
|
17
|
+
case "havoc":
|
|
18
|
+
case "default": return false;
|
|
19
|
+
case "constructor": return (e.args ?? []).some(a => anyExpr(a, pred));
|
|
20
|
+
case "binop": return anyExpr(e.left, pred) || anyExpr(e.right, pred);
|
|
21
|
+
case "unop":
|
|
22
|
+
case "toNat":
|
|
23
|
+
case "toReal": return anyExpr(e.expr, pred);
|
|
24
|
+
case "app": return e.args.some(a => anyExpr(a, pred));
|
|
25
|
+
case "field": return anyExpr(e.obj, pred);
|
|
26
|
+
case "index": return anyExpr(e.arr, pred) || anyExpr(e.idx, pred);
|
|
27
|
+
case "implies": return e.premises.some(p => anyExpr(p, pred)) || anyExpr(e.conclusion, pred);
|
|
28
|
+
case "record": return (e.spread ? anyExpr(e.spread, pred) : false) || e.fields.some(f => anyExpr(f.value, pred));
|
|
29
|
+
case "arrayLiteral": return e.elems.some(x => anyExpr(x, pred));
|
|
30
|
+
case "mapLiteral": return e.entries.some(en => anyExpr(en.key, pred) || anyExpr(en.value, pred));
|
|
31
|
+
case "methodCall": return anyExpr(e.obj, pred) || e.args.some(a => anyExpr(a, pred));
|
|
32
|
+
case "lambda": return e.body.some(s => anyExprInStmt(s, pred));
|
|
33
|
+
case "if": return anyExpr(e.cond, pred) || anyExpr(e.then, pred) || anyExpr(e.else, pred);
|
|
34
|
+
case "match": return (typeof e.scrutinee !== "string" && anyExpr(e.scrutinee, pred)) || e.arms.some(a => anyExpr(a.body, pred));
|
|
35
|
+
case "forall":
|
|
36
|
+
case "exists": return anyExpr(e.body, pred);
|
|
37
|
+
case "let": return anyExpr(e.value, pred) || anyExpr(e.body, pred);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function anyExprInStmt(s, pred) {
|
|
41
|
+
switch (s.kind) {
|
|
42
|
+
case "let":
|
|
43
|
+
case "assign":
|
|
44
|
+
case "bind":
|
|
45
|
+
case "let-bind":
|
|
46
|
+
case "return":
|
|
47
|
+
case "ghostLet":
|
|
48
|
+
case "ghostAssign": return anyExpr(s.value, pred);
|
|
49
|
+
case "assert": return anyExpr(s.expr, pred);
|
|
50
|
+
case "break":
|
|
51
|
+
case "continue": return false;
|
|
52
|
+
case "if": return anyExpr(s.cond, pred) || anyExprInStmts(s.then, pred) || anyExprInStmts(s.else, pred);
|
|
53
|
+
case "match": return (typeof s.scrutinee !== "string" && anyExpr(s.scrutinee, pred)) || s.arms.some(a => anyExprInStmts(a.body, pred));
|
|
54
|
+
case "while": return anyExpr(s.cond, pred) || s.invariants.some(i => anyExpr(i, pred))
|
|
55
|
+
|| (s.decreasing ? anyExpr(s.decreasing, pred) : false) || (s.doneWith ? anyExpr(s.doneWith, pred) : false)
|
|
56
|
+
|| anyExprInStmts(s.body, pred);
|
|
57
|
+
case "forin": return anyExpr(s.bound, pred) || s.invariants.some(i => anyExpr(i, pred)) || anyExprInStmts(s.body, pred);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function anyExprInStmts(stmts, pred) {
|
|
61
|
+
return stmts.some(s => anyExprInStmt(s, pred));
|
|
62
|
+
}
|