lemmascript 0.3.2 → 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,15 +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, callers must use `substituteRawExpr` to narrow.
112
- *
113
- * Does NOT recurse into `&&` callers that need to detect optional checks
114
- * 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 `&&`. */
115
66
  function detectOptionalCheck(cond, ctx) {
116
- if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "==="))
117
- 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
+ }
118
77
  // Identify the expression being checked against undefined
119
78
  let optExpr = null;
120
79
  if (cond.right.kind === "var" && cond.right.name === "undefined")
@@ -123,24 +82,112 @@ function detectOptionalCheck(cond, ctx) {
123
82
  optExpr = cond.right;
124
83
  if (!optExpr)
125
84
  return null;
126
- // Simple variable — env lookup, no substitution needed
127
- if (optExpr.kind === "var") {
128
- 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);
129
93
  if (!ty || ty.kind !== "optional")
130
94
  return null;
131
- return { varName: optExpr.name, innerTy: ty.inner, inThen: cond.op === "!==" };
95
+ return { varName: e.name, innerTy: ty.inner };
132
96
  }
133
- // Field access chain or arbitrary expression — resolve to check type, needs substitution
134
- const resolved = resolveExpr(optExpr, ctx);
135
- if (resolved.ty.kind === "optional") {
136
- const synVar = optExpr.kind === "field" ? `_narr${_synVarCounter++}` : `_opt${_synVarCounter++}`;
137
- return {
138
- varName: synVar, innerTy: resolved.ty.inner, inThen: cond.op === "!==",
139
- fieldExpr: optExpr, narrowedExpr: resolved,
140
- };
97
+ const resolved = resolveExpr(e, ctx);
98
+ if (resolved.ty.kind !== "optional")
99
+ return null;
100
+ return { varName: "", innerTy: resolved.ty.inner, fieldExpr: e };
101
+ }
102
+ /** Collect all optional narrowings from an early-return condition.
103
+ * Handles single checks (x === undefined) and compound || chains
104
+ * (x === undefined || y === undefined). */
105
+ function collectEarlyReturnNarrowings(cond, ctx) {
106
+ if (cond.kind === "binop" && cond.op === "||") {
107
+ return [...collectEarlyReturnNarrowings(cond.left, ctx), ...collectEarlyReturnNarrowings(cond.right, ctx)];
108
+ }
109
+ const narrowed = detectOptionalCheck(cond, ctx);
110
+ if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
111
+ return [{ varName: narrowed.varName, innerTy: narrowed.innerTy }];
112
+ }
113
+ return [];
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] };
141
125
  }
142
126
  return null;
143
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
+ }
144
191
  /** TS reference types that become value types in Dafny/Lean — const bindings need mutable var. */
145
192
  function isRefMutableInTS(ty) {
146
193
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
@@ -317,6 +364,34 @@ function inferMethodReturnTy(fn, args, ctx) {
317
364
  }
318
365
  return { kind: "unknown" };
319
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
+ }
320
395
  // ── Resolve expressions ──────────────────────────────────────
