lemmascript 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,9 @@ import { isBigInt, tyEqual, isTerminatorKind } from "./typedir.js";
8
8
  import { parseTsType, tyToCanonical } from "./types.js";
9
9
  import { parseExpr } from "./specparser.js";
10
10
  import { freshName } from "./names.js";
11
+ import { recognizeBuiltin, builtinSpec } from "./builtins.js";
12
+ import { declOf, declOfKind, declOfDotted, declOfTy, tyBaseName } from "./typedecls.js";
13
+ import { presentFact, variantFact } from "./condition-facts.js";
11
14
  function lookup(env, name) {
12
15
  if (!env)
13
16
  return undefined;
@@ -52,10 +55,36 @@ function resolveTsType(tsType, overrides, varName) {
52
55
  }
53
56
  return parseTsType(tsType);
54
57
  }
55
- /** If expr is a string literal and targetTy is a user type, coerce the literal's type. */
58
+ /** Infer a conditional's result type after its branches have been resolved or
59
+ * contextually coerced. A void branch is the source-level null/undefined arm. */
60
+ function conditionalResultTy(thenTy, elseTy) {
61
+ let ty = thenTy.kind !== "unknown" ? thenTy : elseTy;
62
+ if (thenTy.kind === "void" && elseTy.kind !== "void" && elseTy.kind !== "unknown") {
63
+ ty = { kind: "optional", inner: elseTy };
64
+ }
65
+ else if (elseTy.kind === "void" && thenTy.kind !== "void" && thenTy.kind !== "unknown") {
66
+ ty = { kind: "optional", inner: thenTy };
67
+ }
68
+ else if (thenTy.kind === "optional" && elseTy.kind !== "optional" && elseTy.kind !== "unknown") {
69
+ ty = thenTy;
70
+ }
71
+ else if (elseTy.kind === "optional" && thenTy.kind !== "optional" && thenTy.kind !== "unknown") {
72
+ ty = elseTy;
73
+ }
74
+ return ty;
75
+ }
76
+ /** Contextually type string literals as constructors. Ternary branches inherit
77
+ * the ternary's target, and an optional target contributes its payload type —
78
+ * the caller adds the Some wrapper only after the payload has been coerced. */
56
79
  function coerceStr(expr, targetTy) {
57
- if (expr.kind === "str" && targetTy.kind === "user")
58
- return { ...expr, ty: targetTy };
80
+ const payloadTy = targetTy.kind === "optional" ? targetTy.inner : targetTy;
81
+ if (expr.kind === "str" && payloadTy.kind === "user")
82
+ return { ...expr, ty: payloadTy };
83
+ if (expr.kind === "conditional") {
84
+ const then_ = coerceStr(expr.then, payloadTy);
85
+ const else_ = coerceStr(expr.else, payloadTy);
86
+ return { ...expr, then: then_, else: else_, ty: conditionalResultTy(then_.ty, else_.ty) };
87
+ }
59
88
  return expr;
60
89
  }
61
90
  // ── Helpers ──────────────────────────────────────────────────
@@ -68,7 +97,7 @@ function wrapSome(value, optionalTy) {
68
97
  }
69
98
  /** Find the synth array-union TypeDecl named `name` (discriminant `__isArray__`). */
