lemmascript 0.5.18 → 0.5.19

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;
@@ -68,7 +71,7 @@ function wrapSome(value, optionalTy) {
68
71
  }
69
72
  /** Find the synth array-union TypeDecl named `name` (discriminant `__isArray__`). */
70
73
  function findSynthArrayUnion(name, typeDecls) {
71
- const decl = typeDecls.find(d => d.name === name);
74
+ const decl = declOf(typeDecls, name);
72
75
  if (decl?.kind === "discriminated-union" && decl.discriminant === "__isArray__")
73
76
  return decl;
74
77
  return null;
@@ -103,68 +106,37 @@ function coerceToTargetTy(value, targetTy, typeDecls) {
103
106
  }
104
107
  return value;
105
108
  }
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;
109
+ /** A negated presence check on a bare var (`v === undefined` / `!v`), via
110
+ * the shared condition analyzer (§4) on the *resolved* condition. Narrows
111
+ * the else-branch (or the rest of the block, after an early return). */
112
+ function negatedVarPresence(cond) {
113
+ const f = presentFact(cond);
114
+ if (f && f.negated && f.scrutinee.kind === "var") {
115
+ return { varName: f.scrutinee.name, innerTy: f.innerTy };
119
116
  }
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 };
150
- }
151
- const resolved = resolveExpr(e, ctx);
152
- if (resolved.ty.kind !== "optional")
153
- return null;
154
- return { varName: "", innerTy: resolved.ty.inner, fieldExpr: e };
117
+ return null;
155
118
  }
156
119
  /** Collect all optional narrowings from an early-return condition.
157
120
  * Handles single checks (x === undefined) and compound || chains
158
121
  * (x === undefined || y === undefined). */
159
- function collectEarlyReturnNarrowings(cond, ctx) {
122
+ function collectEarlyReturnNarrowings(cond) {
160
123
  if (cond.kind === "binop" && cond.op === "||") {
161
- return [...collectEarlyReturnNarrowings(cond.left, ctx), ...collectEarlyReturnNarrowings(cond.right, ctx)];
124
+ return [...collectEarlyReturnNarrowings(cond.left), ...collectEarlyReturnNarrowings(cond.right)];
162
125
  }
163
- const narrowed = detectOptionalCheck(cond, ctx);
164
- if (narrowed && !narrowed.inThen && !narrowed.fieldExpr) {
165
- return [{ varName: narrowed.varName, innerTy: narrowed.innerTy }];
126
+ const n = negatedVarPresence(cond);
127
+ return n ? [n] : [];
128
+ }
129
+ /** Negated discriminant check (`path.kind !== "lit"`) on a var or field path.
130
+ * After `if (path.kind !== "lit") return`, the rest of the block knows the
131
+ * path is that variant. */
132
+ function negatedVariantCheck(cond) {
133
+ if (cond.kind === "binop" && cond.op === "!==" && cond.right.kind === "str" &&
134
+ cond.left.kind === "field" && cond.left.isDiscriminant && cond.left.obj.ty.kind === "user") {
135
+ const path = asTExprAccessPath(cond.left.obj);
136
+ if (path)
137
+ return { path, narrowedTy: cond.left.obj.ty, variant: cond.right.value };
166
138
  }
167
- return [];
139
+ return null;
168
140
  }
169
141
  /** TExpr → AccessPath. Counterpart to `asRawAccessPath` for resolved trees.
170
142
  * Used by `extractInAtoms` when pulling atoms out of typed spec expressions. */
@@ -222,23 +194,36 @@ function withInAtoms(ctx, atoms) {
222
194
  return ctx;
223
195
  return { ...ctx, narrowedIndices: [...existing, ...added] };
224
196
  }
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). */
197
+ /** Walk an `&&` chain of `e !== undefined` checks on the *resolved*
198
+ * condition, returning a Ctx with all narrowings applied. Earlier checks
199
+ * are in scope for later checks (the right conjunct was already resolved
200
+ * under the left's narrowings by the `&&` case of resolveExpr). Consults
201
+ * the shared condition analyzer (§4): a positive presence fact on a bare
202
+ * var extends the env; on a pure field path it extends `narrowedPaths`. */
228
203
  function collectAndChainNarrowings(cond, ctx) {
229
204
  if (cond.kind === "binop" && cond.op === "&&") {
230
205
  const leftCtx = collectAndChainNarrowings(cond.left, ctx);
231
206
  return collectAndChainNarrowings(cond.right, leftCtx);
232
207
  }
233
- const n = detectOptionalCheck(cond, ctx);
234
- if (!n || !n.inThen)
208
+ const f = presentFact(cond);
209
+ if (f && !f.negated) {
210
+ if (f.scrutinee.kind === "var") {
211
+ return withEnv(ctx, extend(ctx.env, f.scrutinee.name, f.innerTy));
212
+ }
213
+ const path = asTExprAccessPath(f.scrutinee);
214
+ if (path) {
215
+ return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: f.innerTy }] };
216
+ }
235
217
  return ctx;
