lemmascript 0.5.2 → 0.5.3
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 +4 -1
- package/package.json +3 -2
- package/tools/dist/autohavoc.js +536 -0
- package/tools/dist/dafny-emit.js +38 -3
- package/tools/dist/extract.js +106 -7
- package/tools/dist/lean-emit.js +5 -0
- package/tools/dist/lsc.js +5 -1
- package/tools/dist/resolve.js +13 -4
- package/tools/dist/transform.js +7 -2
package/README.md
CHANGED
|
@@ -18,13 +18,15 @@ See the external case studies:
|
|
|
18
18
|
- **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
|
|
19
19
|
- **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
|
|
20
20
|
- **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
|
|
21
|
+
- **[eventab-lemmascript](https://github.com/midspiral/eventab-lemmascript/)** — greenfield verified group bill-splitter, shipped as a single self-contained client-side page. Largest-remainder allocation in integer cents proves the money never leaks: per-person shares sum exactly to the tab, non-claimers pay nothing, and the settlement nets to zero even when payments are rounded to the dollar (the payer absorbs the difference). Dafny only.
|
|
21
22
|
- **[github-star-checker-lemmascript](https://github.com/midspiral/github-star-checker-lemmascript/)** — small verified CLI that tracks GitHub star counts across repos and reports per-run deltas. Verifies: per-row diff correctness and `totalDiff == sumDiffs(rows)` (via an inductive `SumDiffs_append` lemma); three sign-classified extractors (gainers / losers / unchanged) with soundness, completeness, **ordered completeness** (gainers appear in the notification in the same order they were listed on the command line), and count/sum equalities against prefix-indexed `upTo` helpers; conservation theorem `decompose(r)` — the three splits partition every row exactly once, and `sumDiffs(increases) + sumDiffs(decreases) == totalDiff`. 33 Dafny VCs, 0 errors; proof additions include a head/tail bridge (`sumDiffs` ↔ `sumDiffsUpTo`) and two partition-on-n inductions. Dafny only.
|
|
22
23
|
- **[equality-game-lemmascript](https://github.com/midspiral/equality-game-lemmascript/)** — greenfield verified arithmetic equality card game (React + Tailwind). Sound + complete decision procedure for "can these two card lists be combined into equal expressions": `canEqualize(L, R) ⟺ ∃ eL, eR. eval(eL) == eval(eR) ∧ multiset(leaves(eL)) == multiset(L) ∧ same for R`. Algorithm is subset-DP over a bitmask `m ∈ [1, 2^n − 1)`; the proof composes a `PopCount` upper/lower bound chain (with stdlib `LemmaDivDenominator` / `LemmaFundamentalDivModConverse`), a `splitLeft`/`splitRight` ↔ imperative-loop connection, a `WitnessCombine` lemma threading existential `Expr` witnesses through the cross-product loops, and a `ChooseMask` combinatorial constructor that, given any sub-multiset of `cards`, produces the realizing mask. Capped by `CompletenessFromMaskCoverage`. 753 verification conditions, 0 errors, 0 `assume`s, 0 axioms under `--isolate-assertions --verification-time-limit 180`. Dafny only.
|
|
23
24
|
- **[talktimer-lemmascript](https://github.com/midspiral/talktimer-lemmascript/)** — verified talk timer React app, ported from a Dafny-only [`talktimer-lemmafit`](https://github.com/midspiral/talktimer-lemmafit/) twin. 17-variant `Action` state machine + verified `History` (undo/redo/preview/commitFrom) all in one `domain.ts` — the original Dafny's `Domain refines Kernel` abstract-module pattern inlined since LS has no abstract modules. 108 VCs in `domain.dfy` (invariant preservation) + 123 in `domain.proofs.dfy` (behavioral lemmas + Kernel round-trip). Dafny only.
|
|
24
25
|
- **[node-casbin-lemmascript](https://github.com/midspiral/node-casbin-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [node-casbin](https://github.com/casbin/node-casbin). 5 functions verified, 217 existing tests pass. End-to-end correctness and order independence for all 4 effect modes in both Lean and Dafny (39 lemmas).
|
|
25
26
|
- **[hono-lemmascript](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [hono](https://github.com/honojs/hono)'s security middleware. Four CVEs covered: IP restriction bypass ([CVE-2026-39409](https://github.com/honojs/hono/security/advisories/GHSA-3mpf-rcc7-5347)) and cookie name bypass ([CVE-2026-39410](https://github.com/honojs/hono/security/advisories/GHSA-r5rp-j6wh-rvv4)) — 51 Dafny lemmas, [cookie verification **in-place**](https://github.com/midspiral/hono-lemmascript/blob/lemmascript/src/utils/cookie.ts#L79); plus `serveStatic`'s URL-encoded directory traversal ([CVE-2024-32869](https://github.com/honojs/hono/security/advisories/GHSA-q5w7-8mq6-2hxq)) + repeated-slash bypass ([CVE-2026-39407](https://github.com/honojs/hono/security/advisories/GHSA-jw53-c2g8-vmwm)), proved as a *composition* — `decode(rawPath)` before `check(decoded)`, so a buggy implementation that reordered the steps would fail the proof. First use of `//@ assume` + `//@ havoc`-on-assign. Dafny only.
|
|
27
|
+
- **[hono-rate-limiter-with-lemmascript](https://github.com/midspiral/hono-rate-limiter-with-lemmascript)** — a greenfield verified feature shipped through a brownfield host's *middleware seam*: a standalone [Hono](https://hono.dev) rate-limit middleware whose admission decision is a machine-checked theorem. Every fixed-window "N per minute" limiter leaks the same way — a client fires `limit` requests just before a boundary and `limit` more just after, a 2× burst the docs swear can't happen. The verified core proves the crisp guarantee people actually want: over an integer-millisecond admission log, `admit` keeps `|log| ≤ limit` and window-faithful, and folding it over **any** monotone request stream (`run`) keeps the cumulative log `Spread` — so **no half-open window `(s, s+W]` ever admits more than `limit`** (`SlidingWindowBound`); the boundary burst is not mitigated but *impossible*. The naive cousin is refuted in the same file: `FixedWindowLeaks` exhibits a concrete trace where a fixed-window counter admits `2 × limit` in one sliding window while the verified `admit` rejects the overflow. An invariant-over-a-stateful-core proof (not a one-shot algorithm), wired into a live Hono server that gates real HTTP. 23 Dafny VCs, 0 errors; the clock's monotonicity and the per-key store's atomicity are the named trust boundary. Dafny only.
|
|
26
28
|
- **[charmchat](https://github.com/CHARM-BDF/charmchat/blob/lemma/README_LemmaScript.md)** — brownfield verification of an AI agent orchestration backend. `isEmptyResult` (string emptiness predicate, 8 postconditions, <1s) and `topologicalSort` (Kahn's algorithm — memory safety, output bounds, completeness via acyclicity ranking witness, termination). Full completeness proof: 23 helper lemmas, 14 opaque ghost predicates, 115 loop invariants; 736 VCs verified under `--isolate-assertions --verification-time-limit 600`. Key technique: snapshot-based inner invariants (`ghost var originalRemDeps := remDeps`) replace the mid-iteration SEEN/UNSEEN split so preservation is frame reasoning against a ghost-constant rather than set-subtraction against mutating state. Dafny only.
|
|
27
|
-
- **[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. Dafny only.
|
|
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.
|
|
28
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.
|
|
29
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.
|
|
30
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 only.
|
|
@@ -33,6 +35,7 @@ See the external case studies:
|
|
|
33
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.
|
|
34
36
|
- **[quota-lemmascript](https://github.com/midspiral/quota-lemmascript)** — greenfield verified booking app: providers publish a page of limited-capacity *featured slots*, signed-in users grab them (React + Cloudflare Durable Objects + D1). A deliberate **inverse** of [quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript): where Quorum counts *up to* a threshold over data partitioned **per participant** (no conflicts ⇒ optimistic, lock-free, no rollback), Quota's bookings **contend** for shared inventory, so the load-bearing fact flips from a count to a **bound** — for every slot `j`, `confirmedCount(bookings, j) <= slots[j].capacity` — and so does the concurrency story: the *same* `domain.ts` runs in the browser and the Durable Object, but **server-authoritatively** (it never oversells under contention; no optimistic client apply). Proven: no overbooking (invariant preservation across `tryBook` / cancel), accept-iff-room, an **idempotent three-way `tryBook`** (a retry reads as success, not rejection — only "confirmed" mutates), cancellation frees seats, replay determinism, and full order-invariance of availability under contention — `confirmedCountPerm` / `hasRoomPermInvariant` show availability depends only on the *multiset* of the booking log (any reordering, not just a pairwise swap), via the same `perm(...)` predicate Quorum drove into LemmaScript. NDJSON export is built on the verified `confirmedOnly`. The counting kernel is Quorum's `countFree` re-pointed at bookings — total and precondition-free so it composes. 80 Dafny VCs, 0 errors. Trust boundary (stated plainly): auth, the React UI, WebSocket/DO/D1 I/O, email, slot date/time labeling, and abuse/rate-limiting. Dafny only.
|
|
35
37
|
- **[henri-lemmascript](https://github.com/midspiral/henri-lemmascript)** — a runnable TypeScript coding-agent CLI (a port of [henri](https://github.com/metareflection/henri/); multi-provider, Anthropic + AWS Bedrock) whose security- and protocol-critical core is verified and **imported directly by the live agent** — `decide()` gates every real tool call and the conversation invariant is asserted on every turn (it streams end-to-end against Bedrock). Three modules, 48 Dafny VCs, 0 errors. (1) **Permission gate**: soundness (`decide == Allow ⟺ isAllowed`), **path-traversal containment** — auto-allow-in-cwd can never resolve outside cwd, with `.`/`..` normalization proved in-core so the shell is trusted only to `resolve().split('/')` — grant monotonicity, and `rejectPrompts` is deny-only. (2) **Conversation protocol**: tool-call/result pairing plus the [pi-lemmascript](https://github.com/midspiral/pi-lemmascript)-style **no-orphaned-`tool_result`** property, proved as an invariant *preserved by the loop* — `wellFormed(msgs + [assistant(calls), tool(makeResults(calls))])` — not checked after the fact. (3) **Hook/config merge**: removal, **tool-name uniqueness — a fix** (henri concatenated hook tool lists with no dedup, so two hooks could shadow a name), order-independence, and additivity composed **cross-module** with the gate's monotonicity (merging only grows the allow-sets, which by P3 never revokes an `Allow`). The merge is verified **in place** via `//@ declare-type Tool { name: string }`, shadowing the real `Tool`'s function-valued `execute` so the actual `mergeTools(Tool[])` is the proof target rather than a parallel model. Dafny only.
|
|
38
|
+
- **[eslint-plugin-with-lemmascript](https://github.com/midspiral/eslint-plugin-with-lemmascript)** — a greenfield verified feature shipped through a brownfield host's *extension API*: a real, npm-published [ESLint](https://eslint.org) flat-config plugin whose rule decision is a machine-checked theorem. `no-forbidden-reach` enforces architecture boundaries — "the UI must never reach the DB layer" — through **any** import chain, catching the *laundered* `ui → service → db` violation that every one-hop incumbent (`import/no-restricted-paths`, `eslint-plugin-boundaries`, Nx module boundaries) silently passes. The verified core decides reachability soundly *and* completely (`reachesAny` / `violates`) and **constructs** the offending chain in proven code (`findReachPath` — a path-carrying BFS proven sound + complete by mirroring the frontier's endpoints in a ghost seq, so completeness reduces to the same closure argument as the reachability search; the chain printed in the lint error is therefore itself a *verified* import path, not a heuristic guess). The headline is a meta-theorem — `Domination` + `Strictness` — proving the transitive check **strictly dominates** one-hop checking: every direct violation is caught, *and* there provably exist laundered violations that direct-edge checks miss. 30 Dafny VCs, 0 errors. The reachability decision and the witness are proven; the import-graph extraction (which edges exist) is the stated trust boundary, and `dist/*.js` is `tsc`'s erasure of the verified source it ships alongside. Dafny only.
|
|
36
39
|
|
|
37
40
|
## Setup
|
|
38
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^25.5.0",
|
|
26
|
-
"tsx": "^4.
|
|
26
|
+
"tsx": "^4.22.4",
|
|
27
27
|
"typescript": "^5.7.0"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"url": "https://github.com/midspiral/LemmaScript"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
|
+
"lemmascript",
|
|
35
36
|
"verification",
|
|
36
37
|
"typescript",
|
|
37
38
|
"lean4",
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-havoc pass — validation mode.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites every *unmodellable* expression in the Typed IR to a `havoc` node,
|
|
5
|
+
* and drops unmodellable side-effecting statements. "Unmodellable" = the value
|
|
6
|
+
* derives from something outside LemmaScript's model (an `unknown`-typed
|
|
7
|
+
* receiver/result — e.g. `res.status()`, `req.body`, `JSON.parse(x)`,
|
|
8
|
+
* `process.env.X ?? y`, `uuidv4()` — or an anonymous object literal).
|
|
9
|
+
*
|
|
10
|
+
* This is the abstraction policy for verifying *validation* properties: the
|
|
11
|
+
* only thing that matters is that values reaching a contracted sink (an extern
|
|
12
|
+
* with `//@ requires sanitizer(x)`) are dominated by the sanitizer guard.
|
|
13
|
+
* Everything else is noise and is replaced by an arbitrary value.
|
|
14
|
+
*
|
|
15
|
+
* Soundness: `havoc` is a nondeterministic over-approximation — the verifier
|
|
16
|
+
* assumes nothing about a havoc'd value — so this can only make a proof FAIL
|
|
17
|
+
* (false positive), never spuriously pass. It uses havoc, never assume.
|
|
18
|
+
* (Trust boundary: a real sink hidden inside an unmodellable call is invisible;
|
|
19
|
+
* sinks must be the declared/annotated externs.)
|
|
20
|
+
*
|
|
21
|
+
* It runs after narrow, so any legitimate optional (`?.`, `??` on an
|
|
22
|
+
* optional-typed value) is already a `someMatch`; a `nullish`/`optChain` that
|
|
23
|
+
* survives to here is unmodellable and gets havoc'd.
|
|
24
|
+
*/
|
|
25
|
+
/** A node whose own value can't be modeled and must become an arbitrary value.
|
|
26
|
+
* Leaves (var/literal/havoc) are never havoc'd — a reference to an already
|
|
27
|
+
* havoc'd variable is fine; only the *source* expression is replaced. */
|
|
28
|
+
function isBadNode(e) {
|
|
29
|
+
switch (e.kind) {
|
|
30
|
+
case "var":
|
|
31
|
+
case "num":
|
|
32
|
+
case "str":
|
|
33
|
+
case "bool":
|
|
34
|
+
case "havoc":
|
|
35
|
+
return false;
|
|
36
|
+
// A field/index is unmodellable when its *object* is opaque (`req.body`,
|
|
37
|
+
// `process.env.X`). It is NOT unmodellable just because its own type is
|
|
38
|
+
// `unknown`: a method reference on a known receiver (`path.includes`) has
|
|
39
|
+
// `unknown` type yet emits fine — the method dispatch happens at emit on
|
|
40
|
+
// the object's type, so havocing it would wreck a legitimate call.
|
|
41
|
+
case "field": return e.obj.ty.kind === "unknown";
|
|
42
|
+
case "index": return e.obj.ty.kind === "unknown";
|
|
43
|
+
// anonymous object literal — Dafny has no model for it
|
|
44
|
+
case "record": return e.ty.kind !== "user";
|
|
45
|
+
// a call / nullish / opt-chain / conditional whose *result* is opaque
|
|
46
|
+
default: return e.ty.kind === "unknown";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Does the subtree (including lambda bodies) contain anything unmodellable?
|
|
50
|
+
* Used to havoc a *whole* call like `xs.map(v => ({...}))` whose receiver is
|
|
51
|
+
* modellable but whose lambda produces an unmodellable value — havocing the
|
|
52
|
+
* lambda interior would leave a nondeterministic `*` inside a pure lambda. */
|
|
53
|
+
function containsBad(e) {
|
|
54
|
+
let found = false;
|
|
55
|
+
const visit = (n) => {
|
|
56
|
+
if (found)
|
|
57
|
+
return;
|
|
58
|
+
if (isBadNode(n)) {
|
|
59
|
+
found = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
forEachChildExpr(n, visit);
|
|
63
|
+
};
|
|
64
|
+
visit(e);
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
function mustHavoc(e) {
|
|
68
|
+
if (isBadNode(e))
|
|
69
|
+
return true;
|
|
70
|
+
if (e.kind === "call" && e.args.some(a => a.kind === "lambda" && a.body.some(s => stmtContainsBad(s))))
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// ── structural recursion helpers ────────────────────────────
|
|
75
|
+
function forEachChildExpr(e, f) {
|
|
76
|
+
switch (e.kind) {
|
|
77
|
+
case "binop":
|
|
78
|
+
f(e.left);
|
|
79
|
+
f(e.right);
|
|
80
|
+
return;
|
|
81
|
+
case "unop":
|
|
82
|
+
f(e.expr);
|
|
83
|
+
return;
|
|
84
|
+
case "call":
|
|
85
|
+
f(e.fn);
|
|
86
|
+
e.args.forEach(f);
|
|
87
|
+
return;
|
|
88
|
+
case "index":
|
|
89
|
+
f(e.obj);
|
|
90
|
+
f(e.idx);
|
|
91
|
+
return;
|
|
92
|
+
case "field":
|
|
93
|
+
f(e.obj);
|
|
94
|
+
return;
|
|
95
|
+
case "record":
|
|
96
|
+
if (e.spread)
|
|
97
|
+
f(e.spread);
|
|
98
|
+
e.fields.forEach(fl => f(fl.value));
|
|
99
|
+
return;
|
|
100
|
+
case "arrayLiteral":
|
|
101
|
+
e.elems.forEach(f);
|
|
102
|
+
return;
|
|
103
|
+
case "conditional":
|
|
104
|
+
f(e.cond);
|
|
105
|
+
f(e.then);
|
|
106
|
+
f(e.else);
|
|
107
|
+
return;
|
|
108
|
+
case "optChain":
|
|
109
|
+
f(e.obj);
|
|
110
|
+
return;
|
|
111
|
+
case "nullish":
|
|
112
|
+
f(e.left);
|
|
113
|
+
f(e.right);
|
|
114
|
+
return;
|
|
115
|
+
case "forall":
|
|
116
|
+
case "exists":
|
|
117
|
+
f(e.body);
|
|
118
|
+
return;
|
|
119
|
+
case "someMatch":
|
|
120
|
+
f(e.scrutinee);
|
|
121
|
+
f(e.someBody);
|
|
122
|
+
f(e.noneBody);
|
|
123
|
+
return;
|
|
124
|
+
case "tagMatch":
|
|
125
|
+
f(e.scrutinee);
|
|
126
|
+
e.cases.forEach(c => f(c.body));
|
|
127
|
+
if (e.fallthrough)
|
|
128
|
+
f(e.fallthrough);
|
|
129
|
+
return;
|
|
130
|
+
case "lambda":
|
|
131
|
+
e.body.forEach(s => forEachChildExprInStmt(s, f));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function forEachChildExprInStmt(s, f) {
|
|
136
|
+
switch (s.kind) {
|
|
137
|
+
case "let":
|
|
138
|
+
case "ghostLet":
|
|
139
|
+
f(s.init);
|
|
140
|
+
return;
|
|
141
|
+
case "assign":
|
|
142
|
+
case "ghostAssign":
|
|
143
|
+
f(s.value);
|
|
144
|
+
return;
|
|
145
|
+
case "return":
|
|
146
|
+
f(s.value);
|
|
147
|
+
return;
|
|
148
|
+
case "expr":
|
|
149
|
+
f(s.expr);
|
|
150
|
+
return;
|
|
151
|
+
case "assert":
|
|
152
|
+
f(s.expr);
|
|
153
|
+
return;
|
|
154
|
+
case "if":
|
|
155
|
+
f(s.cond);
|
|
156
|
+
s.then.forEach(st => forEachChildExprInStmt(st, f));
|
|
157
|
+
s.else.forEach(st => forEachChildExprInStmt(st, f));
|
|
158
|
+
return;
|
|
159
|
+
case "while":
|
|
160
|
+
f(s.cond);
|
|
161
|
+
s.body.forEach(st => forEachChildExprInStmt(st, f));
|
|
162
|
+
return;
|
|
163
|
+
case "forof":
|
|
164
|
+
f(s.iterable);
|
|
165
|
+
s.body.forEach(st => forEachChildExprInStmt(st, f));
|
|
166
|
+
return;
|
|
167
|
+
case "switch":
|
|
168
|
+
f(s.expr);
|
|
169
|
+
s.cases.forEach(c => c.body.forEach(st => forEachChildExprInStmt(st, f)));
|
|
170
|
+
s.defaultBody.forEach(st => forEachChildExprInStmt(st, f));
|
|
171
|
+
return;
|
|
172
|
+
case "someMatch":
|
|
173
|
+
f(s.scrutinee);
|
|
174
|
+
s.someBody.forEach(st => forEachChildExprInStmt(st, f));
|
|
175
|
+
s.noneBody.forEach(st => forEachChildExprInStmt(st, f));
|
|
176
|
+
return;
|
|
177
|
+
case "tagMatch":
|
|
178
|
+
f(s.scrutinee);
|
|
179
|
+
s.cases.forEach(c => c.body.forEach(st => forEachChildExprInStmt(st, f)));
|
|
180
|
+
s.fallthrough.forEach(st => forEachChildExprInStmt(st, f));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function stmtContainsBad(s) {
|
|
185
|
+
let found = false;
|
|
186
|
+
forEachChildExprInStmt(s, e => { if (containsBad(e))
|
|
187
|
+
found = true; });
|
|
188
|
+
return found;
|
|
189
|
+
}
|
|
190
|
+
// ── contracted-call ("sink") detection ──────────────────────
|
|
191
|
+
// A call whose callee carries a `//@ requires` precondition. When we havoc an
|
|
192
|
+
// unmodellable expression we must NOT silently discard such a call nested
|
|
193
|
+
// inside it — its precondition would go unchecked (a false pass). Instead we
|
|
194
|
+
// hoist it to a discard statement so the precondition is still verified.
|
|
195
|
+
let _guarded = new Set();
|
|
196
|
+
// Every name LemmaScript models: local functions + all externs. A call to
|
|
197
|
+
// anything else with an unknown callee is "opaque" — abstracted by havoc.
|
|
198
|
+
let _known = new Set();
|
|
199
|
+
function calleeName(e) {
|
|
200
|
+
if (e.kind !== "call")
|
|
201
|
+
return null;
|
|
202
|
+
if (e.fn.kind === "var")
|
|
203
|
+
return e.fn.name;
|
|
204
|
+
if (e.fn.kind === "field")
|
|
205
|
+
return e.fn.field;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** Root variable a receiver chain bottoms out at (`fs.x.y` → `fs`,
|
|
209
|
+
* `res.status(400).send` → `res`, `templateJson.variables.map` → `templateJson`). */
|
|
210
|
+
function rootVar(e) {
|
|
211
|
+
let cur = e;
|
|
212
|
+
for (;;) {
|
|
213
|
+
if (cur.kind === "var")
|
|
214
|
+
return cur.name;
|
|
215
|
+
if (cur.kind === "field")
|
|
216
|
+
cur = cur.obj;
|
|
217
|
+
else if (cur.kind === "call")
|
|
218
|
+
cur = cur.fn;
|
|
219
|
+
else if (cur.kind === "index")
|
|
220
|
+
cur = cur.obj;
|
|
221
|
+
else
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Display name of an *opaque external* call this pass abstracts away — a call
|
|
226
|
+
* on an unknown receiver rooted at a module/global (`fs.readFileSync`,
|
|
227
|
+
* `JSON.parse`) or an unknown free function (`uuidv4`). Returns null for
|
|
228
|
+
* modelled calls (local functions, contracted/dispatched externs, methods on
|
|
229
|
+
* known receivers) and for calls rooted at a function-local/param (e.g.
|
|
230
|
+
* `templateJson.variables.map`, `res.status().send` — framework objects and
|
|
231
|
+
* havoc'd locals, not external sinks). These are the calls a completeness
|
|
232
|
+
* reviewer must confirm are not unguarded sinks. */
|
|
233
|
+
function opaqueCallName(e, locals) {
|
|
234
|
+
// Report exactly the calls the pass abstracts. A call is havoc'd iff its
|
|
235
|
+
// result type is unknown (the same `isBadNode`/`mustHavoc` criterion the pass
|
|
236
|
+
// uses) — keying on that, rather than re-matching the callee's syntax, makes
|
|
237
|
+
// the report complete: it can't miss an unusual form like `fs["read"](p)`.
|
|
238
|
+
if (e.kind !== "call" || e.ty.kind !== "unknown")
|
|
239
|
+
return null;
|
|
240
|
+
const root = rootVar(e.fn);
|
|
241
|
+
// Not an external sink: rooted at a parameter/local value (framework objects,
|
|
242
|
+
// havoc'd locals) or at a known function/extern (already modelled).
|
|
243
|
+
if (root && (locals.has(root) || _known.has(root)))
|
|
244
|
+
return null;
|
|
245
|
+
// Best display we can give the callee.
|
|
246
|
+
const f = e.fn;
|
|
247
|
+
if (f.kind === "field" && f.obj.kind === "var")
|
|
248
|
+
return `${f.obj.name}.${f.field}`;
|
|
249
|
+
if (f.kind === "field")
|
|
250
|
+
return f.field;
|
|
251
|
+
if (f.kind === "index" && f.obj.kind === "var" && f.idx.kind === "str")
|
|
252
|
+
return `${f.obj.name}.${f.idx.value}`;
|
|
253
|
+
if (f.kind === "var")
|
|
254
|
+
return f.name;
|
|
255
|
+
return root ?? "<computed call>";
|
|
256
|
+
}
|
|
257
|
+
/** Every binder in scope anywhere in a function body — params (seed) + let /
|
|
258
|
+
* forof / lambda parameters — so a call rooted at any of them is treated as a
|
|
259
|
+
* local value, not an external sink. A call rooted at a *free* name (a module
|
|
260
|
+
* or global like `fs`/`JSON`/`console`, or a free function like `uuidv4`) is
|
|
261
|
+
* what the report keeps. Still descends into lambdas so a sink hidden in a
|
|
262
|
+
* callback (`arr.forEach(x => fs.writeFileSync(x))`) is not missed. */
|
|
263
|
+
function localNames(stmts, seed) {
|
|
264
|
+
const out = new Set(seed);
|
|
265
|
+
const inExpr = (e) => {
|
|
266
|
+
if (e.kind === "lambda") {
|
|
267
|
+
e.params.forEach(p => out.add(p.name));
|
|
268
|
+
inStmts(e.body);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
forEachChildExpr(e, inExpr);
|
|
272
|
+
};
|
|
273
|
+
const inStmts = (ss) => {
|
|
274
|
+
for (const s of ss) {
|
|
275
|
+
if (s.kind === "let" || s.kind === "ghostLet")
|
|
276
|
+
out.add(s.name);
|
|
277
|
+
if (s.kind === "forof")
|
|
278
|
+
s.names.forEach(n => out.add(n));
|
|
279
|
+
forEachChildExprInStmt(s, inExpr);
|
|
280
|
+
if (s.kind === "if") {
|
|
281
|
+
inStmts(s.then);
|
|
282
|
+
inStmts(s.else);
|
|
283
|
+
}
|
|
284
|
+
else if (s.kind === "while" || s.kind === "forof")
|
|
285
|
+
inStmts(s.body);
|
|
286
|
+
else if (s.kind === "switch") {
|
|
287
|
+
s.cases.forEach(c => inStmts(c.body));
|
|
288
|
+
inStmts(s.defaultBody);
|
|
289
|
+
}
|
|
290
|
+
else if (s.kind === "someMatch") {
|
|
291
|
+
inStmts(s.someBody);
|
|
292
|
+
inStmts(s.noneBody);
|
|
293
|
+
}
|
|
294
|
+
else if (s.kind === "tagMatch") {
|
|
295
|
+
s.cases.forEach(c => inStmts(c.body));
|
|
296
|
+
inStmts(s.fallthrough);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
inStmts(stmts);
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/** All opaque external calls in a function body (deduped, sorted). */
|
|
304
|
+
function collectOpaqueCalls(stmts, locals) {
|
|
305
|
+
const found = new Set();
|
|
306
|
+
const visit = (e) => {
|
|
307
|
+
const n = opaqueCallName(e, locals);
|
|
308
|
+
if (n)
|
|
309
|
+
found.add(n);
|
|
310
|
+
forEachChildExpr(e, visit);
|
|
311
|
+
};
|
|
312
|
+
for (const s of stmts)
|
|
313
|
+
forEachChildExprInStmt(s, visit);
|
|
314
|
+
return [...found].sort();
|
|
315
|
+
}
|
|
316
|
+
/** Outermost contracted calls inside `e`'s subtree (excluding `e` itself, which
|
|
317
|
+
* is the havoc'd wrapper). Does not descend into lambdas — a sink there would
|
|
318
|
+
* reference the lambda's binder and can't be hoisted to the enclosing scope. */
|
|
319
|
+
function collectSinks(e) {
|
|
320
|
+
const out = [];
|
|
321
|
+
const walk = (n) => {
|
|
322
|
+
if (n.kind === "lambda")
|
|
323
|
+
return;
|
|
324
|
+
const name = calleeName(n);
|
|
325
|
+
if (name && _guarded.has(name)) {
|
|
326
|
+
out.push(n);
|
|
327
|
+
return;
|
|
328
|
+
} // outermost: don't descend
|
|
329
|
+
forEachChildExpr(n, walk);
|
|
330
|
+
};
|
|
331
|
+
forEachChildExpr(e, walk);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
// ── rewrite ──────────────────────────────────────────────────
|
|
335
|
+
// `hoisted` accumulates discard statements (preserved contracted calls) to be
|
|
336
|
+
// emitted, in order, immediately before the statement being rewritten.
|
|
337
|
+
function rewriteExpr(e, hoisted) {
|
|
338
|
+
if (mustHavoc(e)) {
|
|
339
|
+
// Preserve any contracted call nested inside the discarded value so its
|
|
340
|
+
// precondition is still checked. transform lowers the `expr` discard to
|
|
341
|
+
// `var _ := call(...)` (and lifts any havoc'd args), so we lean on it.
|
|
342
|
+
for (const sink of collectSinks(e)) {
|
|
343
|
+
hoisted.push({ kind: "expr", expr: rewriteExpr(sink, hoisted) });
|
|
344
|
+
}
|
|
345
|
+
return { kind: "havoc", ty: e.ty };
|
|
346
|
+
}
|
|
347
|
+
switch (e.kind) {
|
|
348
|
+
case "var":
|
|
349
|
+
case "num":
|
|
350
|
+
case "str":
|
|
351
|
+
case "bool":
|
|
352
|
+
case "havoc": return e;
|
|
353
|
+
case "binop": return { ...e, left: rewriteExpr(e.left, hoisted), right: rewriteExpr(e.right, hoisted) };
|
|
354
|
+
case "unop": return { ...e, expr: rewriteExpr(e.expr, hoisted) };
|
|
355
|
+
case "call": return { ...e, fn: rewriteExpr(e.fn, hoisted), args: e.args.map(a => rewriteExpr(a, hoisted)) };
|
|
356
|
+
case "index": return { ...e, obj: rewriteExpr(e.obj, hoisted), idx: rewriteExpr(e.idx, hoisted) };
|
|
357
|
+
case "field": return { ...e, obj: rewriteExpr(e.obj, hoisted) };
|
|
358
|
+
case "record": return { ...e, spread: e.spread ? rewriteExpr(e.spread, hoisted) : null,
|
|
359
|
+
fields: e.fields.map(fl => ({ ...fl, value: rewriteExpr(fl.value, hoisted) })) };
|
|
360
|
+
case "arrayLiteral": return { ...e, elems: e.elems.map(el => rewriteExpr(el, hoisted)) };
|
|
361
|
+
case "conditional": return { ...e, cond: rewriteExpr(e.cond, hoisted), then: rewriteExpr(e.then, hoisted), else: rewriteExpr(e.else, hoisted) };
|
|
362
|
+
case "optChain": return { ...e, obj: rewriteExpr(e.obj, hoisted) };
|
|
363
|
+
case "nullish": return { ...e, left: rewriteExpr(e.left, hoisted), right: rewriteExpr(e.right, hoisted) };
|
|
364
|
+
case "forall":
|
|
365
|
+
case "exists": return { ...e, body: rewriteExpr(e.body, hoisted) };
|
|
366
|
+
case "someMatch": return { ...e, scrutinee: rewriteExpr(e.scrutinee, hoisted), someBody: rewriteExpr(e.someBody, hoisted), noneBody: rewriteExpr(e.noneBody, hoisted) };
|
|
367
|
+
case "tagMatch": return { ...e, scrutinee: rewriteExpr(e.scrutinee, hoisted),
|
|
368
|
+
cases: e.cases.map(c => ({ ...c, body: rewriteExpr(c.body, hoisted) })),
|
|
369
|
+
fallthrough: e.fallthrough ? rewriteExpr(e.fallthrough, hoisted) : null };
|
|
370
|
+
// A lambda body is its own statement scope; rewrite it independently
|
|
371
|
+
// (its hoists belong inside the lambda, not the enclosing statement).
|
|
372
|
+
case "lambda": return { ...e, body: rewriteStmts(e.body) };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** A condition in a boolean context. If it touches anything unmodellable
|
|
376
|
+
* (`!fs.existsSync(...)`), the whole condition becomes a nondeterministic
|
|
377
|
+
* `bool` — havocing a subexpression would leave a non-bool `*` under `!`/`if`.
|
|
378
|
+
* A fully modellable guard (`!validPath(x)`, `s === ""`) is left intact, so
|
|
379
|
+
* the verifier still reasons about it. Contracted calls inside an havoc'd
|
|
380
|
+
* condition are still hoisted. */
|
|
381
|
+
function rewriteCond(e, hoisted) {
|
|
382
|
+
if (containsBad(e)) {
|
|
383
|
+
for (const sink of collectSinks(e)) {
|
|
384
|
+
hoisted.push({ kind: "expr", expr: rewriteExpr(sink, hoisted) });
|
|
385
|
+
}
|
|
386
|
+
return { kind: "havoc", ty: { kind: "bool" } };
|
|
387
|
+
}
|
|
388
|
+
return rewriteExpr(e, hoisted);
|
|
389
|
+
}
|
|
390
|
+
function rewriteStmts(stmts) {
|
|
391
|
+
const out = [];
|
|
392
|
+
for (const s of stmts) {
|
|
393
|
+
// A side-effecting statement whose value is unmodellable (`xs.forEach(...)`,
|
|
394
|
+
// `console.log(x)`) carries no verification content — drop it, but still
|
|
395
|
+
// preserve any contracted call nested inside it.
|
|
396
|
+
if (s.kind === "expr" && mustHavoc(s.expr)) {
|
|
397
|
+
const h = [];
|
|
398
|
+
for (const sink of collectSinks(s.expr)) {
|
|
399
|
+
h.push({ kind: "expr", expr: rewriteExpr(sink, h) });
|
|
400
|
+
}
|
|
401
|
+
out.push(...h);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
out.push(...rewriteStmt(s));
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
function rewriteStmt(s) {
|
|
409
|
+
const h = [];
|
|
410
|
+
const rw = (e) => rewriteExpr(e, h);
|
|
411
|
+
switch (s.kind) {
|
|
412
|
+
case "let": {
|
|
413
|
+
const init = rw(s.init);
|
|
414
|
+
return [...h, { ...s, init }];
|
|
415
|
+
}
|
|
416
|
+
case "ghostLet": {
|
|
417
|
+
const init = rw(s.init);
|
|
418
|
+
return [...h, { ...s, init }];
|
|
419
|
+
}
|
|
420
|
+
case "assign": {
|
|
421
|
+
const value = rw(s.value);
|
|
422
|
+
return [...h, { ...s, value }];
|
|
423
|
+
}
|
|
424
|
+
case "ghostAssign": {
|
|
425
|
+
const value = rw(s.value);
|
|
426
|
+
return [...h, { ...s, value }];
|
|
427
|
+
}
|
|
428
|
+
case "return": {
|
|
429
|
+
const value = rw(s.value);
|
|
430
|
+
return [...h, { ...s, value }];
|
|
431
|
+
}
|
|
432
|
+
case "expr": {
|
|
433
|
+
const expr = rw(s.expr);
|
|
434
|
+
return [...h, { ...s, expr }];
|
|
435
|
+
}
|
|
436
|
+
case "assert": {
|
|
437
|
+
const expr = rw(s.expr);
|
|
438
|
+
return [...h, { ...s, expr }];
|
|
439
|
+
}
|
|
440
|
+
case "if": {
|
|
441
|
+
const cond = rewriteCond(s.cond, h);
|
|
442
|
+
return [...h, { ...s, cond, then: rewriteStmts(s.then), else: rewriteStmts(s.else) }];
|
|
443
|
+
}
|
|
444
|
+
case "while": {
|
|
445
|
+
const cond = rewriteCond(s.cond, h);
|
|
446
|
+
return [...h, { ...s, cond, body: rewriteStmts(s.body) }];
|
|
447
|
+
}
|
|
448
|
+
case "forof": {
|
|
449
|
+
const iterable = rw(s.iterable);
|
|
450
|
+
return [...h, { ...s, iterable, body: rewriteStmts(s.body) }];
|
|
451
|
+
}
|
|
452
|
+
case "switch": {
|
|
453
|
+
const expr = rw(s.expr);
|
|
454
|
+
return [...h, { ...s, expr,
|
|
455
|
+
cases: s.cases.map(c => ({ ...c, body: rewriteStmts(c.body) })),
|
|
456
|
+
defaultBody: rewriteStmts(s.defaultBody) }];
|
|
457
|
+
}
|
|
458
|
+
case "someMatch": {
|
|
459
|
+
const scrutinee = rw(s.scrutinee);
|
|
460
|
+
return [...h, { ...s, scrutinee, someBody: rewriteStmts(s.someBody), noneBody: rewriteStmts(s.noneBody) }];
|
|
461
|
+
}
|
|
462
|
+
case "tagMatch": {
|
|
463
|
+
const scrutinee = rw(s.scrutinee);
|
|
464
|
+
return [...h, { ...s, scrutinee,
|
|
465
|
+
cases: s.cases.map(c => ({ ...c, body: rewriteStmts(c.body) })),
|
|
466
|
+
fallthrough: rewriteStmts(s.fallthrough) }];
|
|
467
|
+
}
|
|
468
|
+
case "break":
|
|
469
|
+
case "continue":
|
|
470
|
+
case "throw": return [s];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Does any statement in the body contain a havoc expression? Havoc is only
|
|
474
|
+
* valid in Dafny `method`s (its `*` can't appear in a deterministic
|
|
475
|
+
* `function`), so a body we've havoc'd must be classified impure. */
|
|
476
|
+
function bodyHasHavoc(stmts) {
|
|
477
|
+
let found = false;
|
|
478
|
+
const visit = (e) => {
|
|
479
|
+
if (found)
|
|
480
|
+
return;
|
|
481
|
+
if (e.kind === "havoc") {
|
|
482
|
+
found = true;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
forEachChildExpr(e, visit);
|
|
486
|
+
};
|
|
487
|
+
for (const s of stmts)
|
|
488
|
+
forEachChildExprInStmt(s, visit);
|
|
489
|
+
return found;
|
|
490
|
+
}
|
|
491
|
+
/** Apply the auto-havoc abstraction to functions opted in via `//@ autohavoc`
|
|
492
|
+
* (file-level or per-function). A no-op when nothing is annotated. When any
|
|
493
|
+
* function opts in, also drop module constants whose initializer is
|
|
494
|
+
* unmodellable (e.g. `const fs = require("fs")` pulled in by reachability). */
|
|
495
|
+
export function autoHavocModule(mod) {
|
|
496
|
+
if (!mod.functions.some(f => f.autohavoc))
|
|
497
|
+
return mod;
|
|
498
|
+
// Names of calls carrying a `//@ requires` precondition — these must never be
|
|
499
|
+
// silently discarded by havoc (see collectSinks / rewriteExpr).
|
|
500
|
+
_guarded = new Set();
|
|
501
|
+
for (const e of mod.externs)
|
|
502
|
+
if (e.requires.length > 0) {
|
|
503
|
+
_guarded.add(e.qualified);
|
|
504
|
+
_guarded.add(e.flat);
|
|
505
|
+
}
|
|
506
|
+
for (const f of mod.functions)
|
|
507
|
+
if (f.requires.length > 0)
|
|
508
|
+
_guarded.add(f.name);
|
|
509
|
+
_known = new Set();
|
|
510
|
+
for (const f of mod.functions)
|
|
511
|
+
_known.add(f.name);
|
|
512
|
+
for (const e of mod.externs) {
|
|
513
|
+
_known.add(e.qualified);
|
|
514
|
+
_known.add(e.flat);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
...mod,
|
|
518
|
+
constants: mod.constants.filter(c => !containsBad(c.value)),
|
|
519
|
+
functions: mod.functions.map(f => {
|
|
520
|
+
if (!f.autohavoc)
|
|
521
|
+
return f;
|
|
522
|
+
// Completeness transparency: surface the opaque external calls this pass
|
|
523
|
+
// abstracts away, so a forgotten sink (e.g. a raw `fs.readFileSync`) is
|
|
524
|
+
// visible rather than silently havoc'd behind a green check.
|
|
525
|
+
const locals = localNames(f.body, new Set(f.params.map(p => p.name)));
|
|
526
|
+
const opaque = collectOpaqueCalls(f.body, locals);
|
|
527
|
+
if (opaque.length > 0) {
|
|
528
|
+
console.error(`autohavoc: ${f.name} abstracts ${opaque.length} external call(s) — confirm none is an unguarded sink: ${opaque.join(", ")}`);
|
|
529
|
+
}
|
|
530
|
+
const body = rewriteStmts(f.body);
|
|
531
|
+
// Havoc forces Dafny `method` emission (`*` is invalid in a `function`).
|
|
532
|
+
const isPure = f.isPure && !bodyHasHavoc(body);
|
|
533
|
+
return { ...f, body, isPure, forcePure: f.forcePure && isPure };
|
|
534
|
+
}),
|
|
535
|
+
};
|
|
536
|
+
}
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -45,11 +45,16 @@ const DAFNY_KEYWORDS = new Set([
|
|
|
45
45
|
"codatatype", "nameonly", "twostate", "opaque", "replaceable", "colemma",
|
|
46
46
|
"copredicate", "inductive",
|
|
47
47
|
]);
|
|
48
|
+
// The Dafny out-parameter name for the method currently being emitted. Default
|
|
49
|
+
// `res`, but bumped (e.g. `res_`) when a parameter is named `res` — set by
|
|
50
|
+
// methodHeader and reset per decl. `\result` in an ensures must use the *same*
|
|
51
|
+
// name, so escapeName routes it here.
|
|
52
|
+
let _resultName = "res";
|
|
48
53
|
function escapeName(name) {
|
|
49
54
|
// \result is carried through the IR as the var name "\\result"; render it
|
|
50
|
-
// as
|
|
55
|
+
// as the current method's out-parameter name.
|
|
51
56
|
if (name === "\\result")
|
|
52
|
-
return
|
|
57
|
+
return _resultName;
|
|
53
58
|
if (DAFNY_KEYWORDS.has(name))
|
|
54
59
|
return `${name}_`;
|
|
55
60
|
// Dafny doesn't allow identifiers starting with _
|
|
@@ -66,7 +71,17 @@ function paramList(params) {
|
|
|
66
71
|
* `returns (res: ())` on a void method fails verification. */
|
|
67
72
|
function methodHeader(prefix, params, returnType) {
|
|
68
73
|
const sig = `${prefix}(${paramList(params)})`;
|
|
69
|
-
|
|
74
|
+
if (returnType.kind === "void")
|
|
75
|
+
return sig;
|
|
76
|
+
// The out-parameter is `res` by default, but a parameter named `res` (e.g. an
|
|
77
|
+
// Express handler's `(req, res)`) would collide; pick a fresh name and record
|
|
78
|
+
// it so `\result` references in the ensures/body resolve to the same name.
|
|
79
|
+
const taken = new Set(params.map(p => escapeName(p.name)));
|
|
80
|
+
let resName = "res";
|
|
81
|
+
while (taken.has(resName))
|
|
82
|
+
resName += "_";
|
|
83
|
+
_resultName = resName;
|
|
84
|
+
return `${sig} returns (${resName}: ${tyToDafny(returnType)})`;
|
|
70
85
|
}
|
|
71
86
|
// ── Lean op → Dafny op ─────────────────────────────────────
|
|
72
87
|
const OP_MAP = {
|
|
@@ -356,6 +371,12 @@ function emitExpr(e) {
|
|
|
356
371
|
needPreamble("BitAnd");
|
|
357
372
|
return `BitAnd(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
|
|
358
373
|
}
|
|
374
|
+
// x | y → BitOr(x, y) (recursive, mirrors BitAnd). Dafny has no `|` on int,
|
|
375
|
+
// only on bitvectors.
|
|
376
|
+
if (e.op === "|") {
|
|
377
|
+
needPreamble("BitOr");
|
|
378
|
+
return `BitOr(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
|
|
379
|
+
}
|
|
359
380
|
// int→real coercion is now injected upstream in transform (toReal nodes),
|
|
360
381
|
// which has full type information — including real-typed variables, not
|
|
361
382
|
// just literals — so no literal-based coercion is needed here.
|
|
@@ -525,6 +546,10 @@ function emitStmt(s, indent) {
|
|
|
525
546
|
return `${pad}var ${escapeName(s.name)}: ${tyToDafny(s.type)} := ${emitExpr(s.value)};`;
|
|
526
547
|
return `${pad}var ${escapeName(s.name)} := ${emitExpr(s.value)};`;
|
|
527
548
|
case "assign":
|
|
549
|
+
// Transform.ts lowers a bare expression statement to an assign with target _
|
|
550
|
+
// so special case this to Dafny's anonymous binding.
|
|
551
|
+
if (s.target === "_")
|
|
552
|
+
return `${pad}var _ := ${emitExpr(s.value)};`;
|
|
528
553
|
return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
|
|
529
554
|
case "ghostLet":
|
|
530
555
|
return `${pad}ghost var ${escapeName(s.name)}: ${tyToDafny(s.type)} := ${emitExpr(s.value)};`;
|
|
@@ -596,6 +621,7 @@ function emitStmt(s, indent) {
|
|
|
596
621
|
}
|
|
597
622
|
// ── Declaration emission ────────────────────────────────────
|
|
598
623
|
function emitDecl(d) {
|
|
624
|
+
_resultName = "res"; // default; methodHeader bumps it if a param is named `res`
|
|
599
625
|
switch (d.kind) {
|
|
600
626
|
case "inductive": {
|
|
601
627
|
const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
|
|
@@ -751,6 +777,14 @@ const BIT_AND = `function BitAnd(x: int, y: int): int
|
|
|
751
777
|
if x == 0 || y == 0 then 0
|
|
752
778
|
else 2 * BitAnd(x / 2, y / 2) + (if x % 2 == 1 && y % 2 == 1 then 1 else 0)
|
|
753
779
|
}`;
|
|
780
|
+
const BIT_OR = `function BitOr(x: int, y: int): int
|
|
781
|
+
requires x >= 0 && y >= 0
|
|
782
|
+
decreases x
|
|
783
|
+
{
|
|
784
|
+
if x == 0 then y
|
|
785
|
+
else if y == 0 then x
|
|
786
|
+
else 2 * BitOr(x / 2, y / 2) + (if x % 2 == 1 || y % 2 == 1 then 1 else 0)
|
|
787
|
+
}`;
|
|
754
788
|
const JS_FLOOR_DIV = `function JSFloorDiv(a: int, b: int): int
|
|
755
789
|
requires b != 0
|
|
756
790
|
{
|
|
@@ -1018,6 +1052,7 @@ const PREAMBLE_CODE = [
|
|
|
1018
1052
|
["SetToSeq", SET_TO_SEQ],
|
|
1019
1053
|
["Pow2", POW2],
|
|
1020
1054
|
["BitAnd", BIT_AND],
|
|
1055
|
+
["BitOr", BIT_OR],
|
|
1021
1056
|
["JSFloorDiv", JS_FLOOR_DIV],
|
|
1022
1057
|
["CeilReal", CEIL_REAL],
|
|
1023
1058
|
["FloorReal", FLOOR_REAL],
|
package/tools/dist/extract.js
CHANGED
|
@@ -69,6 +69,10 @@ function detectCrossFileExtern(callee, sourceFile) {
|
|
|
69
69
|
if (decls.length === 0)
|
|
70
70
|
return null;
|
|
71
71
|
const currentPath = sourceFile.getFilePath();
|
|
72
|
+
// A declaration in the current file is authoritative — don't resolve to a
|
|
73
|
+
// same-named definition in another file.
|
|
74
|
+
if (decls.some(d => d.getSourceFile().getFilePath() === currentPath))
|
|
75
|
+
return null;
|
|
72
76
|
const externalDecl = decls.find(d => d.getSourceFile().getFilePath() !== currentPath);
|
|
73
77
|
if (!externalDecl)
|
|
74
78
|
return null;
|
|
@@ -1745,6 +1749,22 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1745
1749
|
return [{ name: p.getName(), tsType }];
|
|
1746
1750
|
}),
|
|
1747
1751
|
returnType: (() => {
|
|
1752
|
+
// `async` with no `await`: the `Promise<T>` wrapper is just the calling
|
|
1753
|
+
// convention (the body returns T-typed values), so unwrap to T. Gated on
|
|
1754
|
+
// no `await` — that's the suspension point we can't model, and it only
|
|
1755
|
+
// type-checks inside `async`, so the gate is self-justifying. With `await`
|
|
1756
|
+
// present we leave `Promise<...>` (unmodellable) rather than atomize it.
|
|
1757
|
+
const isAsync = fn.isAsync?.() ?? false;
|
|
1758
|
+
const noAwait = fn.getDescendantsOfKind(SyntaxKind.AwaitExpression).length === 0;
|
|
1759
|
+
if (isAsync && noAwait) {
|
|
1760
|
+
const args = fn.getReturnType().getTypeArguments();
|
|
1761
|
+
if (args.length === 1) {
|
|
1762
|
+
if (args[0].isAny())
|
|
1763
|
+
return "unknown";
|
|
1764
|
+
return _eraseGenerics(typeToString(args[0]));
|
|
1765
|
+
}
|
|
1766
|
+
return "void"; // Promise<void>
|
|
1767
|
+
}
|
|
1748
1768
|
const node = fn.getReturnTypeNode();
|
|
1749
1769
|
if (node && Node.isUnionTypeNode(node))
|
|
1750
1770
|
return _eraseGenerics(_tsTypeFromUnionNode(node));
|
|
@@ -1759,6 +1779,7 @@ function extractFunctionInner(fn, parentAnnotations) {
|
|
|
1759
1779
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
1760
1780
|
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
1761
1781
|
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
1782
|
+
autohavoc: false, // set in extractModule (file-level directive or per-function)
|
|
1762
1783
|
typeAnnotations,
|
|
1763
1784
|
body: extractedBody,
|
|
1764
1785
|
line: fn.getStartLineNumber(),
|
|
@@ -1910,27 +1931,86 @@ export function extractModule(sourceFile) {
|
|
|
1910
1931
|
}
|
|
1911
1932
|
}
|
|
1912
1933
|
}
|
|
1934
|
+
// Top-level inline closures: a handler passed directly to a module-level call,
|
|
1935
|
+
// e.g. `app.get("/x", (req, res) => { //@ verify ... })`. "Move" each such
|
|
1936
|
+
// closure to the top level by extracting it as a synthetic named function.
|
|
1937
|
+
// Only top-level call arguments are considered (not nested lambdas), and only
|
|
1938
|
+
// closures carrying a //@ verify (so ordinary callbacks aren't pulled in). The
|
|
1939
|
+
// name is derived from the call's method and route literal (e.g. get_x).
|
|
1940
|
+
const usedNames = new Set(allFns.map(f => f.name));
|
|
1941
|
+
const sanitizeIdent = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "handler";
|
|
1942
|
+
for (const stmt of sourceFile.getStatements()) {
|
|
1943
|
+
if (!Node.isExpressionStatement(stmt))
|
|
1944
|
+
continue;
|
|
1945
|
+
const call = stmt.getExpression();
|
|
1946
|
+
if (!Node.isCallExpression(call))
|
|
1947
|
+
continue;
|
|
1948
|
+
const callee = call.getExpression();
|
|
1949
|
+
const method = Node.isPropertyAccessExpression(callee) ? callee.getName()
|
|
1950
|
+
: Node.isIdentifier(callee) ? callee.getText() : "handler";
|
|
1951
|
+
const routeArg = call.getArguments().find(a => Node.isStringLiteral(a));
|
|
1952
|
+
const route = routeArg && Node.isStringLiteral(routeArg) ? routeArg.getLiteralValue() : "";
|
|
1953
|
+
for (const arg of call.getArguments()) {
|
|
1954
|
+
if (!Node.isArrowFunction(arg))
|
|
1955
|
+
continue;
|
|
1956
|
+
if (!hasLineDirective(arg.getFullText(), "verify"))
|
|
1957
|
+
continue;
|
|
1958
|
+
const base = sanitizeIdent(route ? `${method}_${route}` : method);
|
|
1959
|
+
let name = base, n = 2;
|
|
1960
|
+
while (usedNames.has(name))
|
|
1961
|
+
name = `${base}_${n++}`;
|
|
1962
|
+
usedNames.add(name);
|
|
1963
|
+
allFns.push({ name, node: arg, parentStmt: stmt });
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1913
1966
|
// `//@ extern` on a same-file declaration: register the function as an
|
|
1914
1967
|
// opaque axiom (signature + any //@ requires/ensures), skip its body. Use
|
|
1915
1968
|
// when the function is outside LS's verification model — e.g., wraps a
|
|
1916
1969
|
// regex — but its callers should still be verifiable against an
|
|
1917
1970
|
// uninterpreted predicate. Parallel to auto-extern for cross-file calls,
|
|
1918
1971
|
// and emitted the same way (`function {:axiom} foo(...)` in Dafny).
|
|
1972
|
+
// Match a `//@ <kw>` directive only as the first non-whitespace on a line, so
|
|
1973
|
+
// a mention mid-line in prose or inside a block/JSDoc comment (e.g. "the
|
|
1974
|
+
// `//@ extern` annotation", or ` * //@ extern`) doesn't falsely trigger it.
|
|
1975
|
+
function hasLineDirective(text, kw) {
|
|
1976
|
+
return new RegExp(String.raw `^[ \t]*//@ ${kw}\b`, "m").test(text);
|
|
1977
|
+
}
|
|
1919
1978
|
function hasExtern(f) {
|
|
1920
|
-
if (f.node.getFullText()
|
|
1979
|
+
if (hasLineDirective(f.node.getFullText(), "extern"))
|
|
1921
1980
|
return true;
|
|
1922
1981
|
if (f.parentStmt) {
|
|
1923
1982
|
for (const r of f.parentStmt.getLeadingCommentRanges()) {
|
|
1924
|
-
if (r.getText()
|
|
1983
|
+
if (hasLineDirective(r.getText(), "extern"))
|
|
1925
1984
|
return true;
|
|
1926
1985
|
}
|
|
1927
1986
|
}
|
|
1928
1987
|
return false;
|
|
1929
1988
|
}
|
|
1989
|
+
// `//@ extern NS.method` registers the extern under a *dotted* qualified name,
|
|
1990
|
+
// so a real `NS.method(args)` call dispatches to it (resolve.ts) with no
|
|
1991
|
+
// wrapper — e.g. `//@ extern fs.readFileSync` lets you call `fs.readFileSync`
|
|
1992
|
+
// directly while still discharging its `//@ requires`. The function declaration
|
|
1993
|
+
// just carries the signature/contract; its own name is unused.
|
|
1994
|
+
function externName(f) {
|
|
1995
|
+
const re = /^[ \t]*\/\/@ extern[ \t]+(\S+)/m;
|
|
1996
|
+
const m = f.node.getFullText().match(re);
|
|
1997
|
+
if (m)
|
|
1998
|
+
return m[1];
|
|
1999
|
+
if (f.parentStmt) {
|
|
2000
|
+
for (const r of f.parentStmt.getLeadingCommentRanges()) {
|
|
2001
|
+
const m2 = r.getText().match(re);
|
|
2002
|
+
if (m2)
|
|
2003
|
+
return m2[1];
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
1930
2008
|
for (const f of allFns) {
|
|
1931
2009
|
if (!hasExtern(f))
|
|
1932
2010
|
continue;
|
|
1933
|
-
|
|
2011
|
+
const qualified = externName(f) ?? f.name;
|
|
2012
|
+
const flat = qualified.replace(/\./g, "_");
|
|
2013
|
+
if (_externs.has(qualified))
|
|
1934
2014
|
continue;
|
|
1935
2015
|
const sig = f.node.getType().getCallSignatures()[0];
|
|
1936
2016
|
if (!sig)
|
|
@@ -1947,29 +2027,48 @@ export function extractModule(sourceFile) {
|
|
|
1947
2027
|
const annots = collectFunctionAnnotations(f.node);
|
|
1948
2028
|
const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
|
|
1949
2029
|
const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
|
|
1950
|
-
_externs.set(
|
|
2030
|
+
_externs.set(qualified, { qualified, flat, typeParams, params, returnType, requires, ensures });
|
|
1951
2031
|
}
|
|
1952
2032
|
// If any function has //@ verify, only extract those (brownfield mode).
|
|
1953
2033
|
// For expression-body arrows, //@ verify may be on the parent variable statement.
|
|
1954
2034
|
function hasVerify(f) {
|
|
1955
|
-
if (f.node.getFullText()
|
|
2035
|
+
if (hasLineDirective(f.node.getFullText(), "verify"))
|
|
1956
2036
|
return true;
|
|
1957
2037
|
if (f.parentStmt) {
|
|
1958
2038
|
for (const r of f.parentStmt.getLeadingCommentRanges()) {
|
|
1959
|
-
if (r.getText()
|
|
2039
|
+
if (hasLineDirective(r.getText(), "verify"))
|
|
1960
2040
|
return true;
|
|
1961
2041
|
}
|
|
1962
2042
|
}
|
|
1963
2043
|
return false;
|
|
1964
2044
|
}
|
|
1965
|
-
const hasVerifyDirective = sourceFile.getFullText()
|
|
2045
|
+
const hasVerifyDirective = hasLineDirective(sourceFile.getFullText(), "verify");
|
|
1966
2046
|
const nonExternFns = allFns.filter(f => !hasExtern(f));
|
|
1967
2047
|
const fnsToExtract = hasVerifyDirective ? nonExternFns.filter(hasVerify) : nonExternFns;
|
|
2048
|
+
// `//@ autohavoc` — enable the auto-havoc abstraction (see autohavoc.ts).
|
|
2049
|
+
// File-level: a directive at column 0 (top of file) enables it for every
|
|
2050
|
+
// function. Per-function: the annotation attached to a function (or its
|
|
2051
|
+
// parent variable statement), mirroring `//@ verify`.
|
|
2052
|
+
const fileAutohavoc = /^\/\/@ autohavoc\b/m.test(sourceFile.getFullText());
|
|
2053
|
+
function hasAutohavoc(f) {
|
|
2054
|
+
if (fileAutohavoc)
|
|
2055
|
+
return true;
|
|
2056
|
+
if (hasLineDirective(f.node.getFullText(), "autohavoc"))
|
|
2057
|
+
return true;
|
|
2058
|
+
if (f.parentStmt) {
|
|
2059
|
+
for (const r of f.parentStmt.getLeadingCommentRanges()) {
|
|
2060
|
+
if (hasLineDirective(r.getText(), "autohavoc"))
|
|
2061
|
+
return true;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
return false;
|
|
2065
|
+
}
|
|
1968
2066
|
const functions = fnsToExtract.map(f => {
|
|
1969
2067
|
// For expression-body arrows, annotations come from the parent variable statement
|
|
1970
2068
|
const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
|
|
1971
2069
|
const raw = extractFunction(f.node, parentAnnots);
|
|
1972
2070
|
raw.name = f.name; // use the const name, not "<anonymous>"
|
|
2071
|
+
raw.autohavoc = hasAutohavoc(f);
|
|
1973
2072
|
return raw;
|
|
1974
2073
|
});
|
|
1975
2074
|
// Resolve type references in function signatures via ts-morph's type
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -223,6 +223,11 @@ function emitExpr(e, parentPrec) {
|
|
|
223
223
|
}
|
|
224
224
|
case "app": {
|
|
225
225
|
const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app" || a.kind === "methodCall") ? `(${emitExpr(a)})` : emitExpr(a));
|
|
226
|
+
// Datatype constructor (tagged by transform): Lean needs the qualified name
|
|
227
|
+
// `BaseType.variant`; a bare `variant` is an unknown identifier. (Dafny keeps
|
|
228
|
+
// the bare form, so its output is unaffected.)
|
|
229
|
+
if (e.ctorOf)
|
|
230
|
+
return args.length ? `${e.ctorOf}.${e.fn} ${args.join(" ")}` : `${e.ctorOf}.${e.fn}`;
|
|
226
231
|
// SetToSeq → .toArray for Lean (HashSet has native toArray)
|
|
227
232
|
if (e.fn === "SetToSeq" && args.length === 1)
|
|
228
233
|
return `${args[0]}.toArray`;
|
package/tools/dist/lsc.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from "path";
|
|
|
10
10
|
import { extractModule } from "./extract.js";
|
|
11
11
|
import { resolveModule } from "./resolve.js";
|
|
12
12
|
import { narrowModule } from "./narrow.js";
|
|
13
|
+
import { autoHavocModule } from "./autohavoc.js";
|
|
13
14
|
import { transformModuleLean, transformModuleDafny } from "./transform.js";
|
|
14
15
|
import { peepholeModule } from "./peephole.js";
|
|
15
16
|
import { emitLeanFile } from "./lean-emit.js";
|
|
@@ -95,7 +96,10 @@ function main() {
|
|
|
95
96
|
// Resolve: Raw IR → Typed IR
|
|
96
97
|
const resolved = resolveModule(raw);
|
|
97
98
|
// Narrow: Typed IR → Typed IR (rewrites optional-narrowing patterns to someMatch)
|
|
98
|
-
|
|
99
|
+
// auto-havoc (//@ autohavoc): replace unmodellable expressions with arbitrary
|
|
100
|
+
// values so verification rests only on the declared contracts (a sound
|
|
101
|
+
// over-approximation). No-op unless a function opts in.
|
|
102
|
+
const typed = autoHavocModule(narrowModule(resolved));
|
|
99
103
|
const dir = path.dirname(absPath);
|
|
100
104
|
const base = path.basename(filePath, ".ts");
|
|
101
105
|
// ── Dafny backend ─────────────────────────────────────────
|
package/tools/dist/resolve.js
CHANGED
|
@@ -950,7 +950,13 @@ function resolveExpr(e, ctx) {
|
|
|
950
950
|
return { kind: "exists", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
|
|
951
951
|
}
|
|
952
952
|
case "arrayLiteral": {
|
|
953
|
-
|
|
953
|
+
// Thread the expected element type into each element, so a record/union
|
|
954
|
+
// literal in an array resolves to its named datatype rather than an
|
|
955
|
+
// anonymous tuple (mirrors return-position and call-argument records, which
|
|
956
|
+
// get their type via ctx.returnTy). Only narrow when the context type is an
|
|
957
|
+
// array; otherwise leave ctx untouched.
|
|
958
|
+
const elemCtx = ctx.returnTy.kind === "array" ? { ...ctx, returnTy: ctx.returnTy.elem } : ctx;
|
|
959
|
+
const elems = e.elems.map(el => resolveExpr(el, elemCtx));
|
|
954
960
|
const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
|
|
955
961
|
return { kind: "arrayLiteral", elems, ty: { kind: "array", elem: elemTy } };
|
|
956
962
|
}
|
|
@@ -1106,9 +1112,11 @@ function resolveStmt(s, ctx) {
|
|
|
1106
1112
|
// to its underlying type, so array methods / index-assignment on the
|
|
1107
1113
|
// local dispatch correctly (params get the same treatment, see makeParams).
|
|
1108
1114
|
const declTy = expandAlias(resolveTsType(s.tsType, ctx.overrides, s.name), ctx.typeDecls);
|
|
1109
|
-
// Propagate declared type as returnTy so nested record expressions
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1115
|
+
// Propagate declared type as returnTy so nested record expressions resolve
|
|
1116
|
+
// union variants correctly (e.g., EffectState → mode: EffectMode → { kind:
|
|
1117
|
+
// 'Idle' }). Arrays too, so `const xs: Foo[] = [{...}]` threads the element
|
|
1118
|
+
// type into the array literal (see the arrayLiteral case).
|
|
1119
|
+
const initCtx = (declTy.kind === "user" || declTy.kind === "array") ? { ...ctx, returnTy: declTy } : ctx;
|
|
1112
1120
|
const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
|
|
1113
1121
|
let ty;
|
|
1114
1122
|
if (isUnmodeledTy(declTy, ctx.typeDecls) && !isUnmodeledTy(init.ty, ctx.typeDecls)) {
|
|
@@ -1465,6 +1473,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns
|
|
|
1465
1473
|
decreases: fn.decreases ? resolveSpec(fn.decreases, requiresCtx) : null,
|
|
1466
1474
|
isPure: opts?.forcePure !== undefined ? opts.forcePure : pureFns.has(fn.name),
|
|
1467
1475
|
forcePure: fn.pure,
|
|
1476
|
+
autohavoc: fn.autohavoc,
|
|
1468
1477
|
body: resolveBlock(fn.body, bodyCtx),
|
|
1469
1478
|
};
|
|
1470
1479
|
}
|
package/tools/dist/transform.js
CHANGED
|
@@ -605,12 +605,17 @@ function lowerExpr(e, binds) {
|
|
|
605
605
|
if (nonDiscFields.length === 0) {
|
|
606
606
|
return { kind: "constructor", name: variantName, type: tyName };
|
|
607
607
|
}
|
|
608
|
-
// Constructor with args: match variant field order
|
|
608
|
+
// Constructor with args: match variant field order. Emit a bare `app`
|
|
609
|
+
// (Dafny renders `variantName(args)`, a valid unqualified constructor
|
|
610
|
+
// call — unchanged output) tagged with `ctorOf` so the Lean emitter,
|
|
611
|
+
// which CANNOT take a bare constructor name, qualifies it as
|
|
612
|
+
// `BaseType.variantName args`. Use the BASE type name (no generic args):
|
|
613
|
+
// `Result.true_` is valid in Lean; `Result<Model,Err>.true_` is not.
|
|
609
614
|
const args = variant.fields.map(vf => {
|
|
610
615
|
const ef = nonDiscFields.find(f => f.name === vf.name);
|
|
611
616
|
return ef ? lowerExpr(ef.value, binds) : { kind: "var", name: "None" };
|
|
612
617
|
});
|
|
613
|
-
return { kind: "app", fn: variantName, args };
|
|
618
|
+
return { kind: "app", fn: variantName, args, ctorOf: baseName };
|
|
614
619
|
}
|
|
615
620
|
}
|
|
616
621
|
}
|