lemmascript 0.3.3 → 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.
@@ -5,9 +5,133 @@
5
5
  * The only strings are //@ annotation expressions (parsed later by specparser).
6
6
  */
7
7
  import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
8
+ import { initTypeParser } from "./types.js";
8
9
  // ── Expression extraction ────────────────────────────────────
9
10
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
10
11
  let _havocKey = null;
12
+ /** Auto-detected cross-file calls. Populated by `extractExpr` whenever it sees
13
+ * a call `Obj.method(...)` or `foo(...)` whose ts-morph symbol resolves to a
14
+ * different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`.
15
+ * Cleared at the start of every `extractModule`. */
16
+ const _externs = new Map();
17
+ let _currentSourceFile = null;
18
+ /** True only while extracting a function body. Module-level constants that
19
+ * reference cross-file callees (e.g., `BusEvent.define(...)` inside a
20
+ * module-level record) would otherwise pollute the output with externs
21
+ * that no verified function actually calls — and whose TS return types
22
+ * often don't translate to valid Dafny. */
23
+ let _inFunctionExtraction = false;
24
+ /** Counter for synthetic names used by let-statement array destructuring
25
+ * when the initializer isn't a bare variable (single-eval temp). */
26
+ let _destrCounter = 0;
27
+ /** Register the call's callee as a cross-file extern if applicable, then
28
+ * walk the source declaration's body for nested cross-file calls (so the
29
+ * lifted `requires`/`ensures` see all the symbols they reference). Idempotent
30
+ * via the `_externs` dedup. */
31
+ function registerExternIfCrossFile(callee, sourceFile) {
32
+ const ext = detectCrossFileExtern(callee, sourceFile);
33
+ if (!ext || _externs.has(ext.qualified))
34
+ return;
35
+ _externs.set(ext.qualified, ext);
36
+ // Recurse: scan the source decl's body for nested cross-file calls so any
37
+ // symbol referenced by the copied spec is itself declared in the output.
38
+ let symbol = callee.getSymbol();
39
+ if (!symbol)
40
+ return;
41
+ const aliased = symbol.getAliasedSymbol();
42
+ if (aliased)
43
+ symbol = aliased;
44
+ const sourceDecl = symbol.getDeclarations().find(d => d.getSourceFile().getFilePath() !== sourceFile.getFilePath());
45
+ if (!sourceDecl)
46
+ return;
47
+ const sourceSF = sourceDecl.getSourceFile();
48
+ const body = sourceDecl.getBody?.();
49
+ if (!body)
50
+ return;
51
+ for (const inner of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
52
+ const innerCallee = inner.getExpression();
53
+ if (Node.isPropertyAccessExpression(innerCallee) || Node.isIdentifier(innerCallee)) {
54
+ registerExternIfCrossFile(innerCallee, sourceSF);
55
+ }
56
+ }
57
+ }
58
+ function detectCrossFileExtern(callee, sourceFile) {
59
+ let symbol = callee.getSymbol();
60
+ if (!symbol)
61
+ return null;
62
+ // For bare imports `import { foo } from "..."`, the call-site symbol is the
63
+ // local ImportSpecifier — declared in the current file. Follow the alias to
64
+ // the original `export function` declaration.
65
+ const aliased = symbol.getAliasedSymbol();
66
+ if (aliased)
67
+ symbol = aliased;
68
+ const decls = symbol.getDeclarations();
69
+ if (decls.length === 0)
70
+ return null;
71
+ const currentPath = sourceFile.getFilePath();
72
+ const externalDecl = decls.find(d => d.getSourceFile().getFilePath() !== currentPath);
73
+ if (!externalDecl)
74
+ return null;
75
+ // Skip stdlib / typings — those have built-in dispatch elsewhere or are
76
+ // genuinely out of LS's verification model.
77
+ if (externalDecl.getSourceFile().getFilePath().endsWith(".d.ts"))
78
+ return null;
79
+ const sig = callee.getType().getCallSignatures()[0];
80
+ if (!sig)
81
+ return null;
82
+ // Generic type parameters (e.g. `step<S, A>`). ts-morph reports param/return
83
+ // types in the callee's own type-parameter namespace, so these names match
84
+ // what `params`/`returnType` reference — declare them on the emitted axiom.
85
+ const typeParams = sig.getTypeParameters().map(tp => tp.getText());
86
+ const params = sig.getParameters().map(p => ({
87
+ name: p.getName(),
88
+ tsType: p.getTypeAtLocation(callee).getText(),
89
+ }));
90
+ const returnType = sig.getReturnType().getText();
91
+ let qualified;
92
+ if (Node.isPropertyAccessExpression(callee)) {
93
+ qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
94
+ }
95
+ else {
96
+ qualified = callee.getText();
97
+ }
98
+ const flat = qualified.replace(/\./g, "_");
99
+ // Lift `//@ requires`/`//@ ensures` from the source declaration so callers
100
+ // reason against the source's verified contract, not an unconstrained axiom.
101
+ const annots = collectFunctionAnnotations(externalDecl);
102
+ const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
103
+ const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
104
+ return { qualified, flat, typeParams, params, returnType, requires, ensures };
105
+ }
106
+ /** Build a concat-tree from a mixed list of literal and SpreadElement nodes.
107
+ * Literals collapse into arrayLiteral segments; spreads become bare expressions;
108
+ * segments are joined with `arrayConcat`. Used by array-literal and Math.max/min
109
+ * call-arg spread.
110
+ * Precondition: at least one element. */
111
+ function buildSpreadConcat(elems) {
112
+ const segments = [];
113
+ let currentLiterals = [];
114
+ for (const e of elems) {
115
+ if (Node.isSpreadElement(e)) {
116
+ if (currentLiterals.length > 0) {
117
+ segments.push({ kind: "arrayLiteral", elems: currentLiterals });
118
+ currentLiterals = [];
119
+ }
120
+ segments.push(extractExpr(e.getExpression()));
121
+ }
122
+ else {
123
+ currentLiterals.push(extractExpr(e));
124
+ }
125
+ }
126
+ if (currentLiterals.length > 0) {
127
+ segments.push({ kind: "arrayLiteral", elems: currentLiterals });
128
+ }
129
+ let result = segments[0];
130
+ for (let i = 1; i < segments.length; i++) {
131
+ result = { kind: "binop", op: "arrayConcat", left: result, right: segments[i] };
132
+ }
133
+ return result;
134
+ }
11
135
  /**
12
136
  * Maps property-name fingerprints to type alias names for collapsed single-variant unions.
13
137
  * TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
@@ -15,8 +139,116 @@ let _havocKey = null;
15
139
  * Populated in extractModule before type extraction; used by typeToString.
16
140
  */
17
141
  let _collapsedUnionMap = new Map();
