lemmascript 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,21 @@ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
8
8
  // ── Expression extraction ────────────────────────────────────
9
9
  /** When set, calls whose function/method name matches this key are replaced with havoc. */
10
10
  let _havocKey = null;
11
+ /** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
12
+ let _typeParamMap = new Map();
13
+ function _eraseGenerics(tsType) {
14
+ if (_typeParamMap.size === 0)
15
+ return tsType;
16
+ if (tsType.includes(" | ")) {
17
+ const arms = [...new Set(tsType.split(" | ").map(a => _eraseGenerics(a.trim())))];
18
+ return arms.length === 1 ? arms[0] : arms.join(" | ");
19
+ }
20
+ if (tsType.endsWith("[]"))
21
+ return _eraseGenerics(tsType.slice(0, -2)) + "[]";
22
+ if (_typeParamMap.has(tsType))
23
+ return _typeParamMap.get(tsType);
24
+ return tsType;
25
+ }
11
26
  function extractExpr(node) {
12
27
  // Havoc key matching: replace matching calls with havoc expression
13
28
  if (_havocKey && Node.isCallExpression(node)) {
@@ -63,9 +78,24 @@ function extractExpr(node) {
63
78
  if (node.getKind() === SyntaxKind.ThisKeyword) {
64
79
  return { kind: "var", name: "this" };
65
80
  }
66
- // Property access: x.foo
81
+ // Property access: x.foo or x?.foo
67
82
  if (Node.isPropertyAccessExpression(node)) {
68
- return { kind: "field", obj: extractExpr(node.getExpression()), field: node.getName() };
83
+ const obj = extractExpr(node.getExpression());
84
+ const field = node.getName();
85
+ // Optional chaining on data access: x?.foo → x !== undefined ? x.foo : undefined
86
+ // Skip if this is a method call (parent is CallExpression) — handled differently.
87
+ if (node.hasQuestionDotToken()) {
88
+ const parent = node.getParent();
89
+ const isMethodCall = parent && Node.isCallExpression(parent) && parent.getExpression() === node;
90
+ if (!isMethodCall) {
91
+ return { kind: "conditional",
92
+ cond: { kind: "binop", op: "!==", left: obj, right: { kind: "var", name: "undefined" } },
93
+ then: { kind: "field", obj, field },
94
+ else: { kind: "var", name: "undefined" },
95
+ };
96
+ }
97
+ }
98
+ return { kind: "field", obj, field };
69
99
  }
70
100
  // Element access: arr[i]
71
101
  if (Node.isElementAccessExpression(node)) {
@@ -129,15 +159,38 @@ function extractExpr(node) {
129
159
  }
130
160
  throw new Error(`Unsupported arrow function body: ${node.getText().slice(0, 80)}`);
131
161
  }
132
- // Array literal: [a, b, c] → arrayLiteral, [...arr, elem]push(arr, elem)
162
+ // Array literal: [a, b, c] → arrayLiteral, with spreadsconcatenation
133
163
  if (Node.isArrayLiteralExpression(node)) {
134
164
  const elems = node.getElements();
135
- // [...arr, elem] push(arr, elem)
136
- if (elems.length === 2 && Node.isSpreadElement(elems[0])) {
137
- return { kind: "call", fn: { kind: "field", obj: extractExpr(elems[0].getExpression()), field: "push" }, args: [extractExpr(elems[1])] };
165
+ const hasSpread = elems.some(e => Node.isSpreadElement(e));
166
+ if (!hasSpread) {
167
+ return { kind: "arrayLiteral", elems: elems.map(e => extractExpr(e)) };
168
+ }
169
+ // Build concatenation: [a, ...b, c] → [a] + b + [c]
170
+ // Group consecutive non-spread elements into array literals, spreads are bare
171
+ const segments = [];
172
+ let currentLiterals = [];
173
+ for (const e of elems) {
174
+ if (Node.isSpreadElement(e)) {
175
+ if (currentLiterals.length > 0) {
176
+ segments.push({ kind: "arrayLiteral", elems: currentLiterals });
177
+ currentLiterals = [];
178
+ }
179
+ segments.push(extractExpr(e.getExpression()));
180
+ }
181
+ else {
182
+ currentLiterals.push(extractExpr(e));
183
+ }
138
184
  }
139
- // [a, b, c] or [] → arrayLiteral
140
- return { kind: "arrayLiteral", elems: elems.map(e => extractExpr(e)) };
185
+ if (currentLiterals.length > 0) {
186
+ segments.push({ kind: "arrayLiteral", elems: currentLiterals });
187
+ }
188
+ // Fold segments with arrayConcat
189
+ let result = segments[0];
190
+ for (let i = 1; i < segments.length; i++) {
191
+ result = { kind: "binop", op: "arrayConcat", left: result, right: segments[i] };
192
+ }
193
+ return result;
141
194
  }
142
195
  // Object literal: { res: true, done: false } or { ...obj, res: true }
143
196
  if (Node.isObjectLiteralExpression(node)) {
@@ -172,14 +225,31 @@ function extractExpr(node) {
172
225
  const name = node.getExpression().getText();
173
226
  if (name === "Map" || name === "Set") {
174
227
  const typeArgs = node.getTypeArguments();
228
+ // Use explicit type args if present, otherwise infer from TS type system
175
229
  const tsType = typeArgs && typeArgs.length > 0
176
230
  ? `${name}<${typeArgs.map(t => t.getText()).join(", ")}>`
177
- : name;
231
+ : _eraseGenerics(typeToString(node.getType()));
178
232
  const args = node.getArguments();
179
- // new Map(arr.map(fn)) — map-from-array constructor
233
+ // new Map(source) — clone existing map or build from entries
180
234
  if (name === "Map" && args && args.length === 1) {
235
+ const argType = args[0].getType();
236
+ const argSymbol = argType.getSymbol()?.getName() ?? argType.getAliasSymbol()?.getName();
237
+ if (argSymbol === "Map") {
238
+ // new Map(existingMap) — identity (Dafny maps are value types)
239
+ return extractExpr(args[0]);
240
+ }
241
+ // new Map(entries) — map-from-array constructor
181
242
  return { kind: "call", fn: { kind: "var", name: "__mapFromArray" }, args: [extractExpr(args[0])] };
182
243
  }
244
+ // new Set([a, b, c]) — set with initial elements
245
+ if (name === "Set" && args && args.length === 1) {
246
+ const arg = args[0];
247
+ if (Node.isArrayLiteralExpression(arg)) {
248
+ return { kind: "emptyCollection", collectionType: "Set", tsType, initElems: arg.getElements().map(e => extractExpr(e)) };
249
+ }
250
+ // new Set(existingSet) — pass through
251
+ return extractExpr(arg);
252
+ }
183
253
  return { kind: "emptyCollection", collectionType: name, tsType };
184
254
  }
185
255
  }
@@ -187,6 +257,10 @@ function extractExpr(node) {
187
257
  if (Node.isAsExpression(node)) {
188
258
  return extractExpr(node.getExpression());
189
259
  }
260
+ // null → undefined (both map to None in backends)
261
+ if (Node.isNullLiteral(node)) {
262
+ return { kind: "var", name: "undefined" };
263
+ }
190
264
  throw new Error(`Unsupported expression: ${node.getText()}`);
191
265
  }
192
266
  // ── Annotation parsing ───────────────────────────────────────
@@ -216,13 +290,15 @@ function collectAnnotations(node, body) {
216
290
  return own;
217
291
  }
218
292
  // ── Type declaration extraction ──────────────────────────────
219
- function extractTypeDecl(decl) {
293
+ function extractTypeDecl(decl, extraDecls) {
220
294
  const name = decl.getName();
221
295
  const type = decl.getType();
296
+ const typeParams = decl.getTypeParameters().map(tp => tp.getName());
297
+ const tpField = typeParams.length > 0 ? typeParams : undefined;
222
298
  if (type.isUnion()) {
223
299
  const members = type.getUnionTypes();
224
300
  if (members.every(m => m.isStringLiteral())) {
225
- return { name, kind: "string-union", values: members.map(m => m.getLiteralValue()) };
301
+ return { name, typeParams: tpField, kind: "string-union", values: members.map(m => m.getLiteralValue()) };
226
302
  }
227
303
  if (members.every(m => m.isObject())) {
228
304
  const discriminant = findDiscriminant(members);
@@ -230,7 +306,8 @@ function extractTypeDecl(decl) {
230
306
  const variants = members.map(m => {
231
307
  const tagProp = m.getProperty(discriminant);
232
308
  const tagType = tagProp?.getTypeAtLocation(decl);
233
- const tag = tagType?.getLiteralValue();
309
+ const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue())
310
+ : tagType?.getText() ?? "unknown";
234
311
  const fields = [];
235
312
  for (const prop of m.getProperties()) {
236
313
  if (prop.getName() === discriminant)
@@ -239,15 +316,19 @@ function extractTypeDecl(decl) {
239
316
  }
240
317
  return { name: tag, fields };
241
318
  });
242
- return { name, kind: "discriminated-union", discriminant, variants };
319
+ return { name, typeParams: tpField, kind: "discriminated-union", discriminant, variants };
243
320
  }
244
321
  }
245
322
  }
246
- if (type.isObject())
247
- return extractRecord(name, type, decl);
323
+ if (type.isObject() || type.isIntersection())
324
+ return extractRecord(name, type, decl, undefined, extraDecls);
325
+ // Primitive type alias: type TaskId = number → alias
326
+ const tsType = typeToString(type);
327
+ if (tsType !== name)
328
+ return { name, kind: "alias", aliasOf: tsType };
248
329
  return null;
249
330
  }
250
- function extractInterface(decl) {
331
+ function extractInterface(decl, extraDecls) {
251
332
  // Collect field type overrides from trailing //@ type annotations
252
333
  const overrides = new Map();
253
334
  for (const member of decl.getMembers()) {
@@ -258,16 +339,44 @@ function extractInterface(decl) {
258
339
  overrides.set(member.getName(), match[1]);
259
340
  }
260
341
  }
261
- return extractRecord(decl.getName(), decl.getType(), decl, overrides);
342
+ return extractRecord(decl.getName(), decl.getType(), decl, overrides, extraDecls);
262
343
  }
263
- function extractRecord(name, type, locationNode, overrides) {
344
+ function extractRecord(name, type, locationNode, overrides, extraDecls) {
264
345
  const props = type.getProperties();
265
346
  if (props.length === 0)
266
347
  return null;
267
348
  const fields = [];
268
349
  for (const prop of props) {
269
350
  const override = overrides?.get(prop.getName());
270
- const tsType = override ?? typeToString(prop.getTypeAtLocation(locationNode));
351
+ if (override) {
352
+ fields.push({ name: prop.getName(), tsType: override });
353
+ continue;
354
+ }
355
+ const propType = prop.getTypeAtLocation(locationNode);
356
+ let tsType = typeToString(propType);
357
+ // Inline anonymous object types: ts-morph names them __type.
358
+ // Generate a synthetic named record and reference it by name instead.
359
+ if (extraDecls && tsType.includes("__type")) {
360
+ let innerType = propType;
361
+ let isOptional = false;
362
+ if (propType.isUnion()) {
363
+ const uTypes = propType.getUnionTypes();
364
+ const nonUndef = uTypes.filter(t => !t.isUndefined());
365
+ if (uTypes.some(t => t.isUndefined()) && nonUndef.length === 1) {
366
+ innerType = nonUndef[0];
367
+ isOptional = true;
368
+ }
369
+ }
370
+ if (innerType.isObject() && !innerType.isArray()) {
371
+ const fName = prop.getName();
372
+ const synName = name + fName.charAt(0).toUpperCase() + fName.slice(1);
373
+ const extracted = extractRecord(synName, innerType, locationNode, undefined, extraDecls);
374
+ if (extracted) {
375
+ extraDecls.push(extracted);
376
+ tsType = isOptional ? `${synName} | undefined` : synName;
377
+ }
378
+ }
379
+ }
271
380
  fields.push({ name: prop.getName(), tsType });
272
381
  }
273
382
  return { name, kind: "record", fields };
@@ -283,7 +392,7 @@ function findDiscriminant(members) {
283
392
  if (!p)
284
393
  return false;
285
394
  const t = p.getDeclarations()[0] ? p.getTypeAtLocation(p.getDeclarations()[0]) : null;
286
- return t?.isStringLiteral() ?? false;
395
+ return (t?.isStringLiteral() || t?.isBooleanLiteral()) ?? false;
287
396
  });
288
397
  if (allHave)
289
398
  return name;
@@ -310,7 +419,11 @@ function typeToString(type) {
310
419
  return name;
311
420
  }
312
421
  if (type.isUnion()) {
313
- return type.getUnionTypes().map(typeToString).join(" | ");
422
+ const parts = [...new Set(type.getUnionTypes().map(typeToString))];
423
+ return parts.join(" | ");
424
+ }
425
+ if (type.isTuple()) {
426
+ return `[${type.getTupleElements().map(t => typeToString(t)).join(", ")}]`;
314
427
  }
315
428
  if (type.isArray()) {
316
429
  const elem = type.getArrayElementTypeOrThrow();
@@ -366,18 +479,44 @@ function extractStmts(stmts) {
366
479
  for (const s of stmts) {
367
480
  const line = s.getStartLineNumber();
368
481
  // Ghost annotations from leading comments → inject before this statement
369
- result.push(...parseSpecComments(s.getLeadingCommentRanges(), line));
482
+ const leadingComments = s.getLeadingCommentRanges();
483
+ result.push(...parseSpecComments(leadingComments, line));
484
+ // //@ skip — omit this statement from the verification model
485
+ if (leadingComments.some(r => r.getText().trim() === "//@ skip")) {
486
+ continue;
487
+ }
370
488
  if (Node.isVariableStatement(s)) {
371
489
  const havocMatch = s.getLeadingCommentRanges()
372
- .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s+(\S+))?$/))
490
+ .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+)|(?:\s+(\S+)))?$/))
373
491
  .find(m => m !== null);
374
- const havocKey = havocMatch?.[1] ?? null;
492
+ const havocType = havocMatch?.[1]?.trim() ?? null; // //@ havoc : Type
493
+ const havocKey = havocMatch?.[2] ?? null; // //@ havoc key
375
494
  const isHavoc = !!havocMatch;
376
495
  for (const d of s.getDeclarations()) {
496
+ // Havoc on destructuring: emit each named binding as a separate havoced variable
497
+ const nameNode = d.getNameNode();
498
+ if (isHavoc && !havocKey && Node.isObjectBindingPattern(nameNode)) {
499
+ const rhsType = d.getType();
500
+ const havocTypes = havocType?.split(",").map(t => t.trim()) ?? [];
501
+ const elements = nameNode.getElements();
502
+ for (let ei = 0; ei < elements.length; ei++) {
503
+ const el = elements[ei];
504
+ const name = el.getName();
505
+ const propType = rhsType.getProperty(name)?.getTypeAtLocation(d);
506
+ const tsType = havocTypes[ei] ?? (propType ? _eraseGenerics(typeToString(propType)) : "unknown");
507
+ result.push({
508
+ kind: "let", name, mutable: s.getDeclarationKind() === "let",
509
+ tsType, init: { kind: "havoc", tsType }, line,
510
+ });
511
+ }
512
+ continue;
513
+ }
377
514
  const declType = d.getType();
378
515
  let init;
379
516
  if (isHavoc && !havocKey) {
380
- init = { kind: "havoc", tsType: typeToString(declType) };
517
+ // Use explicit type from //@ havoc : Type, or initializer type (pre-cast), or declared type
518
+ const initType = d.getInitializer()?.getType();
519
+ init = { kind: "havoc", tsType: havocType ?? typeToString(initType ?? declType) };
381
520
  }
382
521
  else {
383
522
  const initializer = d.getInitializer();
@@ -389,7 +528,7 @@ function extractStmts(stmts) {
389
528
  kind: "let",
390
529
  name: d.getName(),
391
530
  mutable: s.getDeclarationKind() === "let",
392
- tsType: typeToString(declType),
531
+ tsType: havocType ?? _eraseGenerics(typeToString(declType)),
393
532
  init,
394
533
  line,
395
534
  });
@@ -419,7 +558,9 @@ function extractStmts(stmts) {
419
558
  const nameNode = decl?.getNameNode();
420
559
  if (nameNode && Node.isArrayBindingPattern(nameNode)) {
421
560
  for (const elem of nameNode.getElements()) {
422
- if (Node.isBindingElement(elem))
561
+ if (Node.isOmittedExpression(elem))
562
+ names.push("_");
563
+ else if (Node.isBindingElement(elem))
423
564
  names.push(elem.getNameNode().getText());
424
565
  }
425
566
  }
@@ -535,6 +676,11 @@ function extractStmts(stmts) {
535
676
  result.push({ kind: "throw", line });
536
677
  continue;
537
678
  }
679
+ // Block statement: { ... } — flatten into parent
680
+ if (Node.isBlock(s)) {
681
+ result.push(...extractStmts(s.getStatements()));
682
+ continue;
683
+ }
538
684
  throw new Error(`Unsupported statement at line ${line}: ${s.getText().slice(0, 80)}`);
539
685
  }
540
686
  // Ghost comments after the last statement (before closing brace) appear as sibling trivia nodes
@@ -568,12 +714,35 @@ function extractStmts(stmts) {
568
714
  return result;
569
715
  }
570
716
  // ── Function extraction ──────────────────────────────────────
571
- function extractFunction(fn) {
717
+ function extractFunction(fn, parentAnnotations) {
718
+ // Generic bounds erasure: <T extends Base> → substitute T with Base everywhere
719
+ // Unbounded type params are preserved as Dafny type parameters
720
+ _typeParamMap = new Map();
721
+ const unboundedTypeParams = [];
722
+ for (const tp of fn.getTypeParameters?.() ?? []) {
723
+ const constraint = tp.getConstraint();
724
+ if (constraint)
725
+ _typeParamMap.set(tp.getName(), constraint.getText());
726
+ else
727
+ unboundedTypeParams.push(tp.getName());
728
+ }
572
729
  const body = fn.getBody();
573
- if (!body || !Node.isBlock(body))
574
- throw new Error(`${fn.getName?.() ?? "arrow"}: function body is not a block`);
575
- const bodyStmts = body.getStatements();
576
- const annots = collectAnnotations(fn, bodyStmts);
730
+ // Expression-body arrow: wrap in implicit return
731
+ let extractedBody;
732
+ let annots;
733
+ if (body && !Node.isBlock(body)) {
734
+ const expr = extractExpr(body);
735
+ extractedBody = [{ kind: "return", value: expr, line: body.getStartLineNumber() }];
736
+ annots = parentAnnotations ?? collectAnnotations(fn);
737
+ }
738
+ else if (body && Node.isBlock(body)) {
739
+ const bodyStmts = body.getStatements();
740
+ extractedBody = extractStmts(bodyStmts);
741
+ annots = collectAnnotations(fn, bodyStmts);
742
+ }
743
+ else {
744
+ throw new Error(`${fn.getName?.() ?? "arrow"}: function has no body`);
745
+ }
577
746
  const typeAnnotations = [];
578
747
  for (const a of annots) {
579
748
  if (a.kind === "type") {
@@ -584,27 +753,94 @@ function extractFunction(fn) {
584
753
  }
585
754
  return {
586
755
  name: fn.getName?.() ?? "<anonymous>",
587
- params: fn.getParameters().map(p => ({ name: p.getName(), tsType: p.getTypeNode()?.getText() ?? "unknown" })),
588
- returnType: fn.getReturnTypeNode()?.getText() ?? "unknown",
756
+ typeParams: unboundedTypeParams,
757
+ params: fn.getParameters().flatMap(p => {
758
+ // Flatten destructured object params into individual params
759
+ const nameNode = p.getNameNode();
760
+ if (Node.isObjectBindingPattern(nameNode)) {
761
+ const type = p.getType();
762
+ return nameNode.getElements().map(el => {
763
+ const name = el.getName();
764
+ const propType = type.getProperty(name)?.getTypeAtLocation(p);
765
+ return { name, tsType: propType ? typeToString(propType) : "unknown" };
766
+ });
767
+ }
768
+ let tsType = _eraseGenerics(p.getTypeNode()?.getText() ?? "unknown");
769
+ // Optional parameters (foo?: T) need | undefined in the type string
770
+ if (p.hasQuestionToken())
771
+ tsType = `${tsType} | undefined`;
772
+ return [{ name: p.getName(), tsType }];
773
+ }),
774
+ returnType: (() => {
775
+ const node = fn.getReturnTypeNode();
776
+ if (node)
777
+ return _eraseGenerics(node.getText());
778
+ const inferred = fn.getReturnType();
779
+ if (inferred.isAny())
780
+ return "unknown";
781
+ return _eraseGenerics(typeToString(inferred));
782
+ })(),
589
783
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
590
784
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
591
785
  typeAnnotations,
592
- body: extractStmts(bodyStmts),
786
+ body: extractedBody,
593
787
  line: fn.getStartLineNumber(),
594
788
  };
595
789
  }
596
790
  // ── Module extraction ────────────────────────────────────────
597
791
  export function extractModule(sourceFile) {
598
792
  const typeDecls = [];
793
+ // Parse //@ declare-type directives from file comments
794
+ for (const range of sourceFile.getLeadingCommentRanges()) {
795
+ const text = range.getText().trim();
796
+ if (!text.startsWith("//@ declare-type "))
797
+ continue;
798
+ const body = text.slice("//@ declare-type ".length);
799
+ const match = body.match(/^(\w+)\s*\{(.+)\}$/);
800
+ if (!match)
801
+ continue;
802
+ const name = match[1];
803
+ const fieldsStr = match[2];
804
+ const fields = fieldsStr.split(",").map(f => f.trim()).filter(Boolean).map(f => {
805
+ const [fname, ftype] = f.split(":").map(s => s.trim());
806
+ return { name: fname, tsType: ftype };
807
+ });
808
+ typeDecls.push({ name, kind: "record", fields });
809
+ }
810
+ // Also scan statement-level comments for declare-type
811
+ for (const stmt of sourceFile.getStatements()) {
812
+ for (const range of stmt.getLeadingCommentRanges()) {
813
+ const text = range.getText().trim();
814
+ if (!text.startsWith("//@ declare-type "))
815
+ continue;
816
+ const body = text.slice("//@ declare-type ".length);
817
+ const match = body.match(/^(\w+)\s*\{(.+)\}$/);
818
+ if (!match)
819
+ continue;
820
+ const name = match[1];
821
+ const fields = match[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
822
+ const [fname, ftype] = f.split(":").map(s => s.trim());
823
+ return { name: fname, tsType: ftype };
824
+ });
825
+ typeDecls.push({ name, kind: "record", fields });
826
+ }
827
+ }
599
828
  // Extract type declarations in source order to respect dependencies
829
+ // Skip types already declared via //@ declare-type
830
+ const declaredNames = new Set(typeDecls.map(d => d.name));
600
831
  for (const stmt of sourceFile.getStatements()) {
601
- if (Node.isTypeAliasDeclaration(stmt)) {
602
- const info = extractTypeDecl(stmt);
832
+ if (Node.isTypeAliasDeclaration(stmt) && !declaredNames.has(stmt.getName())) {
833
+ const extra = [];
834
+ const info = extractTypeDecl(stmt, extra);
835
+ typeDecls.push(...extra);
603
836
  if (info)
604
837
  typeDecls.push(info);
605
838
  }
606
- else if (Node.isInterfaceDeclaration(stmt)) {
607
- const info = extractInterface(stmt);
839
+ else if (Node.isInterfaceDeclaration(stmt) && !declaredNames.has(stmt.getName())) {
840
+ const extra = [];
841
+ const info = extractInterface(stmt, extra);
842
+ // Synthetic types from inline objects must precede the parent type
843
+ typeDecls.push(...extra);
608
844
  if (info)
609
845
  typeDecls.push(info);
610
846
  }
@@ -640,35 +876,134 @@ export function extractModule(sourceFile) {
640
876
  for (const fn of sourceFile.getFunctions()) {
641
877
  allFns.push({ name: fn.getName() ?? "<anonymous>", node: fn });
642
878
  }
643
- // const f = (...) => { ... } treat as named function
879
+ // const f = (...) => expr OR const f = (...) => { ... }
644
880
  for (const stmt of sourceFile.getStatements()) {
645
881
  if (Node.isVariableStatement(stmt)) {
646
882
  for (const decl of stmt.getDeclarationList().getDeclarations()) {
647
883
  const init = decl.getInitializer();
648
884
  if (init && Node.isArrowFunction(init)) {
649
- allFns.push({ name: decl.getName(), node: init });
885
+ allFns.push({ name: decl.getName(), node: init, parentStmt: stmt });
650
886
  }
651
887
  }
652
888
  }
653
889
  }
654
890
  // If any function has //@ verify, only extract those (brownfield mode).
655
- // Otherwise extract all functions (backwards-compatible with existing examples).
656
- const hasVerifyDirective = allFns.some(f => f.node.getFullText().includes('//@ verify'));
657
- const fnsToExtract = hasVerifyDirective
658
- ? allFns.filter(f => f.node.getFullText().includes('//@ verify'))
659
- : allFns;
891
+ // For expression-body arrows, //@ verify may be on the parent variable statement.
892
+ function hasVerify(f) {
893
+ if (f.node.getFullText().includes('//@ verify'))
894
+ return true;
895
+ if (f.parentStmt) {
896
+ for (const r of f.parentStmt.getLeadingCommentRanges()) {
897
+ if (r.getText().includes('//@ verify'))
898
+ return true;
899
+ }
900
+ }
901
+ return false;
902
+ }
903
+ const hasVerifyDirective = allFns.some(hasVerify);
904
+ const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
660
905
  const functions = fnsToExtract.map(f => {
661
- const raw = extractFunction(f.node);
906
+ // For expression-body arrows, annotations come from the parent variable statement
907
+ const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
908
+ const raw = extractFunction(f.node, parentAnnots);
662
909
  raw.name = f.name; // use the const name, not "<anonymous>"
663
910
  return raw;
664
911
  });
912
+ // Resolve imported type names referenced in function signatures and type fields
913
+ const knownTypeNames = new Set(typeDecls.map(d => d.name));
914
+ const primitives = new Set(["number", "string", "boolean", "void", "unknown", "undefined"]);
915
+ const builtinTypes = new Set(["Map", "Set", "Array", "Record", "Promise", "Date", "RegExp", "Error"]);
916
+ function resolveTypeName(name) {
917
+ if (knownTypeNames.has(name) || primitives.has(name) || builtinTypes.has(name))
918
+ return;
919
+ for (const sf2 of sourceFile.getProject().getSourceFiles()) {
920
+ for (const stmt of sf2.getStatements()) {
921
+ if (Node.isTypeAliasDeclaration(stmt) && stmt.getName() === name) {
922
+ const extra = [];
923
+ const info = extractTypeDecl(stmt, extra);
924
+ typeDecls.push(...extra);
925
+ if (info) {
926
+ typeDecls.push(info);
927
+ knownTypeNames.add(name);
928
+ }
929
+ }
930
+ if (Node.isInterfaceDeclaration(stmt) && stmt.getName() === name && !knownTypeNames.has(name)) {
931
+ const extra = [];
932
+ const info = extractInterface(stmt, extra);
933
+ typeDecls.push(...extra);
934
+ if (info) {
935
+ typeDecls.push(info);
936
+ knownTypeNames.add(name);
937
+ }
938
+ }
939
+ }
940
+ if (knownTypeNames.has(name))
941
+ break;
942
+ }
943
+ // Recursively resolve types referenced by the newly added type's fields
944
+ const decl = typeDecls.find(d => d.name === name);
945
+ if (decl?.fields) {
946
+ for (const f of decl.fields) {
947
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
948
+ resolveTypeName(m[1]);
949
+ }
950
+ }
951
+ if (decl?.variants) {
952
+ for (const v of decl.variants) {
953
+ for (const f of v.fields) {
954
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
955
+ resolveTypeName(m[1]);
956
+ }
957
+ }
958
+ }
959
+ }
960
+ for (const fn of functions) {
961
+ const refs = [fn.returnType, ...fn.params.map(p => p.tsType)];
962
+ for (const ref of refs) {
963
+ for (const m of ref.matchAll(/\b([A-Z]\w*)\b/g))
964
+ resolveTypeName(m[1]);
965
+ }
966
+ }
967
+ // Resolve union param types: A | B → intersection of fields
968
+ const typeDeclMap = new Map(typeDecls.map(d => [d.name, d]));
969
+ for (const fn of functions) {
970
+ for (const p of fn.params) {
971
+ if (!p.tsType.includes(" | "))
972
+ continue;
973
+ const arms = p.tsType.split(" | ").map(a => a.trim());
974
+ const armDecls = arms.map(a => typeDeclMap.get(a)).filter((d) => !!d && d.kind === "record");
975
+ if (armDecls.length < 2 || armDecls.length !== arms.length)
976
+ continue;
977
+ // Compute field name intersection
978
+ const fieldSets = armDecls.map(d => new Set(d.fields.map(f => f.name)));
979
+ const common = [...fieldSets[0]].filter(name => fieldSets.every(s => s.has(name)));
980
+ // Find an existing type that matches, or use the first arm's fields
981
+ const match = armDecls.find(d => d.fields.length === common.length && d.fields.every(f => common.includes(f.name)));
982
+ if (match) {
983
+ p.tsType = match.name;
984
+ }
985
+ else {
986
+ // Generate synthetic union type with intersected fields
987
+ const synName = arms.join("Or");
988
+ if (!typeDeclMap.has(synName)) {
989
+ const fields = common.map(name => {
990
+ const f = armDecls[0].fields.find(f => f.name === name);
991
+ return { name: f.name, tsType: f.tsType };
992
+ });
993
+ typeDecls.push({ name: synName, kind: "record", fields });
994
+ typeDeclMap.set(synName, typeDecls[typeDecls.length - 1]);
995
+ }
996
+ p.tsType = synName;
997
+ }
998
+ }
999
+ }
665
1000
  // In brownfield mode, filter consts to only those referenced by verified functions.
666
- // Types are NOT filtered — they may be needed transitively (e.g. Option from T | undefined).
667
1001
  if (hasVerifyDirective) {
668
1002
  const referencedNames = new Set();
669
1003
  function collectNames(stmts) {
670
1004
  for (const s of stmts) {
671
1005
  if (s.kind === "let") {
1006
+ referencedNames.add(s.tsType);
672
1007
  collectNamesExpr(s.init);
673
1008
  }
674
1009
  if (s.kind === "assign") {
@@ -743,6 +1078,26 @@ export function extractModule(sourceFile) {
743
1078
  }
744
1079
  }
745
1080
  constants.splice(0, constants.length, ...constants.filter(c => referencedNames.has(c.name)));
1081
+ // Filter types to only those referenced by verified functions (transitive)
1082
+ const neededTypes = new Set();
1083
+ function markType(name) {
1084
+ if (neededTypes.has(name))
1085
+ return;
1086
+ const d = typeDecls.find(t => t.name === name);
1087
+ if (!d)
1088
+ return;
1089
+ neededTypes.add(name);
1090
+ for (const f of d.fields ?? [])
1091
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1092
+ markType(m[1]);
1093
+ for (const v of d.variants ?? [])
1094
+ for (const f of v.fields)
1095
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1096
+ markType(m[1]);
1097
+ }
1098
+ for (const name of referencedNames)
1099
+ markType(name);
1100
+ typeDecls.splice(0, typeDecls.length, ...typeDecls.filter(d => neededTypes.has(d.name) || declaredNames.has(d.name)));
746
1101
  }
747
1102
  // Resolve imported types: extract types referenced in function signatures but not in this file
748
1103
  const knownTypes = new Set(typeDecls.map(d => d.name));
@@ -753,15 +1108,53 @@ export function extractModule(sourceFile) {
753
1108
  resolveType(t.getArrayElementTypeOrThrow(), locationNode);
754
1109
  return;
755
1110
  }
1111
+ // Resolve type aliases (e.g. string unions imported from other files)
1112
+ const alias = t.getAliasSymbol();
1113
+ if (alias) {
1114
+ const aliasName = alias.getName();
1115
+ if (!knownTypes.has(aliasName) && !builtins.has(aliasName) && !aliasName.startsWith("__")) {
1116
+ const decls = alias.getDeclarations();
1117
+ if (decls.length > 0 && Node.isTypeAliasDeclaration(decls[0])) {
1118
+ const extra = [];
1119
+ const info = extractTypeDecl(decls[0], extra);
1120
+ if (info) {
1121
+ typeDecls.push(...extra);
1122
+ typeDecls.push(info);
1123
+ knownTypes.add(aliasName);
1124
+ }
1125
+ }
1126
+ else if (t.getProperties().length > 0) {
1127
+ // Alias declaration not available (e.g. intersection type) — extract from properties
1128
+ const extra = [];
1129
+ const info = extractRecord(aliasName, t, locationNode, undefined, extra);
1130
+ if (info) {
1131
+ typeDecls.push(...extra);
1132
+ typeDecls.push(info);
1133
+ knownTypes.add(aliasName);
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ if (t.isUnion()) {
1139
+ for (const u of t.getUnionTypes())
1140
+ resolveType(u, locationNode);
1141
+ return;
1142
+ }
756
1143
  for (const arg of t.getTypeArguments())
757
1144
  resolveType(arg, locationNode);
758
1145
  const sym = t.getSymbol() ?? t.getAliasSymbol();
759
1146
  const name = sym?.getName();
760
- if (name && !knownTypes.has(name) && !builtins.has(name) && t.isObject()) {
761
- const info = extractRecord(name, t, locationNode);
1147
+ if (name && !name.startsWith("__") && !knownTypes.has(name) && !builtins.has(name) && (t.isObject() || t.isIntersection())) {
1148
+ const extra = [];
1149
+ const info = extractRecord(name, t, locationNode, undefined, extra);
762
1150
  if (info) {
1151
+ typeDecls.push(...extra);
763
1152
  typeDecls.push(info);
764
1153
  knownTypes.add(name);
1154
+ // Recursively resolve types referenced in this type's fields
1155
+ for (const prop of t.getProperties()) {
1156
+ resolveType(prop.getTypeAtLocation(locationNode), locationNode);
1157
+ }
765
1158
  }
766
1159
  }
767
1160
  }
@@ -769,6 +1162,45 @@ export function extractModule(sourceFile) {
769
1162
  for (const p of f.node.getParameters())
770
1163
  resolveType(p.getType(), p);
771
1164
  }
1165
+ // Resolve anonymous object return types into synthetic named types
1166
+ for (let i = 0; i < fnsToExtract.length; i++) {
1167
+ const f = fnsToExtract[i];
1168
+ const fn = functions[i];
1169
+ const retType = f.node.getReturnType();
1170
+ // Prefer alias symbol (named type aliases) over underlying object symbol (__type)
1171
+ const aliasSym = retType.getAliasSymbol();
1172
+ if (aliasSym && !aliasSym.getName().startsWith("__")) {
1173
+ // Named type alias — resolve it instead of generating a synthetic name
1174
+ resolveType(retType, f.node);
1175
+ const aliasName = aliasSym.getName();
1176
+ if (knownTypes.has(aliasName)) {
1177
+ // Preserve type arguments: Result<Model, Err> not just Result
1178
+ const typeArgs = retType.getAliasTypeArguments();
1179
+ fn.returnType = typeArgs.length > 0
1180
+ ? `${aliasName}<${typeArgs.map(t => typeToString(t)).join(", ")}>`
1181
+ : aliasName;
1182
+ }
1183
+ continue;
1184
+ }
1185
+ const sym = retType.getSymbol();
1186
+ if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
1187
+ const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
1188
+ if (!knownTypes.has(synName)) {
1189
+ const extra = [];
1190
+ const info = extractRecord(synName, retType, f.node, undefined, extra);
1191
+ if (info) {
1192
+ typeDecls.push(...extra);
1193
+ typeDecls.push(info);
1194
+ knownTypes.add(synName);
1195
+ }
1196
+ }
1197
+ fn.returnType = synName;
1198
+ // Also resolve imported types referenced in the return type's fields
1199
+ for (const prop of retType.getProperties()) {
1200
+ resolveType(prop.getTypeAtLocation(f.node), f.node);
1201
+ }
1202
+ }
1203
+ }
772
1204
  // Extract classes with //@ verify methods
773
1205
  const classes = [];
774
1206
  for (const cls of sourceFile.getClasses()) {