321
396
  function resolveExpr(e, ctx) {
322
397
  switch (e.kind) {
@@ -344,16 +419,12 @@ function resolveExpr(e, ctx) {
344
419
  }
345
420
  case "binop": {
346
421
  let left = resolveExpr(e.left, ctx);
347
- // && narrowing: if left is "x !== undefined", narrow x for right side.
348
- // Field chains are excluded resolve can't substitute in sub-expressions;
349
- // 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.)
350
424
  let rightCtx = ctx;
351
425
  let rawRight = e.right;
352
- if (e.op === "&&") {
353
- const narrowed = detectOptionalCheck(e.left, ctx);
354
- if (narrowed && narrowed.inThen && !narrowed.fieldExpr) {
355
- rightCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
356
- }
426
+ if (e.op === "&&" || e.op === "==>") {
427
+ rightCtx = collectAndChainNarrowings(e.left, ctx);
357
428
  }
358
429
  let right = resolveExpr(rawRight, rightCtx);
359
430
  if (e.op === "===" || e.op === "!==") {
@@ -389,49 +460,122 @@ function resolveExpr(e, ctx) {
389
460
  fn.obj.ty.elem.kind === "user") {
390
461
  argCtx = { ...ctx, returnTy: fn.obj.ty.elem };
391
462
  }
392
- const args = coerceCallArgs(rawArgs.map(a => resolveExpr(a, argCtx)), fn, ctx);
393
- const ty = inferMethodReturnTy(fn, args, ctx);
463
+ // Propagate parameter types to arguments for record literal resolution
464
+ // (enables inline discriminated union construction in function arguments)
465
+ const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
466
+ const args = coerceCallArgs(rawArgs.map((a, i) => {
467
+ let aCtx = argCtx;
468
+ if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
469
+ aCtx = { ...aCtx, returnTy: paramTypes[i] };
470
+ }
471
+ return resolveExpr(a, aCtx);
472
+ }), fn, ctx);
473
+ let ty = inferMethodReturnTy(fn, args, ctx);
474
+ // For same-file function calls, use the known return type
475
+ if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
476
+ ty = ctx.fnReturns.get(fn.name);
477
+ }
394
478
  return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
395
479
  }
396
480
  case "index": {
397
481
  const obj = resolveExpr(e.obj, ctx);
398
482
  const idx = resolveExpr(e.idx, ctx);
399
- const idxTy = obj.ty.kind === "array" ? obj.ty.elem
400
- : obj.ty.kind === "map" ? { kind: "optional", inner: obj.ty.value }
401
- : { 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
+ }
402
500
  return { kind: "index", obj, idx, ty: idxTy };
403
501
  }
404
502
  case "field": {
405
503
  const obj = resolveExpr(e.obj, ctx);
406
504
  let isDiscriminant = false;
407
505
  let ty = { kind: "unknown" };
408
- if (e.field === "length" && (obj.ty.kind === "array" || obj.ty.kind === "string")) {
409
- 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
+ }
410
516
  }
411
- else if (e.field === "size" && (obj.ty.kind === "map" || obj.ty.kind === "set")) {
412
- 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;
413
521
  }
414
- else if (obj.ty.kind === "user") {
415
- if (getDiscriminant(ctx, obj.ty.name) === e.field)
416
- isDiscriminant = true;
417
- const decl = findDecl(ctx, obj.ty.name);
418
- if (decl?.kind === "record") {
419
- const f = decl.fields?.find(f => f.name === e.field);
420
- if (f)
421
- 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;
422
553
  }
423
- // Also resolve fields from discriminated-union variants
424
- if (ty.kind === "unknown" && decl?.kind === "discriminated-union" && decl.variants) {
425
- for (const variant of decl.variants) {
426
- const f = variant.fields.find(f => f.name === e.field);
427
- if (f) {
428
- ty = f.type;
429
- break;
430
- }
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";
431
571
  }
572
+ chain.push({ kind: "call", args, ty: callTy, callKind });
573
+ stepInTy = callTy;
432
574
  }
433
575
  }
434
- 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 };
435
579
  }
436
580
  case "record": {
437
581
  const spread = e.spread ? resolveExpr(e.spread, ctx) : null;
@@ -442,8 +586,13 @@ function resolveExpr(e, ctx) {
442
586
  // Clear returnTy for field values — it applies to THIS record, not nested ones
443
587
  const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
444
588
  const fields = e.fields.map(f => {
445
- let value = resolveExpr(f.value, fieldCtx);
446
589
  const fieldDecl = decl?.fields?.find(df => df.name === f.name);
590
+ // Propagate declared field type into context so nested records resolve
591
+ // their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle)
592
+ const valueCtx = (fieldDecl?.type?.kind === "user")
593
+ ? { ...fieldCtx, returnTy: fieldDecl.type }
594
+ : fieldCtx;
595
+ let value = resolveExpr(f.value, valueCtx);
447
596
  if (fieldDecl) {
448
597
  const declTy = fieldDecl.type;
449
598
  value = coerceStr(value, declTy);
@@ -498,56 +647,43 @@ function resolveExpr(e, ctx) {
498
647
  }
499
648
  case "conditional": {
500
649
  const cond = resolveExpr(e.cond, ctx);
501
- let narrowedVar;
502
- let narrowedExprResolved;
503
- let thenCtx = ctx;
504
- let rawThen = e.then;
505
- // Phase 1: Optional truthiness cond itself is optional (e.g. opt ? X : Y)
506
- if (cond.ty.kind === "optional") {
507
- const innerTy = cond.ty.inner;
508
- if (e.cond.kind === "var") {
509
- narrowedVar = e.cond.name;
510
- thenCtx = withEnv(ctx, extend(ctx.env, e.cond.name, innerTy));
511
- }
512
- else {
513
- narrowedVar = `_opt${_synVarCounter++}`;
514
- rawThen = substituteRawExpr(e.then, e.cond, { kind: "var", name: narrowedVar });
515
- thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, innerTy));
516
- }
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);
656
+ let elseCtx = ctx;
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));
517
660
  }
518
- // Phase 2: Explicit check v !== undefined (simple vars, field chains,
519
- // complex expressions all handled uniformly by detectOptionalCheck)
520
- if (!narrowedVar) {
521
- const narrowed = detectOptionalCheck(e.cond, ctx)
522
- // Phase 3: && with optional check — (v !== undefined && ...) ? ... : ...
523
- ?? (e.cond.kind === "binop" && e.cond.op === "&&" ? detectOptionalCheck(e.cond.left, ctx) : null);
524
- if (narrowed && narrowed.inThen) {
525
- narrowedVar = narrowed.varName;
526
- thenCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
527
- if (narrowed.fieldExpr) {
528
- narrowedExprResolved = narrowed.narrowedExpr ?? resolveExpr(narrowed.fieldExpr, ctx);
529
- rawThen = substituteRawExpr(e.then, narrowed.fieldExpr, { kind: "var", name: narrowed.varName });
530
- }
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));
531
669
  }