142
+ /**
143
+ * Accumulator for synthesized `T[] | U` array-union datatypes.
144
+ *
145
+ * Plain TS unions like `string | Part[]` have no backend image (LemmaScript
146
+ * models tagged unions only). When typeToString encounters a binary union
147
+ * where one member is an array and the other is not array/undefined/null,
148
+ * it synthesizes a discriminated-union TypeDeclInfo with variants
149
+ * ArrayBranch(arr: T[]) and NonArrayBranch(val: U) and returns the synthetic
150
+ * name in place of "T[] | U". The runtime discriminator is `Array.isArray`,
151
+ * lowered to a tag predicate by narrow/transform.
152
+ *
153
+ * Set in extractModule to the module's typeDecls; cleared at end. When null,
154
+ * typeToString falls through to the existing union path.
155
+ */
156
+ let _synthArrayUnions = null;
157
+ /** Sanitize an arbitrary type-string fragment for use inside a generated identifier. */
158
+ function _synthName(elemName, otherName) {
159
+ const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
160
+ return `ArrayOf_${sanitize(elemName)}_Or_${sanitize(otherName)}`;
161
+ }
162
+ /**
163
+ * String-level fallback for synth detection on a `T[] | U` shape, used by
164
+ * declare-type field parsing (where no ts-morph TypeNode is available). The
165
+ * format inside declare-type is user-controlled and structurally simple, so
166
+ * a split-on-` | ` is acceptable in that bounded context. Returns the synth
167
+ * name if matched, registers a TypeDeclInfo into the accumulator, else null.
168
+ */
169
+ function _synthFromTsTypeString(ts) {
170
+ if (_synthArrayUnions === null)
171
+ return null;
172
+ if (!ts.includes(" | "))
173
+ return null;
174
+ const arms = ts.split(" | ").map(s => s.trim());
175
+ if (arms.length !== 2)
176
+ return null;
177
+ if (arms.some(a => a === "undefined" || a === "null"))
178
+ return null;
179
+ const arrIdx = arms.findIndex(a => a.endsWith("[]"));
180
+ if (arrIdx === -1)
181
+ return null;
182
+ const otherIdx = 1 - arrIdx;
183
+ if (arms[otherIdx].endsWith("[]"))
184
+ return null;
185
+ const elem = arms[arrIdx].slice(0, -2);
186
+ const other = arms[otherIdx];
187
+ const synthName = _synthName(elem, other);
188
+ if (!_synthArrayUnions.some(d => d.name === synthName)) {
189
+ _synthArrayUnions.push({
190
+ name: synthName,
191
+ kind: "discriminated-union",
192
+ discriminant: "__isArray__",
193
+ variants: [
194
+ { name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elem}[]` }] },
195
+ { name: "NonArrayBranch", fields: [{ name: "val", tsType: other }] },
196
+ ],
197
+ });
198
+ }
199
+ return synthName;
200
+ }
201
+ /**
202
+ * Compute a tsType string from a syntactic union TypeNode (`T | U`), preserving
203
+ * the member nodes' source text (so `type ListId = number` stays `ListId`,
204
+ * which ts-morph erases when you read the resolved Type). When the union matches
205
+ * the `T[] | U` synth shape (U not array/undefined/null), registers a synth-
206
+ * array-union TypeDeclInfo and returns its synthetic name. Otherwise returns
207
+ * the syntactic join (so `ListId | undefined` stays a recognizable union for
208
+ * parseTsType to wrap as Option<ListId>).
209
+ *
210
+ * This is the param/return-type counterpart of the typeToString synthesis hook,
211
+ * which handles record/interface field types via the Type-driven path.
212
+ */
213
+ function _tsTypeFromUnionNode(tn) {
214
+ if (!Node.isUnionTypeNode(tn))
215
+ return tn.getText();
216
+ const members = tn.getTypeNodes();
217
+ if (_synthArrayUnions !== null && members.length === 2) {
218
+ const arrIdx = members.findIndex(m => m.getType().isArray());
219
+ if (arrIdx !== -1) {
220
+ const other = members[1 - arrIdx];
221
+ const ot = other.getType();
222
+ if (!ot.isArray() && !ot.isUndefined() && !ot.isNull()) {
223
+ const arrNode = members[arrIdx];
224
+ const elemName = Node.isArrayTypeNode(arrNode)
225
+ ? arrNode.getElementTypeNode().getText()
226
+ : typeToString(arrNode.getType().getArrayElementTypeOrThrow());
227
+ const otherName = other.getText();
228
+ const synthName = _synthName(elemName, otherName);
229
+ if (!_synthArrayUnions.some(d => d.name === synthName)) {
230
+ _synthArrayUnions.push({
231
+ name: synthName,
232
+ kind: "discriminated-union",
233
+ discriminant: "__isArray__",
234
+ variants: [
235
+ { name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elemName}[]` }] },
236
+ { name: "NonArrayBranch", fields: [{ name: "val", tsType: otherName }] },
237
+ ],
238
+ });
239
+ }
240
+ return synthName;
241
+ }
242
+ }
243
+ }
244
+ return members.map(m => m.getText()).join(" | ");
245
+ }
18
246
  /** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
19
247
  let _typeParamMap = new Map();
248
+ // Fresh for-counter names allocated in the current function — so two sibling
249
+ // loops that both need renaming don't independently pick the same `<name>_N`
250
+ // (detection is read-only, so the source still shows the original names).
251
+ let _reservedForCounterNames = new Set();
20
252
  function _eraseGenerics(tsType) {
21
253
  if (_typeParamMap.size === 0)
22
254
  return tsType;
@@ -86,32 +318,36 @@ function extractExpr(node) {
86
318
  return { kind: "var", name: "this" };
87
319
  }
88
320
  // Property access: x.foo or x?.foo
321
+ // Each `?.` is its own short-circuit point — wrap the inner in a new optChain.
322
+ // Non-`?` continuation of an existing optChain extends the chain (no new
323
+ // short-circuit, just keep evaluating after the prior `?` succeeded).
89
324
  if (Node.isPropertyAccessExpression(node)) {
90
325
  const obj = extractExpr(node.getExpression());
91
326
  const field = node.getName();
92
- // Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
93
- // Skip if this is a method call (parent is CallExpression) — handled differently.
94
327
  if (node.hasQuestionDotToken()) {
95
- const parent = node.getParent();
96
- const isMethodCall = parent && Node.isCallExpression(parent) && parent.getExpression() === node;
97
- if (!isMethodCall) {
98
- return { kind: "conditional",
99
- cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
100
- then: { kind: "field", obj, field },
101
- else: { kind: "var", name: "undefined" },
102
- };
103
- }
328
+ return { kind: "optChain", obj, chain: [{ kind: "field", name: field }] };
329
+ }
330
+ if (obj.kind === "optChain") {
331
+ return { ...obj, chain: [...obj.chain, { kind: "field", name: field }] };
104
332
  }
105
333
  return { kind: "field", obj, field };
106
334
  }
107
- // Element access: arr[i]
335
+ // Element access: arr[i] or arr?.[i]
108
336
  if (Node.isElementAccessExpression(node)) {
109
337
  const arg = node.getArgumentExpression();
110
338
  if (!arg)
111
339
  throw new Error(`Missing index in element access: ${node.getText()}`);
112
- return { kind: "index", obj: extractExpr(node.getExpression()), idx: extractExpr(arg) };
340
+ const obj = extractExpr(node.getExpression());
341
+ const idx = extractExpr(arg);
342
+ if (node.hasQuestionDotToken()) {
343
+ return { kind: "optChain", obj, chain: [{ kind: "index", idx }] };
344
+ }
345
+ if (obj.kind === "optChain") {
346
+ return { ...obj, chain: [...obj.chain, { kind: "index", idx }] };
347
+ }
348
+ return { kind: "index", obj, idx };
113
349
  }
114
- // Call expression: f(a, b)
350
+ // Call expression: f(a, b), x?.foo() (`?.` on the property), or x?.() (`?.` on call)
115
351
  if (Node.isCallExpression(node)) {
116
352
  // Object.fromEntries(map) → identity (Map IS Record in Dafny)
117
353
  const callee = node.getExpression();
@@ -121,11 +357,37 @@ function extractExpr(node) {
121
357
  node.getArguments().length === 1) {
122
358
  return extractExpr(node.getArguments()[0]);
123
359
  }
124
- return {
125
- kind: "call",
126
- fn: extractExpr(node.getExpression()),
127
- args: node.getArguments().map(a => extractExpr(a)),
128
- };
360
+ // Math.max(...) / Math.min(...) with spread args → MaxOfSeq(seq) / MinOfSeq(seq)
361
+ // The spread is desugared at extract time; resolve and downstream passes
362
+ // see an ordinary function call.
363
+ if (Node.isPropertyAccessExpression(callee) &&
364
+ callee.getExpression().getText() === "Math" &&
365
+ (callee.getName() === "max" || callee.getName() === "min")) {
366
+ const argNodes = node.getArguments();
367
+ if (argNodes.some(a => Node.isSpreadElement(a))) {
368
+ const combined = buildSpreadConcat(argNodes);
369
+ const fnName = callee.getName() === "max" ? "MaxOfSeq" : "MinOfSeq";
370
+ return { kind: "call", fn: { kind: "var", name: fnName }, args: [combined] };
371
+ }
372
+ }
373
+ // Auto-extern: if the callee resolves (via ts-morph) to a symbol declared
374
+ // in a different `.ts` file, register it as an opaque extern. Covers both
375
+ // `Obj.method(...)` and bare `foo(...)` imports. Skipped for stdlib/.d.ts
376
+ // declarations — those are either built-in methods (handled in dafny-emit)
377
+ // or genuinely out of scope.
378
+ if (_currentSourceFile && _inFunctionExtraction &&
379
+ (Node.isPropertyAccessExpression(callee) || Node.isIdentifier(callee))) {
380
+ registerExternIfCrossFile(callee, _currentSourceFile);
381
+ }
382
+ const fn = extractExpr(callee);
383
+ const args = node.getArguments().map(a => extractExpr(a));
384
+ if (node.hasQuestionDotToken()) {
385
+ return { kind: "optChain", obj: fn, chain: [{ kind: "call", args }] };
386
+ }
387
+ if (fn.kind === "optChain") {
388
+ return { ...fn, chain: [...fn.chain, { kind: "call", args }] };
389
+ }
390
+ return { kind: "call", fn, args };
129
391
  }
130
392
  // Binary expression: a + b, a === b, etc.
131
393
  if (Node.isBinaryExpression(node)) {
@@ -135,6 +397,10 @@ function extractExpr(node) {
135
397
  // This is an assignment expression; extract as binop for now
136
398
  return { kind: "binop", op: "=", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
137
399
  }
400
+ // Nullish coalescing: a ?? b — single-eval, narrow rewrites to someMatch
401
+ if (op === "??") {
402
+ return { kind: "nullish", left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
403
+ }
138
404
  return { kind: "binop", op, left: extractExpr(node.getLeft()), right: extractExpr(node.getRight()) };
139
405
  }
140
406
  // Prefix unary: !x, -x
@@ -181,30 +447,8 @@ function extractExpr(node) {
181
447
  if (!hasSpread) {
182
448
  return { kind: "arrayLiteral", elems: elems.map(e => extractExpr(e)) };
183
449
  }
184
- // Build concatenation: [a, ...b, c] → [a] + b + [c]
185
- // Group consecutive non-spread elements into array literals, spreads are bare
186
- const segments = [];
187
- let currentLiterals = [];
188
- for (const e of elems) {
189
- if (Node.isSpreadElement(e)) {
190
- if (currentLiterals.length > 0) {
191
- segments.push({ kind: "arrayLiteral", elems: currentLiterals });
192
- currentLiterals = [];
193
- }
194
- segments.push(extractExpr(e.getExpression()));
195
- }
196
- else {
197
- currentLiterals.push(extractExpr(e));
198
- }
199
- }
200
- if (currentLiterals.length > 0) {
201
- segments.push({ kind: "arrayLiteral", elems: currentLiterals });
202
- }
203
- // Fold segments with arrayConcat
204
- let result = segments[0];
205
- for (let i = 1; i < segments.length; i++) {
206
- result = { kind: "binop", op: "arrayConcat", left: result, right: segments[i] };
207
- }
450
+ // [a, ...b, c] → [a] + b + [c] via shared helper
451
+ const result = buildSpreadConcat(elems);
208
452
  return result;
209
453
  }
210
454
  // Object literal: { res: true, done: false } or { ...obj, res: true }
@@ -227,7 +471,10 @@ function extractExpr(node) {
227
471
  computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
228
472
  }
229
473
  else if (init) {
230
- fields.push({ name: prop.getName(), value: extractExpr(init) });
474
+ // String-literal keys (e.g. `"bun run": 3`) — use the unquoted literal
475
+ // value; otherwise `prop.getName()` may include surrounding quotes.
476
+ const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName();
477
+ fields.push({ name, value: extractExpr(init) });
231
478
  }
232
479
  }
233
480
  }
@@ -333,6 +580,27 @@ function collectAnnotations(node, body) {
333
580
  return [...own, ...parseAnnotations(body[0])];
334
581
  return own;
335
582
  }
583
+ // A loop's `//@ invariant`/`decreases`/`done_with` annotations live as leading
584
+ // comments of its first body statement — never on the loop node itself. (The
585
+ // loop node's own leading comments belong to whatever precedes it; when the
586
+ // loop is the first statement of an *enclosing* loop, those comments are the
587
+ // enclosing loop's invariants, which must not leak in.) So collect from the
588
+ // body alone, unlike `collectAnnotations`, which also reads the node (needed for
589
+ // functions, whose specs may precede the declaration).
590
+ function collectLoopAnnotations(body) {
591
+ return body.length > 0 ? parseAnnotations(body[0]) : [];
592
+ }
593
+ /** All `//@ ` annotations for a function-like node, regardless of whether its
594
+ * body is a block (annotations on the first statement) or an expression-body
595
+ * arrow (annotations only on the declaration). Used both for in-file function
596
+ * extraction and for pulling specs off cross-file externs. */
597
+ function collectFunctionAnnotations(fn) {
598
+ const body = fn.getBody?.();
599
+ if (body && Node.isBlock(body)) {
600
+ return collectAnnotations(fn, body.getStatements());
601
+ }
602
+ return collectAnnotations(fn);
603
+ }
336
604
  /** Check for bare `//@ pure` annotation (no expression). */