236
- if (!n.fieldExpr) {
237
- return withEnv(ctx, extend(ctx.env, n.varName, n.innerTy));
238
218
  }
239
- const path = asRawAccessPath(n.fieldExpr);
240
- if (path) {
241
- return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: n.innerTy }] };
219
+ // Positive discriminant check (`x.kind === "bool"`): record the variant so
220
+ // field reads on the path resolve against that variant's field types.
221
+ const vf = variantFact(cond, { decls: ctx.typeDecls, oc: { n: 0 } });
222
+ if (vf) {
223
+ const path = asTExprAccessPath(vf.scrutinee);
224
+ if (path) {
225
+ return { ...ctx, narrowedPaths: [...ctx.narrowedPaths, { path, narrowedTy: vf.scrutinee.ty, variant: vf.variant }] };
226
+ }
242
227
  }
243
228
  return ctx;
244
229
  }
@@ -247,16 +232,10 @@ function isRefMutableInTS(ty) {
247
232
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
248
233
  }
249
234
  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
235
+ // Dotted names (e.g. `Agent.Info`, `Permission.Ruleset`) fall back to the
254
236
  // last segment, so `//@ declare-type Info { ... }` matches a reference to
255
237
  // `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;
238
+ return declOfDotted(ctx.typeDecls, name);
260
239
  }
261
240
  /** Expand alias-kind typeDecls when the alias target is structural (array,
262
241
  * map, set, optional, or another user type). Primitive-typed aliases like
@@ -266,11 +245,7 @@ function expandAlias(ty, typeDecls, seen = new Set()) {
266
245
  if (ty.kind === "user") {
267
246
  if (seen.has(ty.name))
268
247
  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
- }
248
+ const decl = declOfDotted(typeDecls, ty.name);
274
249
  if (decl?.kind === "alias" && decl.aliasOfTy) {
275
250
  const target = decl.aliasOfTy;
276
251
  if (target.kind === "array" || target.kind === "map" || target.kind === "set" || target.kind === "optional" || target.kind === "user") {
@@ -305,11 +280,7 @@ function refEqHazard(ty, typeDecls) {
305
280
  if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set" || ty.kind === "tuple")
306
281
  return true;
307
282
  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
- }
283
+ const decl = declOfDotted(typeDecls, ty.name);
313
284
  if (!decl)
314
285
  return true; // generic type parameter / unknown → assume reference
315
286
  if (decl.kind === "string-union")
@@ -348,35 +319,33 @@ function isUnmodeledTy(ty, typeDecls) {
348
319
  return isUnmodeledTy(ty.elem, typeDecls);
349
320
  if (ty.kind === "map")
350
321
  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
- }
322
+ if (ty.kind === "user")
323
+ return declOfTy(typeDecls, ty) === undefined;
355
324
  return false;
356
325
  }
357
326
  /** A `user` type that resolves to a string-union declare-type — runs as a plain
358
327
  * string at runtime, so it's a refinement of `string`, not an opaque blob. */