532
670
  }
533
- let then_ = resolveExpr(rawThen, thenCtx);
534
- let else_ = resolveExpr(e.else, ctx);
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);
676
+ let else_ = resolveExpr(e.else, elseCtx);
535
677
  then_ = coerceStr(then_, else_.ty);
536
678
  else_ = coerceStr(else_, then_.ty);
537
679
  let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
538
- // When one branch is undefined, result is optional
539
680
  if (then_.ty.kind === "void" && else_.ty.kind !== "void" && else_.ty.kind !== "unknown") {
540
681
  ty = { kind: "optional", inner: else_.ty };
541
682
  }
542
683
  else if (else_.ty.kind === "void" && then_.ty.kind !== "void" && then_.ty.kind !== "unknown") {
543
684
  ty = { kind: "optional", inner: then_.ty };
544
685
  }
545
- // When narrowedExpr is set AND a branch is void, the match produces Optional
546
- const hasVoidBranch = then_.ty.kind === "void" || else_.ty.kind === "void";
547
- if (narrowedExprResolved && hasVoidBranch && ty.kind !== "optional") {
548
- ty = { kind: "optional", inner: ty };
549
- }
550
- return { kind: "conditional", cond, then: then_, else: else_, ty, narrowedVar, narrowedExpr: narrowedExprResolved };
686
+ return { kind: "conditional", cond, then: then_, else: else_, ty };
551
687
  }