337
605
  function hasPureAnnotation(node, body) {
338
606
  const nodes = body && body.length > 0 ? [node, body[0]] : [node];
@@ -350,6 +618,12 @@ function extractTypeDecl(decl, extraDecls) {
350
618
  const type = decl.getType();
351
619
  const typeParams = decl.getTypeParameters().map(tp => tp.getName());
352
620
  const tpField = typeParams.length > 0 ? typeParams : undefined;
621
+ // Leading `//@ type <ty>` overrides extraction — the declared TS type is
622
+ // replaced by the annotated backend type. Used to coerce literal unions
623
+ // (`5 | 15 | 30` → `nat`) and other types LS can't model precisely.
624
+ const override = parseAnnotations(decl).find(a => a.kind === "type");
625
+ if (override)
626
+ return { name, typeParams: tpField, kind: "alias", aliasOf: override.expr };
353
627
  if (type.isUnion()) {
354
628
  const members = type.getUnionTypes();
355
629
  if (members.every(m => m.isStringLiteral())) {
@@ -367,7 +641,12 @@ function extractTypeDecl(decl, extraDecls) {
367
641
  for (const prop of m.getProperties()) {
368
642
  if (prop.getName() === discriminant)
369
643
  continue;
370
- fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
644
+ let tsType = typeToString(prop.getTypeAtLocation(decl));
645
+ const propDecl = prop.getDeclarations()[0];
646
+ if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
647
+ tsType = `${tsType} | undefined`;
648
+ }
649
+ fields.push({ name: prop.getName(), tsType });
371
650
  }
372
651
  return { name: tag, fields };
373
652
  });
@@ -399,6 +678,29 @@ function extractTypeDecl(decl, extraDecls) {
399
678
  }
400
679
  }
401
680
  }
681
+ // Function-type alias: `type Comparator = (a: T, b: T) => boolean` —
682
+ // ts-morph reports these as object-typed with a call signature and no
683
+ // user-visible properties. Emit as a Dafny `type X = (...) -> R` alias.
684
+ if (type.isObject()) {
685
+ const sig = type.getCallSignatures()[0];
686
+ const hasProps = type.getProperties().length > 0;
687
+ if (sig && !hasProps) {
688
+ // Synthesize fresh param names — the ts-morph parser used downstream
689
+ // needs `name: T` syntax; bare `T` is read as a param name with `any`.
690
+ const params = sig.getParameters().map((p, i) => `_p${i}: ${typeToString(p.getTypeAtLocation(decl))}`);
691
+ const ret = typeToString(sig.getReturnType());
692
+ return { name, kind: "alias", aliasOf: `(${params.join(", ")}) => ${ret}` };
693
+ }
694
+ }
695
+ // Array-type alias: `type Board = number[]` → alias to the seq type, not a
696
+ // record. Arrays report as object types, so this must precede the record
697
+ // branch, which would otherwise enumerate the Array prototype as fields.
698
+ // Build the element type directly: typeToString(type) would return the
699
+ // alias's own name (via getAliasSymbol), yielding a self-referential alias.
700
+ if (type.isArray()) {
701
+ const elem = type.getArrayElementTypeOrThrow();
702
+ return { name, typeParams: tpField, kind: "alias", aliasOf: `${typeToString(elem)}[]` };
703
+ }
402
704
  if (type.isObject() || type.isIntersection())
403
705
  return extractRecord(name, type, decl, undefined, extraDecls);
404
706
  // Primitive type alias: type TaskId = number → alias
@@ -433,6 +735,13 @@ function extractRecord(name, type, locationNode, overrides, extraDecls) {
433
735
  }
434
736
  const propType = prop.getTypeAtLocation(locationNode);
435
737
  let tsType = typeToString(propType);
738
+ // Optional property: `foo?: T` reports as `T` (ts-morph strips the
739
+ // `| undefined` from a question-token type). Add it back so the field
740
+ // resolves to `Optional<T>`.
741
+ const propDecl = prop.getDeclarations()[0];
742
+ if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
743
+ tsType = `${tsType} | undefined`;
744
+ }
436
745
  // Inline anonymous object types: ts-morph names them __type.
437
746
  // Generate a synthetic named record and reference it by name instead.
