lemmascript 0.3.3 → 0.4.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.
@@ -6,72 +6,6 @@
6
6
  */
7
7
  import { parseTsType } from "./types.js";
8
8
  import { parseExpr } from "./specparser.js";
9
- // ── Raw expression substitution ─────────────────────────────
10
- let _synVarCounter = 0;
11
- /**
12
- * Structural equality for raw field-access chains (var and field nodes only).
13
- * Exact within a single expression scope — raw IR has no bindings that could
14
- * cause name collisions (those are introduced by resolve, which runs after).
15
- */
16
- function rawExprEquals(a, b) {
17
- if (a.kind === "var" && b.kind === "var")
18
- return a.name === b.name;
19
- if (a.kind === "field" && b.kind === "field")
20
- return a.field === b.field && rawExprEquals(a.obj, b.obj);
21
- if (a.kind === "call" && b.kind === "call")
22
- return rawExprEquals(a.fn, b.fn) && a.args.length === b.args.length && a.args.every((arg, i) => rawExprEquals(arg, b.args[i]));
23
- if (a.kind === "index" && b.kind === "index")
24
- return rawExprEquals(a.obj, b.obj) && rawExprEquals(a.idx, b.idx);
25
- return false;
26
- }
27
- /** Return the root variable name of a field-access chain, or null. */
28
- function rawChainRoot(e) {
29
- if (e.kind === "var")
30
- return e.name;
31
- if (e.kind === "field")
32
- return rawChainRoot(e.obj);
33
- return null;
34
- }
35
- /**
36
- * Replace all occurrences of `target` in `expr` with `replacement`.
37
- * Only matches field-access chains (see rawExprEquals). Stops at lambda
38
- * boundaries that shadow the chain's root variable.
39
- */
40
- function substituteRawExpr(expr, target, replacement) {
41
- if (rawExprEquals(expr, target))
42
- return replacement;
43
- const root = rawChainRoot(target);
44
- const sub = (e) => substituteRawExpr(e, target, replacement);
45
- switch (expr.kind) {
46
- case "var":
47
- case "num":
48
- case "str":
49
- case "bool":
50
- case "result":
51
- case "havoc":
52
- case "emptyCollection":
53
- return expr;
54
- case "binop": return { ...expr, left: sub(expr.left), right: sub(expr.right) };
55
- case "unop": return { ...expr, expr: sub(expr.expr) };
56
- case "call": return { ...expr, fn: sub(expr.fn), args: expr.args.map(sub) };
57
- case "field": return { ...expr, obj: sub(expr.obj) };
58
- case "index": return { ...expr, obj: sub(expr.obj), idx: sub(expr.idx) };
59
- case "record":
60
- return { ...expr, spread: expr.spread ? sub(expr.spread) : null,
61
- fields: expr.fields.map(f => ({ ...f, value: sub(f.value) })) };
62
- case "arrayLiteral": return { ...expr, elems: expr.elems.map(sub) };
63
- case "conditional": return { ...expr, cond: sub(expr.cond), then: sub(expr.then), else: sub(expr.else) };
64
- case "nonNull": return { ...expr, expr: sub(expr.expr) };
65
- case "forall":
66
- case "exists":
67
- return { ...expr, body: sub(expr.body) };
68
- case "lambda":
69
- // Don't cross lambda boundaries that shadow the chain's root variable
70
- if (root && expr.params.some(p => p.name === root))
71
- return expr;
72
- return { ...expr, body: Array.isArray(expr.body) ? expr.body : sub(expr.body) };
73
- }
74
- }
75
9
  function lookup(env, name) {
76
10
  if (!env)
77
11
  return undefined;
@@ -80,6 +14,21 @@ function lookup(env, name) {
80
14
  function extend(env, name, ty) {
81
15
  return { name, ty, parent: env };
82
16
  }
17
+ function asRawAccessPath(e) {
18
+ if (e.kind === "var")
19
+ return { rootVar: e.name, fields: [] };
20
+ if (e.kind === "field") {
21
+ const inner = asRawAccessPath(e.obj);
22
+ if (!inner)
23
+ return null;
24
+ return { rootVar: inner.rootVar, fields: [...inner.fields, e.field] };
25
+ }
26
+ return null;
27
+ }
28
+ function accessPathsEqual(a, b) {
29
+ return a.rootVar === b.rootVar && a.fields.length === b.fields.length &&
30
+ a.fields.every((f, i) => f === b.fields[i]);
31
+ }
83
32
  function withEnv(ctx, env) {
84
33
  return { ...ctx, env };
85
34
  }
@@ -106,17 +55,25 @@ function wrapSome(value, optionalTy) {
106
55
  args: [value], ty: optionalTy, callKind: "pure",
107
56
  };
108
57
  }
109
- /** Detect `v !== undefined` or `undefined !== v` where v: optional<T>.
110
- * Handles simple variables, field access chains, and arbitrary expressions.
111
- * When `fieldExpr` is returned for a simple field chain (obj.field), callers
112
- * can narrow via `narrowedFields` context. For complex expressions (calls, etc.),
113
- * callers should fall back to `substituteRawExpr`.
114
- *
115
- * Does NOT recurse into `&&` callers that need to detect optional checks
116
- * inside `&&` conditions should check `cond.left` explicitly. */
58
+ /** Detect optional checks: `v !== undefined` (positive narrows then-branch),
59
+ * `v === undefined` (negative narrows else-branch), or `!v` (equivalent to
60
+ * `=== undefined`).
61
+ * Returns:
62
+ * - simple var: `varName` set, `fieldExpr` unset
63
+ * - complex (field chain or call): `fieldExpr` set, `varName` empty
64
+ * - inThen: true for `!==` (truthy), false for `===` and `!v` (falsy).
65
+ * Does NOT recurse into `&&`. */
117
66
  function detectOptionalCheck(cond, ctx) {
118
- if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "==="))
119
- return null;
67
+ // `!v` where v is optional same shape as `v === undefined` (inThen: false).
68
+ if (cond.kind === "unop" && cond.op === "!") {
69
+ const inner = classifyOptExpr(cond.expr, ctx);
70
+ return inner ? { ...inner, inThen: false } : null;
71
+ }
72
+ if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "===")) {
73
+ // Bare optional truthiness: `if (v)` where v: T | undefined — same as `v !== undefined`.
74
+ const inner = classifyOptExpr(cond, ctx);
75
+ return inner ? { ...inner, inThen: true } : null;
76
+ }
120
77
  // Identify the expression being checked against undefined
