lemmascript 0.5.18 → 0.5.20

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 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). 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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,125 @@
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.repeat": { ret: STRING, pure: true },
91
+ "string.slice": { ret: STRING, pure: true },
92
+ "string.substring": { ret: STRING, pure: true },
93
+ "string.split": { pure: true,
94
+ ret: () => ({ kind: "array", elem: { kind: "string" } }) },
95
+ "string.includes": { ret: BOOL, pure: true },
96
+ "string.startsWith": { ret: BOOL, pure: true },
97
+ "string.endsWith": { ret: BOOL, pure: true },
98
+ // ── map ───────────────────────────────────────────────────
99
+ "map.get": { pure: true, argIsKey: true,
100
+ ret: (objTy, _args, c) => objTy.kind !== "map" ? UNKNOWN
101
+ : c.inSpec ? objTy.value : { kind: "optional", inner: objTy.value } },
102
+ "map.has": { ret: BOOL, pure: true, argIsKey: true },
103
+ "map.set": { ret: self, pure: false },
104
+ "map.delete": { ret: self, pure: false },
105
+ "map.keys": { ret: () => UNKNOWN, pure: true },
106
+ "map.values": { ret: () => UNKNOWN, pure: true },
107
+ // ── set ───────────────────────────────────────────────────
108
+ "set.has": { ret: BOOL, pure: true, argIsKey: true },
109
+ "set.add": { ret: self, pure: false },
110
+ "set.delete": { ret: self, pure: false },
111
+ };
112
+ /** Recognize a builtin method call by receiver type kind + method name.
113
+ * Called by resolve (once per call node); everything downstream reads the
114
+ * stamped id. `hasOwn`, not `in`: `method` comes from user source, and `in`
115
+ * would match inherited names (`constructor`, `toString`). */
116
+ export function recognizeBuiltin(objTy, method) {
117
+ const k = objTy.kind;
118
+ if (k !== "array" && k !== "string" && k !== "map" && k !== "set")
119
+ return null;
120
+ const id = `${k}.${method}`;
121
+ return Object.hasOwn(BUILTINS, id) ? id : null;
122
+ }
123
+ export function builtinSpec(id) {
124
+ return BUILTINS[id];
125
+ }
@@ -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
+ }