438
747
  if (extraDecls && tsType.includes("__type")) {
@@ -498,7 +807,37 @@ function typeToString(type) {
498
807
  return name;
499
808
  }
500
809
  if (type.isUnion()) {
501
- const parts = [...new Set(type.getUnionTypes().map(typeToString))];
810
+ const unionTypes = type.getUnionTypes();
811
+ // `T[] | U` synthesis: exactly two members, one array, the other not
812
+ // array/undefined/null. Detected via ts-morph type predicates (no string
813
+ // parsing). Registers a discriminated-union TypeDeclInfo with variants
814
+ // ArrayBranch(arr: T[]) and NonArrayBranch(val: U); returns the synthetic
815
+ // name so all downstream tsType slots agree. `T[] | undefined` and
816
+ // `T[] | T[]` fall through to the existing path unchanged.
817
+ if (_synthArrayUnions !== null && unionTypes.length === 2) {
818
+ const [m0, m1] = unionTypes;
819
+ const arrayMember = m0.isArray() ? m0 : (m1.isArray() ? m1 : null);
820
+ const otherMember = arrayMember === m0 ? m1 : m0;
821
+ if (arrayMember && otherMember && !otherMember.isArray()
822
+ && !otherMember.isUndefined() && !otherMember.isNull()) {
823
+ const elemName = typeToString(arrayMember.getArrayElementTypeOrThrow());
824
+ const otherName = typeToString(otherMember);
825
+ const synthName = _synthName(elemName, otherName);
826
+ if (!_synthArrayUnions.some(d => d.name === synthName)) {
827
+ _synthArrayUnions.push({
828
+ name: synthName,
829
+ kind: "discriminated-union",
830
+ discriminant: "__isArray__",
831
+ variants: [
832
+ { name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elemName}[]` }] },
833
+ { name: "NonArrayBranch", fields: [{ name: "val", tsType: otherName }] },
834
+ ],
835
+ });
836
+ }
837
+ return synthName;
838
+ }
839
+ }
840
+ const parts = [...new Set(unionTypes.map(typeToString))];
502
841
  return parts.join(" | ");
503
842
  }
504
843
  if (type.isTuple()) {
@@ -528,7 +867,171 @@ function typeToString(type) {
528
867
  }
529
868
  const COMPOUND_OPS = {
530
869
  "+=": "+", "-=": "-", "*=": "*", "/=": "/", "%=": "%",
870
+ "<<=": "<<", ">>=": ">>", "|=": "|", "&=": "&", "^=": "^", "**=": "**",
531
871
  };
872
+ /** Desugar a statement-position side-effecting expression — `x = e`, `x += e`,
873
+ * `i++`, `arr[i] = v`, etc. — into a `RawAssign`. Returns null when no shape
874
+ * match (caller emits a plain `{kind: "expr"}` or errors). Called by both
875
+ * `ExpressionStatement` extraction (wrapped) and the C-style for-loop
876
+ * incrementor (bare Expression — same shape, no `;` wrapper). */
877
+ function desugarStmtExpr(expr, line) {
878
+ if (Node.isBinaryExpression(expr)) {
879
+ const opText = expr.getOperatorToken().getText();
880
+ const left = expr.getLeft();
881
+ if (opText === "=" && Node.isElementAccessExpression(left)) {
882
+ const obj = extractExpr(left.getExpression());
883
+ const idx = extractExpr(left.getArgumentExpression());
884
+ const val = extractExpr(expr.getRight());
885
+ const target = left.getExpression().getText();
886
+ const withCall = { kind: "call", fn: { kind: "field", obj, field: "with" }, args: [idx, val] };
887
+ return { kind: "assign", target, value: withCall, line };
888
+ }
889
+ if (opText === "=") {
890
+ return { kind: "assign", target: left.getText(), value: extractExpr(expr.getRight()), line };
891
+ }
892
+ const compound = COMPOUND_OPS[opText];
893
+ if (compound) {
894
+ const target = left.getText();
895
+ return {
896
+ kind: "assign", target,
897
+ value: { kind: "binop", op: compound, left: { kind: "var", name: target }, right: extractExpr(expr.getRight()) },
898
+ line,
899
+ };
900
+ }
901
+ }
902
+ if ((Node.isPostfixUnaryExpression(expr) || Node.isPrefixUnaryExpression(expr)) &&
903
+ (expr.getOperatorToken() === SyntaxKind.PlusPlusToken || expr.getOperatorToken() === SyntaxKind.MinusMinusToken)) {
904
+ const target = expr.getOperand().getText();
905
+ const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
906
+ return {
907
+ kind: "assign", target,
908
+ value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } },
909
+ line,
910
+ };
911
+ }
912
+ return null;
913
+ }
914
+ // A C-style `for (let i …)` counter is hoisted out of its loop scope into the
915
+ // enclosing block (the desugar runs `init; while (cond) { body; update }`). If
916
+ // another binding of that name shares the hoisted scope — a sibling `let i`, or
917
+ // an enclosing for-loop whose own `i` stays live where the inner counter and the
918
+ // outer update sit — the two collapse to one Dafny `var i` ("Duplicate
919
+ // local-variable name", or a silently-misbound update). When that happens we
920
+ // rename this counter to a fresh `<name>_N` in the loop's own desugared pieces.
921
+ //
922
+ // `forCounterRename` is a read-only scope check (no AST mutation): it returns the
923
+ // fresh name when this counter would collide, else null — so a non-conflicting
924
+ // loop keeps its name and output is byte-identical. The rename itself is applied
925
+ // to the extracted Raw IR by `renameRawStmts` / `renameRawExpr` (code) and
926
+ // `renameSpec` (the `//@` strings, still unparsed at this phase).
927
+ function forCounterRename(decl, forStmt) {
928
+ const name = decl.getName();
929
+ const fnLike = forStmt.getFirstAncestor(a => Node.isFunctionDeclaration(a) || Node.isArrowFunction(a) ||
930
+ Node.isFunctionExpression(a) || Node.isMethodDeclaration(a));
931
+ const scopeRoot = fnLike ?? forStmt.getSourceFile();
932
+ const isScope = (a) => Node.isBlock(a) || Node.isSourceFile(a) ||
933
+ Node.isForStatement(a) || Node.isForOfStatement(a) || Node.isForInStatement(a);
934
+ // Scopes that enclose this loop — its counter, once hoisted, lives in one of
935
+ // these, so a same-named binding scoped here would collide.
936
+ const enclosing = new Set(forStmt.getAncestors());
937
+ const paramClash = fnLike?.getParameters?.().some((p) => p.getName() === name) ?? false;
938
+ const declClash = scopeRoot.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some(d => {
939
+ if (d === decl || d.getName() !== name)
940
+ return false;
941
+ const scope = d.getFirstAncestor(isScope);
942
+ return !!scope && enclosing.has(scope);
943
+ });
944
+ if (!paramClash && !declClash)
945
+ return null;
946
+ const used = new Set(scopeRoot.getDescendantsOfKind(SyntaxKind.Identifier).map(i => i.getText()));
947
+ let n = 2;
948
+ while (used.has(`${name}_${n}`) || _reservedForCounterNames.has(`${name}_${n}`))
949
+ n++;
950
+ const fresh = `${name}_${n}`;
951
+ _reservedForCounterNames.add(fresh);
952
+ return fresh;
953
+ }
954
+ /** Whole-word rename of an identifier inside an unparsed `//@` spec string. */
955
+ function renameSpec(s, from, to) {
956
+ return s.replace(new RegExp(`\\b${from}\\b`, "g"), to);
957
+ }
958
+ /** Rename free references to `from` → `to` in a Raw expression, stopping under a
959
+ * lambda / quantifier that re-binds the name (it shadows). */
960
+ function renameRawExpr(e, from, to) {
961
+ const r = (x) => renameRawExpr(x, from, to);
962
+ switch (e.kind) {
963
+ case "var": return e.name === from ? { kind: "var", name: to } : e;
964
+ case "num":
965
+ case "str":
966
+ case "bool":
967
+ case "result":
968
+ case "havoc":
969
+ case "emptyCollection": return e;
970
+ case "binop": return { ...e, left: r(e.left), right: r(e.right) };
971
+ case "unop": return { ...e, expr: r(e.expr) };
972
+ case "call": return { ...e, fn: r(e.fn), args: e.args.map(r) };
973
+ case "index": return { ...e, obj: r(e.obj), idx: r(e.idx) };
974
+ case "field": return { ...e, obj: r(e.obj) };
975
+ case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(f => ({ ...f, value: r(f.value) })) };
976
+ case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
977
+ case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
978
+ case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
979
+ case "nonNull": return { ...e, expr: r(e.expr) };
980
+ case "optChain": return { ...e, obj: r(e.obj), chain: e.chain.map(c => c.kind === "call" ? { ...c, args: c.args.map(r) } : c.kind === "index" ? { ...c, idx: r(c.idx) } : c) };
981
+ case "lambda": return e.params.some(p => p.name === from) ? e
982
+ : { ...e, body: Array.isArray(e.body) ? renameRawStmts(e.body, from, to) : r(e.body) };
983
+ case "forall":
984
+ case "exists": return e.var === from ? e : { ...e, body: r(e.body) };
985
+ }
986
+ }
987
+ /** Rename references to `from` → `to` across a Raw statement (code refs via
988
+ * `renameRawExpr`, `//@` strings via `renameSpec`). Statement lists stop
989
+ * renaming once a `let from` re-declares the name (it shadows). */
990
+ function renameRawStmt(s, from, to) {
991
+ const r = (x) => renameRawExpr(x, from, to);
992
+ const sp = (x) => renameSpec(x, from, to);
993
+ switch (s.kind) {
994
+ case "let": return { ...s, init: r(s.init) }; // name kept: a shadowing binding is a different variable
995
+ case "assign": return { ...s, target: s.target === from ? to : s.target, value: r(s.value) };
996
+ case "return": return { ...s, value: r(s.value) };
997
+ case "expr": return { ...s, expr: r(s.expr) };
998
+ case "if": return { ...s, cond: r(s.cond), then: renameRawStmts(s.then, from, to), else: renameRawStmts(s.else, from, to) };
999
+ case "while": return { ...s, cond: r(s.cond), invariants: s.invariants.map(sp),
1000
+ decreases: s.decreases ? sp(s.decreases) : null, doneWith: s.doneWith ? sp(s.doneWith) : null,
1001
+ body: renameRawStmts(s.body, from, to) };
1002
+ case "forof": return e_forof(s);
1003
+ case "switch": return { ...s, expr: r(s.expr), cases: s.cases.map(c => ({ ...c, body: renameRawStmts(c.body, from, to) })),
1004
+ defaultBody: renameRawStmts(s.defaultBody, from, to) };
1005
+ case "ghostLet": return { ...s, init: sp(s.init) };
1006
+ case "ghostAssign": return { ...s, target: s.target === from ? to : s.target, value: sp(s.value) };
1007
+ case "assert": return { ...s, expr: sp(s.expr) };
1008
+ case "break":
1009
+ case "continue":
1010
+ case "throw": return s;
1011
+ }
1012
+ function e_forof(f) {
1013
+ // for-of names bind in the body; if one shadows `from`, leave the body alone.
1014
+ const body = f.names.includes(from) ? f.body : renameRawStmts(f.body, from, to);
1015
+ return { ...f, iterable: r(f.iterable), invariants: f.invariants.map(sp),
1016
+ doneWith: f.doneWith ? sp(f.doneWith) : null, body };
1017
+ }
1018
+ }
1019
+ /** Rename `from` → `to` through a statement list, stopping after a `let from`
1020
+ * shadows it (subsequent references are a different variable). */
1021
+ function renameRawStmts(stmts, from, to) {
1022
+ const out = [];
1023
+ let active = true;
1024
+ for (const s of stmts) {
1025
+ if (!active) {
1026
+ out.push(s);
1027
+ continue;
1028
+ }
1029
+ out.push(renameRawStmt(s, from, to));
1030
+ if (s.kind === "let" && s.name === from)
1031
+ active = false;
1032
+ }
1033
+ return out;
1034
+ }
532
1035
  // ── Statement extraction ─────────────────────────────────────
533
1036
  /** Parse ghost and assert annotations from comment ranges. */
534
1037
  function parseSpecComments(ranges, line) {
@@ -543,13 +1046,19 @@ function parseSpecComments(ranges, line) {
543
1046
  result.push({ kind: "assert", expr: content.slice(7).trim(), line });
544
1047
  continue;
545
1048
  }
1049
+ // assume expr — trusted form of assert; emitted as `assume P;` in Dafny.
1050
+ if (content.startsWith("assume ")) {
1051
+ result.push({ kind: "assert", expr: content.slice(7).trim(), line, assumed: true });
1052
+ continue;
1053
+ }
546
1054
  if (!content.startsWith("ghost "))
547
1055
  continue;
548
1056
  const ghostBody = content.slice(6).trim();
549
1057
  // ghost let varName: type = expr OR ghost let varName = expr
550
- const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*(\w+))?\s*=\s*(.+)$/);
1058
+ // Type segment accepts compound forms like `number[]`, `Map<K,V>`, etc.
1059
+ const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*([^=]+?))?\s*=\s*(.+)$/);
551
1060
  if (letMatch) {
552
- result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
1061
+ result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2]?.trim() ?? null, init: letMatch[3].trim(), line });
553
1062
  continue;
554
1063
  }
555
1064
  // ghost varName = expr
@@ -560,6 +1069,34 @@ function parseSpecComments(ranges, line) {
560
1069
  }
561
1070
  return result;
562
1071
  }