121
78
  let optExpr = null;
122
79
  if (cond.right.kind === "var" && cond.right.name === "undefined")
@@ -125,23 +82,22 @@ function detectOptionalCheck(cond, ctx) {
125
82
  optExpr = cond.right;
126
83
  if (!optExpr)
127
84
  return null;
128
- // Simple variable — env lookup, no substitution needed
129
- if (optExpr.kind === "var") {
130
- const ty = lookup(ctx.env, optExpr.name);
85
+ const inner = classifyOptExpr(optExpr, ctx);
86
+ return inner ? { ...inner, inThen: cond.op === "!==" } : null;
87
+ }
88
+ /** Classify an expression as a simple var or field-chain optional, returning
89
+ * the shape needed by detectOptionalCheck (sans inThen). */
90
+ function classifyOptExpr(e, ctx) {
91
+ if (e.kind === "var") {
92
+ const ty = lookup(ctx.env, e.name);
131
93
  if (!ty || ty.kind !== "optional")
132
94
  return null;
133
- return { varName: optExpr.name, innerTy: ty.inner, inThen: cond.op === "!==" };
134
- }
135
- // Field access chain or arbitrary expression — resolve to check type, needs substitution
136
- const resolved = resolveExpr(optExpr, ctx);
137
- if (resolved.ty.kind === "optional") {
138
- const synVar = optExpr.kind === "field" ? `_narr${_synVarCounter++}` : `_opt${_synVarCounter++}`;
139
- return {
140
- varName: synVar, innerTy: resolved.ty.inner, inThen: cond.op === "!==",
141
- fieldExpr: optExpr, narrowedExpr: resolved,
142
- };
95
+ return { varName: e.name, innerTy: ty.inner };
143
96
  }
144
- return null;
97
+ const resolved = resolveExpr(e, ctx);
98
+ if (resolved.ty.kind !== "optional")
99
+ return null;
100
+ return { varName: "", innerTy: resolved.ty.inner, fieldExpr: e };
145
101
  }
146
102
  /** Collect all optional narrowings from an early-return condition.
147
103
  * Handles single checks (x === undefined) and compound || chains
@@ -156,6 +112,82 @@ function collectEarlyReturnNarrowings(cond, ctx) {
156
112
  }
157
113
  return [];
158
114
  }
115
+ /** TExpr → AccessPath. Counterpart to `asRawAccessPath` for resolved trees.
116
+ * Used by `extractInAtoms` when pulling atoms out of typed spec expressions. */
117
+ function asTExprAccessPath(e) {
118
+ if (e.kind === "var")
119
+ return { rootVar: e.name, fields: [] };
120
+ if (e.kind === "field") {
121
+ const inner = asTExprAccessPath(e.obj);
122
+ if (!inner)
123
+ return null;
124
+ return { rootVar: inner.rootVar, fields: [...inner.fields, e.field] };
125
+ }
126
+ return null;
127
+ }
128
+ /** Walk `e` collecting top-level `k in m` atoms where both sides are pure
129
+ * access paths and the right side is map-typed. Descends through `&&` only.
130
+ * Does NOT descend into `==>`, `||`, negation, `forall`, or `exists` — in
131
+ * those positions an atom is only conditionally known (or a premise, not a
132
+ * conclusion), so treating it as always-true in the enclosing scope would
133
+ * be unsound. */
134
+ function extractInAtoms(e) {
135
+ if (e.kind === "binop" && e.op === "in" && e.right.ty.kind === "map") {
136
+ const obj = asTExprAccessPath(e.right);
137
+ const idx = asTExprAccessPath(e.left);
138
+ if (obj && idx)
139
+ return [{ obj, idx }];
140
+ return [];
141
+ }
142
+ if (e.kind === "binop" && e.op === "&&") {
143
+ return [...extractInAtoms(e.left), ...extractInAtoms(e.right)];
144
+ }
145
+ return [];
146
+ }
147
+ /** Extract `k in m` atoms that hold when `e` is *false*. Currently only
148
+ * strips an outer `!` and hands the inner to `extractInAtoms`; that covers
149
+ * `if (!(k in m)) ...` for the else-branch and early-return patterns.
150
+ * De Morgan over `||` / nested `!(a && b)` not handled yet. */
151
+ function extractInAtomsNegated(e) {
152
+ if (e.kind === "unop" && e.op === "!")
153
+ return extractInAtoms(e.expr);
154
+ return [];
155
+ }
156
+ /** Extend a Ctx with `k in m` atoms. Deduplicates against existing atoms. */
157
+ function withInAtoms(ctx, atoms) {
158
+ if (atoms.length === 0)
159
+ return ctx;
160
+ const existing = ctx.narrowedIndices;
161
+ const added = [];
162
+ for (const a of atoms) {
163
+ if (!existing.some(e => accessPathsEqual(e.obj, a.obj) && accessPathsEqual(e.idx, a.idx))) {
164
+ added.push(a);
165
+ }
166
+ }
167
+ if (added.length === 0)
168
+ return ctx;
169
+ return { ...ctx, narrowedIndices: [...existing, ...added] };
170
+ }
171
+ /** Walk an `&&` chain of `e !== undefined` checks, returning a Ctx with all
172
+ * narrowings applied. Earlier checks are in scope for later checks (so the
173
+ * right side of `&&` sees the left side's narrowings). */
174
+ function collectAndChainNarrowings(cond, ctx) {
175
+ if (cond.kind === "binop" && cond.op === "&&") {
176
+ const leftCtx = collectAndChainNarrowings(cond.left, ctx);
177
+ return collectAndChainNarrowings(cond.right, leftCtx);
178
+ }
179
+ const n = detectOptionalCheck(cond, ctx);
180
+ if (!n || !n.inThen)
181
+ return ctx;
182
+ if (!n.fieldExpr) {
183
+ return withEnv(ctx, extend(ctx.env, n.varName, n.innerTy));
184
+ }
185
+ const path = asRawAccessPath(n.fieldExpr);
186
+ if (path) {
187
+ return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: n.innerTy }] };
188
+ }
189
+ return ctx;
190
+ }
159
191
  /** TS reference types that become value types in Dafny/Lean — const bindings need mutable var. */