359
328
  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");
329
+ return declOfTy(typeDecls, ty)?.kind === "string-union";
364
330
  }
365
331
  /** Infer quantifier variable type from usage in body.
366
332
  * If the variable is used as a map/set key (e.g. map.has(k), map.get(k)),
367
333
  * return the collection's key type. Otherwise return null (default to int). */
368
334
  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
335
+ // Look for membership/lookup builtins (map.has(k), map.get(k),
336
+ // array.includes(k) — registry `argIsKey`) where k is our variable
370
337
  if (body.kind === "call" && body.fn.kind === "field" &&
371
- (body.fn.field === "has" || body.fn.field === "get" || body.fn.field === "includes") &&
372
338
  body.args.length === 1 && body.args[0].kind === "var" && body.args[0].name === varName) {
373
339
  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;
340
+ const id = objTy ? recognizeBuiltin(objTy, body.fn.field) : null;
341
+ if (id && builtinSpec(id).argIsKey && objTy) {
342
+ if (objTy.kind === "map")
343
+ return objTy.key;
344
+ if (objTy.kind === "set")
345
+ return objTy.elem;
346
+ if (objTy.kind === "array")
347
+ return objTy.elem;
348
+ }
380
349
  }
381
350
  // Recurse into subexpressions
382
351
  if (body.kind === "binop") {
@@ -438,6 +407,8 @@ function classifyCall(fn, ctx) {
438
407
  // become multi-statement, illegal in Dafny).
439
408
  if (fn.kind === "var" && ctx.externs.has(fn.name))
440
409
  return "pure";
410
+ if (fn.kind === "var" && lookup(ctx.env, fn.name)?.kind === "fn")
411
+ return "pure";
441
412
  if (fn.kind === "var" && ctx.inSpec) {
442
413
  // Not a known pure function — could be external (Lean-defined spec helper).
443
414
  // Pass through as "pure" and let Lean catch any errors.
@@ -470,8 +441,11 @@ function tyToTsStr(ty) {
470
441
  return undefined;
471
442
  }
472
443
  function inferLambdaParamTypes(fn, rawArgs, ctx) {
444
+ const hofShape = fn.kind === "field"
445
+ ? (id => id ? builtinSpec(id).hof?.shape : undefined)(recognizeBuiltin(fn.obj.ty, fn.field))
446
+ : undefined;
473
447
  // sort's comparator takes two params, both the element type.
474
- if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "sort" &&
448
+ if (hofShape === "comparator" && fn.kind === "field" && fn.obj.ty.kind === "array" &&
475
449
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 1) {
476
450
  const tsType = tyToTsStr(fn.obj.ty.elem);
477
451
  if (tsType) {
@@ -481,7 +455,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
481
455
  }
482
456
  }
483
457
  // 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 &&
458
+ if (hofShape === "reduce" && fn.kind === "field" && fn.obj.ty.kind === "array" && ctx &&
485
459
  rawArgs.length >= 2 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 2) {
486
460
  const accTs = tyToTsStr(resolveExpr(rawArgs[1], ctx).ty);
487
461
  const elemTs = tyToTsStr(fn.obj.ty.elem);
@@ -491,8 +465,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
491
465
  return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
492
466
  }
493
467
  }
494
- if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
495
- ["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex"].includes(fn.field) &&
468
+ if (hofShape === "unary" && fn.kind === "field" && fn.obj.ty.kind === "array" &&
496
469
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
497
470
  rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
498
471
  const elemTy = fn.obj.ty.elem;
@@ -513,7 +486,7 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
513
486
  return a;
514
487
  let pTy = paramTys[i];
515
488
  if (pTy.kind === "user") {
516
- const decl = ctx.typeDecls.find(d => d.name === pTy.name);
489
+ const decl = declOf(ctx.typeDecls, pTy.name);
517
490
  if (decl?.kind === "alias" && decl.aliasOfTy)
518
491
  pTy = decl.aliasOfTy;
519
492
  else if (decl?.kind === "alias" && decl.aliasOf)
@@ -584,68 +557,9 @@ function inferMethodReturnTy(fn, args, ctx) {
584
557
  return { kind: "int" };
585
558
  }
586
559
  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
- }
560
+ const id = recognizeBuiltin(objTy, fn.field);
561
+ if (id)
562
+ return builtinSpec(id).ret(objTy, args, { inSpec: ctx.inSpec });
649
563
  return { kind: "unknown" };
650
564
  }
651
565
  /** Look up the type of `field` on `objTy`. Returns `unknown` if not found. */
@@ -657,7 +571,7 @@ function lookupFieldTy(objTy, field, ctx) {
657
571
  return { ty: { kind: "nat" }, isDiscriminant: false };
658
572
  }
659
573
  if (objTy.kind === "user") {
660
- const baseTyName = objTy.name.includes("<") ? objTy.name.slice(0, objTy.name.indexOf("<")) : objTy.name;
574
+ const baseTyName = tyBaseName(objTy.name);
661
575
  const isDiscriminant = getDiscriminant(ctx, baseTyName) === field;
662
576
  const decl = findDecl(ctx, baseTyName);
663
577
  if (decl?.kind === "record") {
@@ -691,7 +605,7 @@ function resolveRecordMerge(base, override, ctx) {
691
605
  // into its inner type), else the base's.
692
606
  const rTy = overInner.kind === "user" ? overInner
693
607
  : tbase.ty.kind === "user" ? tbase.ty : null;
694
- const decl = rTy ? ctx.typeDecls.find(d => d.name === rTy.name && d.kind === "record") : undefined;
608
+ const decl = rTy ? declOfKind(ctx.typeDecls, rTy.name, "record") : undefined;
695
609
  if (!rTy || !decl?.fields) {
696
610
  throw new Error(`object spread merge { ...a, ...b } needs a known record type for both operands ` +
697
611
  `(base: ${tyToCanonical(tbase.ty)}, override: ${tyToCanonical(tover.ty)})`);
@@ -740,7 +654,7 @@ function tryRecordIndexByEnum(obj, idx, ctx) {
740
654
  const objTy = obj.ty, keyTy = idx.ty;
741
655
  if (objTy.kind !== "user")
742
656
  return null;
743
- const rec = ctx.typeDecls.find(d => d.name === objTy.name && d.kind === "record");
657
+ const rec = declOfKind(ctx.typeDecls, objTy.name, "record");
744
658
  if (!rec?.fields)
745
659
  return null;
746
660
  const fieldByName = new Map(rec.fields.map(f => [f.name, f]));
@@ -750,7 +664,7 @@ function tryRecordIndexByEnum(obj, idx, ctx) {
750
664
  let values = null;
751
665
  let datatype = null;
752
666
  if (keyTy.kind === "user") {
753
- const keyEnum = ctx.typeDecls.find(d => d.name === keyTy.name && d.kind === "string-union");
667
+ const keyEnum = declOfKind(ctx.typeDecls, keyTy.name, "string-union");
754
668
  if (keyEnum?.values?.length) {
755
669
  values = keyEnum.values;
756
670
  datatype = keyEnum.name;
@@ -786,9 +700,11 @@ function resolveExpr(e, ctx) {
786
700
  case "num":
787
701
  if (!Number.isInteger(e.value))
788
702
  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
703
  return { kind: "num", value: e.value, ty: e.value >= 0 ? { kind: "nat" } : { kind: "int" } };
704
+ // Always `int` (never `nat`, even when non-negative), carrying `big` so the
705
+ // surrounding arithmetic picks bigint division semantics — see `isBigInt`.
706
+ case "bigint":
707
+ return { kind: "bigint", value: e.value, ty: { kind: "int", big: true } };
792
708
  case "str":
793
709
  return { kind: "str", value: e.value, ty: { kind: "string" } };
794
710
  case "bool":
@@ -810,7 +726,7 @@ function resolveExpr(e, ctx) {
810
726
  let rightCtx = ctx;
811
727
  let rawRight = e.right;
812
728
  if (e.op === "&&" || e.op === "==>") {
813
- rightCtx = collectAndChainNarrowings(e.left, ctx);
729
+ rightCtx = collectAndChainNarrowings(left, ctx);
814
730
  }
815
731
  let right = resolveExpr(rawRight, rightCtx);
816
732
  if (e.op === "===" || e.op === "!==") {
@@ -920,6 +836,12 @@ function resolveExpr(e, ctx) {
920
836
  if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
921
837
  ty = ctx.fnReturns.get(fn.name);
922
838
  }
839
+ // Call through a function-typed value: its fn type carries the result
840
+ if (ty.kind === "unknown" && fn.kind === "var") {
841
+ const varTy = lookup(ctx.env, fn.name);
842
+ if (varTy?.kind === "fn")
843
+ ty = varTy.result;
844
+ }
923
845
  // filterMap: `seqOfOption.filter(x => x !== undefined)` (a defined-check,
924
846
  // typically with an `x is T` type guard) drops the Nones AND unwraps to
925
847
  // seq<T>. Rewrite to a synthetic `filterSome` call lowered to the proven
@@ -930,7 +852,9 @@ function resolveExpr(e, ctx) {
930
852
  && fn.kind === "field" && fn.obj.ty.kind === "array" && fn.obj.ty.elem.kind === "optional") {
931
853
  return { kind: "call", fn: { ...fn, field: "filterSome" }, args: [], ty: { kind: "array", elem: fn.obj.ty.elem.inner }, callKind: "method" };
932
854
  }
933
- return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx) };
855
+ const builtinId = fn.kind === "field" ? recognizeBuiltin(fn.obj.ty, fn.field) : null;
856
+ return { kind: "call", fn, args, ty, callKind: classifyCall(e.fn, ctx),
857
+ ...(builtinId ? { builtinId } : {}) };
934
858
  }
935
859
  case "index": {
936
860
  const obj = resolveExpr(e.obj, ctx);
@@ -982,21 +906,41 @@ function resolveExpr(e, ctx) {
982
906
  ty = np.narrowedTy;
983
907
  }
984
908
  }
909
+ // A variant narrowing on the object picks the field's type from that
910
+ // variant (a shared field name can have a different type per variant).
911
+ let ofVariant;
912
+ if (ty.kind === "unknown" && ctx.narrowedPaths.length > 0 && obj.ty.kind === "user") {
913
+ const objPath = asRawAccessPath(e.obj);
914
+ const np = objPath ? ctx.narrowedPaths.find(n => n.variant && accessPathsEqual(n.path, objPath)) : undefined;
915
+ if (np?.variant) {
916
+ const decl = findDecl(ctx, tyBaseName(obj.ty.name));
917
+ const f = decl?.variants?.find(v => v.name === np.variant)?.fields.find(f => f.name === e.field);
918
+ if (f?.type) {
919
+ ty = f.type;
920
+ ofVariant = np.variant;
921
+ }
922
+ }
923
+ }
985
924
  if (ty.kind === "unknown") {
986
925
  const lookup = lookupFieldTy(obj.ty, e.field, ctx);
987
926
  ty = lookup.ty;
988
927
  isDiscriminant = lookup.isDiscriminant;
989
928
  }
990
- return { kind: "field", obj, field: e.field, ty, isDiscriminant };
929
+ return { kind: "field", obj, field: e.field, ty, isDiscriminant, ofVariant };
991
930
  }
992
931
  case "nullish": {
993
932
  // left ?? right — result type is left's inner (when left is optional)
994
933
  // or just left's type, unified with right's type.
995
934
  const left = resolveExpr(e.left, ctx);
996
- const ty = left.ty.kind === "optional" ? left.ty.inner : left.ty;
935
+ const inner = left.ty.kind === "optional" ? left.ty.inner : left.ty;
997
936
  // The default shares the result type, so coerce a string literal to a
998
937
  // string-union enum (e.g. `availableLevels[0] ?? "off"`).
999
- const right = coerceStr(resolveExpr(e.right, ctx), ty);
938
+ const right = coerceStr(resolveExpr(e.right, ctx), inner);
939
+ // `??` is only total when its default is: with a nullable right operand
940
+ // (rule-chain style `ruleA(e) ?? ruleB(e) ?? null`), the result stays
941
+ // optional — otherwise the enclosing chain level loses its optionality
942
+ // and narrowing can't rewrite it.
943
+ const ty = right.ty.kind === "optional" ? right.ty : inner;
1000
944
  return { kind: "nullish", left, right, ty };
1001
945
  }
1002
946
  case "optChain": {
@@ -1041,7 +985,9 @@ function resolveExpr(e, ctx) {
1041
985
  const args = rawArgs.map(a => resolveExpr(a, ctx));
1042
986
  callTy = inferMethodReturnTy(fakeFn, args, ctx);
1043
987
  callKind = "method";
1044
- chain.push({ kind: "call", args, ty: callTy, callKind });
988
+ const builtinId = recognizeBuiltin(priorInTy, lastField.name);
989
+ chain.push({ kind: "call", args, ty: callTy, callKind,
990
+ ...(builtinId ? { builtinId } : {}) });
1045
991
  stepInTy = callTy;
1046
992
  continue;
1047
993
  }
@@ -1070,15 +1016,33 @@ function resolveExpr(e, ctx) {
1070
1016
  // has ctx.returnTy = Option<T>, but the record literal's natural type is T.
1071
1017
  const returnTyUnwrapped = ctx.returnTy.kind === "optional" ? ctx.returnTy.inner : ctx.returnTy;
1072
1018
  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;
1019
+ const decl = recordTy ? declOfKind(ctx.typeDecls, recordTy.name, "record") : undefined;
1020
+ // Union-variant literal in union-typed context (a constructed IR node,
1021
+ // `{ kind: "if", … }: TStmt`): contextual field types come from the
1022
+ // variant the literal's discriminant field selects.
1023
+ let declFields = decl?.fields;
1024
+ if (!declFields && recordTy) {
1025
+ const udecl = declOfKind(ctx.typeDecls, recordTy.name, "discriminated-union");
1026
+ if (udecl?.discriminant && udecl.variants) {
1027
+ const tagRaw = e.fields.find(f => f.name === udecl.discriminant)?.value;
1028
+ if (tagRaw?.kind === "str") {
1029
+ declFields = udecl.variants.find(v => v.name === tagRaw.value)?.fields;
1030
+ }
1031
+ }
1032
+ }
1074
1033
  // Clear returnTy for field values — it applies to THIS record, not nested ones
1075
1034
  const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
1076
1035
  const fields = e.fields.map(f => {
1077
- const fieldDecl = decl?.fields?.find(df => df.name === f.name);
1036
+ const fieldDecl = declFields?.find(df => df.name === f.name);
1078
1037
  // 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 }
1038
+ // their union variant correctly (e.g., { kind: 'Idle' } → EffectMode.Idle).
1039
+ // Optional fields propagate their inner type (the Some-wrap is restored
1040
+ // by coerceToTargetTy below); array fields propagate whole, so the
1041
+ // arrayLiteral case can thread the element type.
1042
+ const fdTy = fieldDecl?.type;
1043
+ const fdCtxTy = fdTy?.kind === "optional" ? fdTy.inner : fdTy;
1044
+ const valueCtx = fdCtxTy && (fdCtxTy.kind === "user" || fdCtxTy.kind === "array")
1045
+ ? { ...fieldCtx, returnTy: fdCtxTy }
1082
1046
  : fieldCtx;
1083
1047
  let value = resolveExpr(f.value, valueCtx);
1084
1048
  if (fieldDecl) {
@@ -1132,8 +1096,10 @@ function resolveExpr(e, ctx) {
1132
1096
  // literal in an array resolves to its named datatype rather than an
1133
1097
  // anonymous tuple (mirrors return-position and call-argument records, which
1134
1098
  // 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;
1099
+ // array (unwrapping one optional level — `TStmt[] | null` return positions);
1100
+ // otherwise leave ctx untouched.
1101
+ const rtUnwrapped = ctx.returnTy.kind === "optional" ? ctx.returnTy.inner : ctx.returnTy;
1102
+ const expectedElem = rtUnwrapped.kind === "array" ? rtUnwrapped.elem : null;
1137
1103
  const elemCtx = expectedElem ? { ...ctx, returnTy: expectedElem } : ctx;
1138
1104
  const elems = e.elems.map(el => {
1139
1105
  const r = resolveExpr(el, elemCtx);
@@ -1183,19 +1149,19 @@ function resolveExpr(e, ctx) {
1183
1149
  // with method calls or index ops (bind-first required).
1184
1150
  // For &&-chains, all positive checks narrow the then-branch; earlier
1185
1151
  // checks are in scope when resolving later ones.
1186
- let thenCtx = collectAndChainNarrowings(e.cond, ctx);
1152
+ let thenCtx = collectAndChainNarrowings(cond, ctx);
1187
1153
  let elseCtx = ctx;
1188
1154
  // 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));
1155
+ if (cond.ty.kind === "optional" && cond.kind === "var") {
1156
+ thenCtx = withEnv(thenCtx, extend(thenCtx.env, cond.name, cond.ty.inner));
1191
1157
  }
1192
1158
  // Single === undefined check narrows the else-branch.
1193
- const single = detectOptionalCheck(e.cond, ctx);
1194
- if (single && !single.inThen && !single.fieldExpr) {
1159
+ const single = negatedVarPresence(cond);
1160
+ if (single) {
1195
1161
  elseCtx = withEnv(elseCtx, extend(elseCtx.env, single.varName, single.innerTy));
1196
1162
  }
1197
- if (!single && e.cond.kind === "binop" && e.cond.op === "||") {
1198
- for (const n of collectEarlyReturnNarrowings(e.cond, ctx)) {
1163
+ if (!single && cond.kind === "binop" && cond.op === "||") {
1164
+ for (const n of collectEarlyReturnNarrowings(cond)) {
1199
1165
  elseCtx = withEnv(elseCtx, extend(elseCtx.env, n.varName, n.innerTy));
1200
1166
  }
1201
1167
  }
@@ -1256,8 +1222,9 @@ function resolveBlock(stmts, ctx) {
1256
1222
  const result = [];
1257
1223
  let env = ctx.env;
1258
1224
  let narrowedIndices = ctx.narrowedIndices;
1225
+ let narrowedPaths = ctx.narrowedPaths;
1259
1226
  for (const s of stmts) {
1260
- const currentCtx = { ...ctx, env, narrowedIndices };
1227
+ const currentCtx = { ...ctx, env, narrowedIndices, narrowedPaths };
1261
1228
  const [typed, nextEnv] = resolveStmt(s, currentCtx);
1262
1229
  result.push(typed);
1263
1230
  env = nextEnv;
@@ -1269,9 +1236,13 @@ function resolveBlock(stmts, ctx) {
1269
1236
  // Field chains are excluded — resolve can't substitute in statement lists;
1270
1237
  // transform's emitOptionalMatch handles field chains in statement contexts.
1271
1238
  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);
1239
+ if (typed.kind === "if") {
1240
+ for (const n of collectEarlyReturnNarrowings(typed.cond)) {
1241
+ env = extend(env, n.varName, n.innerTy);
1242
+ }
1243
+ const nv = negatedVariantCheck(typed.cond);
1244
+ if (nv)
1245
+ narrowedPaths = [...narrowedPaths, nv];
1275
1246
  }
1276
1247
  // Map-index narrowing: `if (!(k in m)) return;` means `k in m` holds in rest.
1277
1248
  if (typed.kind === "if") {
@@ -1308,8 +1279,11 @@ function resolveStmt(s, ctx) {
1308
1279
  // Propagate declared type as returnTy so nested record expressions resolve
1309
1280
  // union variants correctly (e.g., EffectState → mode: EffectMode → { kind:
1310
1281
  // '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;
1282
+ // type into the array literal (see the arrayLiteral case). Optionals too
1283
+ // (`const r: TExpr | null = cond ? {…} : null`) the record case unwraps
1284
+ // one optional level when consulting returnTy.
1285
+ const initCtx = (declTy.kind === "user" || declTy.kind === "array" || declTy.kind === "optional")
1286
+ ? { ...ctx, returnTy: declTy } : ctx;
1313
1287
  const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
1314
1288
  let ty;
1315
1289
  if (isUnmodeledTy(declTy, ctx.typeDecls) && !isUnmodeledTy(init.ty, ctx.typeDecls)) {
@@ -1342,7 +1316,12 @@ function resolveStmt(s, ctx) {
1342
1316
  }
1343
1317
  case "assign": {
1344
1318
  const targetTy = lookup(ctx.env, s.target) ?? { kind: "unknown" };
1345
- let value = coerceStr(resolveExpr(s.value, ctx), targetTy);
1319
+ // Propagate the target's type as returnTy, mirroring the annotated-let
1320
+ // case, so record/union literals and array literals in the RHS resolve
1321
+ // to their named datatypes.
1322
+ const valueCtx = (targetTy.kind === "user" || targetTy.kind === "array" || targetTy.kind === "optional")
1323
+ ? { ...ctx, returnTy: targetTy } : ctx;
1324
+ let value = coerceStr(resolveExpr(s.value, valueCtx), targetTy);
1346
1325
  // Auto-wrap non-optional value in Some when target is optional
1347
1326
  const isUndef = value.kind === "var" && value.name === "undefined";
1348
1327
  if (targetTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "unknown" && !isUndef) {
@@ -1371,16 +1350,16 @@ function resolveStmt(s, ctx) {
1371
1350
  // For &&-chains, all positive optional checks narrow the then-branch;
1372
1351
  // earlier checks are in scope when resolving later ones.
1373
1352
  // Single-check === undefined narrows the else-branch.
1374
- let thenCtx = collectAndChainNarrowings(s.cond, ctx);
1353
+ const resolvedCond = resolveExpr(s.cond, ctx);
1354
+ let thenCtx = collectAndChainNarrowings(resolvedCond, ctx);
1375
1355
  let elseCtx = ctx;
1376
- const single = detectOptionalCheck(s.cond, ctx);
1377
- if (single && !single.inThen && !single.fieldExpr) {
1356
+ const single = negatedVarPresence(resolvedCond);
1357
+ if (single) {
1378
1358
  elseCtx = withEnv(ctx, extend(ctx.env, single.varName, single.innerTy));
1379
1359
  }
1380
1360
  // Narrow map index access across `k in m` / `!(k in m)` in the cond:
1381
1361
  // positive atoms (from `k in m` or &&-chains containing it) → then-branch;
1382
1362
  // negated atoms (from `!(k in m)`) → else-branch.
1383
- const resolvedCond = resolveExpr(s.cond, ctx);
1384
1363
  thenCtx = withInAtoms(thenCtx, extractInAtoms(resolvedCond));
1385
1364
  elseCtx = withInAtoms(elseCtx, extractInAtomsNegated(resolvedCond));
1386
1365
  return [{ kind: "if", cond: resolvedCond, then: resolveBlock(s.then, thenCtx), else: resolveBlock(s.else, elseCtx) }, ctx.env];