1072
+ /** Splice `update` before each `continue` in the same loop scope. Recurses
1073
+ * into `if`/`switch` (same scope) but not nested `while`/`forof` (they own
1074
+ * their own continue). Used by the C-style `for` desugar so a `continue`
1075
+ * doesn't skip the loop update. The `update` node is shared by reference —
1076
+ * the IR is transformed functionally downstream (no in-place mutation), so
1077
+ * aliasing it across the body is safe. */
1078
+ function insertUpdateBeforeContinue(stmts, update) {
1079
+ const out = [];
1080
+ for (const s of stmts) {
1081
+ if (s.kind === "continue") {
1082
+ out.push(update, s);
1083
+ }
1084
+ else if (s.kind === "if") {
1085
+ out.push({ ...s, then: insertUpdateBeforeContinue(s.then, update), else: insertUpdateBeforeContinue(s.else, update) });
1086
+ }
1087
+ else if (s.kind === "switch") {
1088
+ out.push({
1089
+ ...s,
1090
+ cases: s.cases.map(c => ({ ...c, body: insertUpdateBeforeContinue(c.body, update) })),
1091
+ defaultBody: insertUpdateBeforeContinue(s.defaultBody, update),
1092
+ });
1093
+ }
1094
+ else {
1095
+ out.push(s);
1096
+ }
1097
+ }
1098
+ return out;
1099
+ }
563
1100
  function extractStmts(stmts) {
564
1101
  const result = [];
565
1102
  for (const s of stmts) {
@@ -597,6 +1134,89 @@ function extractStmts(stmts) {
597
1134
  }
598
1135
  continue;
599
1136
  }
1137
+ // Array destructuring: const [a, , c, ...rest] = arr → individual lets,
1138
+ // each picking from `arr` by position. Rest (if present) must be last
1139
+ // and emits as `arr.slice(N)`. Omitted slots (`,,`) are skipped. Nested
1140
+ // binding patterns throw — extend the helper here when a case study
1141
+ // hits them.
1142
+ if (!isHavoc && Node.isArrayBindingPattern(nameNode)) {
1143
+ const elements = nameNode.getElements();
1144
+ const initializer = d.getInitializer();
1145
+ if (initializer) {
1146
+ let initExpr = extractExpr(initializer);
1147
+ let initVar = initExpr;
1148
+ if (initExpr.kind !== "var") {
1149
+ const tempName = `_destr${_destrCounter++}`;
1150
+ const initTs = _eraseGenerics(typeToString(initializer.getType()));
1151
+ result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
1152
+ initVar = { kind: "var", name: tempName };
1153
+ }
1154
+ for (let i = 0; i < elements.length; i++) {
1155
+ const el = elements[i];
1156
+ if (Node.isOmittedExpression(el))
1157
+ continue;
1158
+ if (!Node.isBindingElement(el))
1159
+ continue;
1160
+ const inner = el.getNameNode();
1161
+ if (!Node.isIdentifier(inner)) {
1162
+ throw new Error(`nested binding pattern in array destructuring not yet supported: ${el.getText()}`);
1163
+ }
1164
+ const name = inner.getText();
1165
+ const isRest = !!el.getDotDotDotToken();
1166
+ const elTs = _eraseGenerics(typeToString(el.getType()));
1167
+ const init = isRest
1168
+ ? { kind: "call",
1169
+ fn: { kind: "field", obj: initVar, field: "slice" },
1170
+ args: [{ kind: "num", value: i }] }
1171
+ : { kind: "index", obj: initVar, idx: { kind: "num", value: i } };
1172
+ result.push({ kind: "let", name, mutable: s.getDeclarationKind() === "let", tsType: elTs, init, line });
1173
+ }
1174
+ continue;
1175
+ }
1176
+ }
1177
+ // Plain object destructuring: const { a, b, c } = obj → field access lets.
1178
+ // Skipped if any element has a computed property (handled by the rest+
1179
+ // computed branch below) or a rest element (also handled below).
1180
+ if (!isHavoc && Node.isObjectBindingPattern(nameNode)) {
1181
+ const elements = nameNode.getElements();
1182
+ const hasRest = elements.some(el => el.getDotDotDotToken());
1183
+ const hasComputed = elements.some(el => {
1184
+ const pn = el.getPropertyNameNode();
1185
+ return pn && Node.isComputedPropertyName(pn);
1186
+ });
1187
+ if (!hasRest && !hasComputed) {
1188
+ const initializer = d.getInitializer();
1189
+ if (initializer) {
1190
+ let initExpr = extractExpr(initializer);
1191
+ let initVar = initExpr;
1192
+ if (initExpr.kind !== "var") {
1193
+ const tempName = `_destr${_destrCounter++}`;
1194
+ const initTs = _eraseGenerics(typeToString(initializer.getType()));
1195
+ result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
1196
+ initVar = { kind: "var", name: tempName };
1197
+ }
1198
+ for (const el of elements) {
1199
+ const inner = el.getNameNode();
1200
+ if (!Node.isIdentifier(inner)) {
1201
+ throw new Error(`nested binding pattern in object destructuring not yet supported: ${el.getText()}`);
1202
+ }
1203
+ const localName = inner.getText();
1204
+ const propNode = el.getPropertyNameNode();
1205
+ const fieldName = propNode ? propNode.getText() : localName;
1206
+ const elTs = _eraseGenerics(typeToString(el.getType()));
1207
+ result.push({
1208
+ kind: "let",
1209
+ name: localName,
1210
+ mutable: s.getDeclarationKind() === "let",
1211
+ tsType: elTs,
1212
+ init: { kind: "field", obj: initVar, field: fieldName },
1213
+ line,
1214
+ });
1215
+ }
1216
+ continue;
1217
+ }
1218
+ }
1219
+ }
600
1220
  // Destructuring rest: const { [k]: _, ...rest } = map → let rest = map.delete(k)
601
1221
  if (!isHavoc && Node.isObjectBindingPattern(nameNode)) {
602
1222
  const elements = nameNode.getElements();
@@ -639,14 +1259,56 @@ function extractStmts(stmts) {
639
1259
  else {
640
1260
  const initializer = d.getInitializer();
641
1261
  _havocKey = havocKey;
642
- init = initializer ? extractExpr(initializer) : { kind: "var", name: "default" };
1262
+ if (initializer) {
1263
+ init = extractExpr(initializer);
1264
+ }
1265
+ else {
1266
+ // No initializer — emit a type-appropriate default so the emitted
1267
+ // Dafny binding `var x: T := <default>;` typechecks. The empty-
1268
+ // collection cases are picked up by dafny-emit's let case, which
1269
+ // adds an explicit `: T` annotation for inference.
1270
+ const tsType = _eraseGenerics(d.getTypeNode()?.getText() ?? typeToString(declType));
1271
+ const isOptional = / \| (null|undefined)\b/.test(tsType)
1272
+ || /^(null|undefined) \| /.test(tsType)
1273
+ || tsType.endsWith(" | undefined") || tsType.endsWith(" | null");
1274
+ const isArray = tsType.endsWith("[]") || /^Array</.test(tsType) || /^readonly /.test(tsType);
1275
+ const isMap = /^Map</.test(tsType);
1276
+ const isSet = /^Set</.test(tsType);
1277
+ if (isOptional)
1278
+ init = { kind: "var", name: "undefined" };
1279
+ else if (isArray)
1280
+ init = { kind: "arrayLiteral", elems: [] };
1281
+ else if (isMap)
1282
+ init = { kind: "emptyCollection", collectionType: "Map", tsType };
1283
+ else if (isSet)
1284
+ init = { kind: "emptyCollection", collectionType: "Set", tsType };
1285
+ else if (tsType === "number")
1286
+ init = { kind: "num", value: 0 };
1287
+ else if (tsType === "boolean")
1288
+ init = { kind: "bool", value: false };
1289
+ else if (tsType === "string")
1290
+ init = { kind: "str", value: "" };
1291
+ else
1292
+ init = { kind: "var", name: "default" };
1293
+ }
643
1294
  _havocKey = null;
644
1295
  }
1296
+ // Use the source-level type annotation if present — ts-morph's
1297
+ // `d.getType()` strips `| undefined` from optional annotations.
1298
+ // When no annotation, fall back to ts-morph's inferred type — except
1299
+ // when the inference collapses to `any` (e.g. brownfield imports
1300
+ // where the imported declaration's shape is opaque to LS): in that
1301
+ // case leave null so resolve infers from the initializer's IR type
1302
+ // (which sees the declare-type stubs).
1303
+ const annotatedText = d.getTypeNode()?.getText();
1304
+ const inferred = annotatedText ? null : _eraseGenerics(typeToString(declType));
1305
+ const tsType = havocType
1306
+ ?? (annotatedText ? _eraseGenerics(annotatedText) : (inferred === "any" ? null : inferred));
645
1307
  result.push({
646
1308
  kind: "let",
647
1309
  name: d.getName(),
648
1310
  mutable: s.getDeclarationKind() === "let",
649
- tsType: havocType ?? _eraseGenerics(typeToString(declType)),
1311
+ tsType,
650
1312
  init,
651
1313
  line,
652
1314
  });
@@ -655,8 +1317,10 @@ function extractStmts(stmts) {
655
1317
  }
656
1318
  if (Node.isWhileStatement(s)) {
657
1319
  const bodyNode = s.getStatement();
658
- const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [];
659
- const annots = collectAnnotations(s, bodyStmts);
1320
+ // A braceless body (`while (c) stmt`) is a single statement, not a Block;
1321
+ // wrap it so it isn't dropped (mirrors the for / for-of / if handlers).
1322
+ const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
1323
+ const annots = collectLoopAnnotations(bodyStmts);
660
1324
  result.push({
661
1325
  kind: "while",
662
1326
  cond: extractExpr(s.getExpression()),
@@ -707,7 +1371,7 @@ function extractStmts(stmts) {
707
1371
  }
708
1372
  const bodyNode = s.getStatement();
709
1373
  const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
710
- const annots = collectAnnotations(s, bodyStmts);
1374
+ const annots = collectLoopAnnotations(bodyStmts);
711
1375
  result.push({
712
1376
  kind: "forof",
713
1377
  names,
@@ -719,6 +1383,83 @@ function extractStmts(stmts) {
719
1383
  });
720
1384
  continue;
721
1385
  }
1386
+ // C-style for(init; cond; update) — desugar to:
1387
+ // init;
1388
+ // while (cond) { body; update }
1389
+ // The init's binding is forced mutable (update mutates it). The update is
1390
+ // a bare Expression in ts-morph (not wrapped in an ExpressionStatement),
1391
+ // so we route it through the same `desugarStmtExpr` helper that the
1392
+ // ExpressionStatement branch above uses — `i++` etc. end up as RawAssign
1393
+ // exactly as they would if written as their own statement.
1394
+ if (Node.isForStatement(s)) {
1395
+ const init = s.getInitializer();
1396
+ const cond = s.getCondition();
1397
+ const incrementor = s.getIncrementor();
1398
+ const bodyNode = s.getStatement();
1399
+ const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
1400
+ const annots = collectLoopAnnotations(bodyStmts);
1401
+ if (!init || !Node.isVariableDeclarationList(init))
1402
+ throw new Error(`for(...) at line ${line}: only variable-declaration init supported`);
1403
+ // Hoist the counter declarations, renaming any that would collide once
1404
+ // lifted out of the loop scope (see forCounterRename). The renames are
1405
+ // then applied to the loop's own desugared pieces below.
1406
+ const hoisted = [];
1407
+ const renames = [];
1408
+ for (const decl of init.getDeclarations()) {
1409
+ const fresh = forCounterRename(decl, s);
1410
+ const name = fresh ?? decl.getName();
1411
+ if (fresh)
1412
+ renames.push([decl.getName(), fresh]);
1413
+ const tsType = decl.getTypeNode()?.getText() ?? typeToString(decl.getType());
1414
+ const initExpr = decl.getInitializer();
1415
+ if (!initExpr)
1416
+ throw new Error(`for(...) at line ${line}: missing initializer for ${name}`);
1417
+ hoisted.push({
1418
+ kind: "let", name, mutable: true, tsType,
1419
+ init: extractExpr(initExpr),
1420
+ line: decl.getStartLineNumber(),
1421
+ });
1422
+ }
1423
+ let extractedBody = extractStmts(bodyStmts);
1424
+ if (incrementor) {
1425
+ const incLine = incrementor.getStartLineNumber();
1426
+ const asStmt = desugarStmtExpr(incrementor, incLine);
1427
+ if (!asStmt)
1428
+ throw new Error(`for(...) at line ${line}: incrementor must be an assignment, compound assignment, or ++/--`);
1429
+ // The loop variable update runs at the bottom of every iteration. A
1430
+ // `continue` in the body would skip it, so emit a copy of the update
1431
+ // immediately before each same-scope `continue` (transform's
1432
+ // eliminateTopLevelContinue then turns `if (X) { update; continue }`
1433
+ // into `if (X) { update } else { rest }`). Nested `while`/`for-of`
1434
+ // loops own their continue scope and are left untouched.
1435
+ extractedBody = insertUpdateBeforeContinue(extractedBody, asStmt);
1436
+ extractedBody.push(asStmt);
1437
+ }
1438
+ let condExpr = cond ? extractExpr(cond) : { kind: "bool", value: true };
1439
+ let invariants = annots.filter(a => a.kind === "invariant").map(a => a.expr);
1440
+ let decreases = annots.find(a => a.kind === "decreases")?.expr ?? null;
1441
+ let doneWith = annots.find(a => a.kind === "done_with")?.expr ?? null;
1442
+ for (const [from, to] of renames) {
1443
+ for (let k = 0; k < hoisted.length; k++)
1444
+ hoisted[k] = renameRawStmt(hoisted[k], from, to);
1445
+ condExpr = renameRawExpr(condExpr, from, to);
1446
+ extractedBody = renameRawStmts(extractedBody, from, to);
1447
+ invariants = invariants.map(inv => renameSpec(inv, from, to));
1448
+ decreases = decreases ? renameSpec(decreases, from, to) : null;
1449
+ doneWith = doneWith ? renameSpec(doneWith, from, to) : null;
1450
+ }
1451
+ result.push(...hoisted);
1452
+ result.push({
1453
+ kind: "while",
1454
+ cond: condExpr,
1455
+ invariants,
1456
+ decreases,
1457
+ doneWith,
1458
+ body: extractedBody,
1459
+ line,
1460
+ });
1461
+ continue;
1462
+ }
722
1463
  // for...in: for (const k in obj) → treat as forof with single key name
723
1464
  if (Node.isForInStatement(s)) {
724
1465
  const init = s.getInitializer();
@@ -728,7 +1469,7 @@ function extractStmts(stmts) {
728
1469
  }
729
1470
  const bodyNode = s.getStatement();
730
1471
  const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
731
- const annots = collectAnnotations(s, bodyStmts);
1472
+ const annots = collectLoopAnnotations(bodyStmts);
732
1473
  result.push({
733
1474
  kind: "forof",
734
1475
  names: [name],
@@ -777,7 +1518,12 @@ function extractStmts(stmts) {
777
1518
  }
778
1519
  if (Node.isReturnStatement(s)) {
779
1520
  const expr = s.getExpression();
780
- result.push({ kind: "return", value: expr ? extractExpr(expr) : { kind: "var", name: "()" }, line });
1521
+ // Bare `return;` in a `T | undefined` function emit `return None;`
1522
+ // ("undefined" is mapped to None by dafny-emit). For void-returning
1523
+ // functions this would emit the wrong shape, but lsc has no current
1524
+ // examples of explicit bare return in void functions; revisit if one
1525
+ // appears.
1526
+ result.push({ kind: "return", value: expr ? extractExpr(expr) : { kind: "var", name: "undefined" }, line });
781
1527
  continue;
782
1528
  }
783
1529
  if (Node.isBreakStatement(s)) {
@@ -790,41 +1536,22 @@ function extractStmts(stmts) {
790
1536
  }
791
1537
  if (Node.isExpressionStatement(s)) {
792
1538
  const expr = s.getExpression();
793
- // arr[i] = v arr = arr.with(i, v)
794
- if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=" && Node.isElementAccessExpression(expr.getLeft())) {
795
- const left = expr.getLeft();
796
- const obj = extractExpr(left.getExpression());
797
- const idx = extractExpr(left.getArgumentExpression());
798
- const val = extractExpr(expr.getRight());
799
- const target = left.getExpression().getText();
800
- const withCall = { kind: "call", fn: { kind: "field", obj, field: "with" }, args: [idx, val] };
801
- result.push({ kind: "assign", target, value: withCall, line });
802
- // x = e
803
- }
804
- else if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=") {
805
- result.push({ kind: "assign", target: expr.getLeft().getText(), value: extractExpr(expr.getRight()), line });
806
- // x += e, x -= e, etc.
807
- }
808
- else if (Node.isBinaryExpression(expr) && COMPOUND_OPS[expr.getOperatorToken().getText()]) {
809
- const op = COMPOUND_OPS[expr.getOperatorToken().getText()];
1539
+ // //@ havoc before `x = e` discard the RHS, assign a nondeterministic
1540
+ // value of x's type. Only applies to plain `=` with an identifier LHS;
1541
+ // compound assigns, `arr[i] = v`, and `x++` fall through to desugaring.
1542
+ const havocMatch = s.getLeadingCommentRanges()
1543
+ .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+))?$/))
1544
+ .find(m => m !== null);
1545
+ if (havocMatch && Node.isBinaryExpression(expr)
1546
+ && expr.getOperatorToken().getText() === "="
1547
+ && Node.isIdentifier(expr.getLeft())) {
810
1548
  const target = expr.getLeft().getText();
811
- result.push({ kind: "assign", target, value: { kind: "binop", op, left: { kind: "var", name: target }, right: extractExpr(expr.getRight()) }, line });
812
- // i++, i--
813
- }
814
- else if (Node.isPostfixUnaryExpression(expr)) {
815
- const target = expr.getOperand().getText();
816
- const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
817
- result.push({ kind: "assign", target, value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } }, line });
818
- // ++i, --i
819
- }
820
- else if (Node.isPrefixUnaryExpression(expr) && (expr.getOperatorToken() === SyntaxKind.PlusPlusToken || expr.getOperatorToken() === SyntaxKind.MinusMinusToken)) {
821
- const target = expr.getOperand().getText();
822
- const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
823
- result.push({ kind: "assign", target, value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } }, line });
824
- }
825
- else {
826
- result.push({ kind: "expr", expr: extractExpr(expr), line });
1549
+ const tsType = havocMatch[1]?.trim() ?? _eraseGenerics(typeToString(expr.getLeft().getType()));
1550
+ result.push({ kind: "assign", target, value: { kind: "havoc", tsType }, line });
1551
+ continue;
827
1552
  }
1553
+ const asAssign = desugarStmtExpr(expr, line);
1554
+ result.push(asAssign ?? { kind: "expr", expr: extractExpr(expr), line });
828
1555
  continue;
829
1556
  }
830
1557
  if (Node.isThrowStatement(s)) {
@@ -852,12 +1579,17 @@ function extractStmts(stmts) {
852
1579
  result.push({ kind: "assert", expr: content.slice(7).trim(), line });
853
1580
  continue;
854
1581
  }
1582
+ // assume expr — trusted form of assert; emitted as `assume P;` in Dafny.
1583
+ if (content.startsWith("assume ")) {
1584
+ result.push({ kind: "assert", expr: content.slice(7).trim(), line, assumed: true });
1585
+ continue;
1586
+ }
855
1587
  if (!content.startsWith("ghost "))
856
1588
  continue;
857
1589
  const ghostBody = content.slice(6).trim();
858
- const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*(\w+))?\s*=\s*(.+)$/);
1590
+ const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*([^=]+?))?\s*=\s*(.+)$/);
859
1591
  if (letMatch) {
860
- result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
1592
+ result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2]?.trim() ?? null, init: letMatch[3].trim(), line });
861
1593
  continue;