160
192
  function isRefMutableInTS(ty) {
161
193
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
@@ -332,6 +364,34 @@ function inferMethodReturnTy(fn, args, ctx) {
332
364
  }
333
365
  return { kind: "unknown" };
334
366
  }
367
+ /** Look up the type of `field` on `objTy`. Returns `unknown` if not found. */
368
+ function lookupFieldTy(objTy, field, ctx) {
369
+ if (field === "length" && (objTy.kind === "array" || objTy.kind === "string")) {
370
+ return { ty: { kind: "nat" }, isDiscriminant: false };
371
+ }
372
+ if (field === "size" && (objTy.kind === "map" || objTy.kind === "set")) {
373
+ return { ty: { kind: "nat" }, isDiscriminant: false };
374
+ }
375
+ if (objTy.kind === "user") {
376
+ const baseTyName = objTy.name.includes("<") ? objTy.name.slice(0, objTy.name.indexOf("<")) : objTy.name;
377
+ const isDiscriminant = getDiscriminant(ctx, baseTyName) === field;
378
+ const decl = findDecl(ctx, baseTyName);
379
+ if (decl?.kind === "record") {
380
+ const f = decl.fields?.find(f => f.name === field);
381
+ if (f)
382
+ return { ty: f.type, isDiscriminant };
383
+ }
384
+ if (decl?.kind === "discriminated-union" && decl.variants) {
385
+ for (const variant of decl.variants) {
386
+ const f = variant.fields.find(f => f.name === field);
387
+ if (f)
388
+ return { ty: f.type, isDiscriminant };
389
+ }
390
+ }
391
+ return { ty: { kind: "unknown" }, isDiscriminant };
392
+ }
393
+ return { ty: { kind: "unknown" }, isDiscriminant: false };
394
+ }
335
395
  // ── Resolve expressions ──────────────────────────────────────
