lemmascript 0.5.1 → 0.5.3

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.
@@ -45,11 +45,16 @@ const DAFNY_KEYWORDS = new Set([
45
45
  "codatatype", "nameonly", "twostate", "opaque", "replaceable", "colemma",
46
46
  "copredicate", "inductive",
47
47
  ]);
48
+ // The Dafny out-parameter name for the method currently being emitted. Default
49
+ // `res`, but bumped (e.g. `res_`) when a parameter is named `res` — set by
50
+ // methodHeader and reset per decl. `\result` in an ensures must use the *same*
51
+ // name, so escapeName routes it here.
52
+ let _resultName = "res";
48
53
  function escapeName(name) {
49
54
  // \result is carried through the IR as the var name "\\result"; render it
50
- // as Dafny's canonical return-value identifier.
55
+ // as the current method's out-parameter name.
51
56
  if (name === "\\result")
52
- return "res";
57
+ return _resultName;
53
58
  if (DAFNY_KEYWORDS.has(name))
54
59
  return `${name}_`;
55
60
  // Dafny doesn't allow identifiers starting with _
@@ -66,7 +71,17 @@ function paramList(params) {
66
71
  * `returns (res: ())` on a void method fails verification. */
67
72
  function methodHeader(prefix, params, returnType) {
68
73
  const sig = `${prefix}(${paramList(params)})`;
69
- return returnType.kind === "void" ? sig : `${sig} returns (res: ${tyToDafny(returnType)})`;
74
+ if (returnType.kind === "void")
75
+ return sig;
76
+ // The out-parameter is `res` by default, but a parameter named `res` (e.g. an
77
+ // Express handler's `(req, res)`) would collide; pick a fresh name and record
78
+ // it so `\result` references in the ensures/body resolve to the same name.
79
+ const taken = new Set(params.map(p => escapeName(p.name)));
80
+ let resName = "res";
81
+ while (taken.has(resName))
82
+ resName += "_";
83
+ _resultName = resName;
84
+ return `${sig} returns (${resName}: ${tyToDafny(returnType)})`;
70
85
  }
71
86
  // ── Lean op → Dafny op ─────────────────────────────────────
72
87
  const OP_MAP = {
@@ -166,6 +181,12 @@ function emitExpr(e) {
166
181
  return `Std.Collections.Seq.Map(${args[0]}, ${obj})`;
167
182
  if (e.method === "filter")
168
183
  return `Std.Collections.Seq.Filter(${args[0]}, ${obj})`;
184
+ // filterMap (synthesized in resolve): drop Nones and unwrap to seq<T>.
185
+ if (e.method === "filterSome") {
186
+ needPreamble("SeqFilterSome");
187
+ needPreamble("OptionType");
188
+ return `SeqFilterSome(${obj})`;
189
+ }
169
190
  if (e.method === "every")
170
191
  return `Std.Collections.Seq.All(${obj}, ${args[0]})`;
171
192
  if (e.method === "findLast") {
@@ -350,16 +371,15 @@ function emitExpr(e) {
350
371
  needPreamble("BitAnd");
351
372
  return `BitAnd(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
352
373
  }
353
- // int * real coercion: wrap int side with "as real"
354
- if (["+", "-", "*", "/"].includes(op)) {
355
- const leftIsReal = e.left.kind === "num" && !Number.isInteger(e.left.value);
356
- const rightIsReal = e.right.kind === "num" && !Number.isInteger(e.right.value);
357
- if (leftIsReal !== rightIsReal) {
358
- const left = leftIsReal ? emitExpr(e.left) : `(${emitExpr(e.left)} as real)`;
359
- const right = rightIsReal ? emitExpr(e.right) : `(${emitExpr(e.right)} as real)`;
360
- return `(${left} ${op} ${right})`;
361
- }
374
+ // x | y BitOr(x, y) (recursive, mirrors BitAnd). Dafny has no `|` on int,
375
+ // only on bitvectors.
376
+ if (e.op === "|") {
377
+ needPreamble("BitOr");
378
+ return `BitOr(${emitExpr(e.left)}, ${emitExpr(e.right)})`;
362
379
  }
380
+ // int→real coercion is now injected upstream in transform (toReal nodes),
381
+ // which has full type information — including real-typed variables, not
382
+ // just literals — so no literal-based coercion is needed here.
363
383
  return `(${wrapQuantifier(e.left)} ${op} ${emitExpr(e.right)})`;
364
384
  }
365
385
  case "implies": {
@@ -399,6 +419,8 @@ function emitExpr(e) {
399
419
  needPreamble("MathMin");
400
420
  needPreamble("MinOfSeq");
401
421
  }
422
+ if (e.fn === "Perm")
423
+ needPreamble("Perm");
402
424
  return `${escapeName(e.fn)}(${args.join(", ")})`;
403
425
  }
404
426
  case "field": {
@@ -414,6 +436,8 @@ function emitExpr(e) {
414
436
  case "toNat":
415
437
  // Dafny doesn't need toNat — just emit the inner expression
416
438
  return emitExpr(e.expr);
439
+ case "toReal":
440
+ return `(${emitExpr(e.expr)} as real)`;
417
441
  case "index": {
418
442
  const obj = emitExpr(e.arr);
419
443
  const idx = emitExpr(e.idx);
@@ -522,6 +546,10 @@ function emitStmt(s, indent) {
522
546
  return `${pad}var ${escapeName(s.name)}: ${tyToDafny(s.type)} := ${emitExpr(s.value)};`;
523
547
  return `${pad}var ${escapeName(s.name)} := ${emitExpr(s.value)};`;
524
548
  case "assign":
549
+ // Transform.ts lowers a bare expression statement to an assign with target _
550
+ // so special case this to Dafny's anonymous binding.
551
+ if (s.target === "_")
552
+ return `${pad}var _ := ${emitExpr(s.value)};`;
525
553
  return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)};`;
526
554
  case "ghostLet":
527
555
  return `${pad}ghost var ${escapeName(s.name)}: ${tyToDafny(s.type)} := ${emitExpr(s.value)};`;
@@ -593,13 +621,33 @@ function emitStmt(s, indent) {
593
621
  }
594
622
  // ── Declaration emission ────────────────────────────────────
595
623
  function emitDecl(d) {
624
+ _resultName = "res"; // default; methodHeader bumps it if a param is named `res`
596
625
  switch (d.kind) {
597
626
  case "inductive": {
598
627
  const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
628
+ // Dafny requires a destructor shared across constructors to have a single
629
+ // type. Two TS variants can legitimately share a field name with different
630
+ // types (e.g. label.targetId: string vs leaf.targetId: string?). Detect
631
+ // such collisions and make those destructors per-constructor unique. Safe:
632
+ // variant-field reads lower to positional match bindings, never named
633
+ // destructors on the union (a name shared with differing types isn't even
634
+ // accessible on the union type in TS), so nothing references the old name.
635
+ const typesByField = new Map();
636
+ for (const c of d.constructors)
637
+ for (const f of c.fields) {
638
+ let s = typesByField.get(f.name);
639
+ if (!s) {
640
+ s = new Set();
641
+ typesByField.set(f.name, s);
642
+ }
643
+ s.add(tyToDafny(f.type));
644
+ }
645
+ const collides = new Set([...typesByField].filter(([, s]) => s.size > 1).map(([n]) => n));
599
646
  const ctors = d.constructors.map(c => {
600
647
  if (c.fields.length === 0)
601
648
  return escapeName(c.name);
602
- return `${escapeName(c.name)}(${paramList(c.fields)})`;
649
+ const fields = c.fields.map(f => collides.has(f.name) ? { ...f, name: `${f.name}_${c.name}` } : f);
650
+ return `${escapeName(c.name)}(${paramList(fields)})`;
603
651
  });
604
652
  return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
605
653
  }
@@ -609,6 +657,11 @@ function emitDecl(d) {
609
657
  case "type-alias": {
610
658
  return `type ${d.name} = ${tyToDafny(d.target)}`;
611
659
  }
660
+ case "opaque-type": {
661
+ // Abstract type — no definition. `(==)` so it can sit inside datatypes
662
+ // that derive structural equality. Never constructed or destructured.
663
+ return `type ${d.name}(==)`;
664
+ }
612
665
  case "def": {
613
666
  const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
614
667
  const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
@@ -724,6 +777,14 @@ const BIT_AND = `function BitAnd(x: int, y: int): int
724
777
  if x == 0 || y == 0 then 0
725
778
  else 2 * BitAnd(x / 2, y / 2) + (if x % 2 == 1 && y % 2 == 1 then 1 else 0)
726
779
  }`;
780
+ const BIT_OR = `function BitOr(x: int, y: int): int
781
+ requires x >= 0 && y >= 0
782
+ decreases x
783
+ {
784
+ if x == 0 then y
785
+ else if y == 0 then x
786
+ else 2 * BitOr(x / 2, y / 2) + (if x % 2 == 1 || y % 2 == 1 then 1 else 0)
787
+ }`;
727
788
  const JS_FLOOR_DIV = `function JSFloorDiv(a: int, b: int): int
728
789
  requires b != 0
729
790
  {
@@ -743,6 +804,12 @@ const CEIL_REAL = `function CeilReal(x: real): int
743
804
  if x == (x.Floor as real) then x.Floor
744
805
  else x.Floor + 1
745
806
  }`;
807
+ const SEQ_FILTER_SOME = `function SeqFilterSome<T>(xs: seq<Option<T>>): seq<T>
808
+ ensures |SeqFilterSome(xs)| <= |xs|
809
+ {
810
+ if |xs| == 0 then []
811
+ else (if xs[0].Some? then [xs[0].value] else []) + SeqFilterSome(xs[1..])
812
+ }`;
746
813
  const SEQ_FIND_INDEX = `function SeqFindIndex<T>(s: seq<T>, p: T -> bool): int
747
814
  ensures -1 <= SeqFindIndex(s, p) < |s|
748
815
  ensures SeqFindIndex(s, p) >= 0 ==> p(s[SeqFindIndex(s, p)])
@@ -956,6 +1023,10 @@ const NAT_TO_STRING = `function NatToString(n: nat): string
956
1023
  else NatToString(n / 10) + [digit]
957
1024
  }`;
958
1025
  const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
1026
+ // perm(a, b) — `a` and `b` are reorderings of each other (equal as multisets).
1027
+ // Transparent (Dafny unfolds it), so hand-proofs can reason with `multiset`
1028
+ // directly. The `(==)` bound requires the element type to support equality.
1029
+ const PERM = `predicate Perm<T(==)>(a: seq<T>, b: seq<T>) { multiset(a) == multiset(b) }`;
959
1030
  const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
960
1031
  ensures forall x :: x in s <==> x in res
961
1032
  ensures |res| == |s|
@@ -981,11 +1052,13 @@ const PREAMBLE_CODE = [
981
1052
  ["SetToSeq", SET_TO_SEQ],
982
1053
  ["Pow2", POW2],
983
1054
  ["BitAnd", BIT_AND],
1055
+ ["BitOr", BIT_OR],
984
1056
  ["JSFloorDiv", JS_FLOOR_DIV],
985
1057
  ["CeilReal", CEIL_REAL],
986
1058
  ["FloorReal", FLOOR_REAL],
987
1059
  ["SeqIndexOf", SEQ_INDEX_OF],
988
1060
  ["SeqFindIndex", SEQ_FIND_INDEX],
1061
+ ["SeqFilterSome", SEQ_FILTER_SOME],
989
1062
  ["SeqFindLast", SEQ_FIND_LAST],
990
1063
  ["SeqFlatten", SEQ_FLATTEN],
991
1064
  ["SeqJoin", SEQ_JOIN],
@@ -1001,6 +1074,7 @@ const PREAMBLE_CODE = [
1001
1074
  ["MathMax", MATH_MAX],
1002
1075
  ["MaxOfSeq", MAX_OF_SEQ],
1003
1076
  ["MinOfSeq", MIN_OF_SEQ],
1077
+ ["Perm", PERM],
1004
1078
  ];
1005
1079
  // ── Constructor and record helpers ───────────────────────────
1006
1080
  let _recordCtors = new Map();
@@ -69,6 +69,10 @@ function detectCrossFileExtern(callee, sourceFile) {
69
69
  if (decls.length === 0)
70
70
  return null;
71
71
  const currentPath = sourceFile.getFilePath();
72
+ // A declaration in the current file is authoritative — don't resolve to a
73
+ // same-named definition in another file.
74
+ if (decls.some(d => d.getSourceFile().getFilePath() === currentPath))
75
+ return null;
72
76
  const externalDecl = decls.find(d => d.getSourceFile().getFilePath() !== currentPath);
73
77
  if (!externalDecl)
74
78
  return null;
@@ -159,6 +163,26 @@ function _synthName(elemName, otherName) {
159
163
  const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
160
164
  return `ArrayOf_${sanitize(elemName)}_Or_${sanitize(otherName)}`;
161
165
  }
166
+ /**
167
+ * Fall-through for a union LS can't model as a tagged union (no runtime type
168
+ * test maps to a tag — e.g. members are unreachable imports with no visible
169
+ * discriminant). Registers an opaque-type TypeDeclInfo and returns its name, so
170
+ * the union becomes one abstract `type` rather than invalid raw-union Dafny.
171
+ *
172
+ * Sound because an opaque type has no constructor and no tag predicate: any
173
+ * attempt to build or type-test the value fails to lower, so it can only be
174
+ * passed through — the one sound use of a union we can't discriminate. Distinct
175
+ * from dropping the field (which collapses values and is unsound): the value is
176
+ * preserved, just uninspectable.
177
+ */
178
+ function _synthOpaque(memberNames) {
179
+ const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
180
+ const name = `Opaque_${memberNames.map(sanitize).join("_or_")}`;
181
+ if (_synthArrayUnions !== null && !_synthArrayUnions.some(d => d.name === name)) {
182
+ _synthArrayUnions.push({ name, kind: "opaque" });
183
+ }
184
+ return name;
185
+ }
162
186
  /**
163
187
  * String-level fallback for synth detection on a `T[] | U` shape, used by
164
188
  * declare-type field parsing (where no ts-morph TypeNode is available). The
@@ -277,10 +301,10 @@ function extractExpr(node) {
277
301
  if (Node.isNumericLiteral(node)) {
278
302
  return { kind: "num", value: Number(node.getLiteralValue()) };
279
303
  }
280
- // BigInt literal (e.g. 32n, 0xffffn) — treat as integer
304
+ // BigInt literal (e.g. 32n, 0xffffn) — integer, with bigint division semantics
281
305
  if (Node.isBigIntLiteral(node)) {
282
306
  const text = node.getText().replace(/n$/, '');
283
- return { kind: "num", value: Number(text) };
307
+ return { kind: "num", value: Number(text), big: true };
284
308
  }
285
309
  // Template literal: `foo${x}bar` → "foo" + x + "bar"
286
310
  if (Node.isTemplateExpression(node)) {
@@ -431,12 +455,16 @@ function extractExpr(node) {
431
455
  const typeNode = p.getTypeNode();
432
456
  return { name: p.getName(), tsType: typeNode ? typeNode.getText() : undefined };
433
457
  });
458
+ // Capture an explicit return annotation (`(x): Out => …`) so resolve can type
459
+ // return-position record literals to their named type instead of a tuple.
460
+ const retNode = node.getReturnTypeNode();
461
+ const returnTsType = retNode ? typeToString(node.getReturnType()) : undefined;
434
462
  const body = node.getBody();
435
463
  if (Node.isExpression(body)) {
436
- return { kind: "lambda", params, body: extractExpr(body) };
464
+ return { kind: "lambda", params, body: extractExpr(body), returnTsType };
437
465
  }
438
466
  if (Node.isBlock(body)) {
439
- return { kind: "lambda", params, body: extractStmts(body.getStatements()) };
467
+ return { kind: "lambda", params, body: extractStmts(body.getStatements()), returnTsType };
440
468
  }
441
469
  throw new Error(`Unsupported arrow function body: ${node.getText().slice(0, 80)}`);
442
470
  }
@@ -552,6 +580,12 @@ function extractExpr(node) {
552
580
  if (Node.isNullLiteral(node)) {
553
581
  return { kind: "var", name: "undefined" };
554
582
  }
583
+ // `typeof X` — only meaningful in a `typeof X === "string"` discriminator over
584
+ // a synth `U | T[]` union (see narrow's parseTypeofStringCheck); any other use
585
+ // survives to emit and errors there.
586
+ if (Node.isTypeOfExpression(node)) {
587
+ return { kind: "unop", op: "typeof", expr: extractExpr(node.getExpression()) };
588
+ }
555
589
  throw new Error(`Unsupported expression: ${node.getText()}`);
556
590
  }
557
591
  // ── Annotation parsing ───────────────────────────────────────
@@ -796,7 +830,10 @@ function typeToString(type) {
796
830
  return "bigint";
797
831
  if (type.isString() || type.isStringLiteral())
798
832
  return "string";
799
- if (type.isBoolean())
833
+ // TS expands `boolean` to the literal union `false | true`; normalize the
834
+ // literals back so the union dedupes to a single `boolean` rather than being
835
+ // mistaken for an unmodelable multi-member union.
836
+ if (type.isBoolean() || type.isBooleanLiteral())
800
837
  return "boolean";
801
838
  // Named type alias (e.g. Priority = "low" | "medium" | "high") — use the alias name
802
839
  if (type.getAliasSymbol()) {
@@ -838,7 +875,15 @@ function typeToString(type) {
838
875
  }
839
876
  }
840
877
  const parts = [...new Set(unionTypes.map(typeToString))];
841
- return parts.join(" | ");
878
+ // `undefined`/`null` are optional markers; a single real member with them
879
+ // is an Option, left as `X | undefined` for the optional lowering.
880
+ const real = parts.filter(p => p !== "undefined" && p !== "null");
881
+ if (real.length <= 1)
882
+ return parts.join(" | ");
883
+ // A genuine multi-member union with no tagged-union shape LS can model →
884
+ // a single opaque type. (See _synthOpaque.) Preserve an outer optional.
885
+ const opaque = _synthOpaque(real);
886
+ return real.length === parts.length ? opaque : `${opaque} | undefined`;
842
887
  }
843
888
  if (type.isTuple()) {
844
889
  return `[${type.getTupleElements().map(t => typeToString(t)).join(", ")}]`;
@@ -1704,6 +1749,22 @@ function extractFunctionInner(fn, parentAnnotations) {
1704
1749
  return [{ name: p.getName(), tsType }];
1705
1750
  }),
1706
1751
  returnType: (() => {
1752
+ // `async` with no `await`: the `Promise<T>` wrapper is just the calling
1753
+ // convention (the body returns T-typed values), so unwrap to T. Gated on
1754
+ // no `await` — that's the suspension point we can't model, and it only
1755
+ // type-checks inside `async`, so the gate is self-justifying. With `await`
1756
+ // present we leave `Promise<...>` (unmodellable) rather than atomize it.
1757
+ const isAsync = fn.isAsync?.() ?? false;
1758
+ const noAwait = fn.getDescendantsOfKind(SyntaxKind.AwaitExpression).length === 0;
1759
+ if (isAsync && noAwait) {
1760
+ const args = fn.getReturnType().getTypeArguments();
1761
+ if (args.length === 1) {
1762
+ if (args[0].isAny())
1763
+ return "unknown";
1764
+ return _eraseGenerics(typeToString(args[0]));
1765
+ }
1766
+ return "void"; // Promise<void>
1767
+ }
1707
1768
  const node = fn.getReturnTypeNode();
1708
1769
  if (node && Node.isUnionTypeNode(node))
1709
1770
  return _eraseGenerics(_tsTypeFromUnionNode(node));
@@ -1718,6 +1779,7 @@ function extractFunctionInner(fn, parentAnnotations) {
1718
1779
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
1719
1780
  decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
1720
1781
  pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
1782
+ autohavoc: false, // set in extractModule (file-level directive or per-function)
1721
1783
  typeAnnotations,
1722
1784
  body: extractedBody,
1723
1785
  line: fn.getStartLineNumber(),
@@ -1869,63 +1931,144 @@ export function extractModule(sourceFile) {
1869
1931
  }
1870
1932
  }
1871
1933
  }
1934
+ // Top-level inline closures: a handler passed directly to a module-level call,
1935
+ // e.g. `app.get("/x", (req, res) => { //@ verify ... })`. "Move" each such
1936
+ // closure to the top level by extracting it as a synthetic named function.
1937
+ // Only top-level call arguments are considered (not nested lambdas), and only
1938
+ // closures carrying a //@ verify (so ordinary callbacks aren't pulled in). The
1939
+ // name is derived from the call's method and route literal (e.g. get_x).
1940
+ const usedNames = new Set(allFns.map(f => f.name));
1941
+ const sanitizeIdent = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "handler";
1942
+ for (const stmt of sourceFile.getStatements()) {
1943
+ if (!Node.isExpressionStatement(stmt))
1944
+ continue;
1945
+ const call = stmt.getExpression();
1946
+ if (!Node.isCallExpression(call))
1947
+ continue;
1948
+ const callee = call.getExpression();
1949
+ const method = Node.isPropertyAccessExpression(callee) ? callee.getName()
1950
+ : Node.isIdentifier(callee) ? callee.getText() : "handler";
1951
+ const routeArg = call.getArguments().find(a => Node.isStringLiteral(a));
1952
+ const route = routeArg && Node.isStringLiteral(routeArg) ? routeArg.getLiteralValue() : "";
1953
+ for (const arg of call.getArguments()) {
1954
+ if (!Node.isArrowFunction(arg))
1955
+ continue;
1956
+ if (!hasLineDirective(arg.getFullText(), "verify"))
1957
+ continue;
1958
+ const base = sanitizeIdent(route ? `${method}_${route}` : method);
1959
+ let name = base, n = 2;
1960
+ while (usedNames.has(name))
1961
+ name = `${base}_${n++}`;
1962
+ usedNames.add(name);
1963
+ allFns.push({ name, node: arg, parentStmt: stmt });
1964
+ }
1965
+ }
1872
1966
  // `//@ extern` on a same-file declaration: register the function as an
1873
1967
  // opaque axiom (signature + any //@ requires/ensures), skip its body. Use
1874
1968
  // when the function is outside LS's verification model — e.g., wraps a
1875
1969
  // regex — but its callers should still be verifiable against an
1876
1970
  // uninterpreted predicate. Parallel to auto-extern for cross-file calls,
1877
1971
  // and emitted the same way (`function {:axiom} foo(...)` in Dafny).
1972
+ // Match a `//@ <kw>` directive only as the first non-whitespace on a line, so
1973
+ // a mention mid-line in prose or inside a block/JSDoc comment (e.g. "the
1974
+ // `//@ extern` annotation", or ` * //@ extern`) doesn't falsely trigger it.
1975
+ function hasLineDirective(text, kw) {
1976
+ return new RegExp(String.raw `^[ \t]*//@ ${kw}\b`, "m").test(text);
1977
+ }
1878
1978
  function hasExtern(f) {
1879
- if (f.node.getFullText().includes('//@ extern'))
1979
+ if (hasLineDirective(f.node.getFullText(), "extern"))
1880
1980
  return true;
1881
1981
  if (f.parentStmt) {
1882
1982
  for (const r of f.parentStmt.getLeadingCommentRanges()) {
1883
- if (r.getText().includes('//@ extern'))
1983
+ if (hasLineDirective(r.getText(), "extern"))
1884
1984
  return true;
1885
1985
  }
1886
1986
  }
1887
1987
  return false;
1888
1988
  }
1989
+ // `//@ extern NS.method` registers the extern under a *dotted* qualified name,
1990
+ // so a real `NS.method(args)` call dispatches to it (resolve.ts) with no
1991
+ // wrapper — e.g. `//@ extern fs.readFileSync` lets you call `fs.readFileSync`
1992
+ // directly while still discharging its `//@ requires`. The function declaration
1993
+ // just carries the signature/contract; its own name is unused.
1994
+ function externName(f) {
1995
+ const re = /^[ \t]*\/\/@ extern[ \t]+(\S+)/m;
1996
+ const m = f.node.getFullText().match(re);
1997
+ if (m)
1998
+ return m[1];
1999
+ if (f.parentStmt) {
2000
+ for (const r of f.parentStmt.getLeadingCommentRanges()) {
2001
+ const m2 = r.getText().match(re);
2002
+ if (m2)
2003
+ return m2[1];
2004
+ }
2005
+ }
2006
+ return null;
2007
+ }
1889
2008
  for (const f of allFns) {
1890
2009
  if (!hasExtern(f))
1891
2010
  continue;
1892
- if (_externs.has(f.name))
2011
+ const qualified = externName(f) ?? f.name;
2012
+ const flat = qualified.replace(/\./g, "_");
2013
+ if (_externs.has(qualified))
1893
2014
  continue;
1894
2015
  const sig = f.node.getType().getCallSignatures()[0];
1895
2016
  if (!sig)
1896
2017
  continue;
1897
2018
  const typeParams = sig.getTypeParameters().map(tp => tp.getText());
2019
+ // Normalize via typeToString (not raw getText): resolves declare-type
2020
+ // shadows and yields bare names, so a param typed by an unreachable import
2021
+ // becomes `AgentMessage`, not `import("/abs/path").AgentMessage`.
1898
2022
  const params = sig.getParameters().map(p => ({
1899
2023
  name: p.getName(),
1900
- tsType: p.getTypeAtLocation(f.node).getText(),
2024
+ tsType: typeToString(p.getTypeAtLocation(f.node)),
1901
2025
  }));
1902
- const returnType = sig.getReturnType().getText();
2026
+ const returnType = typeToString(sig.getReturnType());
1903
2027
  const annots = collectFunctionAnnotations(f.node);
1904
2028
  const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
1905
2029
  const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
1906
- _externs.set(f.name, { qualified: f.name, flat: f.name, typeParams, params, returnType, requires, ensures });
2030
+ _externs.set(qualified, { qualified, flat, typeParams, params, returnType, requires, ensures });
1907
2031
  }
1908
2032
  // If any function has //@ verify, only extract those (brownfield mode).
1909
2033
  // For expression-body arrows, //@ verify may be on the parent variable statement.
1910
2034
  function hasVerify(f) {
1911
- if (f.node.getFullText().includes('//@ verify'))
2035
+ if (hasLineDirective(f.node.getFullText(), "verify"))
1912
2036
  return true;
1913
2037
  if (f.parentStmt) {
1914
2038
  for (const r of f.parentStmt.getLeadingCommentRanges()) {
1915
- if (r.getText().includes('//@ verify'))
2039
+ if (hasLineDirective(r.getText(), "verify"))
1916
2040
  return true;
1917
2041
  }
1918
2042
  }
1919
2043
  return false;
1920
2044
  }
1921
- const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
2045
+ const hasVerifyDirective = hasLineDirective(sourceFile.getFullText(), "verify");
1922
2046
  const nonExternFns = allFns.filter(f => !hasExtern(f));
1923
2047
  const fnsToExtract = hasVerifyDirective ? nonExternFns.filter(hasVerify) : nonExternFns;
2048
+ // `//@ autohavoc` — enable the auto-havoc abstraction (see autohavoc.ts).
2049
+ // File-level: a directive at column 0 (top of file) enables it for every
2050
+ // function. Per-function: the annotation attached to a function (or its
2051
+ // parent variable statement), mirroring `//@ verify`.
2052
+ const fileAutohavoc = /^\/\/@ autohavoc\b/m.test(sourceFile.getFullText());
2053
+ function hasAutohavoc(f) {
2054
+ if (fileAutohavoc)
2055
+ return true;
2056
+ if (hasLineDirective(f.node.getFullText(), "autohavoc"))
2057
+ return true;
2058
+ if (f.parentStmt) {
2059
+ for (const r of f.parentStmt.getLeadingCommentRanges()) {
2060
+ if (hasLineDirective(r.getText(), "autohavoc"))
2061
+ return true;
2062
+ }
2063
+ }
2064
+ return false;
2065
+ }
1924
2066
  const functions = fnsToExtract.map(f => {
1925
2067
  // For expression-body arrows, annotations come from the parent variable statement
1926
2068
  const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
1927
2069
  const raw = extractFunction(f.node, parentAnnots);
1928
2070
  raw.name = f.name; // use the const name, not "<anonymous>"
2071
+ raw.autohavoc = hasAutohavoc(f);
1929
2072
  return raw;
1930
2073
  });
1931
2074
  // Resolve type references in function signatures via ts-morph's type
@@ -2121,10 +2264,17 @@ export function extractModule(sourceFile) {
2121
2264
  collectNamesExpr(e.else);
2122
2265
  }
2123
2266
  }
2267
+ // Signature types (params + return) get base-name stripping below; body /
2268
+ // spec references stay exact-match (so a body `let xs: Hunk[]` doesn't pull
2269
+ // `Hunk` into the filter early and reorder output that resolveType re-adds).
2270
+ const sigTypes = new Set();
2124
2271
  for (const fn of functions) {
2125
- for (const p of fn.params)
2272
+ for (const p of fn.params) {
2126
2273
  referencedNames.add(p.tsType);
2274
+ sigTypes.add(p.tsType);
2275
+ }
2127
2276
  referencedNames.add(fn.returnType);
2277
+ sigTypes.add(fn.returnType);
2128
2278
  collectNames(fn.body);
2129
2279
  // Also scan spec annotations for identifier references
2130
2280
  for (const spec of [...fn.requires, ...fn.ensures]) {
@@ -2153,6 +2303,15 @@ export function extractModule(sourceFile) {
2153
2303
  }
2154
2304
  for (const name of referencedNames)
2155
2305
  markType(name);
2306
+ // Signature types also mark their base after stripping array/optional
2307
+ // WRAPPERS (`Out[]`/`Msg | undefined` → `Out`/`Msg`), so a function returning
2308
+ // a local `Out[]` keeps `Out`. Wrappers only — never dig into generic args
2309
+ // (`Omit<FilePathOptions, …>` must not pull in the inner type).
2310
+ for (const name of sigTypes) {
2311
+ const base = name.replace(/\s*\|\s*(undefined|null)\s*$/, "").replace(/(\[\])+$/, "").trim();
2312
+ if (base !== name && /^[A-Za-z_]\w*$/.test(base))
2313
+ markType(base);
2314
+ }
2156
2315
  typeDecls.splice(0, typeDecls.length, ...typeDecls.filter(d => neededTypes.has(d.name) || declaredNames.has(d.name)));
2157
2316
  }
2158
2317
  // Resolve imported types: extract types referenced in function signatures but not in this file