lemmascript 0.5.17 → 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.
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Dafny emitter — IR → Dafny text.
3
3
  */
4
- import { usesName, usesNameInDecl } from "./ir.js";
5
- import { freshName, userNames } from "./names.js";
4
+ import { exactIntegerLiteral, usesName, usesNameInDecl, usesNameInStmts } from "./ir.js";
5
+ import { freshNameWhere, userNames } from "./names.js";
6
6
  import { renameFreeVar } from "./transform.js";
7
7
  /** Fresh binder for a comprehension wrapping the given subexpressions: `base`
8
8
  * verbatim unless one of them references it, then primed until free. A *local*
9
9
  * check — a same-named name elsewhere in the module keeps the plain binder. */
10
10
  function freshBinder(base, ...wrapped) {
11
- return freshName(base, name => wrapped.some(w => usesName(w, name)));
11
+ return freshNameWhere(base, name => wrapped.some(w => usesName(w, name)));
12
12
  }
13
13
  /** Binder + body for lowering a single-return lambda to a comprehension whose
14
14
  * receiver is emitted inside the binder's scope. TS scoping keeps the receiver
@@ -128,6 +128,13 @@ function resetDafnyNameCache() {
128
128
  _takenDafnyNames.add(emitted);
129
129
  }
130
130
  }
131
+ /** Source-name → emitted-Dafny-name pairs from the most recent emitDafnyFile
132
+ * call (identity-mapped names included). Consumed by `lsc info --typed` so
133
+ * satellites (e.g. lemmascript-crosscheck) can address emitted declarations
134
+ * by their mangled names (`_Box` → `i_Box'`) without re-deriving the rules. */
135
+ export function emittedNameMap() {
136
+ return new Map([..._generatedDafnyNames, ..._userDafnyNames]);
137
+ }
131
138
  function escapeName(name) {
132
139
  // \result is carried through the IR as var "\\result"; render it as the
133
140
  // current method's out-parameter name (chosen locally by methodHeader).
@@ -171,7 +178,7 @@ function methodHeader(prefix, params, returnType, scope) {
171
178
  // name is recorded so `\result` references resolve to it.
172
179
  const taken = (n) => params.some(p => escapeName(p.name) === n) ||
173
180
  (scope !== undefined && usesNameInDecl(scope.requires, scope.ensures, scope.body, n));
174
- const resName = freshName("res", taken);
181
+ const resName = freshNameWhere("res", taken);
175
182
  _resultName = resName;
176
183
  return `${sig} returns (${resName}: ${tyToDafny(returnType)})`;
177
184
  }
@@ -183,9 +190,8 @@ const OP_MAP = {
183
190
  };
184
191
  function mapOp(op) { return OP_MAP[op] ?? op; }
185
192
  // ── Expression emission ─────────────────────────────────────
186
- /** Emit a match scrutinee — either a variable name (string) or an expression. */
187
193
  function emitScrutinee(s) {
188
- return typeof s === "string" ? escapeName(s) : emitExpr(s);
194
+ return emitExpr(s);
189
195
  }
190
196
  /** Collapse nested forall/exists into a single quantifier with multiple bound vars. */
191
197
  function emitQuantifier(e, keyword) {
@@ -212,6 +218,8 @@ function emitExpr(e) {
212
218
  switch (e.kind) {
213
219
  case "var": return e.name === "undefined" ? "None" : escapeName(e.name);
214
220
  case "num": return `${e.value}`;
221
+ // Already canonical decimal; Dafny's `int` is mathematical, so no `n` suffix.
222
+ case "bigint": return e.value;
215
223
  case "bool": return e.value ? "true" : "false";
216
224
  case "str": return `"${e.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
217
225
  case "constructor": {
@@ -281,8 +289,43 @@ function emitExpr(e) {
281
289
  }
282
290
  return `${obj}[${args[0]}..${args[1]}]`;
283
291
  }
284
- if (e.method === "map")
285
- return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
292
+ if (e.method === "map") {
293
+ // Always a seq comprehension: Seq.Map would hide the element access
294
+ // behind a closure, defeating Dafny's termination checker for
295
+ // recursive rebuild walkers. A literal lambda argument is
296
+ // beta-reduced through a `var` binding — an applied closure defeats
297
+ // the checker too; any other argument is applied to the element
298
+ // directly. A non-variable receiver is bound once up front: the
299
+ // comprehension mentions it three times, and splicing would make
300
+ // any closure literal inside it (e.g. a Filter predicate) three
301
+ // distinct closures, unprovably equal through an opaque callee.
302
+ // Name freshness is a local IR-level check until a backend name
303
+ // allocator exists.
304
+ const lam = e.args[0];
305
+ const fresh = (base, taken) => {
306
+ let name = base;
307
+ for (let n = 2; taken(name); n++)
308
+ name = `${base}${n}`;
309
+ return name;
310
+ };
311
+ const taken = (n) => usesName(e.obj, n) || usesName(lam, n) ||
312
+ (lam.kind === "lambda" && (lam.params.some(pp => pp.name === n) ||
313
+ usesNameInStmts(lam.body, n)));
314
+ const bind = e.obj.kind !== "var";
315
+ const s = bind ? fresh("s_map", taken) : obj;
316
+ const idx = fresh("i_map", n => taken(n) || n === s);
317
+ let core;
318
+ if (lam.kind === "lambda" && lam.params.length === 1 &&
319
+ lam.body.length === 1 && lam.body[0].kind === "return") {
320
+ const p = escapeName(lam.params[0].name);
321
+ core = `var ${p} := ${s}[${idx}]; ${emitExpr(lam.body[0].value)}`;
322
+ }
323
+ else {
324
+ core = `(${args[0]})(${s}[${idx}])`;
325
+ }
326
+ const comp = `seq(|${s}|, ${idx} requires 0 <= ${idx} < |${s}| => ${core})`;
327
+ return bind ? `(var ${s} := ${obj}; ${comp})` : comp;
328
+ }
286
329
  if (e.method === "filter")
287
330
  return `Std.Collections.Seq.Filter(${args[0]}, ${obj})`;
288
331
  // filterMap (synthesized in resolve): drop Nones and unwrap to seq<T>.
@@ -293,6 +336,11 @@ function emitExpr(e) {
293
336
  }
294
337
  if (e.method === "every")
295
338
  return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
339
+ if (e.method === "find") {
340
+ needPreamble("OptionType");
341
+ needPreamble("SeqFind");
342
+ return `SeqFind(${obj}, ${args[0]})`;
343
+ }
296
344
  if (e.method === "findLast") {
297
345
  needPreamble("OptionType");
298
346
  needPreamble("SeqFindLast");
@@ -348,10 +396,14 @@ function emitExpr(e) {
348
396
  return `StringSplit(${obj}, ${args[0]})`;
349
397
  }
350
398
  if (e.method === "slice") {
351
- // JS negative index: arr.slice(0, -N) → arr[0..|arr|-N]. After
352
- // transform, unary minus on a numeric literal is folded to a
353
- // negative `num` IR node, so check for that here.
354
- const negVal = (a) => a.kind === "num" && a.value < 0 ? -a.value : null;
399
+ // JS negative index: arr.slice(0, -N) → arr[0..|arr|-N]. Transform
400
+ // folds unary minus on a numeric literal into a negative `num` node,
401
+ // but leaves a negated bigint structural `exactIntegerLiteral`
402
+ // recognizes both.
403
+ const negVal = (a) => {
404
+ const v = exactIntegerLiteral(a);
405
+ return v !== null && v < 0n ? (-v).toString(10) : null;
406
+ };
355
407
  const loN = negVal(e.args[0]);
356
408
  const loEx = loN !== null ? `|${obj}|-${loN}` : args[0];
357
409
  if (args.length === 1)
@@ -453,6 +505,8 @@ function emitExpr(e) {
453
505
  return `!(${emitExpr(e.expr)})`;
454
506
  if (e.op === "-" && e.expr.kind === "num")
455
507
  return `(-(${e.expr.value}))`;
508
+ if (e.op === "-" && e.expr.kind === "bigint")
509
+ return `(-(${e.expr.value}))`;
456
510
  if (e.op === "-")
457
511
  return `(-(${emitExpr(e.expr)}))`;
458
512
  return `${op}(${emitExpr(e.expr)})`;
@@ -468,27 +522,23 @@ function emitExpr(e) {
468
522
  // Bitwise operators on int: translate to arithmetic
469
523
  // x >> n → x / 2^n (right shift)
470
524
  // x << n → x * 2^n (left shift)
471
- if (e.op === ">>") {
472
- if (e.right.kind === "num") {
473
- return `(${emitExpr(e.left)} / ${Math.pow(2, e.right.value)})`;
525
+ if (e.op === ">>" || e.op === "<<") {
526
+ const shift = exactIntegerLiteral(e.right);
527
+ // Cap the fold: a huge literal shift would inline an absurd numeral.
528
+ if (shift !== null && shift >= 0n && shift <= 1024n) {
529
+ const factor = (1n << shift).toString(10);
530
+ return `(${emitExpr(e.left)} ${e.op === ">>" ? "/" : "*"} ${factor})`;
474
531
  }
475
532
  needPreamble("Pow2");
476
- return `(${emitExpr(e.left)} / Pow2(${emitExpr(e.right)}))`;
477
- }
478
- if (e.op === "<<") {
479
- if (e.right.kind === "num") {
480
- return `(${emitExpr(e.left)} * ${Math.pow(2, e.right.value)})`;
481
- }
482
- needPreamble("Pow2");
483
- return `(${emitExpr(e.left)} * Pow2(${emitExpr(e.right)}))`;
533
+ return `(${emitExpr(e.left)} ${e.op === ">>" ? "/" : "*"} Pow2(${emitExpr(e.right)}))`;
484
534
  }
485
535
  // x & mask → x % (mask + 1) for literal masks of form 2^n - 1, else BitAnd
486
536
  if (e.op === "&") {
487
- if (e.right.kind === "num") {
488
- const mask = e.right.value;
489
- const modulus = mask + 1;
490
- if ((modulus & (modulus - 1)) === 0) {
491
- return `(${emitExpr(e.left)} % ${modulus})`;
537
+ const mask = exactIntegerLiteral(e.right);
538
+ if (mask !== null && mask >= 0n) {
539
+ const modulus = mask + 1n;
540
+ if ((modulus & (modulus - 1n)) === 0n) {
541
+ return `(${emitExpr(e.left)} % ${modulus.toString(10)})`;
492
542
  }
493
543
  }
494
544
  needPreamble("BitAnd");
@@ -556,6 +606,15 @@ function emitExpr(e) {
556
606
  needPreamble("Perm");
557
607
  if (e.fn === "SetFromSeq")
558
608
  needPreamble("SetFromSeq");
609
+ // A constructor application must spell the name the same way the
610
+ // datatype declaration does — `dafnyCtorName`, not `escapeName`, since
611
+ // tags come from source strings ("spec-pure") that escapeName leaves alone.
612
+ if (e.ctorOf) {
613
+ const ctor = dafnyCtorName(e.fn);
614
+ return _ambiguousCtors.has(e.fn)
615
+ ? `${e.ctorOf}.${ctor}(${args.join(", ")})`
616
+ : `${ctor}(${args.join(", ")})`;
617
+ }
559
618
  return `${escapeName(e.fn)}(${args.join(", ")})`;
560
619
  }
561
620
  case "field": {
@@ -568,6 +627,11 @@ function emitExpr(e) {
568
627
  return `${obj}.Keys`;
569
628
  if (e.field === "toNat")
570
629
  return obj;
630
+ if (e.ctor && e.fromUnion) {
631
+ const renamed = _ctorFieldRenames.get(`${e.fromUnion}.${e.ctor}.${e.field}`);
632
+ if (renamed)
633
+ return `${obj}.${escapeName(renamed)}`;
634
+ }
571
635
  return `${obj}.${escapeName(e.field)}`;
572
636
  }
573
637
  case "toNat":
@@ -588,7 +652,10 @@ function emitExpr(e) {
588
652
  if (e.fields.length === 0) {
589
653
  return emitExpr(e.spread);
590
654
  }
591
- const updates = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
655
+ const updates = e.fields.map(f => {
656
+ const renamed = e.ctor && e.ctorOf ? _ctorFieldRenames.get(`${e.ctorOf}.${e.ctor}.${f.name}`) : undefined;
657
+ return `${escapeName(renamed ?? f.name)} := ${emitExpr(f.value)}`;
658
+ });
592
659
  return `${emitExpr(e.spread)}.(${updates.join(", ")})`;
593
660
  }
594
661
  // Match constructor by field names — prefer exact match over first-field heuristic
@@ -792,9 +859,9 @@ function emitDecl(d) {
792
859
  const collides = new Set([...typesByField].filter(([, s]) => s.size > 1).map(([n]) => n));
793
860
  const ctors = d.constructors.map(c => {
794
861
  if (c.fields.length === 0)
795
- return escapeName(c.name);
796
- const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name}` } : f);
797
- return `${escapeName(c.name)}(${paramList(fields)})`;
862
+ return dafnyCtorName(c.name);
863
+ const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name.replace(/[^A-Za-z0-9_'?]/g, "_")}` } : f);
864
+ return `${dafnyCtorName(c.name)}(${paramList(fields)})`;
798
865
  });
799
866
  return `datatype ${escapeName(d.name)}${tp} = ${ctors.join(" | ")}`;
800
867
  }
@@ -1036,6 +1103,19 @@ const SEQ_FIND_LAST_INDEX = `function SeqFindLastIndex<T>(s: seq<T>, p: T -> boo
1036
1103
  else if p(s[|s|-1]) then |s| - 1
1037
1104
  else SeqFindLastIndex(s[..|s|-1], p)
1038
1105
  }`;
1106
+ const SEQ_FIND = `function SeqFind<T>(s: seq<T>, p: T -> bool): Option<T>
1107
+ ensures SeqFind(s, p).Some? ==> p(SeqFind(s, p).value)
1108
+ ensures SeqFind(s, p).Some? ==> SeqFind(s, p).value in s
1109
+ ensures SeqFind(s, p).Some? ==>
1110
+ exists i: nat :: i < |s| && s[i] == SeqFind(s, p).value && p(s[i]) &&
1111
+ (forall j: nat :: j < i ==> !p(s[j]))
1112
+ ensures SeqFind(s, p).None? ==> forall i :: 0 <= i < |s| ==> !p(s[i])
1113
+ decreases |s|
1114
+ {
1115
+ if |s| == 0 then None
1116
+ else if p(s[0]) then Some(s[0])
1117
+ else SeqFind(s[1..], p)
1118
+ }`;
1039
1119
  const SEQ_FIND_LAST = `function SeqFindLast<T>(s: seq<T>, p: T -> bool): Option<T>
1040
1120
  ensures SeqFindLast(s, p).Some? ==> p(SeqFindLast(s, p).value)
1041
1121
  ensures SeqFindLast(s, p).Some? ==> SeqFindLast(s, p).value in s
@@ -1294,6 +1374,7 @@ const PREAMBLE_CODE = [
1294
1374
  ["SeqFindIndex", SEQ_FIND_INDEX],
1295
1375
  ["SeqFindLastIndex", SEQ_FIND_LAST_INDEX],
1296
1376
  ["SeqFilterSome", SEQ_FILTER_SOME],
1377
+ ["SeqFind", SEQ_FIND],
1297
1378
  ["SeqFindLast", SEQ_FIND_LAST],
1298
1379
  ["SeqFlatten", SEQ_FLATTEN],
1299
1380
  ["SeqJoin", SEQ_JOIN],
@@ -1319,10 +1400,18 @@ const PREAMBLE_CODE = [
1319
1400
  let _recordCtors = new Map();
1320
1401
  let _structureDecls = new Map();
1321
1402
  let _declaredTypes = new Set();
1403
+ let _ambiguousCtors = new Set();
1404
+ // `"<union>.<ctor>.<field>"` → per-constructor destructor name, for fields the
1405
+ // inductive emission renames (shared name, differing types). Field reads and
1406
+ // datatype updates with a pinned ctor must use the renamed destructor.
1407
+ let _ctorFieldRenames = new Map();
1322
1408
  function buildRecordCtorMap(decls) {
1323
1409
  _recordCtors = new Map();
1324
1410
  _structureDecls = new Map();
1325
1411
  _declaredTypes = new Set();
1412
+ _ambiguousCtors = new Set();
1413
+ _ctorFieldRenames = new Map();
1414
+ const ctorSeen = new Set();
1326
1415
  function collectDecl(d) {
1327
1416
  if (d.kind === "structure") {
1328
1417
  _declaredTypes.add(d.name);
@@ -1330,8 +1419,36 @@ function buildRecordCtorMap(decls) {
1330
1419
  if (d.fields.length > 0)
1331
1420
  _recordCtors.set(d.fields[0].name, d.name);
1332
1421
  }
1333
- if (d.kind === "inductive")
1422
+ if (d.kind === "inductive") {
1334
1423
  _declaredTypes.add(d.name);
1424
+ // Constructor names shared by two datatypes in this module (Expr.let vs
1425
+ // Stmt.let) can't be used bare — emitters must qualify them.
1426
+ for (const c of d.constructors) {
1427
+ if (ctorSeen.has(c.name))
1428
+ _ambiguousCtors.add(c.name);
1429
+ ctorSeen.add(c.name);
1430
+ }
1431
+ // Mirror the destructor renaming the inductive case of emitDecl performs
1432
+ // (shared field name, differing types → per-constructor names), so reads
1433
+ // and updates can be translated to the renamed destructors.
1434
+ const typesByField = new Map();
1435
+ for (const c of d.constructors)
1436
+ for (const f of c.fields) {
1437
+ let s = typesByField.get(f.name);
1438
+ if (!s) {
1439
+ s = new Set();
1440
+ typesByField.set(f.name, s);
1441
+ }
1442
+ s.add(tyToDafny(f.type));
1443
+ }
1444
+ const collides = new Set([...typesByField].filter(([, s]) => s.size > 1).map(([n]) => n));
1445
+ for (const c of d.constructors)
1446
+ for (const f of c.fields) {
1447
+ if (!collides.has(f.name))
1448
+ continue;
1449
+ _ctorFieldRenames.set(`${d.name}.${c.name}.${f.name}`, `${f.name}_${c.name.replace(/[^A-Za-z0-9_'?]/g, "_")}`);
1450
+ }
1451
+ }
1335
1452
  if (d.kind === "type-alias")
1336
1453
  _declaredTypes.add(d.name);
1337
1454
  if (d.kind === "def")
@@ -1359,9 +1476,19 @@ function resolveTy(ty) {
1359
1476
  return { kind: "set", elem: resolveTy(ty.elem) };
1360
1477
  return ty;
1361
1478
  }
1479
+ /** Constructor names come from source strings (string-union values like
1480
+ * "spec-pure", discriminated-union tags), which may contain characters no
1481
+ * TS identifier has; map those to `_` before the ordinary escaping. A
1482
+ * collision after mapping fails loudly in Dafny (duplicate constructor)
1483
+ * rather than silently merging. */
1484
+ function dafnyCtorName(name) {
1485
+ return escapeName(name.replace(/[^A-Za-z0-9_'?]/g, "_"));
1486
+ }
1362
1487
  function qualifyCtor(name, type) {
1363
1488
  const rawName = name.replace(/^\./, "");
1364
- const mapped = CTOR_MAP[rawName] ?? escapeName(rawName);
1489
+ // hasOwn: a ctor literally named "constructor" (the IR's own Expr variant)
1490
+ // must not hit Object.prototype.constructor through the bare index.
1491
+ const mapped = (Object.hasOwn(CTOR_MAP, rawName) ? CTOR_MAP[rawName] : undefined) ?? dafnyCtorName(rawName);
1365
1492
  if (type)
1366
1493
  return `${type}.${mapped}`;
1367
1494
  return mapped;
@@ -1375,7 +1502,7 @@ const CTOR_MAP = { "some": "Some", "none": "None" };
1375
1502
  function translatePattern(p) {
1376
1503
  if (p.kind === "wild")
1377
1504
  return "_";
1378
- const ctorName = CTOR_MAP[p.ctor] ?? escapeName(p.ctor);
1505
+ const ctorName = (Object.hasOwn(CTOR_MAP, p.ctor) ? CTOR_MAP[p.ctor] : undefined) ?? dafnyCtorName(p.ctor);
1379
1506
  if (p.binders.length === 0)
1380
1507
  return ctorName;
1381
1508
  return `${ctorName}(${p.binders.map(escapeName).join(", ")})`;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Project, Node, SyntaxKind, ScriptTarget, ts } from "ts-morph";
8
8
  import { initTypeParser } from "./types.js";
9
+ import { normalizeBigIntLiteral } from "./rawir.js";
9
10
  import { setUserNames, freshName } from "./names.js";
10
11
  // ── Expression extraction ────────────────────────────────────
11
12
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
@@ -15,6 +16,14 @@ let _havocKey = null;
15
16
  * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`.
16
17
  * Cleared at the start of every `extractModule`. */
17
18
  const _externs = new Map();
19
+ /** Signature types of *kept* externs, for the imported-type resolver: a type
20
+ * reachable ONLY through an imported function's signature (e.g. an extern
21
+ * returning PresentFact that no local signature mentions) must still be
22
+ * resolved into a full decl, or it synthesizes opaque. Filled only after the
23
+ * extern survives the `_externs` dedup — a cross-file signature superseded by a
24
+ * same-file `//@ extern` contributes nothing to the output, so resolving its
25
+ * types would emit decls that no emitted declaration mentions. */
26
+ const _externSigTypes = [];
18
27
  let _currentSourceFile = null;
19
28
  /** True only while extracting a function body. Module-level constants that
20
29
  * reference cross-file callees (e.g., `BusEvent.define(...)` inside a
@@ -30,10 +39,14 @@ let _destrCounter = 0;
30
39
  * lifted `requires`/`ensures` see all the symbols they reference). Idempotent
31
40
  * via the `_externs` dedup. */
32
41
  function registerExternIfCrossFile(callee, sourceFile) {
33
- const ext = detectCrossFileExtern(callee, sourceFile);
42
+ const sigTypes = [];
43
+ const ext = detectCrossFileExtern(callee, sourceFile, sigTypes);
34
44
  if (!ext || _externs.has(ext.qualified))
35
45
  return;
36
46
  _externs.set(ext.qualified, ext);
47
+ // Only now that the extern is kept do its signature types become resolver
48
+ // seeds — see `_externSigTypes`.
49
+ _externSigTypes.push(...sigTypes);
37
50
  // Recurse: scan the source decl's body for nested cross-file calls so any
38
51
  // symbol referenced by the copied spec is itself declared in the output.
39
52
  let symbol = callee.getSymbol();
@@ -56,7 +69,7 @@ function registerExternIfCrossFile(callee, sourceFile) {
56
69
  }
57
70
  }
58
71
  }
59
- function detectCrossFileExtern(callee, sourceFile) {
72
+ function detectCrossFileExtern(callee, sourceFile, sigTypesOut) {
60
73
  let symbol = callee.getSymbol();
61
74
  if (!symbol)
62
75
  return null;
@@ -92,12 +105,32 @@ function detectCrossFileExtern(callee, sourceFile) {
92
105
  // kept): a bare `TMsg`, not `import("/abs/path/transcript").TMsg` — the
93
106
  // importing module declares the datatype locally, so the axiom must use the
94
107
  // local name.
95
- const externTypeText = (t) => t.getText(callee, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
108
+ // NoTruncation: a wide expansion (e.g. an alias for a large string-literal
109
+ // union, not importable at the call site) must print whole — a truncated
110
+ // union is unparseable and synthesizes an opaque decl named by its own text.
111
+ const externTypeText = (t) => t.getText(callee, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation);
96
112
  const params = sig.getParameters().map(p => ({
97
113
  name: p.getName(),
98
114
  tsType: externTypeText(p.getTypeAtLocation(callee)),
99
115
  }));
100
116
  const returnType = externTypeText(sig.getReturnType());
117
+ for (const p of sig.getParameters()) {
118
+ sigTypesOut.push({ type: p.getTypeAtLocation(callee), node: callee });
119
+ }
120
+ sigTypesOut.push({ type: sig.getReturnType(), node: callee });
121
+ // Also seed from the source declaration's syntactic type nodes: symbol-based
122
+ // types can drop the alias symbol (the printed signature then names an alias
123
+ // like `TypeDecls` that would otherwise synthesize opaque), while a type
124
+ // node's type keeps it.
125
+ const srcDecl = externalDecl;
126
+ for (const sp of srcDecl.getParameters?.() ?? []) {
127
+ const tn = sp.getTypeNode?.();
128
+ if (tn)
129
+ sigTypesOut.push({ type: tn.getType(), node: tn });
130
+ }
131
+ const rtn = srcDecl.getReturnTypeNode?.();
132
+ if (rtn)
133
+ sigTypesOut.push({ type: rtn.getType(), node: rtn });
101
134
  let qualified;
102
135
  if (Node.isPropertyAccessExpression(callee)) {
103
136
  qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
@@ -307,10 +340,11 @@ function extractExpr(node) {
307
340
  if (Node.isNumericLiteral(node)) {
308
341
  return { kind: "num", value: Number(node.getLiteralValue()) };
309
342
  }
310
- // BigInt literal (e.g. 32n, 0xffffn) — integer, with bigint division semantics
343
+ // BigInt literal (e.g. 32n, 0xffffn) — exact integer, with bigint division
344
+ // semantics. Kept as a decimal string: `getLiteralValue()`/`Number()` would
345
+ // round anything past 2^53 (`9007199254740993n` → `9007199254740992`).
311
346
  if (Node.isBigIntLiteral(node)) {
312
- const text = node.getText().replace(/n$/, '');
313
- return { kind: "num", value: Number(text), big: true };
347
+ return { kind: "bigint", value: normalizeBigIntLiteral(node.getText()) };
314
348
  }
315
349
  // Template literal: `foo${x}bar` → "foo" + x + "bar"
316
350
  if (Node.isTemplateExpression(node)) {
@@ -462,10 +496,9 @@ function extractExpr(node) {
462
496
  const typeNode = p.getTypeNode();
463
497
  return { name: p.getName(), tsType: typeNode ? typeNode.getText() : undefined };
464
498
  });
465
- // Capture an explicit return annotation (`(x): Out => …`) so resolve can type
466
- // return-position record literals to their named type instead of a tuple.
467
- const retNode = node.getReturnTypeNode();
468
- const returnTsType = retNode ? typeToString(node.getReturnType()) : undefined;
499
+ // Return type from the checker inferred when unannotated so resolve can
500
+ // type return-position record literals and give the lambda a real fn type.
501
+ const returnTsType = typeToString(node.getReturnType());
469
502
  const body = node.getBody();
470
503
  if (Node.isExpression(body)) {
471
504
  return { kind: "lambda", params, body: extractExpr(body), returnTsType };
@@ -688,8 +721,14 @@ function extractTypeDecl(decl, extraDecls) {
688
721
  continue;
689
722
  let tsType = typeToString(prop.getTypeAtLocation(decl));
690
723
  const propDecl = prop.getDeclarations()[0];
691
- if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
692
- tsType = `${tsType} | undefined`;
724
+ tsType = declaredTypeTextIfBetter(propDecl, tsType);
725
+ if (propDecl && propDecl.hasQuestionToken?.()) {
726
+ // Normalize checker output that puts `undefined` first, then
727
+ // ensure exactly one trailing `| undefined`.
728
+ if (tsType.startsWith("undefined | "))
729
+ tsType = `${tsType.slice("undefined | ".length)} | undefined`;
730
+ else if (!tsType.includes(" | undefined"))
731
+ tsType = `${tsType} | undefined`;
693
732
  }
694
733
  fields.push({ name: prop.getName(), tsType });
695
734
  }
@@ -780,12 +819,18 @@ function extractRecord(name, type, locationNode, overrides, extraDecls) {
780
819
  }
781
820
  const propType = prop.getTypeAtLocation(locationNode);
782
821
  let tsType = typeToString(propType);
822
+ const propDecl = prop.getDeclarations()[0];
823
+ tsType = declaredTypeTextIfBetter(propDecl, tsType);
783
824
  // Optional property: `foo?: T` reports as `T` (ts-morph strips the
784
825
  // `| undefined` from a question-token type). Add it back so the field
785
826
  // resolves to `Optional<T>`.
786
- const propDecl = prop.getDeclarations()[0];
787
- if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
788
- tsType = `${tsType} | undefined`;
827
+ if (propDecl && propDecl.hasQuestionToken?.()) {
828
+ // Normalize checker output that puts `undefined` first, then ensure
829
+ // exactly one trailing `| undefined`.
830
+ if (tsType.startsWith("undefined | "))
831
+ tsType = `${tsType.slice("undefined | ".length)} | undefined`;
832
+ else if (!tsType.includes(" | undefined"))
833
+ tsType = `${tsType} | undefined`;
789
834
  }
790
835
  // Inline anonymous object types: ts-morph names them __type.
791
836
  // Generate a synthetic named record and reference it by name instead.
@@ -832,6 +877,23 @@ function findDiscriminant(members) {
832
877
  }
833
878
  return null;
834
879
  }
880
+ /** Recover a field's *declared* type text when the semantic printer degraded
881
+ * to ts-morph's anonymous `__type` — a self-referential alias reached
882
+ * through a `| null` union expands structurally and loses its name. The
883
+ * syntactic node text preserves the alias spelling (`TExpr | null`). Only
884
+ * plain reference text is used: inline object literals (containing `{`)
885
+ * keep the `__type` marker so record synthesis can handle them. */
886
+ function declaredTypeTextIfBetter(propDecl, tsType) {
887
+ if (!tsType.includes("__type") || !propDecl)
888
+ return tsType;
889
+ const tn = propDecl.getTypeNode?.();
890
+ if (!tn)
891
+ return tsType;
892
+ const text = tn.getText();
893
+ if (text.includes("__type") || text.includes("{"))
894
+ return tsType;
895
+ return text;
896
+ }
835
897
  function typeToString(type) {
836
898
  if (type.isUndefined())
837
899
  return "undefined";
@@ -1031,6 +1093,7 @@ function renameRawExpr(e, from, to) {
1031
1093
  switch (e.kind) {
1032
1094
  case "var": return e.name === from ? { kind: "var", name: to } : e;
1033
1095
  case "num":
1096
+ case "bigint":
1034
1097
  case "str":
1035
1098
  case "bool":
1036
1099
  case "result":
@@ -1870,6 +1933,7 @@ export function extractModule(sourceFile) {
1870
1933
  // `extractExpr` during call extraction (only symbols *actually used* end up
1871
1934
  // here), deduped by qualified name.
1872
1935
  _externs.clear();
1936
+ _externSigTypes.length = 0;
1873
1937
  // Share the module's ts-morph Project with parseTsType (scratch source file
1874
1938
  // for type-string parsing). Done before declare-type parsing so any
1875
1939
  // parseTsType call downstream uses the same Project.
@@ -2431,13 +2495,12 @@ export function extractModule(sourceFile) {
2431
2495
  // Resolve imported types: extract types referenced in function signatures but not in this file
2432
2496
  const knownTypes = new Set(typeDecls.map(d => d.name));
2433
2497
  const builtins = new Set(["Map", "Set", "Array", "String", "Number", "Boolean", "Promise", "Date", "RegExp", "Error"]);
2498
+ const visitedTypes = new Set();
2434
2499
  function resolveType(t, locationNode) {
2435
- // Unwrap arrays and generics to find user-defined types
2436
- if (t.isArray()) {
2437
- resolveType(t.getArrayElementTypeOrThrow(), locationNode);
2438
- return;
2439
- }
2440
- // Resolve type aliases (e.g. string unions imported from other files)
2500
+ // Resolve type aliases (e.g. string unions imported from other files).
2501
+ // BEFORE the visited guard: the same interned compilerType can arrive both
2502
+ // with and without its alias symbol (getTypeAtLocation drops it), and an
2503
+ // aliasless first visit must not suppress the alias extraction.
2441
2504
  const alias = t.getAliasSymbol();
2442
2505
  if (alias) {
2443
2506
  const aliasName = alias.getName();
@@ -2470,6 +2533,18 @@ export function extractModule(sourceFile) {
2470
2533
  }
2471
2534
  }
2472
2535
  }
2536
+ // Recursion guard: recursive unions (Expr → variant → body: Expr) are
2537
+ // reachable now that anonymous variant fields are walked below. Keyed on
2538
+ // the compiler's interned Type object — alias names are not enough,
2539
+ // because getTypeAtLocation can drop the alias symbol.
2540
+ if (visitedTypes.has(t.compilerType))
2541
+ return;
2542
+ visitedTypes.add(t.compilerType);
2543
+ // Unwrap arrays and generics to find user-defined types
2544
+ if (t.isArray()) {
2545
+ resolveType(t.getArrayElementTypeOrThrow(), locationNode);
2546
+ return;
2547
+ }
2473
2548
  if (t.isUnion()) {
2474
2549
  for (const u of t.getUnionTypes())
2475
2550
  resolveType(u, locationNode);
@@ -2492,6 +2567,15 @@ export function extractModule(sourceFile) {
2492
2567
  }
2493
2568
  }
2494
2569
  }
2570
+ else if (t.isObject() && (!name || name.startsWith("__")) && t.getCallSignatures().length === 0) {
2571
+ // Anonymous object type — typically a variant of an imported
2572
+ // discriminated union. There is no decl to extract, but its fields can
2573
+ // reference named types (`arms: MatchArm[]`) that downstream passes
2574
+ // need declared, so walk them.
2575
+ for (const prop of t.getProperties()) {
2576
+ resolveType(prop.getTypeAtLocation(locationNode), locationNode);
2577
+ }
2578
+ }
2495
2579
  }
2496
2580
  for (let i = 0; i < fnsToExtract.length; i++) {
2497
2581
  const f = fnsToExtract[i];
@@ -2507,6 +2591,9 @@ export function extractModule(sourceFile) {
2507
2591
  resolveType(p.getType(), p);
2508
2592
  }
2509
2593
  }
2594
+ // Extern signature types: see _externSigTypes.
2595
+ for (const r of _externSigTypes)
2596
+ resolveType(r.type, r.node);
2510
2597
  // Resolve anonymous object return types into synthetic named types
2511
2598
  for (let i = 0; i < fnsToExtract.length; i++) {
2512
2599
  const f = fnsToExtract[i];