552
688
  case "emptyCollection": {
553
689
  const ty = parseTsType(e.tsType);
@@ -580,17 +716,34 @@ function splitConj(e) {
580
716
  function resolveBlock(stmts, ctx) {
581
717
  const result = [];
582
718
  let env = ctx.env;
719
+ let narrowedIndices = ctx.narrowedIndices;
583
720
  for (const s of stmts) {
584
- const [typed, nextEnv] = resolveStmt(s, withEnv(ctx, env));
721
+ const currentCtx = { ...ctx, env, narrowedIndices };
722
+ const [typed, nextEnv] = resolveStmt(s, currentCtx);
585
723
  result.push(typed);
586
724
  env = nextEnv;
587
725
  // Flow narrowing: if (x === undefined) { return } narrows x for rest of block.
726
+ // Also handles compound: if (x === undefined || y === undefined) { return }
588
727
  // Field chains are excluded — resolve can't substitute in statement lists;
589
728
  // transform's emitOptionalMatch handles field chains in statement contexts.
590
729
  if (s.kind === "if" && s.then.length > 0 && s.then[s.then.length - 1].kind === "return" && s.else.length === 0) {
591
- const narrowed = detectOptionalCheck(s.cond, withEnv(ctx, env));
592
- if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
593
- env = extend(env, narrowed.varName, narrowed.innerTy);
730
+ const narrowings = collectEarlyReturnNarrowings(s.cond, withEnv(ctx, env));
731
+ for (const n of narrowings) {
732
+ env = extend(env, n.varName, n.innerTy);
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;
594
747
  }
595
748
  }
596
749
  }
@@ -600,7 +753,10 @@ function resolveStmt(s, ctx) {
600
753
  switch (s.kind) {
601
754
  case "let": {
602
755
  const declTy = resolveTsType(s.tsType, ctx.overrides, s.name);
603
- const init = coerceStr(resolveExpr(s.init, ctx), declTy);
756
+ // Propagate declared type as returnTy so nested record expressions
757
+ // resolve union variants correctly (e.g., EffectState → mode: EffectMode → { kind: 'Idle' })
758
+ const initCtx = declTy.kind === "user" ? { ...ctx, returnTy: declTy } : ctx;
759
+ const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
604
760
  // Map indexing: TS says T, but access can fail → use Optional<T> from init
605
761
  const ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
606
762
  // const collections are mutable in value-semantics world (TS mutates in place, Dafny/Lean reassign)
@@ -629,30 +785,36 @@ function resolveStmt(s, ctx) {
629
785
  return [{ kind: "expr", expr: resolveExpr(s.expr, ctx) }, ctx.env];
630
786
  case "if": {
631
787
  // Narrow optional<T> → T when checking !== undefined or undefined !==.
632
- // Also checks left side of && conditions: if (x !== undefined && ...) { ... }
633
- // Field chains are excluded resolve can't substitute in statement bodies;
634
- // transform's emitOptionalMatch handles field chains in statement contexts.
635
- let thenCtx = ctx, elseCtx = ctx;
636
- const narrowed = detectOptionalCheck(s.cond, ctx)
637
- ?? (s.cond.kind === "binop" && s.cond.op === "&&" ? detectOptionalCheck(s.cond.left, ctx) : null);
638
- if (narrowed && !narrowed.fieldExpr) {
639
- const env = extend(ctx.env, narrowed.varName, narrowed.innerTy);
640
- if (narrowed.inThen)
641
- thenCtx = withEnv(ctx, env);
642
- else
643
- 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));
644
796
  }
645
- 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];
646
804
  }
647
805
  case "while": {
648
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));
649
811
  return [{
650
812
  kind: "while",
651
813
  cond: resolveExpr(s.cond, ctx),
652
- invariants: resolveSpecs(s.invariants, whileSpecCtx),
814
+ invariants: resolvedInvariants,
653
815
  decreases: s.decreases ? resolveSpec(s.decreases, whileSpecCtx) : null,
654
816
  doneWith: s.doneWith ? resolveSpec(s.doneWith, whileSpecCtx) : null,
655
- body: resolveBlock(s.body, ctx),
817
+ body: resolveBlock(s.body, bodyCtx),
656
818
  }, ctx.env];
657
819
  }
658
820
  case "forof": {
@@ -833,6 +995,8 @@ function collectCallsStmts(stmts, fns, out) {
833
995
  }
834
996
  function computePureFns(functions) {
835
997
  const allFnNames = new Set(functions.map(fn => fn.name));
998
+ // //@ pure functions are always considered pure — never taint callers
999
+ const forcePure = new Set(functions.filter(fn => fn.pure).map(fn => fn.name));
836
1000
  // Build call graph: fn → set of same-file functions it calls
837
1001
  const callGraph = new Map();
838
1002
  for (const fn of functions) {
@@ -840,8 +1004,8 @@ function computePureFns(functions) {
840
1004
  collectCallsStmts(fn.body, allFnNames, calls);
841
1005
  callGraph.set(fn.name, calls);
842
1006
  }
843
- // Seed: syntactically non-pure functions
844
- const nonPure = new Set(functions.filter(fn => !isSyntacticallyPure(fn.body)).map(fn => fn.name));
1007
+ // Seed: syntactically non-pure functions (skip //@ pure)
1008
+ const nonPure = new Set(functions.filter(fn => !forcePure.has(fn.name) && !isSyntacticallyPure(fn.body)).map(fn => fn.name));
845
1009
  // Build reverse graph: fn → set of functions that call it
846
1010
  const callers = new Map();
847
1011
  for (const name of allFnNames)
@@ -850,12 +1014,12 @@ function computePureFns(functions) {
850
1014
  for (const callee of callees)
851
1015
  callers.get(callee).add(caller);
852
1016
  }
853
- // Propagate impurity through reverse call graph
1017
+ // Propagate impurity through reverse call graph (skip //@ pure)
854
1018
  const worklist = [...nonPure];
855
1019
  while (worklist.length > 0) {
856
1020
  const fn = worklist.pop();
857
1021
  for (const caller of callers.get(fn) ?? []) {
858
- if (!nonPure.has(caller)) {
1022
+ if (!nonPure.has(caller) && !forcePure.has(caller)) {
859
1023
  nonPure.add(caller);
860
1024
  worklist.push(caller);
861
1025
  }
@@ -888,7 +1052,7 @@ function containsReturn(stmts) {
888
1052
  return false;
889
1053
  }
890
1054
  // ── Resolve function / module ────────────────────────────────
891
- function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
1055
+ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), opts) {
892
1056
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
893
1057
  const params = fn.params.map(p => ({ name: p.name, ty: resolveTsType(p.tsType, overrides, p.name) }));
894
1058
  const returnTy = resolveTsType(fn.returnType, overrides, "\\result");
@@ -897,7 +1061,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
897
1061
  env = extend(env, opts.thisBinding.name, opts.thisBinding.ty);
898
1062
  for (const p of params)
899
1063
  env = extend(env, p.name, p.ty);
900
- const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, inSpec: false, inLambda: false };
1064
+ const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
901
1065
  const requiresCtx = { ...baseCtx, inSpec: true };
902
1066
  const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
903
1067
  // Apply type parameter constraints from //@ type T (==) annotations
@@ -905,21 +1069,31 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), opts) {
905
1069
  const constraint = overrides.get(tp);
906
1070
  return constraint ? `${tp}${constraint}` : tp;
907
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);
908
1080
  return {
909
1081
  name: fn.name, typeParams, params, returnTy,
910
- requires: resolveSpecs(fn.requires, requiresCtx),
911
- ensures: resolveSpecs(fn.ensures, ensuresCtx),
1082
+ requires: resolvedRequires,
1083
+ ensures: resolveSpecs(fn.ensures, ensuresCtxNarrowed),
1084
+ decreases: fn.decreases ? resolveSpec(fn.decreases, requiresCtx) : null,
912
1085
  isPure: opts?.forcePure !== undefined ? opts.forcePure : pureFns.has(fn.name),
913
- body: resolveBlock(fn.body, baseCtx),
1086
+ forcePure: fn.pure,
1087
+ body: resolveBlock(fn.body, bodyCtx),
914
1088
  };
915
1089
  }
916
- function resolveClass(cls, typeDecls, pureFns, fnParams = new Map()) {
1090
+ function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map()) {
917
1091
  const fields = cls.fields.map(f => ({ name: f.name, ty: parseTsType(f.tsType) }));
918
1092
  // Create a synthetic record type for 'this' so field access resolves
919
1093
  const thisType = { kind: "user", name: cls.name };
920
1094
  const thisDecl = { name: cls.name, kind: "record", fields: cls.fields.map(f => ({ name: f.name, tsType: f.tsType, type: parseTsType(f.tsType) })) };
921
1095
  const allTypeDecls = [...typeDecls, thisDecl];
922
- const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, {
1096
+ const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, fnReturns, {
923
1097
  thisBinding: { name: "this", ty: thisType },
924
1098
  forcePure: false, // class methods are never pure (they access this)
925
1099
  }));
@@ -943,13 +1117,15 @@ function precomputeFieldTypes(typeDecls) {
943
1117
  export function resolveModule(raw) {
944
1118
  precomputeFieldTypes(raw.typeDecls);
945
1119
  const pureFns = computePureFns(raw.functions);
946
- // Pre-compute function parameter types for optional coercion
1120
+ // Pre-compute function parameter and return types
947
1121
  const fnParams = new Map();
1122
+ const fnReturns = new Map();
948
1123
  for (const fn of raw.functions) {
949
1124
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
950
1125
  fnParams.set(fn.name, fn.params.map(p => resolveTsType(p.tsType, overrides, p.name)));
1126
+ fnReturns.set(fn.name, resolveTsType(fn.returnType, overrides, "\\result"));
951
1127
  }
952
- const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, inSpec: false, inLambda: false };
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: [] };
953
1129
  const constants = (raw.constants ?? []).map(c => ({
954
1130
  name: c.name,
955
1131
  ty: parseTsType(c.tsType),
@@ -959,7 +1135,7 @@ export function resolveModule(raw) {
959
1135
  file: raw.file,
960
1136
  typeDecls: raw.typeDecls,
961
1137
  constants,
962
- functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams)),
963
- classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams)),
1138
+ functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams, fnReturns)),
1139
+ classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams, fnReturns)),
964
1140
  };
965
1141
  }