862
1594
  }
863
1595
  const assignMatch = ghostBody.match(/^(\w+)\s*=\s*(.+)$/);
@@ -870,9 +1602,20 @@ function extractStmts(stmts) {
870
1602
  }
871
1603
  // ── Function extraction ──────────────────────────────────────
872
1604
  function extractFunction(fn, parentAnnotations) {
1605
+ const prevInFn = _inFunctionExtraction;
1606
+ _inFunctionExtraction = true;
1607
+ try {
1608
+ return extractFunctionInner(fn, parentAnnotations);
1609
+ }
1610
+ finally {
1611
+ _inFunctionExtraction = prevInFn;
1612
+ }
1613
+ }
1614
+ function extractFunctionInner(fn, parentAnnotations) {
873
1615
  // Generic bounds erasure: <T extends Base> → substitute T with Base everywhere
874
1616
  // Unbounded type params are preserved as Dafny type parameters
875
1617
  _typeParamMap = new Map();
1618
+ _reservedForCounterNames = new Set();
876
1619
  const unboundedTypeParams = [];
877
1620
  for (const tp of fn.getTypeParameters?.() ?? []) {
878
1621
  const constraint = tp.getConstraint();
@@ -888,12 +1631,11 @@ function extractFunction(fn, parentAnnotations) {
888
1631
  if (body && !Node.isBlock(body)) {
889
1632
  const expr = extractExpr(body);
890
1633
  extractedBody = [{ kind: "return", value: expr, line: body.getStartLineNumber() }];
891
- annots = parentAnnotations ?? collectAnnotations(fn);
1634
+ annots = parentAnnotations ?? collectFunctionAnnotations(fn);
892
1635
  }
893
1636
  else if (body && Node.isBlock(body)) {
894
- const bodyStmts = body.getStatements();
895
- extractedBody = extractStmts(bodyStmts);
896
- annots = collectAnnotations(fn, bodyStmts);
1637
+ extractedBody = extractStmts(body.getStatements());
1638
+ annots = collectFunctionAnnotations(fn);
897
1639
  }
898
1640
  else {
899
1641
  throw new Error(`${fn.getName?.() ?? "arrow"}: function has no body`);
@@ -920,14 +1662,29 @@ function extractFunction(fn, parentAnnotations) {
920
1662
  return { name, tsType: propType ? typeToString(propType) : "unknown" };
921
1663
  });
922
1664
  }
923
- let tsType = _eraseGenerics(p.getTypeNode()?.getText() ?? "unknown");
924
- // Optional parameters (foo?: T) need | undefined in the type string
1665
+ // Syntactic union nodes go through _tsTypeFromUnionNode so synth fires
1666
+ // and aliases are preserved. Non-union nodes use the syntactic text;
1667
+ // when no annotation is present, fall back to the computed type
1668
+ // (e.g., `eof = false` infers `boolean` from the default value).
1669
+ const tn = p.getTypeNode();
1670
+ let tsType;
1671
+ if (tn && Node.isUnionTypeNode(tn)) {
1672
+ tsType = _eraseGenerics(_tsTypeFromUnionNode(tn));
1673
+ }
1674
+ else if (tn) {
1675
+ tsType = _eraseGenerics(tn.getText());
1676
+ }
1677
+ else {
1678
+ tsType = _eraseGenerics(typeToString(p.getType()));
1679
+ }
925
1680
  if (p.hasQuestionToken())
926
1681
  tsType = `${tsType} | undefined`;
927
1682
  return [{ name: p.getName(), tsType }];
928
1683
  }),
