lemmascript 0.5.18 → 0.5.19
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/autohavoc.js +2 -0
- package/tools/dist/builtins.js +124 -0
- package/tools/dist/condition-facts.js +364 -0
- package/tools/dist/dafny-emit.js +162 -35
- package/tools/dist/extract.js +108 -21
- package/tools/dist/info-command.js +68 -0
- package/tools/dist/ir.js +27 -7
- package/tools/dist/lean-emit.js +27 -16
- package/tools/dist/lsc.js +53 -5
- package/tools/dist/names.js +10 -6
- package/tools/dist/narrow.js +296 -677
- package/tools/dist/peephole.js +12 -94
- package/tools/dist/rawir.js +15 -1
- package/tools/dist/resolve.js +182 -203
- package/tools/dist/specparser.js +21 -17
- package/tools/dist/transform.js +298 -108
- package/tools/dist/typedecls.js +59 -0
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ See the external case studies:
|
|
|
34
34
|
- **[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.
|
|
35
35
|
- **[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.
|
|
36
36
|
- **[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.
|
|
37
|
-
- **[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).
|
|
37
|
+
- **[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). The deliberate **inverse** of [quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript): bookings **contend** for shared inventory, so the invariant is a **bound** — for every slot `j`, `confirmedCount(bookings, j) <= slots[j].capacity` — and the *same* `domain.ts` runs in the browser and the Durable Object **server-authoritatively**, never overselling under contention (no optimistic client apply). Proven: no overbooking across `tryBook`/cancel, accept-iff-room, an **idempotent three-way `tryBook`** (a retry reads as success, not rejection), cancellation frees seats, replay determinism, and order-invariance — `confirmedCountPerm` / `hasRoomPermInvariant` show availability depends only on the *multiset* of the booking log, via the same `perm(...)` predicate Quorum drove into LemmaScript. 80 Dafny VCs, 0 errors. Trust boundary: auth, the React UI, WebSocket/DO/D1 I/O, email, slot date/time labeling, and abuse/rate-limiting. Dafny only.
|
|
38
38
|
- **[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.
|
|
39
39
|
- **[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.
|
|
40
40
|
- **[infisical-lemmascript](https://github.com/midspiral/infisical-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the permission-boundary glob set-containment check in [Infisical](https://github.com/Infisical/infisical) — the privilege-escalation guard that stops a role from delegating broader secret-path access than it holds. The loop-bearing `segmentMatch` compiles to a Dafny `method` (unnameable in specs), so soundness is proven by **refinement**: the method certifies `result == segMatchSpec` (a pure mirror), and a standalone lemma proves that spec sound against a hand-written segment-glob semantics — *if it returns true, every path the subset glob matches, the parent matches too*. 12 Dafny VCs, 0 errors; drove a toolchain fix (recursive `method`s now carry a method-level `//@ decreases`). Dafny only.
|
package/package.json
CHANGED
package/tools/dist/autohavoc.js
CHANGED
|
@@ -29,6 +29,7 @@ function isBadNode(e) {
|
|
|
29
29
|
switch (e.kind) {
|
|
30
30
|
case "var":
|
|
31
31
|
case "num":
|
|
32
|
+
case "bigint":
|
|
32
33
|
case "str":
|
|
33
34
|
case "bool":
|
|
34
35
|
case "havoc":
|
|
@@ -347,6 +348,7 @@ function rewriteExpr(e, hoisted) {
|
|
|
347
348
|
switch (e.kind) {
|
|
348
349
|
case "var":
|
|
349
350
|
case "num":
|
|
351
|
+
case "bigint":
|
|
350
352
|
case "str":
|
|
351
353
|
case "bool":
|
|
352
354
|
case "havoc": return e;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin registry — the single classification table for builtin methods on
|
|
3
|
+
* collection/string receivers (DESIGN_LS_IN_LS.md §3).
|
|
4
|
+
*
|
|
5
|
+
* One stable `BuiltinId` per supported operation, one entry per identity.
|
|
6
|
+
* `resolve` recognizes `(receiver type kind, method name)` once — via
|
|
7
|
+
* `recognizeBuiltin` — and stamps the typed call node (and optChain call
|
|
8
|
+
* steps) with the id. Downstream, `narrow` reads `pure` and `transform`
|
|
9
|
+
* reads `hof`/`intArgPositions` from the stamp; neither re-recognizes by
|
|
10
|
+
* spelling. The emitters are deliberately NOT keyed on the stamp: they
|
|
11
|
+
* dispatch on `(objTy, method)` over the backend IR, where transform
|
|
12
|
+
* legitimately synthesizes method calls with no source-level identity.
|
|
13
|
+
*
|
|
14
|
+
* The registry holds classification only — no lowerings. Namespace builtins
|
|
15
|
+
* (`Math.*`, `Array.isArray`) are recognized syntactically in resolve and
|
|
16
|
+
* are not registry entries: they are not methods on receiver values.
|
|
17
|
+
*
|
|
18
|
+
* Classification values mirror the historical per-pass lists exactly
|
|
19
|
+
* (byte-for-byte gauntlet compatibility), including their quirks — e.g.
|
|
20
|
+
* `array.shift` returns the element type un-wrapped and `array.with` types
|
|
21
|
+
* as `unknown`. Widen such bits deliberately, with tests.
|
|
22
|
+
*/
|
|
23
|
+
const UNKNOWN = { kind: "unknown" };
|
|
24
|
+
const BOOL = () => ({ kind: "bool" });
|
|
25
|
+
const INT = () => ({ kind: "int" });
|
|
26
|
+
const STRING = () => ({ kind: "string" });
|
|
27
|
+
const self = (objTy) => objTy;
|
|
28
|
+
const elem = (objTy) => objTy.kind === "array" ? objTy.elem : UNKNOWN;
|
|
29
|
+
const optElem = (objTy) => objTy.kind === "array" ? { kind: "optional", inner: objTy.elem } : UNKNOWN;
|
|
30
|
+
/** The table is the single source: each key is `<RecvKind>.<method>`, which
|
|
31
|
+
* *is* the `BuiltinId`. */
|
|
32
|
+
export const BUILTINS = {
|
|
33
|
+
// ── array ─────────────────────────────────────────────────
|
|
34
|
+
"array.includes": { ret: BOOL, pure: true,
|
|
35
|
+
intArgPositions: [1], argIsKey: true },
|
|
36
|
+
"array.indexOf": { ret: INT, pure: true,
|
|
37
|
+
intArgPositions: [1] },
|
|
38
|
+
"array.shift": { ret: elem, pure: false },
|
|
39
|
+
"array.pop": { ret: optElem, pure: false },
|
|
40
|
+
"array.push": { ret: self, pure: false },
|
|
41
|
+
"array.unshift": { ret: self, pure: false },
|
|
42
|
+
"array.concat": { ret: self, pure: true },
|
|
43
|
+
"array.sort": { ret: self, pure: false,
|
|
44
|
+
hof: { shape: "comparator" } },
|
|
45
|
+
"array.filter": { ret: self, pure: false,
|
|
46
|
+
hof: { shape: "unary" } },
|
|
47
|
+
"array.every": { ret: BOOL, pure: false,
|
|
48
|
+
hof: { shape: "unary" } },
|
|
49
|
+
"array.some": { ret: BOOL, pure: false,
|
|
50
|
+
hof: { shape: "unary" } },
|
|
51
|
+
"array.reduce": { pure: false,
|
|
52
|
+
hof: { shape: "reduce" },
|
|
53
|
+
ret: (_objTy, args) => args.length === 2 ? args[1].ty : UNKNOWN },
|
|
54
|
+
"array.find": { ret: optElem, pure: false,
|
|
55
|
+
hof: { shape: "unary" } },
|
|
56
|
+
"array.findLast": { ret: optElem, pure: false,
|
|
57
|
+
hof: { shape: "unary" } },
|
|
58
|
+
"array.findIndex": { ret: INT, pure: false,
|
|
59
|
+
hof: { shape: "unary" } },
|
|
60
|
+
"array.findLastIndex": { ret: INT,
|
|
61
|
+
pure: false, hof: { shape: "unary" } },
|
|
62
|
+
"array.flat": { pure: true,
|
|
63
|
+
ret: (objTy) => objTy.kind === "array" && objTy.elem.kind === "array"
|
|
64
|
+
? { kind: "array", elem: objTy.elem.elem } : UNKNOWN },
|
|
65
|
+
"array.slice": { ret: self, pure: true },
|
|
66
|
+
"array.join": { pure: true,
|
|
67
|
+
ret: (objTy) => objTy.kind === "array" && objTy.elem.kind === "string"
|
|
68
|
+
? STRING() : UNKNOWN },
|
|
69
|
+
"array.map": { pure: false,
|
|
70
|
+
hof: { shape: "unary" },
|
|
71
|
+
// Prefer the lambda's declared return type (handles multi-statement bodies
|
|
72
|
+
// where body[0] is an `if`, not a `return`); fall back to the body's return.
|
|
73
|
+
ret: (_objTy, args) => {
|
|
74
|
+
if (args.length >= 1 && args[0].kind === "lambda") {
|
|
75
|
+
const lam = args[0];
|
|
76
|
+
const retTy = lam.ty.kind === "fn" ? lam.ty.result
|
|
77
|
+
: lam.body.length > 0 && lam.body[0].kind === "return" ? lam.body[0].value.ty : UNKNOWN;
|
|
78
|
+
return { kind: "array", elem: retTy };
|
|
79
|
+
}
|
|
80
|
+
return UNKNOWN;
|
|
81
|
+
} },
|
|
82
|
+
"array.with": { ret: () => UNKNOWN, pure: true,
|
|
83
|
+
intArgPositions: [0] },
|
|
84
|
+
// ── string ────────────────────────────────────────────────
|
|
85
|
+
"string.trim": { ret: STRING, pure: true },
|
|
86
|
+
"string.trimEnd": { ret: STRING, pure: true },
|
|
87
|
+
"string.trimStart": { ret: STRING, pure: true },
|
|
88
|
+
"string.toLowerCase": { ret: STRING, pure: true },
|
|
89
|
+
"string.toUpperCase": { ret: STRING, pure: true },
|
|
90
|
+
"string.slice": { ret: STRING, pure: true },
|
|
91
|
+
"string.substring": { ret: STRING, pure: true },
|
|
92
|
+
"string.split": { pure: true,
|
|
93
|
+
ret: () => ({ kind: "array", elem: { kind: "string" } }) },
|
|
94
|
+
"string.includes": { ret: BOOL, pure: true },
|
|
95
|
+
"string.startsWith": { ret: BOOL, pure: true },
|
|
96
|
+
"string.endsWith": { ret: BOOL, pure: true },
|
|
97
|
+
// ── map ───────────────────────────────────────────────────
|
|
98
|
+
"map.get": { pure: true, argIsKey: true,
|
|
99
|
+
ret: (objTy, _args, c) => objTy.kind !== "map" ? UNKNOWN
|
|
100
|
+
: c.inSpec ? objTy.value : { kind: "optional", inner: objTy.value } },
|
|
101
|
+
"map.has": { ret: BOOL, pure: true, argIsKey: true },
|
|
102
|
+
"map.set": { ret: self, pure: false },
|
|
103
|
+
"map.delete": { ret: self, pure: false },
|
|
104
|
+
"map.keys": { ret: () => UNKNOWN, pure: true },
|
|
105
|
+
"map.values": { ret: () => UNKNOWN, pure: true },
|
|
106
|
+
// ── set ───────────────────────────────────────────────────
|
|
107
|
+
"set.has": { ret: BOOL, pure: true, argIsKey: true },
|
|
108
|
+
"set.add": { ret: self, pure: false },
|
|
109
|
+
"set.delete": { ret: self, pure: false },
|
|
110
|
+
};
|
|
111
|
+
/** Recognize a builtin method call by receiver type kind + method name.
|
|
112
|
+
* Called by resolve (once per call node); everything downstream reads the
|
|
113
|
+
* stamped id. `hasOwn`, not `in`: `method` comes from user source, and `in`
|
|
114
|
+
* would match inherited names (`constructor`, `toString`). */
|
|
115
|
+
export function recognizeBuiltin(objTy, method) {
|
|
116
|
+
const k = objTy.kind;
|
|
117
|
+
if (k !== "array" && k !== "string" && k !== "map" && k !== "set")
|
|
118
|
+
return null;
|
|
119
|
+
const id = `${k}.${method}`;
|
|
120
|
+
return Object.hasOwn(BUILTINS, id) ? id : null;
|
|
121
|
+
}
|
|
122
|
+
export function builtinSpec(id) {
|
|
123
|
+
return BUILTINS[id];
|
|
124
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* condition-facts — what a condition establishes (DESIGN_LS_IN_LS.md §4).
|
|
3
|
+
*
|
|
4
|
+
* One module owns condition semantics: detecting the facts a condition
|
|
5
|
+
* establishes (optional presence, discriminant variants, synth array-union
|
|
6
|
+
* branches, `||`-chain None-detectors, in-bounds) and materializing them as
|
|
7
|
+
* IR (`someMatch` / `tagMatch` / bounds conditionals). `narrow`'s rules are
|
|
8
|
+
* positional drivers over these helpers; `resolve` consults the same
|
|
9
|
+
* detection for branch typing.
|
|
10
|
+
*
|
|
11
|
+
* Analysis shape: a condition yields ONE leading fact plus a residual
|
|
12
|
+
* condition (`leadingPresent` on `&&`-chains); nesting comes from the
|
|
13
|
+
* drivers re-walking the residual, so recursion order carries the
|
|
14
|
+
* dependency between chained facts (`x !== undefined && x.f !== undefined`
|
|
15
|
+
* reifies the second check inside the match introduced by the first). This
|
|
16
|
+
* realizes §4's ordered-fact list iteratively rather than as a returned
|
|
17
|
+
* list — same semantics, no intermediate structure.
|
|
18
|
+
*
|
|
19
|
+
* State is explicit (§6.1): detection that mints binders or consults type
|
|
20
|
+
* declarations takes a `CondCtx`; nothing module-level.
|
|
21
|
+
*/
|
|
22
|
+
import { freshName } from "./names.js";
|
|
23
|
+
import { unionDeclOfTy, discriminantOf } from "./typedecls.js";
|
|
24
|
+
export function freshOcBinder(ctx) {
|
|
25
|
+
return freshName(`_oc${ctx.oc.n++}_val`);
|
|
26
|
+
}
|
|
27
|
+
/** Pure access paths: var(x) or field(purePath, name). Walks down to the
|
|
28
|
+
* var root, collecting field names. Returns `_root_field1..._val` (or
|
|
29
|
+
* `_root_val` for a bare var); null for non-path shapes. */
|
|
30
|
+
export function binderHintFor(e) {
|
|
31
|
+
const fields = [];
|
|
32
|
+
let cur = e;
|
|
33
|
+
while (cur.kind === "field") {
|
|
34
|
+
fields.unshift(cur.field);
|
|
35
|
+
cur = cur.obj;
|
|
36
|
+
}
|
|
37
|
+
if (cur.kind !== "var")
|
|
38
|
+
return null;
|
|
39
|
+
// \result is stored as the IR var name "\\result"; sanitize for a valid identifier.
|
|
40
|
+
const root = cur.name === "\\result" ? "result" : cur.name;
|
|
41
|
+
return fields.length === 0 ? `_${root}_val` : `_${root}_${fields.join("_")}_val`;
|
|
42
|
+
}
|
|
43
|
+
/** Detect optional checks: `e !== undefined`, `e === undefined`, `!e`, or a
|
|
44
|
+
* bare optional `e` — for a pure-access-path optional-typed `e`. `!e` and
|
|
45
|
+
* bare `e` are truthiness forms. Following TS, only pure access paths
|
|
46
|
+
* narrow; complex scrutinees return null. */
|
|
47
|
+
export function presentFact(cond) {
|
|
48
|
+
// `!e` where e is optional — a truthiness form: false iff e is absent OR its
|
|
49
|
+
// inner value is itself falsy (so `Some(0)`/`Some("")` count as falsy too).
|
|
50
|
+
if (cond.kind === "unop" && cond.op === "!" && cond.expr.ty.kind === "optional") {
|
|
51
|
+
const e = cond.expr;
|
|
52
|
+
const innerTy = cond.expr.ty.inner;
|
|
53
|
+
const hint = binderHintFor(e);
|
|
54
|
+
if (hint === null)
|
|
55
|
+
return null;
|
|
56
|
+
return { scrutinee: e, innerTy, negated: true, binder: freshName(hint), truthiness: true };
|
|
57
|
+
}
|
|
58
|
+
if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "===")) {
|
|
59
|
+
// Bare optional truthiness: `if (e)` where e: T | undefined — true iff e is
|
|
60
|
+
// present AND its inner value is truthy.
|
|
61
|
+
if (cond.ty.kind === "optional") {
|
|
62
|
+
const hint = binderHintFor(cond);
|
|
63
|
+
if (hint === null)
|
|
64
|
+
return null;
|
|
65
|
+
return { scrutinee: cond, innerTy: cond.ty.inner, negated: false, binder: freshName(hint), truthiness: true };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Explicit `e === undefined` / `e !== undefined` — a pure presence check,
|
|
70
|
+
// independent of the inner value (so NOT a truthiness form).
|
|
71
|
+
let e = null;
|
|
72
|
+
if (cond.right.kind === "var" && cond.right.name === "undefined")
|
|
73
|
+
e = cond.left;
|
|
74
|
+
if (cond.left.kind === "var" && cond.left.name === "undefined")
|
|
75
|
+
e = cond.right;
|
|
76
|
+
if (!e || e.ty.kind !== "optional")
|
|
77
|
+
return null;
|
|
78
|
+
const hint = binderHintFor(e);
|
|
79
|
+
if (hint === null)
|
|
80
|
+
return null;
|
|
81
|
+
return { scrutinee: e, innerTy: e.ty.inner, negated: cond.op === "===", binder: freshName(hint), truthiness: false };
|
|
82
|
+
}
|
|
83
|
+
/** Which types have falsy values in JS (`0`, `""`, `false`); everything else
|
|
84
|
+
* (array, user type, …) is always truthy. The single home of that set —
|
|
85
|
+
* transform's `valueTruthyCond` gates on it too. */
|
|
86
|
+
export const isFalsyCapableTy = (ty) => ["int", "nat", "string", "bool"].includes(ty.kind);
|
|
87
|
+
// `Some(0)` / `Some("")` / `Some(false)` are falsy, so a truthiness check
|
|
88
|
+
// (`if (o)`, `!o`, `o ? :`) over a nullable primitive must still test the bound
|
|
89
|
+
// value. Nullable objects/arrays are always truthy, and `!== undefined` is a pure
|
|
90
|
+
// presence check — neither needs the gate.
|
|
91
|
+
export const canBeFalsy = (f) => f.truthiness && isFalsyCapableTy(f.innerTy);
|
|
92
|
+
export const bound = (f) => ({ kind: "var", name: f.binder, ty: f.innerTy });
|
|
93
|
+
// ── `&&`-chain analysis: one leading fact + residual ────────
|
|
94
|
+
/** Pull one `parse`-matching conjunct out of an `&&` tree, returning it plus
|
|
95
|
+
* the tree with that conjunct removed:
|
|
96
|
+
* `(x !== undefined && b) && c` → { check, restCond: b && c }.
|
|
97
|
+
*
|
|
98
|
+
* Search order is shallowest-first, left-biased — both immediate operands
|
|
99
|
+
* before either nested chain — so the conjunct found is not necessarily the
|
|
100
|
+
* source-leftmost one. That is sound because every `parse` passed here
|
|
101
|
+
* (`leadingPresent`, `leadingIsArray`) matches only pure, total checks on an
|
|
102
|
+
* already-typed path, so hoisting one above the others is observationally
|
|
103
|
+
* neutral. The one thing it does change: the residual now sits inside the
|
|
104
|
+
* match arm, so a conjunct that would have run before a failing check no
|
|
105
|
+
* longer runs at all.
|
|
106
|
+
* Harmless while conditions stay pure — revisit if that ever stops holding. */
|
|
107
|
+
export function extractConjunct(cond, parse) {
|
|
108
|
+
if (cond.kind !== "binop" || cond.op !== "&&")
|
|
109
|
+
return null;
|
|
110
|
+
const left = parse(cond.left);
|
|
111
|
+
if (left)
|
|
112
|
+
return { check: left, restCond: cond.right };
|
|
113
|
+
const right = parse(cond.right);
|
|
114
|
+
if (right)
|
|
115
|
+
return { check: right, restCond: cond.left };
|
|
116
|
+
if (cond.left.kind === "binop" && cond.left.op === "&&") {
|
|
117
|
+
const inner = extractConjunct(cond.left, parse);
|
|
118
|
+
if (inner)
|
|
119
|
+
return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
|
|
120
|
+
}
|
|
121
|
+
if (cond.right.kind === "binop" && cond.right.op === "&&") {
|
|
122
|
+
const inner = extractConjunct(cond.right, parse);
|
|
123
|
+
if (inner)
|
|
124
|
+
return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** The positive presence fact an `&&` chain contributes, plus the residual.
|
|
129
|
+
* "Leading" describes the rewrite, not the source: this fact becomes the
|
|
130
|
+
* outermost match, wherever in the chain it was written. */
|
|
131
|
+
export function leadingPresent(cond) {
|
|
132
|
+
return extractConjunct(cond, e => {
|
|
133
|
+
const f = presentFact(e);
|
|
134
|
+
return f && !f.negated ? f : null;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// ── `||`-chain analysis (De Morgan): None-detectors ─────────
|
|
138
|
+
/** Flatten a nested `||` chain into its leaf conditions. */
|
|
139
|
+
export function flattenOr(e) {
|
|
140
|
+
if (e.kind === "binop" && e.op === "||")
|
|
141
|
+
return [...flattenOr(e.left), ...flattenOr(e.right)];
|
|
142
|
+
return [e];
|
|
143
|
+
}
|
|
144
|
+
export function noneDetector(leaf, ctx) {
|
|
145
|
+
// `x?.chain !== lit` — `undefined !== lit` is true when x is None.
|
|
146
|
+
if (leaf.kind === "binop" && leaf.op === "!==") {
|
|
147
|
+
const oc = leaf.left.kind === "optChain" ? leaf.left : leaf.right.kind === "optChain" ? leaf.right : null;
|
|
148
|
+
if (oc && oc.kind === "optChain" && oc.obj.ty.kind === "optional") {
|
|
149
|
+
const hint = binderHintFor(oc.obj);
|
|
150
|
+
if (hint === null)
|
|
151
|
+
return null;
|
|
152
|
+
const binder = freshName(hint);
|
|
153
|
+
const unwrapped = applyChain({ kind: "var", name: binder, ty: oc.obj.ty.inner }, oc.chain);
|
|
154
|
+
restoreDiscriminantFlag(unwrapped, ctx.decls);
|
|
155
|
+
const lit = leaf.left === oc ? leaf.right : leaf.left;
|
|
156
|
+
return { scrutinee: oc.obj, innerTy: oc.obj.ty.inner, binder, residual: { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } } };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// `!x` / `x === undefined`.
|
|
160
|
+
const f = presentFact(leaf);
|
|
161
|
+
if (f && f.negated) {
|
|
162
|
+
const residual = canBeFalsy(f)
|
|
163
|
+
? { kind: "unop", op: "!", expr: { kind: "var", name: f.binder, ty: f.innerTy }, ty: { kind: "bool" } }
|
|
164
|
+
: null;
|
|
165
|
+
return { scrutinee: f.scrutinee, innerTy: f.innerTy, binder: f.binder, residual };
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** Apply an optional chain's steps (field / index / call) to a base expr. */
|
|
170
|
+
export function applyChain(body, chain) {
|
|
171
|
+
for (const step of chain) {
|
|
172
|
+
if (step.kind === "field")
|
|
173
|
+
body = { kind: "field", obj: body, field: step.name, ty: step.ty };
|
|
174
|
+
else if (step.kind === "index")
|
|
175
|
+
body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
|
|
176
|
+
else
|
|
177
|
+
body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind,
|
|
178
|
+
...(step.builtinId ? { builtinId: step.builtinId } : {}) };
|
|
179
|
+
}
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
/** `applyChain` rebuilds a field access without the `isDiscriminant` flag
|
|
183
|
+
* resolve sets on a direct `x.disc`; restore it when the unwrapped access
|
|
184
|
+
* is the binder union's discriminant, so the guard feeds discriminant
|
|
185
|
+
* narrowing. (The single home of a fixup formerly duplicated per rule.) */
|
|
186
|
+
export function restoreDiscriminantFlag(unwrapped, decls) {
|
|
187
|
+
if (unwrapped.kind === "field" &&
|
|
188
|
+
discriminantOf(decls, unwrapped.obj.ty) === unwrapped.field) {
|
|
189
|
+
unwrapped.isDiscriminant = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ── In-bounds facts ─────────────────────────────────────────
|
|
193
|
+
/** `0 <= idx && idx < arr.length` — the in-bounds guard for an array index. */
|
|
194
|
+
export function arrayBoundsCond(arr, idx) {
|
|
195
|
+
const len = { kind: "field", obj: arr, field: "length", ty: { kind: "int" } };
|
|
196
|
+
const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
|
|
197
|
+
const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
|
|
198
|
+
return { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
|
|
199
|
+
}
|
|
200
|
+
// ── Map-membership facts ────────────────────────────────────
|
|
201
|
+
/** Minimal structural equality on the IR shapes we narrow against: var, field
|
|
202
|
+
* chain, and index (with pure key). Enough to recognize `m[k]` on both sides
|
|
203
|
+
* of a `k in m ? m[k] : default` ternary. */
|
|
204
|
+
export function exprEqual(a, b) {
|
|
205
|
+
if (a.kind !== b.kind)
|
|
206
|
+
return false;
|
|
207
|
+
if (a.kind === "var" && b.kind === "var")
|
|
208
|
+
return a.name === b.name;
|
|
209
|
+
if (a.kind === "field" && b.kind === "field")
|
|
210
|
+
return a.field === b.field && exprEqual(a.obj, b.obj);
|
|
211
|
+
if (a.kind === "index" && b.kind === "index")
|
|
212
|
+
return exprEqual(a.obj, b.obj) && exprEqual(a.idx, b.idx);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/** Produce a reader-friendly binder hint for `m[k]` when both m and k are
|
|
216
|
+
* access-path shaped (var / field chain). Falls back to a generic counter
|
|
217
|
+
* name for computed keys. */
|
|
218
|
+
export function binderHintForMapAccess(m, k, ctx) {
|
|
219
|
+
const mHint = binderHintFor(m);
|
|
220
|
+
const kHint = binderHintFor(k);
|
|
221
|
+
if (mHint && kHint) {
|
|
222
|
+
// mHint is `_m_val`, kHint is `_k_val` — stitch into `_m_k_val`.
|
|
223
|
+
const mStem = mHint.replace(/_val$/, "");
|
|
224
|
+
const kStem = kHint.replace(/^_/, "").replace(/_val$/, "");
|
|
225
|
+
return freshName(`${mStem}_${kStem}_val`);
|
|
226
|
+
}
|
|
227
|
+
return freshOcBinder(ctx);
|
|
228
|
+
}
|
|
229
|
+
/** A "narrowable path" is a var or a chain of field accesses rooted at a var
|
|
230
|
+
* — i.e., pure and structurally addressable, so transforms can substitute
|
|
231
|
+
* occurrences inside a matched arm without worrying about re-evaluation. */
|
|
232
|
+
export function isNarrowablePath(e) {
|
|
233
|
+
if (e.kind === "var")
|
|
234
|
+
return true;
|
|
235
|
+
if (e.kind === "field")
|
|
236
|
+
return isNarrowablePath(e.obj);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
/** Detect `Array.isArray(<path>)` where `<path>` is a narrowable path whose
|
|
240
|
+
* type is a synthesized array-union (discriminant `"__isArray__"`). */
|
|
241
|
+
export function isArrayFact(call, ctx) {
|
|
242
|
+
if (call.kind !== "call")
|
|
243
|
+
return null;
|
|
244
|
+
if (call.fn.kind !== "field" || call.fn.field !== "isArray")
|
|
245
|
+
return null;
|
|
246
|
+
if (call.fn.obj.kind !== "var" || call.fn.obj.name !== "Array")
|
|
247
|
+
return null;
|
|
248
|
+
if (call.args.length !== 1)
|
|
249
|
+
return null;
|
|
250
|
+
const arg = call.args[0];
|
|
251
|
+
if (!isNarrowablePath(arg) || arg.ty.kind !== "user")
|
|
252
|
+
return null;
|
|
253
|
+
const decl = unionDeclOfTy(ctx.decls, arg.ty);
|
|
254
|
+
if (decl?.discriminant !== "__isArray__")
|
|
255
|
+
return null;
|
|
256
|
+
return { scrutinee: arg, typeName: arg.ty.name, variant: "ArrayBranch" };
|
|
257
|
+
}
|
|
258
|
+
/** Detect `typeof <path> === "string"` where `<path>`'s type is a synth array-
|
|
259
|
+
* union (`U | T[]`) AND its `NonArrayBranch` payload `U` is itself `string`.
|
|
260
|
+
* The runtime `=== "string"` test matches that branch only when `U` is string —
|
|
261
|
+
* for any other non-array payload (`number | T[]`, …) it never holds, so we must
|
|
262
|
+
* NOT narrow. Returns the `NonArrayBranch` variant; the dual of `Array.isArray`. */
|
|
263
|
+
export function typeofStringFact(e, ctx) {
|
|
264
|
+
if (e.kind !== "binop" || e.op !== "===")
|
|
265
|
+
return null;
|
|
266
|
+
const tof = e.left.kind === "unop" && e.left.op === "typeof" ? e.left.expr
|
|
267
|
+
: e.right.kind === "unop" && e.right.op === "typeof" ? e.right.expr : null;
|
|
268
|
+
const lit = e.left.kind === "str" ? e.left.value : e.right.kind === "str" ? e.right.value : null;
|
|
269
|
+
if (!tof || lit !== "string")
|
|
270
|
+
return null;
|
|
271
|
+
if (!isNarrowablePath(tof) || tof.ty.kind !== "user")
|
|
272
|
+
return null;
|
|
273
|
+
const decl = unionDeclOfTy(ctx.decls, tof.ty);
|
|
274
|
+
if (decl?.discriminant !== "__isArray__")
|
|
275
|
+
return null;
|
|
276
|
+
const valTy = decl.variants?.find(v => v.name === "NonArrayBranch")?.fields.find(f => f.name === "val")?.type;
|
|
277
|
+
if (valTy?.kind !== "string")
|
|
278
|
+
return null; // guard: the non-array branch must actually be `string`
|
|
279
|
+
return { scrutinee: tof, typeName: tof.ty.name, variant: "NonArrayBranch" };
|
|
280
|
+
}
|
|
281
|
+
/** Leading `Array.isArray(path)` fact of an `&&` chain (positive form only — a
|
|
282
|
+
* negated `!Array.isArray(...)` would narrow to the wrong variant for then-body
|
|
283
|
+
* consumers, so those are left to the untouched-conditional path). */
|
|
284
|
+
export function leadingIsArray(cond, ctx) {
|
|
285
|
+
return extractConjunct(cond, e => isArrayFact(e, ctx));
|
|
286
|
+
}
|
|
287
|
+
/** Detect `x.kind === "variant"`, `'key' in x`, or `Array.isArray(x)` (synth
|
|
288
|
+
* array-union) as a positive discriminant check on a bare-var scrutinee. */
|
|
289
|
+
export function variantFact(cond, ctx) {
|
|
290
|
+
// Pattern: x.discriminant === "variant"
|
|
291
|
+
if (cond.kind === "binop" && cond.op === "===" && cond.right.kind === "str" &&
|
|
292
|
+
cond.left.kind === "field" && cond.left.isDiscriminant &&
|
|
293
|
+
cond.left.obj.kind === "var" && cond.left.obj.ty.kind === "user") {
|
|
294
|
+
return { scrutinee: cond.left.obj, typeName: cond.left.obj.ty.name, variant: cond.right.value };
|
|
295
|
+
}
|
|
296
|
+
// Pattern: 'key' in x — narrows x to the unique variant containing `key`.
|
|
297
|
+
if (cond.kind === "binop" && cond.op === "in" &&
|
|
298
|
+
cond.left.kind === "str" && cond.right.kind === "var" &&
|
|
299
|
+
cond.right.ty.kind === "user") {
|
|
300
|
+
const key = cond.left.value;
|
|
301
|
+
const typeName = cond.right.ty.name;
|
|
302
|
+
const decl = unionDeclOfTy(ctx.decls, cond.right.ty);
|
|
303
|
+
if (decl?.variants) {
|
|
304
|
+
const matches = decl.variants.filter(v => v.fields.some(f => f.name === key));
|
|
305
|
+
if (matches.length === 1) {
|
|
306
|
+
return { scrutinee: cond.right, typeName, variant: matches[0].name };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Pattern: Array.isArray(x) — narrows x to the ArrayBranch variant of a
|
|
311
|
+
// synthesized array-union (discriminant "__isArray__"). Statement-level
|
|
312
|
+
// discriminant chains (`if (Array.isArray(x)) {...} else if (...)`) still
|
|
313
|
+
// require a bare-var scrutinee since the existing var-name-keyed
|
|
314
|
+
// replacement machinery in transform.ts only handles that shape; path
|
|
315
|
+
// scrutinees (e.g. `m.content`) are handled exclusively by the
|
|
316
|
+
// conditional/expression tagMatch drivers.
|
|
317
|
+
const arrCheck = isArrayFact(cond, ctx);
|
|
318
|
+
if (arrCheck && arrCheck.scrutinee.kind === "var") {
|
|
319
|
+
return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: arrCheck.variant };
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
/** Detect `x.kind !== "variant"` (negative discriminant check) or
|
|
324
|
+
* `!Array.isArray(x)` (synth array-union, narrows to NonArrayBranch). */
|
|
325
|
+
export function negVariantFact(cond, ctx) {
|
|
326
|
+
if (cond.kind === "binop" && cond.op === "!==" && cond.right.kind === "str" &&
|
|
327
|
+
cond.left.kind === "field" && cond.left.isDiscriminant &&
|
|
328
|
+
cond.left.obj.kind === "var" && cond.left.obj.ty.kind === "user") {
|
|
329
|
+
return { scrutinee: cond.left.obj, typeName: cond.left.obj.ty.name, variant: cond.right.value };
|
|
330
|
+
}
|
|
331
|
+
// Pattern: !Array.isArray(x) — narrows x to the NonArrayBranch variant.
|
|
332
|
+
// Same var-scrutinee restriction as variantFact.
|
|
333
|
+
if (cond.kind === "unop" && cond.op === "!") {
|
|
334
|
+
const arrCheck = isArrayFact(cond.expr, ctx);
|
|
335
|
+
if (arrCheck && arrCheck.scrutinee.kind === "var") {
|
|
336
|
+
return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: "NonArrayBranch" };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
// ── Materializers ───────────────────────────────────────────
|
|
342
|
+
/** Reify a presence fact in statement position: the falsy gate (for
|
|
343
|
+
* truthiness checks over falsy-capable inners) tests the bound value inside
|
|
344
|
+
* the Some arm, routing falsy inners to `none`. */
|
|
345
|
+
export function presentMatchStmts(f, some, none) {
|
|
346
|
+
return {
|
|
347
|
+
kind: "someMatch",
|
|
348
|
+
scrutinee: f.scrutinee, binderTy: f.innerTy,
|
|
349
|
+
binder: f.binder,
|
|
350
|
+
someBody: canBeFalsy(f) ? [{ kind: "if", cond: bound(f), then: some, else: none }] : some,
|
|
351
|
+
noneBody: none,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** Reify a presence fact in expression position; same falsy gate. */
|
|
355
|
+
export function presentMatchExpr(f, some, none, ty) {
|
|
356
|
+
return {
|
|
357
|
+
kind: "someMatch",
|
|
358
|
+
scrutinee: f.scrutinee, binderTy: f.innerTy,
|
|
359
|
+
binder: f.binder,
|
|
360
|
+
someBody: canBeFalsy(f) ? { kind: "conditional", cond: bound(f), then: some, else: none, ty } : some,
|
|
361
|
+
noneBody: none,
|
|
362
|
+
ty,
|
|
363
|
+
};
|
|
364
|
+
}
|