lemmascript 0.4.0 → 0.5.0

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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `lsc info` — emit a JSON summary of verified functions in a TS file.
3
+ *
4
+ * Pipeline: extract only (no resolve/transform/emit). Walks Raw IR for
5
+ * top-level functions and class methods, preserving the original `//@ `
6
+ * source text for clauses (no specparser round-trip).
7
+ *
8
+ * Output: `foo.ts.json` next to `foo.ts`, with shape:
9
+ * { method: { sig, requires, ensures, decreases }, ... }
10
+ * Class methods key as `ClassName.method`.
11
+ */
12
+ import { writeFileSync } from "fs";
13
+ import { parseTsType, tyToCanonical } from "./types.js";
14
+ function renderSig(fn) {
15
+ const params = fn.params.map(p => `${p.name}: ${tyToCanonical(parseTsType(p.tsType))}`).join(", ");
16
+ return `(${params}): ${tyToCanonical(parseTsType(fn.returnType))}`;
17
+ }
18
+ function fnToInfo(fn) {
19
+ return {
20
+ sig: renderSig(fn),
21
+ requires: fn.requires,
22
+ ensures: fn.ensures,
23
+ decreases: fn.decreases,
24
+ };
25
+ }
26
+ export function runInfo(raw, outPath) {
27
+ const out = {};
28
+ for (const fn of raw.functions) {
29
+ out[fn.name] = fnToInfo(fn);
30
+ }
31
+ for (const cls of raw.classes) {
32
+ for (const m of cls.methods) {
33
+ out[`${cls.name}.${m.name}`] = fnToInfo(m);
34
+ }
35
+ }
36
+ writeFileSync(outPath, JSON.stringify(out, null, 2) + "\n");
37
+ console.log(`Wrote ${outPath}`);
38
+ }
@@ -31,6 +31,15 @@ function tyToLean(ty) {
31
31
  return inner.includes(" ") ? `Option (${inner})` : `Option ${inner}`;
32
32
  }
33
33
  case "user": return ty.name;
34
+ case "fn": {
35
+ const params = ty.params.map(p => {
36
+ const s = tyToLean(p);
37
+ return s.includes(" ") ? `(${s})` : s;
38
+ });
39
+ const ret = tyToLean(ty.result);
40
+ const retStr = ret.includes(" ") ? `(${ret})` : ret;
41
+ return [...params, retStr].join(" → ");
42
+ }
34
43
  case "unknown": return "_";
35
44
  }
36
45
  }
@@ -45,6 +54,10 @@ const LEAN_KEYWORDS = new Set([
45
54
  "at", "from", "to", "deriving", "extends", "true", "false",
46
55
  ]);
47
56
  function escapeName(name) {
57
+ // \result is carried through the IR as the var name "\\result"; render it
58
+ // as Lean's canonical return-value identifier (matches `return (res : T)`).
59
+ if (name === "\\result")
60
+ return "res";
48
61
  return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
49
62
  }
50
63
  // ── Operator precedence (for parenthesization) ──────────────
@@ -109,6 +122,13 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
109
122
  throw new Error(`Unsupported Lean method call: .${method}() on ${tyKind}`);
110
123
  }
111
124
  // ── Expression emission ─────────────────────────────────────