929
1684
  returnType: (() => {
930
1685
  const node = fn.getReturnTypeNode();
1686
+ if (node && Node.isUnionTypeNode(node))
1687
+ return _eraseGenerics(_tsTypeFromUnionNode(node));
931
1688
  if (node)
932
1689
  return _eraseGenerics(node.getText());
933
1690
  const inferred = fn.getReturnType();
@@ -947,41 +1704,53 @@ function extractFunction(fn, parentAnnotations) {
947
1704
  // ── Module extraction ────────────────────────────────────────
948
1705
  export function extractModule(sourceFile) {
949
1706
  const typeDecls = [];
950
- // Parse //@ declare-type directives from file comments
1707
+ // Cross-file calls are auto-externed: ts-morph resolves the call's symbol;
1708
+ // if it's defined in a different source file we treat the symbol as opaque
1709
+ // and emit a body-less `function {:axiom}` in Dafny. Populated by
1710
+ // `extractExpr` during call extraction (only symbols *actually used* end up
1711
+ // here), deduped by qualified name.
1712
+ _externs.clear();
1713
+ // Share the module's ts-morph Project with parseTsType (scratch source file
1714
+ // for type-string parsing). Done before declare-type parsing so any
1715
+ // parseTsType call downstream uses the same Project.
1716
+ initTypeParser(sourceFile.getProject());
1717
+ // Activate the synthesized-array-union accumulator. typeToString registers
1718
+ // a discriminated-union TypeDeclInfo for any `T[] | U` shape it encounters,
1719
+ // pushing into `typeDecls` so resolve sees the synth as a regular user type.
1720
+ // Set before declare-type parsing so declare-type field types can also synth.
1721
+ _synthArrayUnions = typeDecls;
1722
+ // `//@ declare-type Name { f1: T1, ... }` — record form.
1723
+ // `//@ declare-type Name = TsType` — alias form (e.g. `Ruleset = Rule[]`).
1724
+ function parseDeclareType(body) {
1725
+ const recordMatch = body.match(/^(\w+)\s*\{(.+)\}$/);
1726
+ if (recordMatch) {
1727
+ const name = recordMatch[1];
1728
+ const fields = recordMatch[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
1729
+ const [fname, ftype] = f.split(":").map(s => s.trim());
1730
+ const synth = _synthFromTsTypeString(ftype);
1731
+ return { name: fname, tsType: synth ?? ftype };
1732
+ });
1733
+ typeDecls.push({ name, kind: "record", fields });
1734
+ return;
1735
+ }
1736
+ const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
1737
+ if (aliasMatch) {
1738
+ typeDecls.push({ name: aliasMatch[1], kind: "alias", aliasOf: aliasMatch[2].trim() });
1739
+ }
1740
+ }
951
1741
  for (const range of sourceFile.getLeadingCommentRanges()) {
952
1742
  const text = range.getText().trim();
953
- if (!text.startsWith("//@ declare-type "))
954
- continue;
955
- const body = text.slice("//@ declare-type ".length);
956
- const match = body.match(/^(\w+)\s*\{(.+)\}$/);
957
- if (!match)
958
- continue;
959
- const name = match[1];
960
- const fieldsStr = match[2];
961
- const fields = fieldsStr.split(",").map(f => f.trim()).filter(Boolean).map(f => {
962
- const [fname, ftype] = f.split(":").map(s => s.trim());
963
- return { name: fname, tsType: ftype };
964
- });
965
- typeDecls.push({ name, kind: "record", fields });
1743
+ if (text.startsWith("//@ declare-type "))
1744
+ parseDeclareType(text.slice("//@ declare-type ".length));
966
1745
  }
967
- // Also scan statement-level comments for declare-type
968
1746
  for (const stmt of sourceFile.getStatements()) {
969
1747
  for (const range of stmt.getLeadingCommentRanges()) {
970
1748
  const text = range.getText().trim();
971
- if (!text.startsWith("//@ declare-type "))
972
- continue;
973
- const body = text.slice("//@ declare-type ".length);
974
- const match = body.match(/^(\w+)\s*\{(.+)\}$/);
975
- if (!match)
976
- continue;
977
- const name = match[1];
978
- const fields = match[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
979
- const [fname, ftype] = f.split(":").map(s => s.trim());
980
- return { name: fname, tsType: ftype };
981
- });
982
- typeDecls.push({ name, kind: "record", fields });
1749
+ if (text.startsWith("//@ declare-type "))
1750
+ parseDeclareType(text.slice("//@ declare-type ".length));
983
1751
  }
984
1752
  }
1753
+ _currentSourceFile = sourceFile;
985
1754
  // Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
986
1755
  // TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
987
1756
  // the alias. We record a fingerprint (sorted property names) → alias name mapping.
@@ -1028,11 +1797,17 @@ export function extractModule(sourceFile) {
1028
1797
  // Skip huge string constants — they crash the verifier and have no verification value
1029
1798
  const initType = decl.getType();
1030
1799
  const isHugeString = (initType.isString() || initType.isStringLiteral()) && init.getText().length > 200;
1031
- if (init && !isHugeString && !Node.isArrowFunction(init)) {
1800
+ // Skip anonymous-object consts (e.g., `const Util = { dotMatch(s, p) { ... } }`).
1801
+ // ts-morph names these `__type` / `__object`; Dafny has no model for
1802
+ // object-namespace-with-methods. The methods themselves should be
1803
+ // extracted via the function path if marked `//@ verify`.
1804
+ const declTsType = typeToString(decl.getType());
1805
+ const isAnonObject = declTsType.startsWith("__");
1806
+ if (init && !isHugeString && !isAnonObject && !Node.isArrowFunction(init)) {
1032
1807
  try {
1033
1808
  constants.push({
1034
1809
  name: decl.getName(),
1035
- tsType: typeToString(decl.getType()),
1810
+ tsType: declTsType,
1036
1811
  value: extractExpr(init),
1037
1812
  });
1038
1813
  }
@@ -1060,6 +1835,42 @@ export function extractModule(sourceFile) {
1060
1835
  }
1061
1836
  }
1062
1837
  }
1838
+ // `//@ extern` on a same-file declaration: register the function as an
1839
+ // opaque axiom (signature + any //@ requires/ensures), skip its body. Use
1840
+ // when the function is outside LS's verification model — e.g., wraps a
1841
+ // regex — but its callers should still be verifiable against an
1842
+ // uninterpreted predicate. Parallel to auto-extern for cross-file calls,
1843
+ // and emitted the same way (`function {:axiom} foo(...)` in Dafny).
1844
+ function hasExtern(f) {
1845
+ if (f.node.getFullText().includes('//@ extern'))
1846
+ return true;
1847
+ if (f.parentStmt) {
1848
+ for (const r of f.parentStmt.getLeadingCommentRanges()) {
1849
+ if (r.getText().includes('//@ extern'))
1850
+ return true;
1851
+ }
1852
+ }
1853
+ return false;
1854
+ }
1855
+ for (const f of allFns) {
1856
+ if (!hasExtern(f))
1857
+ continue;
1858
+ if (_externs.has(f.name))
1859
+ continue;
1860
+ const sig = f.node.getType().getCallSignatures()[0];
1861
+ if (!sig)
1862
+ continue;
1863
+ const typeParams = sig.getTypeParameters().map(tp => tp.getText());
1864
+ const params = sig.getParameters().map(p => ({
1865
+ name: p.getName(),
1866
+ tsType: p.getTypeAtLocation(f.node).getText(),
1867
+ }));
1868
+ const returnType = sig.getReturnType().getText();
1869
+ const annots = collectFunctionAnnotations(f.node);
1870
+ const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
1871
+ const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
1872
+ _externs.set(f.name, { qualified: f.name, flat: f.name, typeParams, params, returnType, requires, ensures });
1873
+ }
1063
1874
  // If any function has //@ verify, only extract those (brownfield mode).
1064
1875
  // For expression-body arrows, //@ verify may be on the parent variable statement.
1065
1876
  function hasVerify(f) {
@@ -1073,8 +1884,9 @@ export function extractModule(sourceFile) {
1073
1884
  }
1074
1885
  return false;
1075
1886
  }
1076
- const hasVerifyDirective = allFns.some(hasVerify);
1077
- const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
1887
+ const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
1888
+ const nonExternFns = allFns.filter(f => !hasExtern(f));
1889
+ const fnsToExtract = hasVerifyDirective ? nonExternFns.filter(hasVerify) : nonExternFns;
1078
1890
  const functions = fnsToExtract.map(f => {
1079
1891
  // For expression-body arrows, annotations come from the parent variable statement
1080
1892
  const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
@@ -1082,61 +1894,97 @@ export function extractModule(sourceFile) {
1082
1894
  raw.name = f.name; // use the const name, not "<anonymous>"
1083
1895
  return raw;
1084
1896
  });
1085
- // Resolve imported type names referenced in function signatures and type fields
1897
+ // Resolve type references in function signatures via ts-morph's type
1898
+ // checker — walk the TypeNode tree, resolve each TypeReferenceNode to its
1899
+ // declaration through symbol resolution, recurse. This is the principled
1900
+ // replacement for an earlier regex-based walker that extracted identifier
1901
+ // names from tsType strings and searched the whole project by name — that
1902
+ // approach had ambiguous resolution (name collisions in generated files
1903
+ // could shadow what the user actually imported).
1086
1904
  const knownTypeNames = new Set(typeDecls.map(d => d.name));
1087
- const primitives = new Set(["number", "string", "boolean", "void", "unknown", "undefined"]);
1088
- const builtinTypes = new Set(["Map", "Set", "Array", "Record", "Promise", "Date", "RegExp", "Error"]);
1089
- function resolveTypeName(name) {
1090
- if (knownTypeNames.has(name) || primitives.has(name) || builtinTypes.has(name))
1905
+ function resolveTypeNodeRefs(tn) {
1906
+ if (!tn)
1091
1907
  return;
1092
- for (const sf2 of sourceFile.getProject().getSourceFiles()) {
1093
- for (const stmt of sf2.getStatements()) {
1094
- if (Node.isTypeAliasDeclaration(stmt) && stmt.getName() === name) {
1095
- const extra = [];
1096
- const info = extractTypeDecl(stmt, extra);
1097
- typeDecls.push(...extra);
1098
- if (info) {
1099
- typeDecls.push(info);
1100
- knownTypeNames.add(name);
1908
+ if (Node.isTypeReference(tn)) {
1909
+ const sym = tn.getTypeName().getSymbol();
1910
+ if (sym) {
1911
+ for (const d of sym.getDeclarations()) {
1912
+ // Skip ambient/built-in declarations: `.d.ts` files (lib.dom.d.ts,
1913
+ // node_modules typings) describe runtime/host types, not user code
1914
+ // — backends map these directly (`Map<K,V>` → `map<K,V>`) without
1915
+ // needing the interface dump.
1916
+ if (d.getSourceFile().getFilePath().endsWith(".d.ts"))
1917
+ continue;
1918
+ let added = null;
1919
+ if (Node.isTypeAliasDeclaration(d) && !knownTypeNames.has(d.getName())) {
1920
+ const extra = [];
1921
+ added = extractTypeDecl(d, extra);
1922
+ typeDecls.push(...extra);
1923
+ if (added) {
1924
+ typeDecls.push(added);
1925
+ knownTypeNames.add(d.getName());
1926
+ }
1101
1927
  }
1102
- }
1103
- if (Node.isInterfaceDeclaration(stmt) && stmt.getName() === name && !knownTypeNames.has(name)) {
1104
- const extra = [];
1105
- const info = extractInterface(stmt, extra);
1106
- typeDecls.push(...extra);
1107
- if (info) {
1108
- typeDecls.push(info);
1109
- knownTypeNames.add(name);
1928
+ else if (Node.isInterfaceDeclaration(d) && !knownTypeNames.has(d.getName())) {
1929
+ const extra = [];
1930
+ added = extractInterface(d, extra);
1931
+ typeDecls.push(...extra);
1932
+ if (added) {
1933
+ typeDecls.push(added);
1934
+ knownTypeNames.add(d.getName());
1935
+ }
1936
+ }
1937
+ // Recurse into the newly-added declaration's TypeNodes — preferring
1938
+ // the actual AST over re-parsing tsType strings.
1939
+ if (added) {
1940
+ if (Node.isTypeAliasDeclaration(d))
1941
+ resolveTypeNodeRefs(d.getTypeNode());
1942
+ else if (Node.isInterfaceDeclaration(d)) {
1943
+ for (const m of d.getProperties())
1944
+ resolveTypeNodeRefs(m.getTypeNode());
1945
+ }
1110
1946
  }
1111
1947
  }
1112
1948
  }
1113
- if (knownTypeNames.has(name))
1114
- break;
1949
+ for (const a of tn.getTypeArguments())
1950
+ resolveTypeNodeRefs(a);
1951
+ return;
1115
1952
  }
1116
- // Recursively resolve types referenced by the newly added type's fields
1117
- const decl = typeDecls.find(d => d.name === name);
1118
- if (decl?.fields) {
1119
- for (const f of decl.fields) {
1120
- for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1121
- resolveTypeName(m[1]);
1122
- }
1953
+ if (Node.isUnionTypeNode(tn) || Node.isIntersectionTypeNode(tn)) {
1954
+ for (const arm of tn.getTypeNodes())
1955
+ resolveTypeNodeRefs(arm);
1956
+ return;
1123
1957
  }
1124
- if (decl?.variants) {
1125
- for (const v of decl.variants) {
1126
- for (const f of v.fields) {
1127
- for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1128
- resolveTypeName(m[1]);
1129
- }
1130
- }
1958
+ if (Node.isArrayTypeNode(tn)) {
1959
+ resolveTypeNodeRefs(tn.getElementTypeNode());
1960
+ return;
1131
1961
  }
1132
- }
1133
- for (const fn of functions) {
1134
- const refs = [fn.returnType, ...fn.params.map(p => p.tsType)];
1135
- for (const ref of refs) {
1136
- for (const m of ref.matchAll(/\b([A-Z]\w*)\b/g))
1137
- resolveTypeName(m[1]);
1962
+ if (Node.isTupleTypeNode(tn)) {
1963
+ for (const el of tn.getElements())
1964
+ resolveTypeNodeRefs(el);
1965
+ return;
1966
+ }
1967
+ if (Node.isParenthesizedTypeNode(tn)) {
1968
+ resolveTypeNodeRefs(tn.getTypeNode());
1969
+ return;
1970
+ }
1971
+ if (Node.isFunctionTypeNode(tn)) {
1972
+ for (const p of tn.getParameters())
1973
+ resolveTypeNodeRefs(p.getTypeNode());
1974
+ resolveTypeNodeRefs(tn.getReturnTypeNode());
1975
+ return;
1976
+ }
1977
+ if (Node.isTypeLiteral(tn)) {
1978
+ for (const m of tn.getProperties())
1979
+ resolveTypeNodeRefs(m.getTypeNode());
1980
+ return;
1138
1981
  }
1139
1982
  }
1983
+ for (const f of fnsToExtract) {
1984
+ for (const p of f.node.getParameters())
1985
+ resolveTypeNodeRefs(p.getTypeNode());
1986
+ resolveTypeNodeRefs(f.node.getReturnTypeNode());
1987
+ }
1140
1988
  // Resolve union param types: A | B → intersection of fields
1141
1989
  const typeDeclMap = new Map(typeDecls.map(d => [d.name, d]));
1142
1990
  for (const fn of functions) {
@@ -1176,7 +2024,8 @@ export function extractModule(sourceFile) {
1176
2024
  function collectNames(stmts) {
1177
2025
  for (const s of stmts) {
1178
2026
  if (s.kind === "let") {
1179
- referencedNames.add(s.tsType);
2027
+ if (s.tsType)
2028
+ referencedNames.add(s.tsType);
1180
2029
  collectNamesExpr(s.init);
1181
2030
  }
1182
2031
  if (s.kind === "assign") {
@@ -1285,6 +2134,12 @@ export function extractModule(sourceFile) {
1285
2134
  const alias = t.getAliasSymbol();
1286
2135
  if (alias) {
1287
2136
  const aliasName = alias.getName();
2137
+ // A //@ declare-type already defines the verification surface for this
2138
+ // alias. Don't walk the imported structure — its union members would
2139
+ // leak unrelated variant datatypes (and the types those reference, which
2140
+ // we don't model) into the output. See examples/declareTypeShadow.ts.
2141
+ if (declaredNames.has(aliasName))
2142
+ return;
1288
2143
  if (!knownTypes.has(aliasName) && !builtins.has(aliasName) && !aliasName.startsWith("__")) {
1289
2144
  const decls = alias.getDeclarations();
1290
2145
  if (decls.length > 0 && Node.isTypeAliasDeclaration(decls[0])) {
@@ -1331,9 +2186,19 @@ export function extractModule(sourceFile) {
1331
2186
  }
1332
2187
  }
1333
2188
  }
1334
- for (const f of fnsToExtract) {
1335
- for (const p of f.node.getParameters())
2189
+ for (let i = 0; i < fnsToExtract.length; i++) {
2190
+ const f = fnsToExtract[i];
2191
+ const fn = functions[i];
2192
+ // Skip params whose TS type was overridden by `//@ type <param> <Override>`
2193
+ // — the verification works against the override, so cross-file resolving
2194
+ // the original type pulls in unused datatypes (often with unsupported
2195
+ // shapes the override exists precisely to avoid).
2196
+ const overriddenNames = new Set(fn.typeAnnotations.map(a => a.name));
2197
+ for (const p of f.node.getParameters()) {
2198
+ if (overriddenNames.has(p.getName()))
2199
+ continue;
1336
2200
  resolveType(p.getType(), p);
2201
+ }
1337
2202
  }
1338
2203
  // Resolve anonymous object return types into synthetic named types
1339
2204
  for (let i = 0; i < fnsToExtract.length; i++) {
@@ -1343,9 +2208,15 @@ export function extractModule(sourceFile) {
1343
2208
  // Prefer alias symbol (named type aliases) over underlying object symbol (__type)
1344
2209
  const aliasSym = retType.getAliasSymbol();
1345
2210
  if (aliasSym && !aliasSym.getName().startsWith("__")) {
1346
- // Named type alias — resolve it instead of generating a synthetic name
1347
- resolveType(retType, f.node);
1348
2211
  const aliasName = aliasSym.getName();
2212
+ // If the alias name is already locally declared (e.g. via `//@ declare-type
2213
+ // Ruleset = Rule[]`), don't unwrap further — the declared shape is the
2214
+ // verification surface, and walking the original cross-file alias pulls
2215
+ // unused datatypes (often with unsupported shapes) into the gen.
2216
+ if (!knownTypes.has(aliasName)) {
2217
+ // Named type alias — resolve it instead of generating a synthetic name
2218
+ resolveType(retType, f.node);
2219
+ }
1349
2220
  if (knownTypes.has(aliasName)) {
1350
2221
  // Preserve type arguments: Result<Model, Err> not just Result
1351
2222
  const typeArgs = retType.getAliasTypeArguments();
@@ -1355,27 +2226,58 @@ export function extractModule(sourceFile) {
1355
2226
  }
1356
2227
  continue;
1357
2228
  }
2229
+ // Inline-anon return type — bare `{...}` (no union wrapping).
2230
+ //
2231
+ // ts-morph's `fn.getReturnType()` returns the COMPUTED type, which strips
2232
+ // `| null` in non-strict mode (and sometimes `| undefined`). The source
2233
+ // annotation, however, encodes the user's actual intent. Check the
2234
+ // already-extracted `fn.returnType` string for nullish suffixes to detect
2235
+ // the wrap-in-Optional case.
2236
+ let innerType = null;
2237
+ let wrapOptional = false;
2238
+ const sourceReturnText = fn.returnType ?? "";
2239
+ const sourceHadNullish = / \| (null|undefined)$/.test(sourceReturnText)
2240
+ || sourceReturnText.includes(" | null ") || sourceReturnText.includes(" | undefined ")
2241
+ || sourceReturnText.includes(" | null|") || sourceReturnText.includes(" | undefined|");
1358
2242
  const sym = retType.getSymbol();
1359
2243
  if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
2244
+ innerType = retType;
2245
+ if (sourceHadNullish)
2246
+ wrapOptional = true;
2247
+ }
2248
+ else if (retType.isUnion()) {
2249
+ const arms = retType.getUnionTypes();
2250
+ const nullish = arms.filter(t => t.isNull() || t.isUndefined());
2251
+ const others = arms.filter(t => !t.isNull() && !t.isUndefined());
2252
+ if (nullish.length >= 1 && others.length === 1) {
2253
+ const onlyOther = others[0];
2254
+ const otherSym = onlyOther.getSymbol();
2255
+ if (otherSym?.getName() === "__type" && onlyOther.isObject() && !onlyOther.isArray()) {
2256
+ innerType = onlyOther;
2257
+ wrapOptional = true;
2258
+ }
2259
+ }
2260
+ }
2261
+ if (innerType) {
1360
2262
  // Try typeToString first — it resolves collapsed single-variant unions
1361
- const resolved = typeToString(retType);
2263
+ const resolved = typeToString(innerType);
1362
2264
  if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
1363
- fn.returnType = resolved;
2265
+ fn.returnType = wrapOptional ? `${resolved} | undefined` : resolved;
1364
2266
  continue;
1365
2267
  }
1366
2268
  const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
1367
2269
  if (!knownTypes.has(synName)) {
1368
2270
  const extra = [];
1369
- const info = extractRecord(synName, retType, f.node, undefined, extra);
2271
+ const info = extractRecord(synName, innerType, f.node, undefined, extra);
1370
2272
  if (info) {
1371
2273
  typeDecls.push(...extra);
1372
2274
  typeDecls.push(info);
1373
2275
  knownTypes.add(synName);
1374
2276
  }
1375
2277
  }
1376
- fn.returnType = synName;
2278
+ fn.returnType = wrapOptional ? `${synName} | undefined` : synName;
1377
2279
  // Also resolve imported types referenced in the return type's fields
1378
- for (const prop of retType.getProperties()) {
2280
+ for (const prop of innerType.getProperties()) {
1379
2281
  resolveType(prop.getTypeAtLocation(f.node), f.node);
1380
2282
  }
1381
2283
  }
@@ -1397,9 +2299,13 @@ export function extractModule(sourceFile) {
1397
2299
  }
1398
2300
  classes.push({ name: cls.getName() ?? "Anonymous", fields, methods });
1399
2301
  }
2302
+ // Clear the synth-union accumulator so typeToString reverts to plain
2303
+ // union stringification outside of an extractModule call.
2304
+ _synthArrayUnions = null;
1400
2305
  return {
1401
2306
  file: sourceFile.getFilePath(),
1402
2307
  typeDecls,
2308
+ externs: Array.from(_externs.values()),
1403
2309
  constants,
1404
2310
  functions,
1405
2311
  classes,