70
99
  function findSynthArrayUnion(name, typeDecls) {
71
- const decl = typeDecls.find(d => d.name === name);
100
+ const decl = declOf(typeDecls, name);
72
101
  if (decl?.kind === "discriminated-union" && decl.discriminant === "__isArray__")
73
102
  return decl;
74
103
  return null;
@@ -81,6 +110,7 @@ function findSynthArrayUnion(name, typeDecls) {
81
110
  * Returns `value` unchanged if no coercion applies (types already match,
82
111
  * source is unknown, or no rule matches). */
83
112
  function coerceToTargetTy(value, targetTy, typeDecls) {
113
+ value = coerceStr(value, targetTy);
84
114
  if (value.ty.kind === "unknown" || value.ty.kind === "void")
85
115
  return value;
86
116
  if (targetTy.kind === "optional" && value.ty.kind !== "optional") {
@@ -103,68 +133,37 @@ function coerceToTargetTy(value, targetTy, typeDecls) {
103
133
  }
104
134
  return value;
105
135
  }
106
- /** Detect optional checks: `v !== undefined` (positive narrows then-branch),
107
- * `v === undefined` (negative narrows else-branch), or `!v` (equivalent to
108
- * `=== undefined`).
109
- * Returns:
110
- * - simple var: `varName` set, `fieldExpr` unset
111
- * - complex (field chain or call): `fieldExpr` set, `varName` empty
112
- * - inThen: true for `!==` (truthy), false for `===` and `!v` (falsy).
113
- * Does NOT recurse into `&&`. */
114
- function detectOptionalCheck(cond, ctx) {
115
- // `!v` where v is optional — same shape as `v === undefined` (inThen: false).
116
- if (cond.kind === "unop" && cond.op === "!") {
117
- const inner = classifyOptExpr(cond.expr, ctx);
118
- return inner ? { ...inner, inThen: false } : null;
119
- }
120
- if (cond.kind !== "binop" || (cond.op !== "!==" && cond.op !== "===")) {
121
- // Bare optional truthiness: `if (v)` where v: T | undefined — same as `v !== undefined`.
122
- const inner = classifyOptExpr(cond, ctx);
123
- return inner ? { ...inner, inThen: true } : null;
124
- }
125
- // Identify the expression being checked against undefined
126
- let optExpr = null;
127
- if (cond.right.kind === "var" && cond.right.name === "undefined")
128
- optExpr = cond.left;
129
- if (cond.left.kind === "var" && cond.left.name === "undefined")
130
- optExpr = cond.right;
131
- if (!optExpr)
132
- return null;
133
- const inner = classifyOptExpr(optExpr, ctx);
134
- return inner ? { ...inner, inThen: cond.op === "!==" } : null;
135
- }
136
- /** Classify an expression as a simple var or field-chain optional, returning
137
- * the shape needed by detectOptionalCheck (sans inThen). */
138
- function classifyOptExpr(e, ctx) {
139
- if (e.kind === "var") {
140
- const ty = lookup(ctx.env, e.name);
141
- if (!ty || ty.kind !== "optional")
142
- return null;
143
- return { varName: e.name, innerTy: ty.inner };
144
- }
145
- if (e.kind === "result") {
146
- const ty = lookup(ctx.env, "\\result");
147
- if (!ty || ty.kind !== "optional")
148
- return null;
149
- return { varName: "\\result", innerTy: ty.inner };
136
+ /** A negated presence check on a bare var (`v === undefined` / `!v`), via
137
+ * the shared condition analyzer (§4) on the *resolved* condition. Narrows
138
+ * the else-branch (or the rest of the block, after an early return). */
139
+ function negatedVarPresence(cond) {
140
+ const f = presentFact(cond);
141
+ if (f && f.negated && f.scrutinee.kind === "var") {
142
+ return { varName: f.scrutinee.name, innerTy: f.innerTy };
150
143
  }
151
- const resolved = resolveExpr(e, ctx);
152
- if (resolved.ty.kind !== "optional")
153
- return null;
154
- return { varName: "", innerTy: resolved.ty.inner, fieldExpr: e };
144
+ return null;
155
145
  }
156
146
  /** Collect all optional narrowings from an early-return condition.
157
147
  * Handles single checks (x === undefined) and compound || chains
158
148
  * (x === undefined || y === undefined). */
159
- function collectEarlyReturnNarrowings(cond, ctx) {
149
+ function collectEarlyReturnNarrowings(cond) {
160
150
  if (cond.kind === "binop" && cond.op === "||") {
161
- return [...collectEarlyReturnNarrowings(cond.left, ctx), ...collectEarlyReturnNarrowings(cond.right, ctx)];
151
+ return [...collectEarlyReturnNarrowings(cond.left), ...collectEarlyReturnNarrowings(cond.right)];
162
152
  }
163
- const narrowed = detectOptionalCheck(cond, ctx);
164
- if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
165
- return [{ varName: narrowed.varName, innerTy: narrowed.innerTy }];
153
+ const n = negatedVarPresence(cond);
154
+ return n ? [n] : [];
155
+ }
156
+ /** Negated discriminant check (`path.kind !== "lit"`) on a var or field path.
157
+ * After `if (path.kind !== "lit") return`, the rest of the block knows the
158
+ * path is that variant. */
159
+ function negatedVariantCheck(cond) {
160
+ if (cond.kind === "binop" && cond.op === "!==" && cond.right.kind === "str" &&
161
+ cond.left.kind === "field" && cond.left.isDiscriminant && cond.left.obj.ty.kind === "user") {
162
+ const path = asTExprAccessPath(cond.left.obj);
163
+ if (path)
164
+ return { path, narrowedTy: cond.left.obj.ty, variant: cond.right.value };
166
165
  }
167
- return [];
166
+ return null;
168
167
  }
169
168
  /** TExpr → AccessPath. Counterpart to `asRawAccessPath` for resolved trees.
170
169
  * Used by `extractInAtoms` when pulling atoms out of typed spec expressions. */
@@ -222,23 +221,36 @@ function withInAtoms(ctx, atoms) {
222
221
  return ctx;
223
222
  return { ...ctx, narrowedIndices: [...existing, ...added] };
224
223
  }
225
- /** Walk an `&&` chain of `e !== undefined` checks, returning a Ctx with all
226
- * narrowings applied. Earlier checks are in scope for later checks (so the
227
- * right side of `&&` sees the left side's narrowings). */
224
+ /** Walk an `&&` chain of `e !== undefined` checks on the *resolved*
225
+ * condition, returning a Ctx with all narrowings applied. Earlier checks
226
+ * are in scope for later checks (the right conjunct was already resolved
227
+ * under the left's narrowings by the `&&` case of resolveExpr). Consults
228
+ * the shared condition analyzer (§4): a positive presence fact on a bare
229
+ * var extends the env; on a pure field path it extends `narrowedPaths`. */
228
230
  function collectAndChainNarrowings(cond, ctx) {
229
231
  if (cond.kind === "binop" && cond.op === "&&") {
230
232
  const leftCtx = collectAndChainNarrowings(cond.left, ctx);
231
233
  return collectAndChainNarrowings(cond.right, leftCtx);
232
234
  }
233
- const n = detectOptionalCheck(cond, ctx);
234
- if (!n || !n.inThen)
235
+ const f = presentFact(cond);
236
+ if (f && !f.negated) {
237
+ if (f.scrutinee.kind === "var") {
238
+ return withEnv(ctx, extend(ctx.env, f.scrutinee.name, f.innerTy));
239
+ }
240
+ const path = asTExprAccessPath(f.scrutinee);
241
+ if (path) {
242
+ return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: f.innerTy }] };
243
+ }
235
244
  return ctx;
236
- if (!n.fieldExpr) {
237
- return withEnv(ctx, extend(ctx.env, n.varName, n.innerTy));
238
245
  }
239
- const path = asRawAccessPath(n.fieldExpr);
240
- if (path) {
241
- return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: n.innerTy }] };
246
+ // Positive discriminant check (`x.kind === "bool"`): record the variant so
247
+ // field reads on the path resolve against that variant's field types.
248
+ const vf = variantFact(cond, { decls: ctx.typeDecls, oc: { n: 0 } });
249
+ if (vf) {
250
+ const path = asTExprAccessPath(vf.scrutinee);
251
+ if (path) {
252
+ return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: vf.scrutinee.ty, variant: vf.variant }] };
253
+ }
242
254
  }
243
255
  return ctx;
244
256
  }
@@ -247,16 +259,10 @@ function isRefMutableInTS(ty) {
247
259
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
248
260
  }
249
261
  function findDecl(ctx, name) {
250
- const direct = ctx.typeDecls.find(d => d.name === name);
251
- if (direct)
252
- return direct;
253
- // Dotted names (e.g. `Agent.Info`, `Permission.Ruleset`): fall back to the
262
+ // Dotted names (e.g. `Agent.Info`, `Permission.Ruleset`) fall back to the
254
263
  // last segment, so `//@ declare-type Info { ... }` matches a reference to
255
264
  // `Agent.Info` without forcing the user to repeat the namespace.
256
- const dotIdx = name.lastIndexOf(".");
257
- if (dotIdx >= 0)
258
- return ctx.typeDecls.find(d => d.name === name.slice(dotIdx + 1));
259
- return undefined;
265
+ return declOfDotted(ctx.typeDecls, name);
260
266
  }
261
267
  /** Expand alias-kind typeDecls when the alias target is structural (array,
262
268
  * map, set, optional, or another user type). Primitive-typed aliases like
@@ -266,11 +272,7 @@ function expandAlias(ty, typeDecls, seen = new Set()) {
266
272
  if (ty.kind === "user") {
267
273
  if (seen.has(ty.name))
268
274
  return ty;
269
- let decl = typeDecls.find(d => d.name === ty.name);
270
- if (!decl && ty.name.includes(".")) {
271
- const tail = ty.name.slice(ty.name.lastIndexOf(".") + 1);
272
- decl = typeDecls.find(d => d.name === tail);
273
- }
275
+ const decl = declOfDotted(typeDecls, ty.name);
274
276
  if (decl?.kind === "alias" && decl.aliasOfTy) {
275
277
  const target = decl.aliasOfTy;
276
278
  if (target.kind === "array" || target.kind === "map" || target.kind === "set" || target.kind === "optional" || target.kind === "user") {
@@ -305,11 +307,7 @@ function refEqHazard(ty, typeDecls) {
305
307
  if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set" || ty.kind === "tuple")
306
308
  return true;
307
309
  if (ty.kind === "user") {
308
- let decl = typeDecls.find(d => d.name === ty.name);
309
- if (!decl && ty.name.includes(".")) {
310
- const tail = ty.name.slice(ty.name.lastIndexOf(".") + 1);
311
- decl = typeDecls.find(d => d.name === tail);
312
- }
310
+ const decl = declOfDotted(typeDecls, ty.name);
313
311
  if (!decl)
314
312
  return true; // generic type parameter / unknown → assume reference
315
313
  if (decl.kind === "string-union")
@@ -348,35 +346,44 @@ function isUnmodeledTy(ty, typeDecls) {
348
346
  return isUnmodeledTy(ty.elem, typeDecls);
349
347
  if (ty.kind === "map")
350
348
  return isUnmodeledTy(ty.key, typeDecls) || isUnmodeledTy(ty.value, typeDecls);
351
- if (ty.kind === "user") {
352
- const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
353
- return !typeDecls.some(d => d.name === base);
354
- }
349
+ if (ty.kind === "user")
350
+ return declOfTy(typeDecls, ty) === undefined;
355
351
  return false;
356
352
  }
357
353
  /** A `user` type that resolves to a string-union declare-type — runs as a plain
358
354
  * string at runtime, so it's a refinement of `string`, not an opaque blob. */