336
396
  function resolveExpr(e, ctx) {
337
397
  switch (e.kind) {
@@ -359,16 +419,12 @@ function resolveExpr(e, ctx) {
359
419
  }
360
420
  case "binop": {
361
421
  let left = resolveExpr(e.left, ctx);
362
- // && narrowing: if left is "x !== undefined", narrow x for right side.
363
- // Field chains are excluded resolve can't substitute in sub-expressions;
364
- // transform's emitOptionalMatch handles field chains in statement contexts.
422
+ // && and ==> narrowing: left-side optional checks narrow the right side.
423
+ // (For ==>, the premise is assumed in the conclusion — same principle.)
365
424
  let rightCtx = ctx;
366
425
  let rawRight = e.right;
367
- if (e.op === "&&") {
368
- const narrowed = detectOptionalCheck(e.left, ctx);
369
- if (narrowed && narrowed.inThen && !narrowed.fieldExpr) {
370
- rightCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
371
- }
426
+ if (e.op === "&&" || e.op === "==>") {
427
+ rightCtx = collectAndChainNarrowings(e.left, ctx);
372
428
  }
373
429
  let right = resolveExpr(rawRight, rightCtx);
374
430
  if (e.op === "===" || e.op === "!==") {
@@ -424,50 +480,102 @@ function resolveExpr(e, ctx) {
424
480
  case "index": {
425
481
  const obj = resolveExpr(e.obj, ctx);
426
482
  const idx = resolveExpr(e.idx, ctx);
427
- const idxTy = obj.ty.kind === "array" ? obj.ty.elem
428
- : obj.ty.kind === "map" ? { kind: "optional", inner: obj.ty.value }
429
- : { kind: "unknown" };
483
+ // Map bracket access: default to Option<V>. But if the enclosing scope has a
484
+ // known `k in m` atom matching (obj, idx) from requires, assert, an enclosing
485
+ // `if (k in m)`, or a loop invariant — narrow to V. Parallels how `narrowedPaths`
486
+ // narrows `obj.field.field` under optional-undefined checks.
487
+ let idxTy;
488
+ if (obj.ty.kind === "array") {
489
+ idxTy = obj.ty.elem;
490
+ }
491
+ else if (obj.ty.kind === "map") {
492
+ const objPath = asTExprAccessPath(obj);
493
+ const idxPath = asTExprAccessPath(idx);
494
+ const narrowed = objPath && idxPath && ctx.narrowedIndices.some(n => accessPathsEqual(n.obj, objPath) && accessPathsEqual(n.idx, idxPath));
495
+ idxTy = narrowed ? obj.ty.value : { kind: "optional", inner: obj.ty.value };
496
+ }
497
+ else {
498
+ idxTy = { kind: "unknown" };
499
+ }
430
500
  return { kind: "index", obj, idx, ty: idxTy };
431
501
  }
432
502
  case "field": {
433
503
  const obj = resolveExpr(e.obj, ctx);
434
504
  let isDiscriminant = false;
435
505
  let ty = { kind: "unknown" };
436
- // Check narrowed field context (from conditional optional checks on field chains)
437
- if (obj.kind === "var" && ctx.narrowedFields.length > 0) {
438
- const nf = ctx.narrowedFields.find(n => n.objName === obj.name && n.fieldName === e.field);
439
- if (nf)
440
- ty = nf.narrowedTy;
441
- }
442
- if (ty.kind === "unknown" && e.field === "length" && (obj.ty.kind === "array" || obj.ty.kind === "string")) {
443
- ty = { kind: "nat" };
506
+ // Check narrowed path context (from conditional optional checks).
507
+ // Applies when the current field-access forms a pure access path AND
508
+ // that path is in the narrowedPaths list.
509
+ if (ctx.narrowedPaths.length > 0) {
510
+ const myPath = asRawAccessPath(e);
511
+ if (myPath) {
512
+ const np = ctx.narrowedPaths.find(n => accessPathsEqual(n.path, myPath));
513
+ if (np)
514
+ ty = np.narrowedTy;
515
+ }
444
516
  }
445
- else if (ty.kind === "unknown" && e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
446
- ty = { kind: "nat" };
517
+ if (ty.kind === "unknown") {
518
+ const lookup = lookupFieldTy(obj.ty, e.field, ctx);
519
+ ty = lookup.ty;
520
+ isDiscriminant = lookup.isDiscriminant;
447
521
  }
448
- else if (ty.kind === "unknown" && obj.ty.kind === "user") {
449
- // Strip generic args for type lookup: "Result<Model, Err>" → "Result"
450
- const baseTyName = obj.ty.name.includes("<") ? obj.ty.name.slice(0, obj.ty.name.indexOf("<")) : obj.ty.name;
451
- if (getDiscriminant(ctx, baseTyName) === e.field)
452
- isDiscriminant = true;
453
- const decl = findDecl(ctx, baseTyName);
454
- if (decl?.kind === "record") {
455
- const f = decl.fields?.find(f => f.name === e.field);
456
- if (f)
457
- ty = f.type;
522
+ return { kind: "field", obj, field: e.field, ty, isDiscriminant };
523
+ }
524
+ case "nullish": {
525
+ // left ?? right — result type is left's inner (when left is optional)
526
+ // or just left's type, unified with right's type.
527
+ const left = resolveExpr(e.left, ctx);
528
+ const right = resolveExpr(e.right, ctx);
529
+ const ty = left.ty.kind === "optional" ? left.ty.inner : left.ty;
530
+ return { kind: "nullish", left, right, ty };
531
+ }
532
+ case "optChain": {
533
+ // obj?.<chain> — obj has type Option<T>; we walk the chain stepping
534
+ // through types from T. The final result is Option<finalStepTy>
535
+ // (collapsed: if finalStepTy is already optional, we don't double-wrap).
536
+ // Narrow rewrites this to a someMatch with the chain applied to the binder.
537
+ const obj = resolveExpr(e.obj, ctx);
538
+ let stepInTy = obj.ty.kind === "optional" ? obj.ty.inner : obj.ty;
539
+ const chain = [];
540
+ for (const step of e.chain) {
541
+ if (step.kind === "field") {
542
+ const fieldTy = lookupFieldTy(stepInTy, step.name, ctx).ty;
543
+ chain.push({ kind: "field", name: step.name, ty: fieldTy });
544
+ stepInTy = fieldTy;
545
+ }
546
+ else if (step.kind === "index") {
547
+ const idx = resolveExpr(step.idx, ctx);
548
+ const idxTy = stepInTy.kind === "array" ? stepInTy.elem
549
+ : stepInTy.kind === "map" ? { kind: "optional", inner: stepInTy.value }
550
+ : { kind: "unknown" };
551
+ chain.push({ kind: "index", idx, ty: idxTy });
552
+ stepInTy = idxTy;
458
553
  }
459
- // Also resolve fields from discriminated-union variants
460
- if (ty.kind === "unknown" && decl?.kind === "discriminated-union" && decl.variants) {
461
- for (const variant of decl.variants) {
462
- const f = variant.fields.find(f => f.name === e.field);
463
- if (f) {
464
- ty = f.type;
465
- break;
466
- }
554
+ else {
555
+ // call: prev step yielded a callable (typically a method via field).
556
+ // Build a fake fn TExpr from prev steps to reuse inferMethodReturnTy.
557
+ const args = step.args.map(a => resolveExpr(a, ctx));
558
+ const lastField = chain.length > 0 && chain[chain.length - 1].kind === "field"
559
+ ? chain[chain.length - 1] : null;
560
+ let callTy = { kind: "unknown" };
561
+ let callKind = "unknown";
562
+ if (lastField) {
563
+ // Build a synthetic field TExpr with the prior step's input type as obj
564
+ // so inferMethodReturnTy can dispatch on the receiver type.
565
+ const priorInTy = chain.length >= 2 ? chain[chain.length - 2].ty
566
+ : (obj.ty.kind === "optional" ? obj.ty.inner : obj.ty);
567
+ const fakeObj = { kind: "var", name: "_chain_recv", ty: priorInTy };
568
+ const fakeFn = { kind: "field", obj: fakeObj, field: lastField.name, ty: lastField.ty };
569
+ callTy = inferMethodReturnTy(fakeFn, args, ctx);
570
+ callKind = "method";
467
571
  }
572
+ chain.push({ kind: "call", args, ty: callTy, callKind });
573
+ stepInTy = callTy;
468
574
  }
469
575
  }
470
- return { kind: "field", obj, field: e.field, ty, isDiscriminant };
576
+ const finalTy = stepInTy;
577
+ const ty = finalTy.kind === "optional" ? finalTy : { kind: "optional", inner: finalTy };
578
+ return { kind: "optChain", obj, chain, ty };
471
579
  }
472
580
  case "record": {
473
581
  const spread = e.spread ? resolveExpr(e.spread, ctx) : null;
@@ -539,87 +647,43 @@ function resolveExpr(e, ctx) {
539
647
  }
540
648
  case "conditional": {
541
649
  const cond = resolveExpr(e.cond, ctx);
542
- let narrowedVar;
543
- let thenCtx = ctx;
544
- let rawThen = e.then;
545
- // Phase 1: Optional truthiness cond itself is optional (e.g. opt ? X : Y)
546
- if (cond.ty.kind === "optional") {
547
- const innerTy = cond.ty.inner;
548
- if (e.cond.kind === "var") {
549
- narrowedVar = e.cond.name;
550
- thenCtx = withEnv(ctx, extend(ctx.env, e.cond.name, innerTy));
551
- }
552
- else {
553
- narrowedVar = `_opt${_synVarCounter++}`;
554
- rawThen = substituteRawExpr(e.then, e.cond, { kind: "var", name: narrowedVar });
555
- thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, innerTy));
556
- }
557
- }
558
- // Phase 2/3: Explicit check — v !== undefined, or && with optional check.
559
- // Resolve only narrows the type environment; transform handles all structural
560
- // narrowing (match generation, variable binding, && splitting).
561
- let narrowedExprResolved;
650
+ // Type narrowing for the then/else branches. Following TS, we narrow
651
+ // simple vars and any pure access path (`a.b.c.d`) — but not expressions
652
+ // with method calls or index ops (bind-first required).
653
+ // For &&-chains, all positive checks narrow the then-branch; earlier
654
+ // checks are in scope when resolving later ones.
655
+ let thenCtx = collectAndChainNarrowings(e.cond, ctx);
562
656
  let elseCtx = ctx;
563
- if (!narrowedVar) {
564
- const narrowed = detectOptionalCheck(e.cond, ctx)
565
- ?? (e.cond.kind === "binop" && e.cond.op === "&&" ? detectOptionalCheck(e.cond.left, ctx) : null);
566
- if (narrowed && narrowed.inThen) {
567
- if (!narrowed.fieldExpr) {
568
- // Simple var: extend env only — transform will detect and generate match
569
- thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
570
- }
571
- else if (narrowed.fieldExpr.kind === "field" && narrowed.fieldExpr.obj.kind === "var") {
572
- // Simple field chain (obj.field): narrow via field context no substitution
573
- thenCtx = {
574
- ...thenCtx,
575
- narrowedFields: [...thenCtx.narrowedFields, {
576
- objName: narrowed.fieldExpr.obj.name,
577
- fieldName: narrowed.fieldExpr.field,
578
- narrowedTy: narrowed.innerTy,
579
- }],
580
- };
581
- }
582
- else {
583
- // Complex expression (call result, deep chain, etc.): transform can't detect these,
584
- // so keep old behavior — substitute + narrowedVar + narrowedExpr
585
- narrowedVar = narrowed.varName;
586
- narrowedExprResolved = narrowed.narrowedExpr ?? resolveExpr(narrowed.fieldExpr, ctx);
587
- rawThen = substituteRawExpr(e.then, narrowed.fieldExpr, { kind: "var", name: narrowed.varName });
588
- thenCtx = withEnv(thenCtx, extend(thenCtx.env, narrowed.varName, narrowed.innerTy));
589
- }
590
- }
591
- else if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
592
- // v === undefined: narrow v in the else branch
593
- elseCtx = withEnv(elseCtx, extend(elseCtx.env, narrowed.varName, narrowed.innerTy));
594
- }
595
- // Compound || with === undefined: narrow all checked vars in else branch
596
- // e.g. if (a === undefined || b === undefined) then X else Y → narrow a,b in Y
597
- // TODO: resolve-time narrowing works but transform doesn't emit match unwrap
598
- // for || conditions yet — decompose || into nested matches in transform.
599
- // Workaround: split || into separate if guards in user code.
600
- if (!narrowed && e.cond.kind === "binop" && e.cond.op === "||") {
601
- for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
602
- elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
603
- }
657
+ // Truthiness — cond itself is optional (`opt ? a : b`), only for simple vars.
658
+ if (cond.ty.kind === "optional" && e.cond.kind === "var") {
659
+ thenCtx = withEnv(thenCtx, extend(thenCtx.env, e.cond.name, cond.ty.inner));
660
+ }
661
+ // Single === undefined check narrows the else-branch.
662
+ const single = detectOptionalCheck(e.cond, ctx);
663
+ if (single && !single.inThen && !single.fieldExpr) {
664
+ elseCtx = withEnv(elseCtx, extend(elseCtx.env, single.varName, single.innerTy));
665
+ }
666
+ if (!single && e.cond.kind === "binop" && e.cond.op === "||") {
667
+ for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
668
+ elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
604
669
  }
605
670
  }
606
- let then_ = resolveExpr(rawThen, thenCtx);
671
+ // Map-index narrowing: `k in m` in the cond narrows the then-branch; `!(k in m)`
672
+ // narrows the else-branch. Parallel to the if-statement hook in resolveStmt.
673
+ thenCtx = withInAtoms(thenCtx, extractInAtoms(cond));
674
+ elseCtx = withInAtoms(elseCtx, extractInAtomsNegated(cond));
675
+ let then_ = resolveExpr(e.then, thenCtx);
607
676
  let else_ = resolveExpr(e.else, elseCtx);
608
677
  then_ = coerceStr(then_, else_.ty);
609
678
  else_ = coerceStr(else_, then_.ty);
610
679
  let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
611
- // When one branch is undefined, result is optional
612
680
  if (then_.ty.kind === "void" && else_.ty.kind !== "void" && else_.ty.kind !== "unknown") {
613
681
  ty = { kind: "optional", inner: else_.ty };
614
682
  }
615
683
  else if (else_.ty.kind === "void" && then_.ty.kind !== "void" && then_.ty.kind !== "unknown") {
616
684
  ty = { kind: "optional", inner: then_.ty };
617
685
  }
618
- // When narrowedExpr is set AND a branch is void, the match produces Optional
619
- if (narrowedExprResolved && (then_.ty.kind === "void" || else_.ty.kind === "void") && ty.kind !== "optional") {
620
- ty = { kind: "optional", inner: ty };
621
- }
622
- return { kind: "conditional", cond, then: then_, else: else_, ty, narrowedVar, narrowedExpr: narrowedExprResolved };
686
+ return { kind: "conditional", cond, then: then_, else: else_, ty };
623
687
  }
624
688
  case "emptyCollection": {
625
689
  const ty = parseTsType(e.tsType);
@@ -652,8 +716,10 @@ function splitConj(e) {
652
716
  function resolveBlock(stmts, ctx) {
653
717
  const result = [];
654
718
  let env = ctx.env;
719
+ let narrowedIndices = ctx.narrowedIndices;
655
720
  for (const s of stmts) {
656
- const [typed, nextEnv] = resolveStmt(s, withEnv(ctx, env));
721
+ const currentCtx = { ...ctx, env, narrowedIndices };
722
+ const [typed, nextEnv] = resolveStmt(s, currentCtx);
657
723
  result.push(typed);
658
724
  env = nextEnv;
659
725
  // Flow narrowing: if (x === undefined) { return } narrows x for rest of block.
@@ -665,6 +731,20 @@ function resolveBlock(stmts, ctx) {
665
731
  for (const n of narrowings) {
666
732
  env = extend(env, n.varName, n.innerTy);
667
733
  }
734
+ // Map-index narrowing: `if (!(k in m)) return;` means `k in m` holds in rest.
735
+ if (typed.kind === "if") {
736
+ const addedAtoms = extractInAtomsNegated(typed.cond);
737
+ if (addedAtoms.length > 0) {
738
+ narrowedIndices = withInAtoms({ ...ctx, narrowedIndices }, addedAtoms).narrowedIndices;
739
+ }
740
+ }
741
+ }
742
+ // Assert narrowing: `//@ assert k in m` adds atoms for the rest of the block.
743
+ if (typed.kind === "assert") {
744
+ const addedAtoms = extractInAtoms(typed.expr);
745
+ if (addedAtoms.length > 0) {
746
+ narrowedIndices = withInAtoms({ ...ctx, narrowedIndices }, addedAtoms).narrowedIndices;
747
+ }
668
748
  }
669
749
  }
670
750
  return result;
@@ -705,30 +785,36 @@ function resolveStmt(s, ctx) {
705
785
  return [{ kind: "expr", expr: resolveExpr(s.expr, ctx) }, ctx.env];
706
786
  case "if": {
707
787
  // Narrow optional<T> → T when checking !== undefined or undefined !==.
708
- // Also checks left side of && conditions: if (x !== undefined && ...) { ... }
709
- // Field chains are excluded resolve can't substitute in statement bodies;
710
- // transform's emitOptionalMatch handles field chains in statement contexts.
711
- let thenCtx = ctx, elseCtx = ctx;
712
- const narrowed = detectOptionalCheck(s.cond, ctx)
713
- ?? (s.cond.kind === "binop" && s.cond.op === "&&" ? detectOptionalCheck(s.cond.left, ctx) : null);
714
- if (narrowed && !narrowed.fieldExpr) {
715
- const env = extend(ctx.env, narrowed.varName, narrowed.innerTy);
716
- if (narrowed.inThen)
717
- thenCtx = withEnv(ctx, env);
718
- else
719
- elseCtx = withEnv(ctx, env);
788
+ // For &&-chains, all positive optional checks narrow the then-branch;
789
+ // earlier checks are in scope when resolving later ones.
790
+ // Single-check === undefined narrows the else-branch.
791
+ let thenCtx = collectAndChainNarrowings(s.cond, ctx);
792
+ let elseCtx = ctx;
793
+ const single = detectOptionalCheck(s.cond, ctx);
794
+ if (single && !single.inThen && !single.fieldExpr) {
795
+ elseCtx = withEnv(ctx, extend(ctx.env, single.varName, single.innerTy));
720
796
  }
721
- return [{ kind: "if", cond: resolveExpr(s.cond, ctx), then: resolveBlock(s.then, thenCtx), else: resolveBlock(s.else, elseCtx) }, ctx.env];
797
+ // Narrow map index access across `k in m` / `!(k in m)` in the cond:
798
+ // positive atoms (from `k in m` or &&-chains containing it) → then-branch;
799
+ // negated atoms (from `!(k in m)`) → else-branch.
800
+ const resolvedCond = resolveExpr(s.cond, ctx);
801
+ thenCtx = withInAtoms(thenCtx, extractInAtoms(resolvedCond));
802
+ elseCtx = withInAtoms(elseCtx, extractInAtomsNegated(resolvedCond));
803
+ return [{ kind: "if", cond: resolvedCond, then: resolveBlock(s.then, thenCtx), else: resolveBlock(s.else, elseCtx) }, ctx.env];
722
804
  }
723
805
  case "while": {
724
806
  const whileSpecCtx = { ...ctx, inSpec: true };
807
+ const resolvedInvariants = resolveSpecs(s.invariants, whileSpecCtx);
808
+ // Invariants hold at the top of the body, so any `k in m` atoms among them
809
+ // narrow map index access in the body.
810
+ const bodyCtx = withInAtoms(ctx, resolvedInvariants.flatMap(extractInAtoms));
725
811
  return [{
726
812
  kind: "while",
727
813
  cond: resolveExpr(s.cond, ctx),
728
- invariants: resolveSpecs(s.invariants, whileSpecCtx),
814
+ invariants: resolvedInvariants,
729
815
  decreases: s.decreases ? resolveSpec(s.decreases, whileSpecCtx) : null,
730
816
  doneWith: s.doneWith ? resolveSpec(s.doneWith, whileSpecCtx) : null,
731
- body: resolveBlock(s.body, ctx),
817
+ body: resolveBlock(s.body, bodyCtx),
732
818
  }, ctx.env];
733
819
  }
734
820
  case "forof": {
@@ -975,7 +1061,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns
975
1061
  env = extend(env, opts.thisBinding.name, opts.thisBinding.ty);
976
1062
  for (const p of params)
977
1063
  env = extend(env, p.name, p.ty);
978
- const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
1064
+ const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
979
1065
  const requiresCtx = { ...baseCtx, inSpec: true };
980
1066
  const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
981
1067
  // Apply type parameter constraints from //@ type T (==) annotations
@@ -983,14 +1069,22 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns
983
1069
  const constraint = overrides.get(tp);
984
1070
  return constraint ? `${tp}${constraint}` : tp;
985
1071
  });
1072
+ // Resolve requires first so we can extract any `k in m` atoms and seed them
1073
+ // into the body context and the ensures context — they hold for the whole
1074
+ // body (pure fns) and for post-state references in the ensures (map params
1075
+ // aren't mutated through their binding in the Dafny translation).
1076
+ const resolvedRequires = resolveSpecs(fn.requires, requiresCtx);
1077
+ const requiresAtoms = resolvedRequires.flatMap(extractInAtoms);
1078
+ const bodyCtx = withInAtoms(baseCtx, requiresAtoms);
1079
+ const ensuresCtxNarrowed = withInAtoms(ensuresCtx, requiresAtoms);
986
1080
  return {
987
1081
  name: fn.name, typeParams, params, returnTy,
988
- requires: resolveSpecs(fn.requires, requiresCtx),
989
- ensures: resolveSpecs(fn.ensures, ensuresCtx),
1082
+ requires: resolvedRequires,
1083
+ ensures: resolveSpecs(fn.ensures, ensuresCtxNarrowed),
990
1084
  decreases: fn.decreases ? resolveSpec(fn.decreases, requiresCtx) : null,
991
1085
  isPure: opts?.forcePure !== undefined ? opts.forcePure : pureFns.has(fn.name),
992
1086
  forcePure: fn.pure,
993
- body: resolveBlock(fn.body, baseCtx),
1087
+ body: resolveBlock(fn.body, bodyCtx),
994
1088
  };
995
1089
  }
996
1090
  function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map()) {
@@ -1021,7 +1115,6 @@ function precomputeFieldTypes(typeDecls) {
1021
1115
  }
1022
1116
  }
1023
1117
  export function resolveModule(raw) {
1024
- _synVarCounter = 0;
1025
1118
  precomputeFieldTypes(raw.typeDecls);
1026
1119
  const pureFns = computePureFns(raw.functions);
1027
1120
  // Pre-compute function parameter and return types
@@ -1032,7 +1125,7 @@ export function resolveModule(raw) {
1032
1125
  fnParams.set(fn.name, fn.params.map(p => resolveTsType(p.tsType, overrides, p.name)));
1033
1126
  fnReturns.set(fn.name, resolveTsType(fn.returnType, overrides, "\\result"));
1034
1127
  }
1035
- const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedFields: [] };
1128
+ const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
1036
1129
  const constants = (raw.constants ?? []).map(c => ({
1037
1130
  name: c.name,
1038
1131
  ty: parseTsType(c.tsType),