lemmascript 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,15 @@ function lookup(env, name) {
14
14
  function extend(env, name, ty) {
15
15
  return { name, ty, parent: env };
16
16
  }
17
+ function envKeys(env) {
18
+ const out = [];
19
+ let e = env;
20
+ while (e) {
21
+ out.push(e.name);
22
+ e = e.parent;
23
+ }
24
+ return out;
25
+ }
17
26
  function asRawAccessPath(e) {
18
27
  if (e.kind === "var")
19
28
  return { rootVar: e.name, fields: [] };
@@ -55,6 +64,43 @@ function wrapSome(value, optionalTy) {
55
64
  args: [value], ty: optionalTy, callKind: "pure",
56
65
  };
57
66
  }
67
+ /** Find the synth array-union TypeDecl named `name` (discriminant `__isArray__`). */
68
+ function findSynthArrayUnion(name, typeDecls) {
69
+ const decl = typeDecls.find(d => d.name === name);
70
+ if (decl?.kind === "discriminated-union" && decl.discriminant === "__isArray__")
71
+ return decl;
72
+ return null;
73
+ }
74
+ /** Coerce `value` to `targetTy` at an assignment-position. Mirrors TS subtyping
75
+ * for the two upcast shapes LS synthesizes:
76
+ * - `T` into `optional<T>` slot → wrap with `Some(...)`
77
+ * - `T[]` into a synth `T[] | U` slot → wrap with `ArrayBranch(...)`
78
+ * - `U` into a synth `T[] | U` slot → wrap with `NonArrayBranch(...)`
79
+ * Returns `value` unchanged if no coercion applies (types already match,
80
+ * source is unknown, or no rule matches). */
81
+ function coerceToTargetTy(value, targetTy, typeDecls) {
82
+ if (value.ty.kind === "unknown" || value.ty.kind === "void")
83
+ return value;
84
+ if (targetTy.kind === "optional" && value.ty.kind !== "optional") {
85
+ return wrapSome(value, targetTy);
86
+ }
87
+ if (targetTy.kind === "user") {
88
+ const synth = findSynthArrayUnion(targetTy.name, typeDecls);
89
+ if (synth && synth.variants && synth.variants.length === 2) {
90
+ const arrVariant = synth.variants.find(v => v.name === "ArrayBranch");
91
+ const nonVariant = synth.variants.find(v => v.name === "NonArrayBranch");
92
+ if (value.ty.kind === "array" && arrVariant) {
93
+ return { kind: "call", fn: { kind: "var", name: "ArrayBranch", ty: targetTy },
94
+ args: [value], ty: targetTy, callKind: "pure" };
95
+ }
96
+ if (value.ty.kind !== "array" && nonVariant) {
97
+ return { kind: "call", fn: { kind: "var", name: "NonArrayBranch", ty: targetTy },
98
+ args: [value], ty: targetTy, callKind: "pure" };
99
+ }
100
+ }
101
+ }
102
+ return value;
103
+ }
58
104
  /** Detect optional checks: `v !== undefined` (positive narrows then-branch),
59
105
  * `v === undefined` (negative narrows else-branch), or `!v` (equivalent to
60
106
  * `=== undefined`).
@@ -94,6 +140,12 @@ function classifyOptExpr(e, ctx) {
94
140
  return null;
95
141
  return { varName: e.name, innerTy: ty.inner };
96
142
  }
143
+ if (e.kind === "result") {
144
+ const ty = lookup(ctx.env, "\\result");
145
+ if (!ty || ty.kind !== "optional")
146
+ return null;
147
+ return { varName: "\\result", innerTy: ty.inner };
148
+ }
97
149
  const resolved = resolveExpr(e, ctx);
98
150
  if (resolved.ty.kind !== "optional")
99
151
  return null;
@@ -193,11 +245,74 @@ function isRefMutableInTS(ty) {
193
245
  return ty.kind === "array" || ty.kind === "map" || ty.kind === "set";
194
246
  }
195
247
  function findDecl(ctx, name) {
196
- return ctx.typeDecls.find(d => d.name === name);
248
+ const direct = ctx.typeDecls.find(d => d.name === name);
249
+ if (direct)
250
+ return direct;
251
+ // Dotted names (e.g. `Agent.Info`, `Permission.Ruleset`): fall back to the
252
+ // last segment, so `//@ declare-type Info { ... }` matches a reference to
253
+ // `Agent.Info` without forcing the user to repeat the namespace.
254
+ const dotIdx = name.lastIndexOf(".");
255
+ if (dotIdx >= 0)
256
+ return ctx.typeDecls.find(d => d.name === name.slice(dotIdx + 1));
257
+ return undefined;
258
+ }
259
+ /** Expand alias-kind typeDecls when the alias target is structural (array,
260
+ * map, set, optional, or another user type). Primitive-typed aliases like
261
+ * `type TaskId = number` stay as `user("TaskId")` so the generated Dafny
262
+ * preserves the alias name. Recursive through compound types; cycle-safe. */
263
+ function expandAlias(ty, typeDecls, seen = new Set()) {
264
+ if (ty.kind === "user") {
265
+ if (seen.has(ty.name))
266
+ return ty;
267
+ let decl = typeDecls.find(d => d.name === ty.name);
268
+ if (!decl && ty.name.includes(".")) {
269
+ const tail = ty.name.slice(ty.name.lastIndexOf(".") + 1);
270
+ decl = typeDecls.find(d => d.name === tail);
271
+ }
272
+ if (decl?.kind === "alias" && decl.aliasOfTy) {
273
+ const target = decl.aliasOfTy;
274
+ if (target.kind === "array" || target.kind === "map" || target.kind === "set" || target.kind === "optional" || target.kind === "user") {
275
+ return expandAlias(target, typeDecls, new Set([...seen, ty.name]));
276
+ }
277
+ }
278
+ return ty;
279
+ }
280
+ if (ty.kind === "optional")
281
+ return { kind: "optional", inner: expandAlias(ty.inner, typeDecls, seen) };
282
+ if (ty.kind === "array")
283
+ return { kind: "array", elem: expandAlias(ty.elem, typeDecls, seen) };
284
+ if (ty.kind === "set")
285
+ return { kind: "set", elem: expandAlias(ty.elem, typeDecls, seen) };
286
+ if (ty.kind === "map")
287
+ return { kind: "map", key: expandAlias(ty.key, typeDecls, seen), value: expandAlias(ty.value, typeDecls, seen) };
288
+ return ty;
197
289
  }
198
290
  function getDiscriminant(ctx, typeName) {
199
291
  return findDecl(ctx, typeName)?.discriminant;
200
292
  }
293
+ /** A type ts-morph handed us that LemmaScript hasn't modeled: contains
294
+ * `unknown` (TS `any`), or a `user` type whose name isn't a known declaration
295
+ * (an opaque expanded union like `"AssistantMsg | ToolMsg"` that ts-morph
296
+ * produced by expanding an alias LS shadows via declare-type). Used by
297
+ * `case "let"` to decide when LS's own `init.ty` is the better source of
298
+ * structure. */
299
+ function isUnmodeledTy(ty, typeDecls) {
300
+ if (ty.kind === "unknown")
301
+ return true;
302
+ if (ty.kind === "optional")
303
+ return isUnmodeledTy(ty.inner, typeDecls);
304
+ if (ty.kind === "array")
305
+ return isUnmodeledTy(ty.elem, typeDecls);
306
+ if (ty.kind === "set")
307
+ return isUnmodeledTy(ty.elem, typeDecls);
308
+ if (ty.kind === "map")
309
+ return isUnmodeledTy(ty.key, typeDecls) || isUnmodeledTy(ty.value, typeDecls);
310
+ if (ty.kind === "user") {
311
+ const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
312
+ return !typeDecls.some(d => d.name === base);
313
+ }
314
+ return false;
315
+ }
201
316
  /** Infer quantifier variable type from usage in body.
202
317
  * If the variable is used as a map/set key (e.g. map.has(k), map.get(k)),
203
318
  * return the collection's key type. Otherwise return null (default to int). */
@@ -264,8 +379,16 @@ function inferQuantVarType(varName, body, ctx) {
264
379
  function classifyCall(fn, ctx) {
265
380
  if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "Math")
266
381
  return "pure";
382
+ if (fn.kind === "field" && fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray")
383
+ return "pure";
267
384
  if (fn.kind === "var" && (ctx.inSpec || ctx.inLambda) && ctx.pureFns.has(fn.name))
268
385
  return "spec-pure";
386
+ // Bare-name `//@ extern` declarations are emitted as `function {:axiom}` —
387
+ // pure from the verifier's perspective. Classify them as pure so callers
388
+ // don't get lifted to statement-level binds (which would force lambdas to
389
+ // become multi-statement, illegal in Dafny).
390
+ if (fn.kind === "var" && ctx.externs.has(fn.name))
391
+ return "pure";
269
392
  if (fn.kind === "var" && ctx.inSpec) {
270
393
  // Not a known pure function — could be external (Lean-defined spec helper).
271
394
  // Pass through as "pure" and let Lean catch any errors.
@@ -276,24 +399,56 @@ function classifyCall(fn, ctx) {
276
399
  return "unknown";
277
400
  }
278
401
  // ── Call resolution helpers ─────────────────────────────────
279
- /** Infer lambda param types from array method context (map, filter, etc.).
280
- * Returns updated rawArgs with inferred tsType on the first lambda param. */
281
- function inferLambdaParamTypes(fn, rawArgs) {
402
+ /** Infer lambda param types from array method context (map, filter, etc.)
403
+ * AND from function-typed parameters of named callees (e.g., a `Comparator =
404
+ * (a, b) => bool` parameter propagates `string, string` to the lambda's
405
+ * inline params). Returns updated rawArgs with inferred tsType. */
406
+ function tyToTsStr(ty) {
407
+ if (ty.kind === "user")
408
+ return ty.name;
409
+ if (ty.kind === "string")
410
+ return "string";
411
+ if (ty.kind === "int" || ty.kind === "nat")
412
+ return "number";
413
+ if (ty.kind === "bool")
414
+ return "boolean";
415
+ return undefined;
416
+ }
417
+ function inferLambdaParamTypes(fn, rawArgs, ctx) {
282
418
  if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
283
- ["map", "filter", "every", "some", "find"].includes(fn.field) &&
419
+ ["map", "filter", "every", "some", "find", "findLast", "findIndex"].includes(fn.field) &&
284
420
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
285
421
  rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
286
422
  const elemTy = fn.obj.ty.elem;
287
- const tsType = elemTy.kind === "user" ? elemTy.name
288
- : elemTy.kind === "string" ? "string"
289
- : elemTy.kind === "int" || elemTy.kind === "nat" ? "number"
290
- : elemTy.kind === "bool" ? "boolean" : undefined;
423
+ const tsType = tyToTsStr(elemTy);
291
424
  if (tsType) {
292
425
  const lam = rawArgs[0];
293
426
  const updatedParams = [{ ...lam.params[0], tsType }, ...lam.params.slice(1)];
294
427
  return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
295
428
  }
296
429
  }
430
+ // Named-callee propagation: when an argument position expects a function
431
+ // type, infer the lambda's param types from that function type. Aliases
432
+ // (e.g., `Comparator`) are expanded via the typeDecls.
433
+ if (fn.kind === "var" && ctx?.fnParams.has(fn.name)) {
434
+ const paramTys = ctx.fnParams.get(fn.name);
435
+ return rawArgs.map((a, i) => {
436
+ if (a.kind !== "lambda" || i >= paramTys.length)
437
+ return a;
438
+ let pTy = paramTys[i];
439
+ if (pTy.kind === "user") {
440
+ const decl = ctx.typeDecls.find(d => d.name === pTy.name);
441
+ if (decl?.kind === "alias" && decl.aliasOfTy)
442
+ pTy = decl.aliasOfTy;
443
+ else if (decl?.kind === "alias" && decl.aliasOf)
444
+ pTy = parseTsType(decl.aliasOf);
445
+ }
446
+ if (pTy.kind !== "fn")
447
+ return a;
448
+ const updatedParams = a.params.map((p, idx) => p.tsType || idx >= pTy.params.length ? p : { ...p, tsType: tyToTsStr(pTy.params[idx]) });
449
+ return { ...a, params: updatedParams };
450
+ });
451
+ }
297
452
  return rawArgs;
298
453
  }
299
454
  /** Coerce call arguments: string literals → user types, non-optional → Some, pad missing optional args. */
@@ -322,6 +477,11 @@ function coerceCallArgs(args, fn, ctx) {
322
477
  function inferMethodReturnTy(fn, args, ctx) {
323
478
  if (fn.kind !== "field")
324
479
  return { kind: "unknown" };
480
+ // `Array.isArray(x)` always returns boolean. narrow.ts recognizes this call as
481
+ // a discriminator predicate when `x` has type of a synthesized array-union.
482
+ if (fn.obj.kind === "var" && fn.obj.name === "Array" && fn.field === "isArray") {
483
+ return { kind: "bool" };
484
+ }
325
485
  const objTy = fn.obj.ty;
326
486
  if (objTy.kind === "map") {
327
487
  if (fn.field === "get")
@@ -344,12 +504,24 @@ function inferMethodReturnTy(fn, args, ctx) {
344
504
  return { kind: "int" };
345
505
  if (fn.field === "shift")
346
506
  return objTy.elem;
507
+ if (fn.field === "pop")
508
+ return { kind: "optional", inner: objTy.elem };
347
509
  if (fn.field === "push" || fn.field === "concat")
348
510
  return objTy;
349
511
  if (fn.field === "filter")
350
512
  return objTy;
351
513
  if (fn.field === "every" || fn.field === "some")
352
514
  return { kind: "bool" };
515
+ if (fn.field === "find" || fn.field === "findLast")
516
+ return { kind: "optional", inner: objTy.elem };
517
+ if (fn.field === "findIndex")
518
+ return { kind: "int" };
519
+ if (fn.field === "flat" && objTy.elem.kind === "array")
520
+ return { kind: "array", elem: objTy.elem.elem };
521
+ if (fn.field === "slice")
522
+ return objTy;
523
+ if (fn.field === "join" && objTy.elem.kind === "string")
524
+ return { kind: "string" };
353
525
  if (fn.field === "map" && args.length >= 1 && args[0].kind === "lambda") {
354
526
  const retTy = args[0].body.length > 0 && args[0].body[0].kind === "return"
355
527
  ? args[0].body[0].value.ty : { kind: "unknown" };
@@ -357,9 +529,13 @@ function inferMethodReturnTy(fn, args, ctx) {
357
529
  }
358
530
  }
359
531
  else if (objTy.kind === "string") {
360
- if (fn.field === "trim" || fn.field === "toLowerCase" || fn.field === "toUpperCase")
532
+ if (fn.field === "trim" || fn.field === "trimEnd" || fn.field === "trimStart" || fn.field === "toLowerCase" || fn.field === "toUpperCase")
361
533
  return { kind: "string" };
362
- if (fn.field === "includes")
534
+ if (fn.field === "slice" || fn.field === "substring")
535
+ return { kind: "string" };
536
+ if (fn.field === "split")
537
+ return { kind: "array", elem: { kind: "string" } };
538
+ if (fn.field === "includes" || fn.field === "startsWith" || fn.field === "endsWith")
363
539
  return { kind: "bool" };
364
540
  }
365
541
  return { kind: "unknown" };
@@ -452,8 +628,21 @@ function resolveExpr(e, ctx) {
452
628
  return { kind: "unop", op: e.op, expr, ty: e.op === "!" ? { kind: "bool" } : expr.ty };
453
629
  }
454
630
  case "call": {
631
+ // Extern dispatch: `NS.method(args)` where NS.method is declared via
632
+ // `//@ extern`. Rewrite into a flat-name call (`NS_method(args)`) so the
633
+ // rest of the pipeline sees an ordinary pure function. The extern's
634
+ // declaration is emitted alongside the file as `function {:axiom} ...`.
635
+ if (e.fn.kind === "field" && e.fn.obj.kind === "var") {
636
+ const qualified = `${e.fn.obj.name}.${e.fn.field}`;
637
+ const ext = ctx.externs.get(qualified);
638
+ if (ext) {
639
+ const args = e.args.map(a => resolveExpr(a, ctx));
640
+ const fn = { kind: "var", name: ext.flat, ty: { kind: "unknown" } };
641
+ return { kind: "call", fn, args, ty: ext.returnTy, callKind: "pure" };
642
+ }
643
+ }
455
644
  const fn = resolveExpr(e.fn, ctx);
456
- const rawArgs = inferLambdaParamTypes(fn, e.args);
645
+ const rawArgs = inferLambdaParamTypes(fn, e.args, ctx);
457
646
  // For .push() on a typed array, resolve args with element type context
458
647
  let argCtx = ctx;
459
648
  if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "push" &&
@@ -463,13 +652,20 @@ function resolveExpr(e, ctx) {
463
652
  // Propagate parameter types to arguments for record literal resolution
464
653
  // (enables inline discriminated union construction in function arguments)
465
654
  const paramTypes = fn.kind === "var" && ctx.fnParams.has(fn.name) ? ctx.fnParams.get(fn.name) : null;
466
- const args = coerceCallArgs(rawArgs.map((a, i) => {
655
+ let args = coerceCallArgs(rawArgs.map((a, i) => {
467
656
  let aCtx = argCtx;
468
657
  if (paramTypes && i < paramTypes.length && paramTypes[i].kind === "user") {
469
658
  aCtx = { ...aCtx, returnTy: paramTypes[i] };
470
659
  }
471
660
  return resolveExpr(a, aCtx);
472
661
  }), fn, ctx);
662
+ // Array method `.with(i, v)`: coerce the value arg to the element type
663
+ // so `arr[i] = v` on `(T|null)[]` wraps `T` → `Some(T)` (and similarly
664
+ // for synth array-unions). Same shape as the record-field coercion
665
+ // below: assigning a narrower value into a wider slot.
666
+ if (fn.kind === "field" && fn.field === "with" && fn.obj.ty.kind === "array" && args.length === 2) {
667
+ args = [args[0], coerceToTargetTy(args[1], fn.obj.ty.elem, ctx.typeDecls)];
668
+ }
473
669
  let ty = inferMethodReturnTy(fn, args, ctx);
474
670
  // For same-file function calls, use the known return type
475
671
  if (ty.kind === "unknown" && fn.kind === "var" && ctx.fnReturns.has(fn.name)) {
@@ -553,22 +749,29 @@ function resolveExpr(e, ctx) {
553
749
  }
554
750
  else {
555
751
  // 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));
752
+ // Build a fake fn TExpr from prev steps to reuse inferMethodReturnTy
753
+ // and inferLambdaParamTypes without the latter, a lambda arg buried
754
+ // inside `obj?.filter(r => ...)` gets `int`-typed params instead of
755
+ // the array's element type.
558
756
  const lastField = chain.length > 0 && chain[chain.length - 1].kind === "field"
559
757
  ? chain[chain.length - 1] : null;
560
758
  let callTy = { kind: "unknown" };
561
759
  let callKind = "unknown";
760
+ let rawArgs = step.args;
562
761
  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
762
  const priorInTy = chain.length >= 2 ? chain[chain.length - 2].ty
566
763
  : (obj.ty.kind === "optional" ? obj.ty.inner : obj.ty);
567
764
  const fakeObj = { kind: "var", name: "_chain_recv", ty: priorInTy };
568
765
  const fakeFn = { kind: "field", obj: fakeObj, field: lastField.name, ty: lastField.ty };
766
+ rawArgs = inferLambdaParamTypes(fakeFn, rawArgs);
767
+ const args = rawArgs.map(a => resolveExpr(a, ctx));
569
768
  callTy = inferMethodReturnTy(fakeFn, args, ctx);
570
769
  callKind = "method";
770
+ chain.push({ kind: "call", args, ty: callTy, callKind });
771
+ stepInTy = callTy;
772
+ continue;
571
773
  }
774
+ const args = rawArgs.map(a => resolveExpr(a, ctx));
572
775
  chain.push({ kind: "call", args, ty: callTy, callKind });
573
776
  stepInTy = callTy;
574
777
  }
@@ -580,8 +783,19 @@ function resolveExpr(e, ctx) {
580
783
  case "record": {
581
784
  const spread = e.spread ? resolveExpr(e.spread, ctx) : null;
582
785
  const ty = spread ? spread.ty : { kind: "unknown" };
583
- // Infer record type: from spread, or from return type context
584
- const recordTy = ty.kind === "user" ? ty : ctx.returnTy.kind === "user" ? ctx.returnTy : null;
786
+ // Record literal in map-typed context (e.g. `const M: Record<string, V> = {a: ...}`):
787
+ // attach the map type so transform/emit can produce a map literal.
788
+ if (!spread && ctx.returnTy.kind === "map") {
789
+ const mapTy = ctx.returnTy;
790
+ const fieldCtx = { ...ctx, returnTy: mapTy.value };
791
+ const fields = e.fields.map(f => ({ name: f.name, value: resolveExpr(f.value, fieldCtx) }));
792
+ return { kind: "record", spread: null, fields, ty: mapTy };
793
+ }
794
+ // Infer record type: from spread, or from return type context. Unwrap
795
+ // an outer Optional when looking at returnTy — `return {...} : null`
796
+ // has ctx.returnTy = Option<T>, but the record literal's natural type is T.
797
+ const returnTyUnwrapped = ctx.returnTy.kind === "optional" ? ctx.returnTy.inner : ctx.returnTy;
798
+ const recordTy = ty.kind === "user" ? ty : returnTyUnwrapped.kind === "user" ? returnTyUnwrapped : null;
585
799
  const decl = recordTy ? ctx.typeDecls.find(d => d.name === recordTy.name && d.kind === "record") : undefined;
586
800
  // Clear returnTy for field values — it applies to THIS record, not nested ones
587
801
  const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
@@ -600,19 +814,22 @@ function resolveExpr(e, ctx) {
600
814
  if (value.kind === "record" && value.fields.length === 0 && !value.spread && declTy.kind === "map") {
601
815
  value = { kind: "arrayLiteral", elems: [], ty: declTy };
602
816
  }
603
- // Coerce non-optional to optional: wrap in Some (only when value type is concrete)
604
- if (declTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "void" && value.ty.kind !== "unknown") {
605
- value = wrapSome(value, declTy);
606
- }
817
+ // Assignment-position upcasts: T Option<T>, T[] ArrayBranch(T[]),
818
+ // U → NonArrayBranch(U). Handles both optional fields and fields
819
+ // typed as a synth array-union (`T[] | U`).
820
+ value = coerceToTargetTy(value, declTy, ctx.typeDecls);
607
821
  }
608
822
  return { name: f.name, value };
609
823
  });
610
824
  return { kind: "record", spread, fields, ty: recordTy ?? ty };
611
825
  }
612
826
  case "result":
827
+ // \result desugars to a regular var so all the variable-narrowing
828
+ // machinery (env lookup, optional checks, path matching) just works.
829
+ // The env in ensuresCtx is pre-seeded with "\result" → returnTy.
613
830
  if (!ctx.allowResult)
614
831
  throw new Error("\\result is only valid in ensures");
615
- return { kind: "result", ty: ctx.returnTy };
832
+ return { kind: "var", name: "\\result", ty: lookup(ctx.env, "\\result") ?? ctx.returnTy };
616
833
  case "forall": {
617
834
  const varTy = e.varType !== "int" ? parseTsType(e.varType)
618
835
  : inferQuantVarType(e.var, e.body, ctx) ?? { kind: "int" };
@@ -683,6 +900,14 @@ function resolveExpr(e, ctx) {
683
900
  else if (else_.ty.kind === "void" && then_.ty.kind !== "void" && then_.ty.kind !== "unknown") {
684
901
  ty = { kind: "optional", inner: then_.ty };
685
902
  }
903
+ else if (then_.ty.kind === "optional" && else_.ty.kind !== "optional" && else_.ty.kind !== "unknown") {
904
+ // Asymmetric optional: one branch returns Option<T>, the other returns T.
905
+ // Widen to Option<T> so callers/return-coercion see the wider type.
906
+ ty = then_.ty;
907
+ }
908
+ else if (else_.ty.kind === "optional" && then_.ty.kind !== "optional" && then_.ty.kind !== "unknown") {
909
+ ty = else_.ty;
910
+ }
686
911
  return { kind: "conditional", cond, then: then_, else: else_, ty };
687
912
  }
688
913
  case "emptyCollection": {
@@ -752,20 +977,48 @@ function resolveBlock(stmts, ctx) {
752
977
  function resolveStmt(s, ctx) {
753
978
  switch (s.kind) {
754
979
  case "let": {
755
- const declTy = resolveTsType(s.tsType, ctx.overrides, s.name);
980
+ // No source annotation → infer type from initializer (resolved first).
981
+ if (s.tsType === null) {
982
+ const init = resolveExpr(s.init, ctx);
983
+ const ty = init.ty;
984
+ const mutable = s.mutable || isRefMutableInTS(ty);
985
+ return [{ kind: "let", name: s.name, ty, mutable, init }, extend(ctx.env, s.name, ty)];
986
+ }
987
+ // expandAlias unwraps an array/collection alias (`type Board = number[]`)
988
+ // to its underlying type, so array methods / index-assignment on the
989
+ // local dispatch correctly (params get the same treatment, see makeParams).
990
+ const declTy = expandAlias(resolveTsType(s.tsType, ctx.overrides, s.name), ctx.typeDecls);
756
991
  // Propagate declared type as returnTy so nested record expressions
757
992
  // resolve union variants correctly (e.g., EffectState → mode: EffectMode → { kind: 'Idle' })
758
993
  const initCtx = declTy.kind === "user" ? { ...ctx, returnTy: declTy } : ctx;
759
994
  const init = coerceStr(resolveExpr(s.init, initCtx), declTy);
760
- // Map indexing: TS says T, but access can fail → use Optional<T> from init
761
- const ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
995
+ let ty;
996
+ if (isUnmodeledTy(declTy, ctx.typeDecls) && !isUnmodeledTy(init.ty, ctx.typeDecls)) {
997
+ // ts-morph's declared type is opaque to us (an expanded union it made
998
+ // by inlining an alias we shadow via declare-type, or any-laden), but
999
+ // LS resolved the initializer to something concrete. Take the structure
1000
+ // from `init.ty`, keeping only the optionality ts-morph reported.
1001
+ ty = declTy.kind === "optional" && init.ty.kind !== "optional"
1002
+ ? { kind: "optional", inner: init.ty }
1003
+ : init.ty;
1004
+ }
1005
+ else {
1006
+ // Map indexing: TS says T, but access can fail → use Optional<T> from init
1007
+ ty = (declTy.kind !== "optional" && init.ty.kind === "optional") ? init.ty : declTy;
1008
+ }
762
1009
  // const collections are mutable in value-semantics world (TS mutates in place, Dafny/Lean reassign)
763
1010
  const mutable = s.mutable || isRefMutableInTS(ty);
764
1011
  return [{ kind: "let", name: s.name, ty, mutable, init }, extend(ctx.env, s.name, ty)];
765
1012
  }
766
1013
  case "assign": {
767
1014
  const targetTy = lookup(ctx.env, s.target) ?? { kind: "unknown" };
768
- return [{ kind: "assign", target: s.target, value: coerceStr(resolveExpr(s.value, ctx), targetTy) }, ctx.env];
1015
+ let value = coerceStr(resolveExpr(s.value, ctx), targetTy);
1016
+ // Auto-wrap non-optional value in Some when target is optional
1017
+ const isUndef = value.kind === "var" && value.name === "undefined";
1018
+ if (targetTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "unknown" && !isUndef) {
1019
+ value = wrapSome(value, targetTy);
1020
+ }
1021
+ return [{ kind: "assign", target: s.target, value }, ctx.env];
769
1022
  }
770
1023
  case "return": {
771
1024
  let value = coerceStr(resolveExpr(s.value, ctx), ctx.returnTy);
@@ -877,7 +1130,7 @@ function resolveStmt(s, ctx) {
877
1130
  case "assert": {
878
1131
  const specCtx = { ...ctx, inSpec: true };
879
1132
  const expr = resolveExpr(parseExpr(s.expr), specCtx);
880
- return [{ kind: "assert", expr }, ctx.env];
1133
+ return [{ kind: "assert", expr, assumed: s.assumed }, ctx.env];
881
1134
  }
882
1135
  }
883
1136
  }
@@ -1052,18 +1305,22 @@ function containsReturn(stmts) {
1052
1305
  return false;
1053
1306
  }
1054
1307
  // ── Resolve function / module ────────────────────────────────
1055
- function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), opts) {
1308
+ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), externs = new Map(), moduleConstants = new Map(), opts) {
1056
1309
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
1057
- const params = fn.params.map(p => ({ name: p.name, ty: resolveTsType(p.tsType, overrides, p.name) }));
1058
- const returnTy = resolveTsType(fn.returnType, overrides, "\\result");
1310
+ const params = fn.params.map(p => ({ name: p.name, ty: expandAlias(resolveTsType(p.tsType, overrides, p.name), typeDecls) }));
1311
+ const returnTy = expandAlias(resolveTsType(fn.returnType, overrides, "\\result"), typeDecls);
1059
1312
  let env = null;
1313
+ // Module-level constants are in scope for every function body. Added before
1314
+ // params so a param named the same as a const would shadow it (param wins).
1315
+ for (const [name, ty] of moduleConstants)
1316
+ env = extend(env, name, ty);
1060
1317
  if (opts?.thisBinding)
1061
1318
  env = extend(env, opts.thisBinding.name, opts.thisBinding.ty);
1062
1319
  for (const p of params)
1063
1320
  env = extend(env, p.name, p.ty);
1064
- const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
1321
+ const baseCtx = { env, typeDecls, overrides, allowResult: false, returnTy, pureFns, fnParams, fnReturns, externs, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
1065
1322
  const requiresCtx = { ...baseCtx, inSpec: true };
1066
- const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
1323
+ const ensuresCtx = { ...baseCtx, env: extend(env, "\\result", returnTy), allowResult: true, inSpec: true };
1067
1324
  // Apply type parameter constraints from //@ type T (==) annotations
1068
1325
  const typeParams = fn.typeParams.map(tp => {
1069
1326
  const constraint = overrides.get(tp);
@@ -1087,13 +1344,13 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map(), fnReturns
1087
1344
  body: resolveBlock(fn.body, bodyCtx),
1088
1345
  };
1089
1346
  }
1090
- function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map()) {
1347
+ function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns = new Map(), externs = new Map(), moduleConstants = new Map()) {
1091
1348
  const fields = cls.fields.map(f => ({ name: f.name, ty: parseTsType(f.tsType) }));
1092
1349
  // Create a synthetic record type for 'this' so field access resolves
1093
1350
  const thisType = { kind: "user", name: cls.name };
1094
1351
  const thisDecl = { name: cls.name, kind: "record", fields: cls.fields.map(f => ({ name: f.name, tsType: f.tsType, type: parseTsType(f.tsType) })) };
1095
1352
  const allTypeDecls = [...typeDecls, thisDecl];
1096
- const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, fnReturns, {
1353
+ const methods = cls.methods.map(fn => resolveFunction(fn, allTypeDecls, pureFns, fnParams, fnReturns, externs, moduleConstants, {
1097
1354
  thisBinding: { name: "this", ty: thisType },
1098
1355
  forcePure: false, // class methods are never pure (they access this)
1099
1356
  }));
@@ -1102,6 +1359,22 @@ function resolveClass(cls, typeDecls, pureFns, fnParams = new Map(), fnReturns =
1102
1359
  /** Pre-compute Ty on all TypeDeclInfo fields/variants/aliases.
1103
1360
  * Called once per module so consumers can read field.type instead of re-parsing tsType. */
1104
1361
  function precomputeFieldTypes(typeDecls) {
1362
+ precomputeFieldTypesInner(typeDecls);
1363
+ // Expand alias references inside record/variant field types so downstream
1364
+ // code doesn't have to follow `user("Ruleset")` indirection at every lookup.
1365
+ for (const d of typeDecls) {
1366
+ if (d.fields)
1367
+ for (const f of d.fields)
1368
+ if (f.type)
1369
+ f.type = expandAlias(f.type, typeDecls);
1370
+ if (d.variants)
1371
+ for (const v of d.variants)
1372
+ for (const f of v.fields)
1373
+ if (f.type)
1374
+ f.type = expandAlias(f.type, typeDecls);
1375
+ }
1376
+ }
1377
+ function precomputeFieldTypesInner(typeDecls) {
1105
1378
  for (const d of typeDecls) {
1106
1379
  if (d.fields)
1107
1380
  for (const f of d.fields)
@@ -1122,20 +1395,76 @@ export function resolveModule(raw) {
1122
1395
  const fnReturns = new Map();
1123
1396
  for (const fn of raw.functions) {
1124
1397
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
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"));
1398
+ fnParams.set(fn.name, fn.params.map(p => expandAlias(resolveTsType(p.tsType, overrides, p.name), raw.typeDecls)));
1399
+ fnReturns.set(fn.name, expandAlias(resolveTsType(fn.returnType, overrides, "\\result"), raw.typeDecls));
1127
1400
  }
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: [] };
1129
- const constants = (raw.constants ?? []).map(c => ({
1130
- name: c.name,
1131
- ty: parseTsType(c.tsType),
1132
- value: resolveExpr(c.value, emptyCtx),
1133
- }));
1401
+ // Externs: resolve param/return types once. For bare-name externs (no dot),
1402
+ // also register in fnReturns so ordinary `foo(args)` calls get the right
1403
+ // return type at resolution; dotted externs are handled in resolveExpr's
1404
+ // call case via the externs map directly.
1405
+ const externs = new Map();
1406
+ // First pass: register signatures so spec resolution (below) can reference
1407
+ // them — including the extern referring to itself, or specs that mention
1408
+ // sibling externs.
1409
+ for (const ext of raw.externs ?? []) {
1410
+ const params = ext.params.map(p => parseTsType(p.tsType));
1411
+ const returnTy = parseTsType(ext.returnType);
1412
+ externs.set(ext.qualified, { flat: ext.flat, params, returnTy });
1413
+ if (!ext.qualified.includes("."))
1414
+ fnReturns.set(ext.qualified, returnTy);
1415
+ }
1416
+ // Second pass: resolve the lifted `requires`/`ensures` strings in each
1417
+ // extern's own param scope. `\result` is in scope under `ensures`.
1418
+ const tExterns = (raw.externs ?? []).map(ext => {
1419
+ const sig = externs.get(ext.qualified);
1420
+ let env = null;
1421
+ for (let i = 0; i < ext.params.length; i++) {
1422
+ env = extend(env, ext.params[i].name, sig.params[i]);
1423
+ }
1424
+ const baseCtx = { env, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: sig.returnTy, pureFns, fnParams, fnReturns, externs, inSpec: true, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
1425
+ const ensuresCtx = { ...baseCtx, env: extend(env, "\\result", sig.returnTy), allowResult: true };
1426
+ const requires = ext.requires.map(s => {
1427
+ try {
1428
+ return resolveSpec(s, baseCtx);
1429
+ }
1430
+ catch {
1431
+ return null;
1432
+ }
1433
+ }).filter((e) => e !== null);
1434
+ const ensures = ext.ensures.map(s => {
1435
+ try {
1436
+ return resolveSpec(s, ensuresCtx);
1437
+ }
1438
+ catch {
1439
+ return null;
1440
+ }
1441
+ }).filter((e) => e !== null);
1442
+ return {
1443
+ qualified: ext.qualified,
1444
+ flat: ext.flat,
1445
+ typeParams: ext.typeParams,
1446
+ params: ext.params.map((p, i) => ({ name: p.name, ty: sig.params[i] })),
1447
+ returnTy: sig.returnTy,
1448
+ requires,
1449
+ ensures,
1450
+ };
1451
+ });
1452
+ const emptyCtx = { env: null, typeDecls: raw.typeDecls, overrides: new Map(), allowResult: false, returnTy: { kind: "int" }, pureFns, fnParams, fnReturns, externs, inSpec: false, inLambda: false, narrowedPaths: [], narrowedIndices: [] };
1453
+ const constants = (raw.constants ?? []).map(c => {
1454
+ const ty = expandAlias(parseTsType(c.tsType), raw.typeDecls);
1455
+ // Propagate the declared type into the value's resolution context so that
1456
+ // record literals on map-typed constants (e.g. `Record<string, number>`)
1457
+ // get their `ty` set to `map<...>` rather than `user("...")`.
1458
+ const valueCtx = { ...emptyCtx, returnTy: ty };
1459
+ return { name: c.name, ty, value: resolveExpr(c.value, valueCtx) };
1460
+ });
1461
+ const moduleConstants = new Map(constants.map(c => [c.name, c.ty]));
1134
1462
  return {
1135
1463
  file: raw.file,
1136
1464
  typeDecls: raw.typeDecls,
1465
+ externs: tExterns,
1137
1466
  constants,
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)),
1467
+ functions: raw.functions.map(fn => resolveFunction(fn, raw.typeDecls, pureFns, fnParams, fnReturns, externs, moduleConstants)),
1468
+ classes: (raw.classes ?? []).map(cls => resolveClass(cls, raw.typeDecls, pureFns, fnParams, fnReturns, externs, moduleConstants)),
1140
1469
  };
1141
1470
  }
@@ -231,6 +231,12 @@ class Parser {
231
231
  this.advance();
232
232
  return { kind: "bool", value: false };
233
233
  }
234
+ // Match the body extractor (extract.ts NullLiteral): `null` and
235
+ // `undefined` are interchangeable in LS, both map to None.
236
+ if (t.value === "null") {
237
+ this.advance();
238
+ return { kind: "var", name: "undefined" };
239
+ }
234
240
  // new Set<T>() / new Map<K,V>()
235
241
  if (t.value === "new") {
236
242
  this.advance();