359
355
  function isStringUnionTy(ty, typeDecls) {
360
- if (ty.kind !== "user")
361
- return false;
362
- const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
363
- return typeDecls.some(d => d.name === base && d.kind === "string-union");
356
+ return declOfTy(typeDecls, ty)?.kind === "string-union";
357
+ }
358
+ /** Whether ts-morph widened a string-union initializer to the declared string
359
+ * shape. Inferred optional locals need the same rescue as bare locals:
360
+ * `Option<string>` from TS must not erase an `Option<Color>` initializer. */
361
+ function isWidenedStringUnionTy(declTy, initTy, typeDecls) {
362
+ if (declTy.kind === "string" && isStringUnionTy(initTy, typeDecls))
363
+ return true;
364
+ if (declTy.kind === "optional" && initTy.kind === "optional") {
365
+ return isWidenedStringUnionTy(declTy.inner, initTy.inner, typeDecls);
366
+ }
367
+ return false;
364
368
  }
365
369
  /** Infer quantifier variable type from usage in body.
366
370
  * If the variable is used as a map/set key (e.g. map.has(k), map.get(k)),
367
371
  * return the collection's key type. Otherwise return null (default to int). */
368
372
  function inferQuantVarType(varName, body, ctx) {
369
- // Look for calls like map.has(k), map.get(k), or array.includes(k) where k is our variable
373
+ // Look for membership/lookup builtins (map.has(k), map.get(k),
374
+ // array.includes(k) — registry `argIsKey`) where k is our variable
370
375
  if (body.kind === "call" && body.fn.kind === "field" &&
371
- (body.fn.field === "has" || body.fn.field === "get" || body.fn.field === "includes") &&
372
376
  body.args.length === 1 && body.args[0].kind === "var" && body.args[0].name === varName) {
373
377
  const objTy = lookup(ctx.env, body.fn.obj.kind === "var" ? body.fn.obj.name : "");
374
- if (objTy?.kind === "map")
375
- return objTy.key;
376
- if (objTy?.kind === "set")
377
- return objTy.elem;
378
- if (objTy?.kind === "array")
379
- return objTy.elem;
378
+ const id = objTy ? recognizeBuiltin(objTy, body.fn.field) : null;
379
+ if (id && builtinSpec(id).argIsKey && objTy) {
380
+ if (objTy.kind === "map")
381
+ return objTy.key;
382
+ if (objTy.kind === "set")
383
+ return objTy.elem;
384
+ if (objTy.kind === "array")
385
+ return objTy.elem;
386
+ }
380
387
  }
381
388
  // Recurse into subexpressions
382
389
  if (body.kind === "binop") {
@@ -430,6 +437,8 @@ function classifyCall(fn, ctx) {
430
437
  return "pure";
431
438
  if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray")
432
439
  return "pure";
440
+ if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "String" && fn.field === "fromCharCode")
441
+ return "pure";
433
442
  if (fn.kind === "var" && (ctx.inSpec || ctx.inLambda) && ctx.pureFns.has(fn.name))
434
443
  return "spec-pure";
435
444
  // Bare-name `//@ extern` declarations are emitted as `function {:axiom}` —
@@ -438,6 +447,8 @@ function classifyCall(fn, ctx) {
438
447
  // become multi-statement, illegal in Dafny).
439
448
  if (fn.kind === "var" && ctx.externs.has(fn.name))
440
449
  return "pure";
450
+ if (fn.kind === "var" && lookup(ctx.env, fn.name)?.kind === "fn")
451
+ return "pure";
441
452
  if (fn.kind === "var" && ctx.inSpec) {
442
453
  // Not a known pure function — could be external (Lean-defined spec helper).
443
454
  // Pass through as "pure" and let Lean catch any errors.
@@ -470,8 +481,11 @@ function tyToTsStr(ty) {
470
481
  return undefined;
471
482
  }
472
483
  function inferLambdaParamTypes(fn, rawArgs, ctx) {
484
+ const hofShape = fn.kind === "field"
485
+ ? (id => id ? builtinSpec(id).hof?.shape : undefined)(recognizeBuiltin(fn.obj.ty, fn.field))
486
+ : undefined;
473
487
  // sort's comparator takes two params, both the element type.
474
- if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "sort" &&
488
+ if (hofShape === "comparator" && fn.kind === "field" && fn.obj.ty.kind === "array" &&
475
489
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 1) {
476
490
  const tsType = tyToTsStr(fn.obj.ty.elem);
477
491
  if (tsType) {
@@ -481,7 +495,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
481
495
  }
482
496
  }
483
497
  // reduce's callback is (acc, elem): acc from the init arg's type, elem from the array.
484
- if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "reduce" && ctx &&
498
+ if (hofShape === "reduce" && fn.kind === "field" && fn.obj.ty.kind === "array" && ctx &&
485
499
  rawArgs.length >= 2 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 2) {
486
500
  const accTs = tyToTsStr(resolveExpr(rawArgs[1], ctx).ty);
487
501
  const elemTs = tyToTsStr(fn.obj.ty.elem);
@@ -491,8 +505,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
491
505
  return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
492
506
  }
493
507
  }
494
- if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
495
- ["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex"].includes(fn.field) &&
508
+ if (hofShape === "unary" && fn.kind === "field" && fn.obj.ty.kind === "array" &&
496
509
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
497
510
  rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
498
511
  const elemTy = fn.obj.ty.elem;
@@ -513,7 +526,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
513
526
  return a;
514
527
  let pTy = paramTys[i];
515
528
  if (pTy.kind === "user") {
516
- const decl = ctx.typeDecls.find(d => d.name === pTy.name);
529
+ const decl = declOf(ctx.typeDecls, pTy.name);
517
530
  if (decl?.kind === "alias" && decl.aliasOfTy)
518
531
  pTy = decl.aliasOfTy;
519
532
  else if (decl?.kind === "alias" && decl.aliasOf)
@@ -542,7 +555,7 @@ function isDefinedCheckRawLambda(raw) {
542
555
  const isUndef = (x) => x.kind === "var" && x.name === "undefined";
543
556
  return (isParam(body.left) && isUndef(body.right)) || (isParam(body.right) && isUndef(body.left));
544
557
  }
545
- /** Coerce call arguments: string literals user types, non-optional → Some, pad missing optional args. */
558
+ /** Coerce call arguments to their declared parameter slots and pad missing optional args. */
546
559
  function coerceCallArgs(args, fn, ctx) {
547
560
  if (fn.kind !== "var" || !ctx.fnParams.has(fn.name))
548
561
  return args;
@@ -550,11 +563,7 @@ function coerceCallArgs(args, fn, ctx) {
550
563
  args = args.map((a, i) => {
551
564
  if (i >= paramTys.length)
552
565
  return a;
553
- a = coerceStr(a, paramTys[i]);
554
- if (a.ty.kind !== "optional" && paramTys[i].kind === "optional") {
555
- return wrapSome(a, paramTys[i]);
556
- }
557
- return a;
566
+ return coerceToTargetTy(a, paramTys[i], ctx.typeDecls);
558
567
  });
559
568
  // Pad missing optional args with None
560
569
  for (let i = args.length; i < paramTys.length; i++) {
@@ -573,6 +582,11 @@ function inferMethodReturnTy(fn, args, ctx) {
573
582
  if (fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray") {
574
583
  return { kind: "bool" };
575
584
  }
585
+ // `String.fromCharCode(n)` is the inverse of `s.charCodeAt(i)`: an int code
586
+ // point in, a one-character string out.
587
+ if (fn.obj.kind === "var" && fn.obj.name === "String" && fn.field === "fromCharCode") {
588
+ return { kind: "string" };
589
+ }
576
590
  // Math.* numeric builtins: abs/min/max preserve the operand's numeric type
577
591
  // (real if any operand is real); floor/ceil/round/trunc return an integer.
578
592
  if (fn.obj.kind === "var" && fn.obj.name === "Math") {
@@ -584,68 +598,9 @@ function inferMethodReturnTy(fn, args, ctx) {
584
598
  return { kind: "int" };
585
599
  }
586
600
  const objTy = fn.obj.ty;
587
- if (objTy.kind === "map") {
588
- if (fn.field === "get")
589
- return ctx.inSpec ? objTy.value : { kind: "optional", inner: objTy.value };
590
- if (fn.field === "has")
591
- return { kind: "bool" };
592
- if (fn.field === "set" || fn.field === "delete")
593
- return objTy;
594
- }
595
- else if (objTy.kind === "set") {
596
- if (fn.field === "has")
597
- return { kind: "bool" };
598
- if (fn.field === "add" || fn.field === "delete")
599
- return objTy;
600
- }
601
- else if (objTy.kind === "array") {
602
- if (fn.field === "includes")
603
- return { kind: "bool" };
604
- if (fn.field === "indexOf")
605
- return { kind: "int" };
606
- if (fn.field === "shift")
607
- return objTy.elem;
608
- if (fn.field === "pop")
609
- return { kind: "optional", inner: objTy.elem };
610
- if (fn.field === "push" || fn.field === "unshift" || fn.field === "concat")
611
- return objTy;
612
- if (fn.field === "sort")
613
- return objTy;
614
- if (fn.field === "filter")
615
- return objTy;
616
- if (fn.field === "every" || fn.field === "some")
617
- return { kind: "bool" };
618
- if (fn.field === "reduce" && args.length === 2)
619
- return args[1].ty;
620
- if (fn.field === "find" || fn.field === "findLast")
621
- return { kind: "optional", inner: objTy.elem };
622
- if (fn.field === "findIndex" || fn.field === "findLastIndex")
623
- return { kind: "int" };
624
- if (fn.field === "flat" && objTy.elem.kind === "array")
625
- return { kind: "array", elem: objTy.elem.elem };
626
- if (fn.field === "slice")
627
- return objTy;
628
- if (fn.field === "join" && objTy.elem.kind === "string")
629
- return { kind: "string" };
630
- if (fn.field === "map" && args.length >= 1 && args[0].kind === "lambda") {
631
- const lam = args[0];
632
- // Prefer the lambda's declared return type (handles multi-statement bodies
633
- // where body[0] is an `if`, not a `return`); fall back to the body's return.
634
- const retTy = lam.ty.kind === "fn" ? lam.ty.result
635
- : lam.body.length > 0 && lam.body[0].kind === "return" ? lam.body[0].value.ty : { kind: "unknown" };
636
- return { kind: "array", elem: retTy };
637
- }
638
- }
639
- else if (objTy.kind === "string") {
640
- if (fn.field === "trim" || fn.field === "trimEnd" || fn.field === "trimStart" || fn.field === "toLowerCase" || fn.field === "toUpperCase")
641
- return { kind: "string" };
642
- if (fn.field === "slice" || fn.field === "substring")
643
- return { kind: "string" };
644
- if (fn.field === "split")
645
- return { kind: "array", elem: { kind: "string" } };
646
- if (fn.field === "includes" || fn.field === "startsWith" || fn.field === "endsWith")
647
- return { kind: "bool" };
648
- }
601
+ const id = recognizeBuiltin(objTy, fn.field);
602
+ if (id)
603
+ return builtinSpec(id).ret(objTy, args, { inSpec: ctx.inSpec });
649
604
  return { kind: "unknown" };
650
605
  }
651
606
  /** Look up the type of `field` on `objTy`. Returns `unknown` if not found. */
@@ -657,7 +612,7 @@ function lookupFieldTy(objTy, field, ctx) {
657
612
  return { ty: { kind: "nat" }, isDiscriminant: false };
658
613
  }
659
614
  if (objTy.kind === "user") {
660
- const baseTyName = objTy.name.includes("<") ? objTy.name.slice(0, objTy.name.indexOf("<")) : objTy.name;
615
+ const baseTyName = tyBaseName(objTy.name);
661
616
  const isDiscriminant = getDiscriminant(ctx, baseTyName) === field;
662
617
  const decl = findDecl(ctx, baseTyName);
663
618
  if (decl?.kind === "record") {
@@ -691,7 +646,7 @@ function resolveRecordMerge(base, override, ctx) {
691
646
  // into its inner type), else the base's.
692
647
  const rTy = overInner.kind === "user" ? overInner
693
648
  : tbase.ty.kind === "user" ? tbase.ty : null;
694
- const decl = rTy ? ctx.typeDecls.find(d => d.name === rTy.name && d.kind === "record") : undefined;
649
+ const decl = rTy ? declOfKind(ctx.typeDecls, rTy.name, "record") : undefined;
695
650
  if (!rTy || !decl?.fields) {
696
651
  throw new Error(`object spread merge { ...a, ...b } needs a known record type for both operands ` +
697
652
  `(base: ${tyToCanonical(tbase.ty)}, override: ${tyToCanonical(tover.ty)})`);
@@ -740,7 +695,7 @@ function tryRecordIndexByEnum(obj, idx, ctx) {
740
695
  const objTy = obj.ty, keyTy = idx.ty;
741
696
  if (objTy.kind !== "user")
742
697
  return null;
743
- const rec = ctx.typeDecls.find(d => d.name === objTy.name && d.kind === "record");
698
+ const rec = declOfKind(ctx.typeDecls, objTy.name, "record");
744
699
  if (!rec?.fields)
745
700
  return null;
746
701
  const fieldByName = new Map(rec.fields.map(f => [f.name, f]));
@@ -750,7 +705,7 @@ function tryRecordIndexByEnum(obj, idx, ctx) {
750
705
  let values = null;
751
706
  let datatype = null;
752
707
  if (keyTy.kind === "user") {
753
- const keyEnum = ctx.typeDecls.find(d => d.name === keyTy.name && d.kind === "string-union");
708
+ const keyEnum = declOfKind(ctx.typeDecls, keyTy.name, "string-union");
754
709
  if (keyEnum?.values?.length) {
755
710
  values = keyEnum.values;
756
711
  datatype = keyEnum.name;
@@ -786,9 +741,11 @@ function resolveExpr(e, ctx) {
786
741
  case "num":
787
742
  if (!Number.isInteger(e.value))
788
743
  return { kind: "num", value: e.value, ty: { kind: "real" } };
789
- if (e.big)
790
- return { kind: "num", value: e.value, ty: { kind: "int", big: true } };
791
744
  return { kind: "num", value: e.value, ty: e.value >= 0 ? { kind: "nat" } : { kind: "int" } };
745
+ // Always `int` (never `nat`, even when non-negative), carrying `big` so the
746
+ // surrounding arithmetic picks bigint division semantics — see `isBigInt`.
747
+ case "bigint":
748
+ return { kind: "bigint", value: e.value, ty: { kind: "int", big: true } };
792
749
  case "str":
793
750
  return { kind: "str", value: e.value, ty: { kind: "string" } };
794
751
  case "bool":
@@ -810,7 +767,7 @@ function resolveExpr(e, ctx) {
810
767
  let rightCtx = ctx;
811
768
  let rawRight = e.right;
812
769
  if (e.op === "&&" || e.op === "==>") {
813
- rightCtx = collectAndChainNarrowings(e.left, ctx);
770
+ rightCtx = collectAndChainNarrowings(left, ctx);
814
771
  }
815
772
  let right = resolveExpr(rawRight, rightCtx);
816
773
  if (e.op === "===" || e.op === "!==") {
@@ -887,7 +844,7 @@ function resolveExpr(e, ctx) {
887
844
  if (ext) {
888
845
  const args = e.args.map(a => resolveExpr(a, ctx));
889
846
  const fn = { kind: "var", name: ext.flat, ty: { kind: "unknown" } };
890
- return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure" };
847
+ return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure", paramTys: ext.params };
891
848
  }
892
849
  }
893
850
  const fn = resolveExpr(e.fn, ctx);
@@ -903,7 +860,8 @@ function resolveExpr(e, ctx) {
903
860
  const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
904
861
  let args = coerceCallArgs(rawArgs.map((a, i) => {
905
862
  let aCtx = argCtx;
906
- if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
863
+ if (paramTypes && i < paramTypes.length &&
864
+ (paramTypes[i].kind === "user" || paramTypes[i].kind === "array" || paramTypes[i].kind === "optional")) {
907
865
  aCtx = { ...aCtx, returnTy: paramTypes[i] };
908
866
  }
909
867
  return resolveExpr(a, aCtx);
@@ -920,6 +878,12 @@ function resolveExpr(e, ctx) {
920
878
  if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
921
879
  ty = ctx.fnReturns.get(fn.name);
922
880
  }
881
+ // Call through a function-typed value: its fn type carries the result
882
+ if (ty.kind === "unknown" && fn.kind === "var") {
883
+ const varTy = lookup(ctx.env, fn.name);
884
+ if (varTy?.kind === "fn")
885
+ ty = varTy.result;
886
+ }
923
887
  // filterMap: `seqOfOption.filter(x => x !== undefined)` (a defined-check,
924
888
  // typically with an `x is T` type guard) drops the Nones AND unwraps to
925
889
  // seq<T>. Rewrite to a synthetic `filterSome` call lowered to the proven
@@ -930,7 +894,10 @@ function resolveExpr(e, ctx) {
930
894
  && fn.kind === "field" && fn.obj.ty.kind === "array" && fn.obj.ty.elem.kind === "optional") {
931
895
  return { kind: "call", fn: { ...fn, field: "filterSome" }, args: [], ty: { kind: "array", elem: fn.obj.ty.elem.inner }, callKind: "method" };
932
896
  }
933
- return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
897
+ const builtinId = fn.kind === "field" ? recognizeBuiltin(fn.obj.ty, fn.field) : null;
898
+ return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx),
899
+ ...(builtinId ? { builtinId } : {}),
900
+ ...(paramTypes ? { paramTys: paramTypes } : {}) };
934
901
  }
935
902
  case "index": {
936
903
  const obj = resolveExpr(e.obj, ctx);
@@ -982,21 +949,41 @@ function resolveExpr(e, ctx) {
982
949
  ty = np.narrowedTy;
983
950
  }
984
951
  }
952
+ // A variant narrowing on the object picks the field's type from that
953
+ // variant (a shared field name can have a different type per variant).
954
+ let ofVariant;
955
+ if (ty.kind === "unknown" && ctx.narrowedPaths.length > 0 && obj.ty.kind === "user") {
956
+ const objPath = asRawAccessPath(e.obj);
957
+ const np = objPath ? ctx.narrowedPaths.find(n => n.variant && accessPathsEqual(n.path, objPath)) : undefined;
958
+ if (np?.variant) {
959
+ const decl = findDecl(ctx, tyBaseName(obj.ty.name));
960
+ const f = decl?.variants?.find(v => v.name === np.variant)?.fields.find(f => f.name === e.field);
961
+ if (f?.type) {
962
+ ty = f.type;
963
+ ofVariant = np.variant;
964
+ }
965
+ }
966
+ }
985
967
  if (ty.kind === "unknown") {
986
968
  const lookup = lookupFieldTy(obj.ty, e.field, ctx);
987
969
  ty = lookup.ty;
988
970
  isDiscriminant = lookup.isDiscriminant;
989
971
  }
990
- return { kind: "field", obj, field: e.field, ty, isDiscriminant };
972
+ return { kind: "field", obj, field: e.field, ty, isDiscriminant, ofVariant };
991
973
  }
992
974
  case "nullish": {
993
975
  // left ?? right — result type is left's inner (when left is optional)
994
976
  // or just left's type, unified with right's type.
995
977
  const left = resolveExpr(e.left, ctx);
996
- const ty = left.ty.kind === "optional" ? left.ty.inner : left.ty;
978
+ const inner = left.ty.kind === "optional" ? left.ty.inner : left.ty;
997
979
  // The default shares the result type, so coerce a string literal to a
998
980
  // string-union enum (e.g. `availableLevels[0] ?? "off"`).
999
- const right = coerceStr(resolveExpr(e.right, ctx), ty);
981
+ const right = coerceToTargetTy(resolveExpr(e.right, ctx), inner, ctx.typeDecls);
982
+ // `??` is only total when its default is: with a nullable right operand
983
+ // (rule-chain style `ruleA(e) ?? ruleB(e) ?? null`), the result stays
984
+ // optional — otherwise the enclosing chain level loses its optionality
985
+ // and narrowing can't rewrite it.
986
+ const ty = right.ty.kind === "optional" ? right.ty : inner;
1000
987
  return { kind: "nullish", left, right, ty };
1001
988
  }
1002
989
  case "optChain": {
@@ -1041,7 +1028,9 @@ function resolveExpr(e, ctx) {
1041
1028
  const args = rawArgs.map(a => resolveExpr(a, ctx));
1042
1029
  callTy = inferMethodReturnTy(fakeFn, args, ctx);
1043
1030
  callKind = "method";
1044
- chain.push({ kind: "call", args, ty: callTy, callKind });
1031
+ const builtinId = recognizeBuiltin(priorInTy, lastField.name);
1032
+ chain.push({ kind: "call", args, ty: callTy, callKind,
1033
+ ...(builtinId ? { builtinId } : {}) });
1045
1034
  stepInTy = callTy;
1046
1035
  continue;
1047
1036
  }
@@ -1070,20 +1059,37 @@ function resolveExpr(e, ctx) {
1070
1059
  // has ctx.returnTy = Option<T>, but the record literal's natural type is T.
1071
1060
  const returnTyUnwrapped = ctx.returnTy.kind === "optional" ? ctx.returnTy.inner : ctx.returnTy;
1072
1061
  const recordTy = ty.kind === "user" ? ty : returnTyUnwrapped.kind === "user" ? returnTyUnwrapped : null;
1073
- const decl = recordTy ? ctx.typeDecls.find(d => d.name === recordTy.name && d.kind === "record") : undefined;
1062
+ const decl = recordTy ? declOfKind(ctx.typeDecls, recordTy.name, "record") : undefined;
1063
+ // Union-variant literal in union-typed context (a constructed IR node,
1064
+ // `{ kind: "if", … }: TStmt`): contextual field types come from the
1065
+ // variant the literal's discriminant field selects.
1066
+ let declFields = decl?.fields;
1067
+ if (!declFields && recordTy) {
1068
+ const udecl = declOfKind(ctx.typeDecls, recordTy.name, "discriminated-union");
1069
+ if (udecl?.discriminant && udecl.variants) {
1070
+ const tagRaw = e.fields.find(f => f.name === udecl.discriminant)?.value;
1071
+ if (tagRaw?.kind === "str") {
1072
+ declFields = udecl.variants.find(v => v.name === tagRaw.value)?.fields;
1073
+ }
1074
+ }
1075
+ }
1074
1076
  // Clear returnTy for field values — it applies to THIS record, not nested ones
1075
1077
  const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
1076
1078
  const fields = e.fields.map(f => {
1077
- const fieldDecl = decl?.fields?.find(df => df.name === f.name);
1079
+ const fieldDecl = declFields?.find(df => df.name === f.name);
1078
1080
  // Propagate declared field type into context so nested records resolve
1079
- // their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle)
1080
- const valueCtx = (fieldDecl?.type?.kind === "user")
1081
- ? { ...fieldCtx, returnTy: fieldDecl.type }
1081
+ // their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle).
1082
+ // Optional fields propagate their inner type (the Some-wrap is restored
1083
+ // by coerceToTargetTy below); array fields propagate whole, so the
1084
+ // arrayLiteral case can thread the element type.
1085
+ const fdTy = fieldDecl?.type;
1086
+ const fdCtxTy = fdTy?.kind === "optional" ? fdTy.inner : fdTy;
1087
+ const valueCtx = fdCtxTy && (fdCtxTy.kind === "user" || fdCtxTy.kind === "array")
1088
+ ? { ...fieldCtx, returnTy: fdCtxTy }
1082
1089
  : fieldCtx;
1083
1090
  let value = resolveExpr(f.value, valueCtx);
1084
1091
  if (fieldDecl) {
1085
1092
  const declTy = fieldDecl.type;
1086
- value = coerceStr(value, declTy);
1087
1093
  // Empty {} for map-typed fields → empty map (arrayLiteral with map type → emptyMap in transform)
1088
1094
  if (value.kind === "record" && value.fields.length === 0 && !value.spread && declTy.kind === "map") {
1089
1095
  value = { kind: "arrayLiteral", elems: [], ty: declTy };
@@ -1124,7 +1130,7 @@ function resolveExpr(e, ctx) {
1124
1130
  const elems = e.elems.map((el, i) => {
1125
1131
  const slot = slots[i];
1126
1132
  const r = resolveExpr(el, slot ? { ...ctx, returnTy: slot } : ctx);
1127
- return slot ? coerceStr(r, slot) : r;
1133
+ return slot ? coerceToTargetTy(r, slot, ctx.typeDecls) : r;
1128
1134
  });
1129
1135
  return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
1130
1136
  }
@@ -1132,14 +1138,16 @@ function resolveExpr(e, ctx) {
1132
1138
  // literal in an array resolves to its named datatype rather than an
1133
1139
  // anonymous tuple (mirrors return-position and call-argument records, which
1134
1140
  // get their type via ctx.returnTy). Only narrow when the context type is an
1135
- // array; otherwise leave ctx untouched.
1136
- const expectedElem = ctx.returnTy.kind === "array" ? ctx.returnTy.elem : null;
1141
+ // array (unwrapping one optional level — `TStmt[] | null` return positions);
1142
+ // otherwise leave ctx untouched.
1143
+ const rtUnwrapped = ctx.returnTy.kind === "optional" ? ctx.returnTy.inner : ctx.returnTy;
1144
+ const expectedElem = rtUnwrapped.kind === "array" ? rtUnwrapped.elem : null;
1137
1145
  const elemCtx = expectedElem ? { ...ctx, returnTy: expectedElem } : ctx;
1138
1146
  const elems = e.elems.map(el => {
1139
1147
  const r = resolveExpr(el, elemCtx);
1140
1148
  // Coerce a bare string-literal element to a string-union enum (e.g.
1141
1149
  // `["off", …]: ModelThinkingLevel[]`), like return/arg positions.
1142
- return expectedElem ? coerceStr(r, expectedElem) : r;
1150
+ return expectedElem ? coerceToTargetTy(r, expectedElem, ctx.typeDecls) : r;
1143
1151
  });
1144
1152
  const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
1145
1153
  // No expected collection type: infer array vs tuple from the elements —
@@ -1183,19 +1191,19 @@ function resolveExpr(e, ctx) {
1183
1191
  // with method calls or index ops (bind-first required).
1184
1192
  // For &&-chains, all positive checks narrow the then-branch; earlier
1185
1193
  // checks are in scope when resolving later ones.
1186
- let thenCtx = collectAndChainNarrowings(e.cond, ctx);
1194
+ let thenCtx = collectAndChainNarrowings(cond, ctx);
1187
1195
  let elseCtx = ctx;
1188
1196
  // Truthiness — cond itself is optional (`opt ? a : b`), only for simple vars.
1189
- if (cond.ty.kind === "optional" && e.cond.kind === "var") {
1190
- thenCtx = withEnv(thenCtx, extend(thenCtx.env, e.cond.name, cond.ty.inner));
1197
+ if (cond.ty.kind === "optional" && cond.kind === "var") {
1198
+ thenCtx = withEnv(thenCtx, extend(thenCtx.env, cond.name, cond.ty.inner));
1191
1199
  }
1192
1200
  // Single === undefined check narrows the else-branch.
1193
- const single = detectOptionalCheck(e.cond, ctx);
1194
- if (single && !single.inThen && !single.fieldExpr) {
1201
+ const single = negatedVarPresence(cond);
1202
+ if (single) {
1195
1203
  elseCtx = withEnv(elseCtx, extend(elseCtx.env, single.varName, single.innerTy));
1196
1204
  }
1197
- if (!single && e.cond.kind === "binop" && e.cond.op === "||") {
1198
- for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
1205
+ if (!single && cond.kind === "binop" && cond.op === "||") {
1206
+ for (const n of collectEarlyReturnNarrowings(cond)) {
1199
1207
  elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
1200
1208
  }
1201
1209
  }
@@ -1207,21 +1215,7 @@ function resolveExpr(e, ctx) {
1207
1215
  let else_ = resolveExpr(e.else, elseCtx);
1208
1216
  then_ = coerceStr(then_, else_.ty);
1209
1217
  else_ = coerceStr(else_, then_.ty);
1210
- let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
1211
- if (then_.ty.kind === "void" && else_.ty.kind !== "void" && else_.ty.kind !== "unknown") {
1212
- ty = { kind: "optional", inner: else_.ty };
1213
- }
1214
- else if (else_.ty.kind === "void" && then_.ty.kind !== "void" && then_.ty.kind !== "unknown") {
1215
- ty = { kind: "optional", inner: then_.ty };
1216
- }
1217
- else if (then_.ty.kind === "optional" && else_.ty.kind !== "optional" && else_.ty.kind !== "unknown") {
1218
- // Asymmetric optional: one branch returns Option<T>, the other returns T.
1219
- // Widen to Option<T> so callers/return-coercion see the wider type.
1220
- ty = then_.ty;
1221
- }
1222
- else if (else_.ty.kind === "optional" && then_.ty.kind !== "optional" && then_.ty.kind !== "unknown") {
1223
- ty = else_.ty;
1224
- }
1218
+ const ty = conditionalResultTy(then_.ty, else_.ty);
1225
1219
  return { kind: "conditional", cond, then: then_, else: else_, ty };
1226
1220
  }
1227
1221
  case "emptyCollection": {
@@ -1256,8 +1250,9 @@ function resolveBlock(stmts, ctx) {
1256
1250
  const result = [];
1257
1251
  let env = ctx.env;
1258
1252
  let narrowedIndices = ctx.narrowedIndices;
1253
+ let narrowedPaths = ctx.narrowedPaths;
1259
1254
  for (const s of stmts) {
1260
- const currentCtx = { ...ctx, env, narrowedIndices };
1255
+ const currentCtx = { ...ctx, env, narrowedIndices, narrowedPaths };
1261
1256
  const [typed, nextEnv] = resolveStmt(s, currentCtx);
1262
1257
  result.push(typed);
1263
1258
  env = nextEnv;
@@ -1269,9 +1264,13 @@ function resolveBlock(stmts, ctx) {
1269
1264
  // Field chains are excluded — resolve can't substitute in statement lists;
1270
1265
  // transform's emitOptionalMatch handles field chains in statement contexts.
1271
1266
  if (s.kind === "if" && s.then.length > 0 && isTerminatorKind(s.then[s.then.length - 1].kind) && s.else.length === 0) {
1272
- const narrowings = collectEarlyReturnNarrowings(s.cond, withEnv(ctx, env));
1273
- for (const n of narrowings) {
1274
- env = extend(env, n.varName, n.innerTy);
1267
+ if (typed.kind === "if") {
1268
+ for (const n of collectEarlyReturnNarrowings(typed.cond)) {
1269
+ env = extend(env, n.varName, n.innerTy);
1270
+ }
1271
+ const nv = negatedVariantCheck(typed.cond);
1272
+ if (nv)
1273
+ narrowedPaths = [...narrowedPaths, nv];
1275
1274
  }
1276
1275
  // Map-index narrowing: `if (!(k in m)) return;` means `k in m` holds in rest.
1277
1276
  if (typed.kind === "if") {
@@ -1308,9 +1307,21 @@ function resolveStmt(s, ctx) {
1308
1307
  // Propagate declared type as returnTy so nested record expressions resolve
1309
1308
  // union variants correctly (e.g., EffectState → mode: EffectMode → { kind:
1310
1309
  // 'Idle' }). Arrays too, so `const xs: Foo[] = [{...}]` threads the element
1311
- // type into the array literal (see the arrayLiteral case).
1312
- const initCtx = (declTy.kind === "user" || declTy.kind === "array") ? { ...ctx, returnTy: declTy } : ctx;
1313
- const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
1310
+ // type into the array literal (see the arrayLiteral case). Optionals too
1311
+ // (`const r: TExpr | null = cond ? {…} : null`) the record case unwraps
1312
+ // one optional level when consulting returnTy.
1313
+ const initCtx = (declTy.kind === "user" || declTy.kind === "array" || declTy.kind === "optional")
1314
+ ? { ...ctx, returnTy: declTy } : ctx;
1315
+ let init = coerceStr(resolveExpr(s.init, initCtx), declTy);
1316
+ // Under noUncheckedIndexedAccess, TS gives `const e = arr[i]` type T | undefined
1317
+ // while the index expression itself resolves to T. Leave that mismatch intact:
1318
+ // narrow.ts's ruleOptionalIndexBinding adds the runtime bounds guard and the
1319
+ // corresponding Some/None branches. Wrapping here would turn it into an
1320
+ // unconditional Some(arr[i]) and prevent that JS-semantics rewrite from firing.
1321
+ const deferOptionalIndex = declTy.kind === "optional" && init.kind === "index" &&
1322
+ init.obj.ty.kind === "array" && init.ty.kind !== "optional";
1323
+ if (!deferOptionalIndex)
1324
+ init = coerceToTargetTy(init, declTy, ctx.typeDecls);
1314
1325
  let ty;
1315
1326
  if (isUnmodeledTy(declTy, ctx.typeDecls) && !isUnmodeledTy(init.ty, ctx.typeDecls)) {
1316
1327
  // ts-morph's declared type is opaque to us (an expanded union it made
@@ -1321,7 +1332,7 @@ function resolveStmt(s, ctx) {
1321
1332
  ? { kind: "optional", inner: init.ty }
1322
1333
  : init.ty;
1323
1334
  }
1324
- else if (declTy.kind === "string" && isStringUnionTy(init.ty, ctx.typeDecls) && !ctx.overrides.has(s.name)) {
1335
+ else if (isWidenedStringUnionTy(declTy, init.ty, ctx.typeDecls) && !ctx.overrides.has(s.name)) {
1325
1336
  // ts-morph widened a string-union to `string`; keep the initializer's
1326
1337
  // datatype so `local === "lit"` lowers to a discriminant test.
1327
1338
  ty = init.ty;
@@ -1342,22 +1353,16 @@ function resolveStmt(s, ctx) {
1342
1353
  }
1343
1354
  case "assign": {
1344
1355
  const targetTy = lookup(ctx.env, s.target) ?? { kind: "unknown" };
1345
- let value = coerceStr(resolveExpr(s.value, ctx), targetTy);
1346
- // Auto-wrap non-optional value in Some when target is optional
1347
- const isUndef = value.kind === "var" && value.name === "undefined";
1348
- if (targetTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "unknown" && !isUndef) {
1349
- value = wrapSome(value, targetTy);
1350
- }
1356
+ // Propagate the target's type as returnTy, mirroring the annotated-let
1357
+ // case, so record/union literals and array literals in the RHS resolve
1358
+ // to their named datatypes.
1359
+ const valueCtx = (targetTy.kind === "user" || targetTy.kind === "array" || targetTy.kind === "optional")
1360
+ ? { ...ctx, returnTy: targetTy } : ctx;
1361
+ const value = coerceToTargetTy(resolveExpr(s.value, valueCtx), targetTy, ctx.typeDecls);
1351
1362
  return [{ kind: "assign", target: s.target, value }, ctx.env];
1352
1363
  }
1353
1364
  case "return": {
1354
- let value = coerceStr(resolveExpr(s.value, ctx), ctx.returnTy);
1355
- // Wrap non-optional return value in Some when function returns optional
1356
- // Skip if already optional, void, or undefined (which maps to None)
1357
- const isUndef = value.kind === "var" && value.name === "undefined";
1358
- if (ctx.returnTy.kind === "optional" && value.ty.kind !== "optional" && !isUndef) {
1359
- value = wrapSome(value, ctx.returnTy);
1360
- }
1365
+ const value = coerceToTargetTy(resolveExpr(s.value, ctx), ctx.returnTy, ctx.typeDecls);
1361
1366
  return [{ kind: "return", value }, ctx.env];
1362
1367
  }
1363
1368
  case "break":
@@ -1371,16 +1376,16 @@ function resolveStmt(s, ctx) {
1371
1376
  // For &&-chains, all positive optional checks narrow the then-branch;
1372
1377
  // earlier checks are in scope when resolving later ones.
1373
1378
  // Single-check === undefined narrows the else-branch.
1374
- let thenCtx = collectAndChainNarrowings(s.cond, ctx);
1379
+ const resolvedCond = resolveExpr(s.cond, ctx);
1380
+ let thenCtx = collectAndChainNarrowings(resolvedCond, ctx);
1375
1381
  let elseCtx = ctx;
1376
- const single = detectOptionalCheck(s.cond, ctx);
1377
- if (single && !single.inThen && !single.fieldExpr) {
1382
+ const single = negatedVarPresence(resolvedCond);
1383
+ if (single) {
1378
1384
  elseCtx = withEnv(ctx, extend(ctx.env, single.varName, single.innerTy));
1379
1385
  }
1380
1386
  // Narrow map index access across `k in m` / `!(k in m)` in the cond:
1381
1387
  // positive atoms (from `k in m` or &&-chains containing it) → then-branch;
1382
1388
  // negated atoms (from `!(k in m)`) → else-branch.
1383
- const resolvedCond = resolveExpr(s.cond, ctx);
1384
1389
  thenCtx = withInAtoms(thenCtx, extractInAtoms(resolvedCond));
1385
1390
  elseCtx = withInAtoms(elseCtx, extractInAtomsNegated(resolvedCond));
1386
1391
  return [{ kind: "if", cond: resolvedCond, then: resolveBlock(s.then, thenCtx), else: resolveBlock(s.else, elseCtx) }, ctx.env];
@@ -1473,14 +1478,27 @@ function resolveStmt(s, ctx) {
1473
1478
  }
1474
1479
  }
1475
1480
  // ── Pure / return-in-loop detection ──────────────────────────
1476
- /** Syntactic purity: no while, no for-of, no mutable let. */
1481
+ /** Whether a raw statement or expression tree contains a havoc anywhere. */
1482
+ function containsHavoc(v) {
1483
+ if (Array.isArray(v))
1484
+ return v.some(containsHavoc);
1485
+ if (v === null || typeof v !== "object")
1486
+ return false;
1487
+ if (v.kind === "havoc")
1488
+ return true;
1489
+ return Object.values(v).some(containsHavoc);
1490
+ }
1491
+ /** Syntactic purity: no while, no for-of, no mutable let, no havoc. */
1477
1492
  function isSyntacticallyPure(stmts) {
1478
1493
  for (const s of stmts) {
1494
+ // Havoc lowers to Dafny's `*`, which is only valid in a method.
1495
+ if (containsHavoc(s))
1496
+ return false;
1479
1497
  switch (s.kind) {
1480
1498
  case "while":
1481
1499
  case "forof": return false;
1482
1500
  case "let":
1483
- if (s.mutable || s.init.kind === "havoc")
1501
+ if (s.mutable)
1484
1502
  return false;
1485
1503
  break;
1486
1504
  case "if":
@@ -1800,7 +1818,8 @@ export function resolveModule(raw) {
1800
1818
  // record literals on map-typed constants (e.g. `Record<string, number>`)
1801
1819
  // get their `ty` set to `map<...>` rather than `user("...")`.
1802
1820
  const valueCtx = { ...emptyCtx, returnTy: ty };
1803
- return { name: c.name, ty, value: resolveExpr(c.value, valueCtx) };
1821
+ const value = coerceToTargetTy(resolveExpr(c.value, valueCtx), ty, raw.typeDecls);
1822
+ return { name: c.name, ty, value };
1804
1823
  });
1805
1824
  const moduleConstants = new Map(constants.map(c => [c.name, c.ty]));
1806
1825
  return {