125
+ // Lean's `∀`/`∃` body extends as far as possible. So `(∃ x, P) <op> Q`
126
+ // (or `∃ x, P → Q`) would parse with the operator absorbed into the body.
127
+ // Wrap a quantifier in parens to terminate its body before the operator.
128
+ function wrapQuantifier(sub, parentPrec) {
129
+ const inner = emitExpr(sub, parentPrec);
130
+ return (sub.kind === "forall" || sub.kind === "exists") ? `(${inner})` : inner;
131
+ }
112
132
  function emitExpr(e, parentPrec) {
113
133
  switch (e.kind) {
114
134
  case "var": return escapeName(e.name);
@@ -163,11 +183,11 @@ function emitExpr(e, parentPrec) {
163
183
  return `${wrap ? `(${recv})` : recv}.contains ${emitExpr(e.left)}`;
164
184
  }
165
185
  const op = e.op === "arrayConcat" ? "++" : e.op;
166
- const s = `${emitExpr(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
186
+ const s = `${wrapQuantifier(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
167
187
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
168
188
  }
169
189
  case "implies": {
170
- const parts = [...e.premises.map(p => emitExpr(p)), emitExpr(e.conclusion)];
190
+ const parts = [...e.premises.map(p => wrapQuantifier(p)), emitExpr(e.conclusion)];
171
191
  const s = parts.join(" → ");
172
192
  return parentPrec !== undefined ? `(${s})` : s;
173
193
  }
@@ -249,6 +269,8 @@ function emitStmt(s, indent) {
249
269
  return `${pad}let mut ${escapeName(s.name)} : ${tyToLean(s.type)} := ${emitExpr(s.value)}`;
250
270
  case "ghostAssign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
251
271
  case "assert": {
272
+ if (s.assumed)
273
+ throw new Error("//@ assume: not supported in Lean backend.");
252
274
  // WPGen.assert needs a Prop; bare Bool expressions (`k in m` → `.contains`,
253
275
  // method calls, vars) lack a matching WPGen instance and silently fall back
254
276
  // to WPGen.default, which drops the assertion. Coerce to Prop via `= true`.
@@ -399,6 +421,10 @@ function emitDecl(d) {
399
421
  throw new Error(`Lean class support not yet implemented: ${d.name}`);
400
422
  case "const":
401
423
  return `def ${escapeName(d.name)} : ${tyToLean(d.type)} := ${emitExpr(d.value)}`;
424
+ case "extern":
425
+ // Lean: emit an opaque function declaration. The user is expected to
426
+ // provide an axiomatic body or a stub in the companion spec file.
427
+ throw new Error(`Lean extern support not yet implemented: ${d.name}`);
402
428
  }
403
429
  }
404
430
  /** Emit a pure expression with indented if/match blocks. */
package/tools/dist/lsc.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * lsc — LemmaScript compiler CLI
4
4
  *
5
- * Pipeline: extract → resolve → transform → emit
5
+ * Pipeline: extract → resolve → narrow → transform → peephole → emit
6
6
  */
7
7
  import { Project, ScriptTarget } from "ts-morph";
8
8
  import { existsSync } from "fs";
@@ -16,6 +16,7 @@ import { emitLeanFile } from "./lean-emit.js";
16
16
  import { emitDafnyFile } from "./dafny-emit.js";
17
17
  import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
18
18
  import { leanGen, leanCheck } from "./lean-commands.js";
19
+ import { runInfo } from "./info-command.js";
19
20
  function main() {
20
21
  const args = process.argv.slice(2);
21
22
  const backendIdx = args.findIndex(a => a.startsWith("--backend="));
@@ -43,7 +44,7 @@ function main() {
43
44
  }
44
45
  const [cmd, filePath] = args;
45
46
  if (!cmd || !filePath) {
46
- console.error("Usage: lsc <gen|check|regen|extract> [--backend=lean|dafny] <file.ts>");
47
+ console.error("Usage: lsc <gen|check|regen|extract|info> [--backend=lean|dafny] <file.ts>");
47
48
  process.exit(1);
48
49
  }
49
50
  const absPath = path.resolve(filePath);
@@ -70,18 +71,27 @@ function main() {
70
71
  : new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
71
72
  const sourceFile = project.addSourceFileAtPath(absPath);
72
73
  project.resolveSourceFileDependencies();
73
- // Check //@ backend directive — skip if backend doesn't match
74
- const backendDirective = sourceFile.getFullText().match(/\/\/@ backend (\w+)/);
75
- if (backendDirective && backendDirective[1] !== backend) {
74
+ const fullText = sourceFile.getFullText();
75
+ // Check //@ backend directive — skip if backend doesn't match.
76
+ // `extract` and `info` are backend-neutral and always run.
77
+ const backendDirective = fullText.match(/\/\/@ backend (\w+)/);
78
+ if (cmd !== "extract" && cmd !== "info" && backendDirective && backendDirective[1] !== backend) {
76
79
  console.log(`Skipped: ${path.basename(filePath)} (//@ backend ${backendDirective[1]}, current: ${backend})`);
77
80
  return;
78
81
  }
82
+ // File-level directives consumed by the Dafny emitter.
83
+ const safeSlice = /\/\/@ safe-slice\b/.test(fullText);
79
84
  // Extract: ts-morph → Raw IR
80
85
  const raw = extractModule(sourceFile);
81
86
  if (cmd === "extract") {
82
87
  console.log(JSON.stringify(raw, null, 2));
83
88
  return;
84
89
  }
90
+ if (cmd === "info") {
91
+ const outPath = path.join(path.dirname(absPath), `${path.basename(filePath, ".ts")}.ts.json`);
92
+ runInfo(raw, outPath);
93
+ return;
94
+ }
85
95
  // Resolve: Raw IR → Typed IR
86
96
  const resolved = resolveModule(raw);
87
97
  // Narrow: Typed IR → Typed IR (rewrites optional-narrowing patterns to someMatch)
@@ -96,7 +106,7 @@ function main() {
96
106
  defFile = peepholeModule(defFile, "dafny");
97
107
  const allDecls = [...(typesFile?.decls ?? []), ...defFile.decls];
98
108
  const merged = { ...defFile, decls: allDecls };
99
- const text = emitDafnyFile(merged, path.basename(filePath));
109
+ const text = emitDafnyFile(merged, path.basename(filePath), { safeSlice });
100
110
  const genPath = path.join(dir, `${base}.dfy.gen`);
101
111
  const dfyPath = path.join(dir, `${base}.dfy`);
102
112
  const basePath = path.join(dir, `${base}.dfy.base`);
@@ -87,14 +87,16 @@ function binderHintFor(e) {
87
87
  }
88
88
  if (cur.kind !== "var")
89
89
  return null;
90
- return fields.length === 0 ? `_${cur.name}_val` : `_${cur.name}_${fields.join("_")}_val`;
90
+ // \result is stored as the IR var name "\\result"; sanitize for a valid identifier.
91
+ const root = cur.name === "\\result" ? "result" : cur.name;
92
+ return fields.length === 0 ? `_${root}_val` : `_${root}_${fields.join("_")}_val`;
91
93
  }
92
94
  // Aliased for code that historically called the simpler check.
93
95
  const parseSimpleOptionalCheck = parseOptionalCheck;
94
96
  // ── Walkers ──────────────────────────────────────────────────
95
97
  function walkExpr(e) {
96
98
  const r = recurseExpr(e);
97
- return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
99
+ return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
98
100
  }
99
101
  function recurseExpr(e) {
100
102
  const re = walkExpr;
@@ -103,7 +105,6 @@ function recurseExpr(e) {
103
105
  case "num":
104
106
  case "str":
105
107
  case "bool":
106
- case "result":
107
108
  case "havoc":
108
109
  return e;
109
110
  case "binop": return { ...e, left: re(e.left), right: re(e.right) };
@@ -132,10 +133,14 @@ function recurseExpr(e) {
132
133
  function walkStmt(s) {
133
134
  // Recurse into children first, then try rules at this node.
134
135
  const r = recurseStmt(s);
135
- // && rule fires before simple rule because it produces nested ifs whose
136
- // inner shape doesn't match simple rule directly. someMatch result needs
137
- // no further rewriting at this level.
138
- return ruleIfAndOptional(r) ?? ruleIfOptionalSimple(r) ?? r;
136
+ // Optional narrowing fires before Array.isArray narrowing: in a chain like
137
+ // `next && Array.isArray(next.content)` the optional check must unwrap `next`
138
+ // *outside* the array match, since `next.content` is unreachable until then.
139
+ // (When the chain has no leading optional, ruleIfAndOptional no-ops and the
140
+ // array rule fires; independent narrows commute, so the order is harmless.)
141
+ // && rules fire before the simple rule because they produce nested ifs whose
142
+ // inner shape doesn't match the simple rule directly.
143
+ return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? r;
139
144
  }
140
145
  function walkStmts(stmts) {
141
146
  const result = [];
@@ -307,6 +312,60 @@ function ruleConditionalOptionalSimple(e) {
307
312
  ty: e.ty,
308
313
  };
309
314
  }
315
+ /** Rule (expression): `Array.isArray(x) ==> B` or `!Array.isArray(x) ==> B` —
316
+ * premise narrowing for spec implications. Mirrors `ruleImplOptional` but for
317
+ * synth array-union discriminators.
318
+ * → `tagMatch x { ArrayBranch => walkExpr(B), _ => true }` (or NonArrayBranch).
319
+ * The other variant becomes a vacuous-true fallthrough (the implication is
320
+ * trivially satisfied when the premise is false). */
321
+ function ruleImplArrayIsArray(e) {
322
+ if (e.kind !== "binop" || e.op !== "==>")
323
+ return null;
324
+ const pos = parseArrayIsArrayCall(e.left);
325
+ const neg = e.left.kind === "unop" && e.left.op === "!"
326
+ ? parseArrayIsArrayCall(e.left.expr)
327
+ : null;
328
+ const matched = pos ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
329
+ if (!matched)
330
+ return null;
331
+ return {
332
+ kind: "tagMatch",
333
+ scrutinee: matched.scrutinee,
334
+ typeName: matched.typeName,
335
+ cases: [{ variant: matched.variant, body: walkExpr(e.right) }],
336
+ fallthrough: { kind: "bool", value: true, ty: { kind: "bool" } },
337
+ ty: { kind: "bool" },
338
+ };
339
+ }
340
+ /** Rule (expression): `Array.isArray(x) ? a : b` — ternary narrowing for
341
+ * synth array-unions. Mirrors `ruleImplArrayIsArray` but at the conditional
342
+ * position rather than the `==>` position.
343
+ * → `tagMatch x { ArrayBranch => walkExpr(a) } fallthrough walkExpr(b)`
344
+ * (or NonArrayBranch when the condition is negated).
345
+ * Inside the matched arm, bare references to `x` are rewritten to the
346
+ * variant's payload field (e.g. `x.arr`) by `transformExpr` when emitting
347
+ * the tagMatch — same mechanism `ruleImplArrayIsArray` already relies on. */
348
+ function ruleConditionalArrayIsArray(e) {
349
+ if (e.kind !== "conditional")
350
+ return null;
351
+ const pos = parseArrayIsArrayCall(e.cond);
352
+ const neg = e.cond.kind === "unop" && e.cond.op === "!"
353
+ ? parseArrayIsArrayCall(e.cond.expr)
354
+ : null;
355
+ const matched = pos ?? (neg ? { scrutinee: neg.scrutinee, typeName: neg.typeName, variant: "NonArrayBranch" } : null);
356
+ if (!matched)
357
+ return null;
358
+ const thenBody = pos ? e.then : e.else;
359
+ const elseBody = pos ? e.else : e.then;
360
+ return {
361
+ kind: "tagMatch",
362
+ scrutinee: matched.scrutinee,
363
+ typeName: matched.typeName,
364
+ cases: [{ variant: matched.variant, body: walkExpr(thenBody) }],
365
+ fallthrough: walkExpr(elseBody),
366
+ ty: e.ty,
367
+ };
368
+ }
310
369
  /** Rule (expression): `(path !== undefined [&& rest]) ==> B` — premise narrowing
311
370
  * for spec implications (ensures/requires). The premise's optional checks
312
371
  * bind narrowed values that the conclusion can use.
@@ -471,19 +530,29 @@ function ruleConditionalOptionalTruthy(e) {
471
530
  someBody: e.then, noneBody: e.else, ty: e.ty,
472
531
  };
473
532
  }
474
- /** Extract the leftmost optional check from an `&&` chain.
475
- * `(x !== undefined && b) && c` → { check, restCond: b && c }. */
533
+ /** Extract an optional check from any position in an `&&` chain.
534
+ * `(x !== undefined && b) && c` → { check, restCond: b && c }.
535
+ * `a && (x !== undefined)` → { check, restCond: a }.
536
+ * Conjunct order doesn't carry semantic weight, so either side is fine. */
476
537
  function extractLeftmostOptionalCheck(cond) {
477
538
  if (cond.kind !== "binop" || cond.op !== "&&")
478
539
  return null;
479
- const check = parseSimpleOptionalCheck(cond.left);
480
- if (check && !check.negated)
481
- return { check, restCond: cond.right };
540
+ const leftCheck = parseSimpleOptionalCheck(cond.left);
541
+ if (leftCheck && !leftCheck.negated)
542
+ return { check: leftCheck, restCond: cond.right };
543
+ const rightCheck = parseSimpleOptionalCheck(cond.right);
544
+ if (rightCheck && !rightCheck.negated)
545
+ return { check: rightCheck, restCond: cond.left };
482
546
  if (cond.left.kind === "binop" && cond.left.op === "&&") {
483
547
  const inner = extractLeftmostOptionalCheck(cond.left);
484
548
  if (inner)
485
549
  return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
486
550
  }
551
+ if (cond.right.kind === "binop" && cond.right.op === "&&") {
552
+ const inner = extractLeftmostOptionalCheck(cond.right);
553
+ if (inner)
554
+ return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
555
+ }
487
556
  return null;
488
557
  }
489
558
  /** Rule: `if (x !== undefined && rest) then` (no else) where x is a pure
@@ -510,8 +579,68 @@ function ruleIfAndOptional(s) {
510
579
  };
511
580
  }
512
581
  // ── Discriminant narrowing ──────────────────────────────────
513
- /** Detect `x.kind === "variant"` or `'key' in x` as a positive discriminant
514
- * check. Returns the scrutinee var (with its type), type name, and variant. */
582
+ /** Detect `Array.isArray(<path>)` where `<path>` is a var or a chain of
583
+ * field accesses rooted at a var, and the path's type is a synthesized
584
+ * array-union (discriminant `"__isArray__"`). Returns the variant name to
585
+ * narrow to. The scrutinee is whatever path the user wrote — downstream
586
+ * transforms substitute it inside the matched arm. */
587
+ function parseArrayIsArrayCall(call) {
588
+ if (call.kind !== "call")
589
+ return null;
590
+ if (call.fn.kind !== "field" || call.fn.field !== "isArray")
591
+ return null;
592
+ if (call.fn.obj.kind !== "var" || call.fn.obj.name !== "Array")
593
+ return null;
594
+ if (call.args.length !== 1)
595
+ return null;
596
+ const arg = call.args[0];
597
+ if (!isNarrowablePath(arg) || arg.ty.kind !== "user")
598
+ return null;
599
+ const baseTyName = arg.ty.name.includes("<") ? arg.ty.name.slice(0, arg.ty.name.indexOf("<")) : arg.ty.name;
600
+ const decl = _typeDecls.find(d => d.name === baseTyName);
601
+ if (decl?.kind !== "discriminated-union" || decl.discriminant !== "__isArray__")
602
+ return null;
603
+ return { scrutinee: arg, typeName: arg.ty.name, variant: "ArrayBranch" };
604
+ }
605
+ /** A "narrowable path" is a var or a chain of field accesses rooted at a var
606
+ * — i.e., pure and structurally addressable, so transforms can substitute
607
+ * occurrences inside a matched arm without worrying about re-evaluation. */
608
+ function isNarrowablePath(e) {
609
+ if (e.kind === "var")
610
+ return true;
611
+ if (e.kind === "field")
612
+ return isNarrowablePath(e.obj);
613
+ return false;
614
+ }
615
+ /** Mirror of `extractLeftmostOptionalCheck` for synth-array-union checks:
616
+ * finds `Array.isArray(path)` somewhere in a `&&` chain, returns it plus
617
+ * the remaining conjunction. The check must be the positive form (negated
618
+ * `!Array.isArray(...)` would narrow to the wrong variant for then-body
619
+ * consumers, so we leave those to the existing untouched-conditional path). */
620
+ function extractLeftmostArrayIsArrayCheck(cond) {
621
+ if (cond.kind !== "binop" || cond.op !== "&&")
622
+ return null;
623
+ const leftCheck = parseArrayIsArrayCall(cond.left);
624
+ if (leftCheck)
625
+ return { check: leftCheck, restCond: cond.right };
626
+ const rightCheck = parseArrayIsArrayCall(cond.right);
627
+ if (rightCheck)
628
+ return { check: rightCheck, restCond: cond.left };
629
+ if (cond.left.kind === "binop" && cond.left.op === "&&") {
630
+ const inner = extractLeftmostArrayIsArrayCheck(cond.left);
631
+ if (inner)
632
+ return { check: inner.check, restCond: { ...cond, left: inner.restCond } };
633
+ }
634
+ if (cond.right.kind === "binop" && cond.right.op === "&&") {
635
+ const inner = extractLeftmostArrayIsArrayCheck(cond.right);
636
+ if (inner)
637
+ return { check: inner.check, restCond: { ...cond, right: inner.restCond } };
638
+ }
639
+ return null;
640
+ }
641
+ /** Detect `x.kind === "variant"`, `'key' in x`, or `Array.isArray(x)` (synth
642
+ * array-union) as a positive discriminant check. Returns the scrutinee var
643
+ * (with its type), type name, and variant. */
515
644
  function parseDiscriminantCond(cond) {
516
645
  // Pattern: x.discriminant === "variant"
517
646
  if (cond.kind === "binop" && cond.op === "===" && cond.right.kind === "str" &&
@@ -534,15 +663,35 @@ function parseDiscriminantCond(cond) {
534
663
  }
535
664
  }
536
665
  }
666
+ // Pattern: Array.isArray(x) — narrows x to the ArrayBranch variant of a
667
+ // synthesized array-union (discriminant "__isArray__"). Statement-level
668
+ // discriminant chains (`if (Array.isArray(x)) {...} else if (...)`) still
669
+ // require a bare-var scrutinee since the existing var-name-keyed
670
+ // replacement machinery in transform.ts only handles that shape; path
671
+ // scrutinees (e.g. `m.content`) are handled exclusively by
672
+ // `ruleConditionalArrayIsArray` and the expression-form tagMatch path.
673
+ const arrCheck = parseArrayIsArrayCall(cond);
674
+ if (arrCheck && arrCheck.scrutinee.kind === "var") {
675
+ return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: arrCheck.variant };
676
+ }
537
677
  return null;
538
678
  }
539
- /** Detect `x.kind !== "variant"` (negative discriminant check). */
679
+ /** Detect `x.kind !== "variant"` (negative discriminant check) or
680
+ * `!Array.isArray(x)` (synth array-union, narrows to NonArrayBranch). */
540
681
  function parseNegativeDiscriminantCond(cond) {
541
682
  if (cond.kind === "binop" && cond.op === "!==" && cond.right.kind === "str" &&
542
683
  cond.left.kind === "field" && cond.left.isDiscriminant &&
543
684
  cond.left.obj.kind === "var" && cond.left.obj.ty.kind === "user") {
544
685
  return { scrutinee: cond.left.obj, typeName: cond.left.obj.ty.name, variant: cond.right.value };
545
686
  }
687
+ // Pattern: !Array.isArray(x) — narrows x to the NonArrayBranch variant.
688
+ // Same var-scrutinee restriction as parseDiscriminantCond.
689
+ if (cond.kind === "unop" && cond.op === "!") {
690
+ const arrCheck = parseArrayIsArrayCall(cond.expr);
691
+ if (arrCheck && arrCheck.scrutinee.kind === "var") {
692
+ return { scrutinee: arrCheck.scrutinee, typeName: arrCheck.typeName, variant: "NonArrayBranch" };
693
+ }
694
+ }
546
695
  return null;
547
696
  }
548
697
  function isTerminating(stmts) {
@@ -657,7 +806,6 @@ function containsMethodCall(e) {
657
806
  case "num":
658
807
  case "str":
659
808
  case "bool":
660
- case "result":
661
809
  case "havoc":
662
810
  return false;
663
811
  case "binop": return containsMethodCall(e.left) || containsMethodCall(e.right);
@@ -712,6 +860,53 @@ function ruleConditionalAndOptional(e) {
712
860
  someBody: walkExpr(innerCond), noneBody: e.else, ty: e.ty,
713
861
  };
714
862
  }
863
+ /** Rule (statement): `if (<rest> && Array.isArray(path) && <more>) then [else]`
864
+ * → `tagMatch path { ArrayBranch => if (<rest && more>) then [else] }`.
865
+ * The remaining conjuncts move inside the matched arm so any narrowing the
866
+ * `then` body relies on (typed `path` accesses) sees the unwrapped variant.
867
+ * Mirrors `ruleIfAndOptional` but for synth array-unions. */
868
+ function ruleIfAndArrayIsArray(s) {
869
+ if (s.kind !== "if")
870
+ return null;
871
+ const extracted = extractLeftmostArrayIsArrayCheck(s.cond);
872
+ if (!extracted)
873
+ return null;
874
+ const { check, restCond } = extracted;
875
+ // Inner if uses the remaining conjunction (or just the then-body if rest is
876
+ // a tautology — but in practice extractLeftmost leaves at least one other
877
+ // conjunct). Walk recursively so nested checks compose.
878
+ const innerThen = [{ kind: "if", cond: restCond, then: s.then, else: s.else }];
879
+ return {
880
+ kind: "tagMatch",
881
+ scrutinee: check.scrutinee,
882
+ typeName: check.typeName,
883
+ cases: [{ variant: check.variant, body: innerThen.map(walkStmt) }],
884
+ fallthrough: s.else,
885
+ };
886
+ }
887
+ /** Rule (expression): `(<rest> && Array.isArray(path)) ? a : b`
888
+ * → `tagMatch path { ArrayBranch => (<rest>) ? a : b } fallthrough b`.
889
+ * Mirrors `ruleConditionalAndOptional`. */
890
+ function ruleConditionalAndArrayIsArray(e) {
891
+ if (e.kind !== "conditional")
892
+ return null;
893
+ const extracted = extractLeftmostArrayIsArrayCheck(e.cond);
894
+ if (!extracted)
895
+ return null;
896
+ const { check, restCond } = extracted;
897
+ const innerCond = {
898
+ kind: "conditional",
899
+ cond: restCond, then: e.then, else: e.else, ty: e.ty,
900
+ };
901
+ return {
902
+ kind: "tagMatch",
903
+ scrutinee: check.scrutinee,
904
+ typeName: check.typeName,
905
+ cases: [{ variant: check.variant, body: walkExpr(innerCond) }],
906
+ fallthrough: e.else,
907
+ ty: e.ty,
908
+ };
909
+ }
715
910
  // ── Function / module entry ──────────────────────────────────
716
911
  function narrowFunction(fn) {
717
912
  return {
@@ -12,7 +12,8 @@ function mapExpr(e, f) {
12
12
  case "constructor":
13
13
  case "emptyMap":
14
14
  case "emptySet":
15
- case "havoc": return e;
15
+ case "havoc":
16
+ case "mapLiteral": return e;
16
17
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
17
18
  case "unop": return { ...e, expr: r(e.expr) };
18
19
  case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
@@ -355,7 +356,8 @@ function rewriteChildrenExpr(e) {
355
356
  case "constructor":
356
357
  case "emptyMap":
357
358
  case "emptySet":
358
- case "havoc": return e;
359
+ case "havoc":
360
+ case "mapLiteral": return e;
359
361
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
360
362
  case "unop": return { ...e, expr: r(e.expr) };
361
363
  case "implies": return { ...e, premises: e.premises.map(r), conclusion: r(e.conclusion) };
@@ -443,6 +445,7 @@ function peepholeDecl(d) {
443
445
  case "inductive":
444
446
  case "structure":
445
447
  case "type-alias":
448
+ case "extern":
446
449
  return d;
447
450
  }
448
451
  }