lemmascript 0.2.0 → 0.3.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.
@@ -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
  });
@@ -535,6 +674,11 @@ function extractStmts(stmts) {
535
674
  result.push({ kind: "throw", line });
536
675
  continue;
537
676
  }
677
+ // Block statement: { ... } — flatten into parent
678
+ if (Node.isBlock(s)) {
679
+ result.push(...extractStmts(s.getStatements()));
680
+ continue;
681
+ }
538
682
  throw new Error(`Unsupported statement at line ${line}: ${s.getText().slice(0, 80)}`);
539
683
  }
540
684
  // Ghost comments after the last statement (before closing brace) appear as sibling trivia nodes
@@ -568,12 +712,35 @@ function extractStmts(stmts) {
568
712
  return result;
569
713
  }
570
714
  // ── Function extraction ──────────────────────────────────────
571
- function extractFunction(fn) {
715
+ function extractFunction(fn, parentAnnotations) {
716
+ // Generic bounds erasure: <T extends Base> → substitute T with Base everywhere
717
+ // Unbounded type params are preserved as Dafny type parameters
718
+ _typeParamMap = new Map();
719
+ const unboundedTypeParams = [];
720
+ for (const tp of fn.getTypeParameters?.() ?? []) {
721
+ const constraint = tp.getConstraint();
722
+ if (constraint)
723
+ _typeParamMap.set(tp.getName(), constraint.getText());
724
+ else
725
+ unboundedTypeParams.push(tp.getName());
726
+ }
572
727
  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);
728
+ // Expression-body arrow: wrap in implicit return
729
+ let extractedBody;
730
+ let annots;
731
+ if (body && !Node.isBlock(body)) {
732
+ const expr = extractExpr(body);
733
+ extractedBody = [{ kind: "return", value: expr, line: body.getStartLineNumber() }];
734
+ annots = parentAnnotations ?? collectAnnotations(fn);
735
+ }
736
+ else if (body && Node.isBlock(body)) {
737
+ const bodyStmts = body.getStatements();
738
+ extractedBody = extractStmts(bodyStmts);
739
+ annots = collectAnnotations(fn, bodyStmts);
740
+ }
741
+ else {
742
+ throw new Error(`${fn.getName?.() ?? "arrow"}: function has no body`);
743
+ }
577
744
  const typeAnnotations = [];
578
745
  for (const a of annots) {
579
746
  if (a.kind === "type") {
@@ -584,27 +751,94 @@ function extractFunction(fn) {
584
751
  }
585
752
  return {
586
753
  name: fn.getName?.() ?? "<anonymous>",
587
- params: fn.getParameters().map(p => ({ name: p.getName(), tsType: p.getTypeNode()?.getText() ?? "unknown" })),
588
- returnType: fn.getReturnTypeNode()?.getText() ?? "unknown",
754
+ typeParams: unboundedTypeParams,
755
+ params: fn.getParameters().flatMap(p => {
756
+ // Flatten destructured object params into individual params
757
+ const nameNode = p.getNameNode();
758
+ if (Node.isObjectBindingPattern(nameNode)) {
759
+ const type = p.getType();
760
+ return nameNode.getElements().map(el => {
761
+ const name = el.getName();
762
+ const propType = type.getProperty(name)?.getTypeAtLocation(p);
763
+ return { name, tsType: propType ? typeToString(propType) : "unknown" };
764
+ });
765
+ }
766
+ let tsType = _eraseGenerics(p.getTypeNode()?.getText() ?? "unknown");
767
+ // Optional parameters (foo?: T) need | undefined in the type string
768
+ if (p.hasQuestionToken())
769
+ tsType = `${tsType} | undefined`;
770
+ return [{ name: p.getName(), tsType }];
771
+ }),
772
+ returnType: (() => {
773
+ const node = fn.getReturnTypeNode();
774
+ if (node)
775
+ return _eraseGenerics(node.getText());
776
+ const inferred = fn.getReturnType();
777
+ if (inferred.isAny())
778
+ return "unknown";
779
+ return _eraseGenerics(typeToString(inferred));
780
+ })(),
589
781
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
590
782
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
591
783
  typeAnnotations,
592
- body: extractStmts(bodyStmts),
784
+ body: extractedBody,
593
785
  line: fn.getStartLineNumber(),
594
786
  };
595
787
  }
596
788
  // ── Module extraction ────────────────────────────────────────
597
789
  export function extractModule(sourceFile) {
598
790
  const typeDecls = [];
791
+ // Parse //@ declare-type directives from file comments
792
+ for (const range of sourceFile.getLeadingCommentRanges()) {
793
+ const text = range.getText().trim();
794
+ if (!text.startsWith("//@ declare-type "))
795
+ continue;
796
+ const body = text.slice("//@ declare-type ".length);
797
+ const match = body.match(/^(\w+)\s*\{(.+)\}$/);
798
+ if (!match)
799
+ continue;
800
+ const name = match[1];
801
+ const fieldsStr = match[2];
802
+ const fields = fieldsStr.split(",").map(f => f.trim()).filter(Boolean).map(f => {
803
+ const [fname, ftype] = f.split(":").map(s => s.trim());
804
+ return { name: fname, tsType: ftype };
805
+ });
806
+ typeDecls.push({ name, kind: "record", fields });
807
+ }
808
+ // Also scan statement-level comments for declare-type
809
+ for (const stmt of sourceFile.getStatements()) {
810
+ for (const range of stmt.getLeadingCommentRanges()) {
811
+ const text = range.getText().trim();
812
+ if (!text.startsWith("//@ declare-type "))
813
+ continue;
814
+ const body = text.slice("//@ declare-type ".length);
815
+ const match = body.match(/^(\w+)\s*\{(.+)\}$/);
816
+ if (!match)
817
+ continue;
818
+ const name = match[1];
819
+ const fields = match[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
820
+ const [fname, ftype] = f.split(":").map(s => s.trim());
821
+ return { name: fname, tsType: ftype };
822
+ });
823
+ typeDecls.push({ name, kind: "record", fields });
824
+ }
825
+ }
599
826
  // Extract type declarations in source order to respect dependencies
827
+ // Skip types already declared via //@ declare-type
828
+ const declaredNames = new Set(typeDecls.map(d => d.name));
600
829
  for (const stmt of sourceFile.getStatements()) {
601
- if (Node.isTypeAliasDeclaration(stmt)) {
602
- const info = extractTypeDecl(stmt);
830
+ if (Node.isTypeAliasDeclaration(stmt) && !declaredNames.has(stmt.getName())) {
831
+ const extra = [];
832
+ const info = extractTypeDecl(stmt, extra);
833
+ typeDecls.push(...extra);
603
834
  if (info)
604
835
  typeDecls.push(info);
605
836
  }
606
- else if (Node.isInterfaceDeclaration(stmt)) {
607
- const info = extractInterface(stmt);
837
+ else if (Node.isInterfaceDeclaration(stmt) && !declaredNames.has(stmt.getName())) {
838
+ const extra = [];
839
+ const info = extractInterface(stmt, extra);
840
+ // Synthetic types from inline objects must precede the parent type
841
+ typeDecls.push(...extra);
608
842
  if (info)
609
843
  typeDecls.push(info);
610
844
  }
@@ -640,35 +874,134 @@ export function extractModule(sourceFile) {
640
874
  for (const fn of sourceFile.getFunctions()) {
641
875
  allFns.push({ name: fn.getName() ?? "<anonymous>", node: fn });
642
876
  }
643
- // const f = (...) => { ... } treat as named function
877
+ // const f = (...) => expr OR const f = (...) => { ... }
644
878
  for (const stmt of sourceFile.getStatements()) {
645
879
  if (Node.isVariableStatement(stmt)) {
646
880
  for (const decl of stmt.getDeclarationList().getDeclarations()) {
647
881
  const init = decl.getInitializer();
648
882
  if (init && Node.isArrowFunction(init)) {
649
- allFns.push({ name: decl.getName(), node: init });
883
+ allFns.push({ name: decl.getName(), node: init, parentStmt: stmt });
650
884
  }
651
885
  }
652
886
  }
653
887
  }
654
888
  // 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;
889
+ // For expression-body arrows, //@ verify may be on the parent variable statement.
890
+ function hasVerify(f) {
891
+ if (f.node.getFullText().includes('//@ verify'))
892
+ return true;
893
+ if (f.parentStmt) {
894
+ for (const r of f.parentStmt.getLeadingCommentRanges()) {
895
+ if (r.getText().includes('//@ verify'))
896
+ return true;
897
+ }
898
+ }
899
+ return false;
900
+ }
901
+ const hasVerifyDirective = allFns.some(hasVerify);
902
+ const fnsToExtract = hasVerifyDirective ? allFns.filter(hasVerify) : allFns;
660
903
  const functions = fnsToExtract.map(f => {
661
- const raw = extractFunction(f.node);
904
+ // For expression-body arrows, annotations come from the parent variable statement
905
+ const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
906
+ const raw = extractFunction(f.node, parentAnnots);
662
907
  raw.name = f.name; // use the const name, not "<anonymous>"
663
908
  return raw;
664
909
  });
910
+ // Resolve imported type names referenced in function signatures and type fields
911
+ const knownTypeNames = new Set(typeDecls.map(d => d.name));
912
+ const primitives = new Set(["number", "string", "boolean", "void", "unknown", "undefined"]);
913
+ const builtinTypes = new Set(["Map", "Set", "Array", "Record", "Promise", "Date", "RegExp", "Error"]);
914
+ function resolveTypeName(name) {
915
+ if (knownTypeNames.has(name) || primitives.has(name) || builtinTypes.has(name))
916
+ return;
917
+ for (const sf2 of sourceFile.getProject().getSourceFiles()) {
918
+ for (const stmt of sf2.getStatements()) {
919
+ if (Node.isTypeAliasDeclaration(stmt) && stmt.getName() === name) {
920
+ const extra = [];
921
+ const info = extractTypeDecl(stmt, extra);
922
+ typeDecls.push(...extra);
923
+ if (info) {
924
+ typeDecls.push(info);
925
+ knownTypeNames.add(name);
926
+ }
927
+ }
928
+ if (Node.isInterfaceDeclaration(stmt) && stmt.getName() === name && !knownTypeNames.has(name)) {
929
+ const extra = [];
930
+ const info = extractInterface(stmt, extra);
931
+ typeDecls.push(...extra);
932
+ if (info) {
933
+ typeDecls.push(info);
934
+ knownTypeNames.add(name);
935
+ }
936
+ }
937
+ }
938
+ if (knownTypeNames.has(name))
939
+ break;
940
+ }
941
+ // Recursively resolve types referenced by the newly added type's fields
942
+ const decl = typeDecls.find(d => d.name === name);
943
+ if (decl?.fields) {
944
+ for (const f of decl.fields) {
945
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
946
+ resolveTypeName(m[1]);
947
+ }
948
+ }
949
+ if (decl?.variants) {
950
+ for (const v of decl.variants) {
951
+ for (const f of v.fields) {
952
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
953
+ resolveTypeName(m[1]);
954
+ }
955
+ }
956
+ }
957
+ }
958
+ for (const fn of functions) {
959
+ const refs = [fn.returnType, ...fn.params.map(p => p.tsType)];
960
+ for (const ref of refs) {
961
+ for (const m of ref.matchAll(/\b([A-Z]\w*)\b/g))
962
+ resolveTypeName(m[1]);
963
+ }
964
+ }
965
+ // Resolve union param types: A | B → intersection of fields
966
+ const typeDeclMap = new Map(typeDecls.map(d => [d.name, d]));
967
+ for (const fn of functions) {
968
+ for (const p of fn.params) {
969
+ if (!p.tsType.includes(" | "))
970
+ continue;
971
+ const arms = p.tsType.split(" | ").map(a => a.trim());
972
+ const armDecls = arms.map(a => typeDeclMap.get(a)).filter((d) => !!d && d.kind === "record");
973
+ if (armDecls.length < 2 || armDecls.length !== arms.length)
974
+ continue;
975
+ // Compute field name intersection
976
+ const fieldSets = armDecls.map(d => new Set(d.fields.map(f => f.name)));
977
+ const common = [...fieldSets[0]].filter(name => fieldSets.every(s => s.has(name)));
978
+ // Find an existing type that matches, or use the first arm's fields
979
+ const match = armDecls.find(d => d.fields.length === common.length && d.fields.every(f => common.includes(f.name)));
980
+ if (match) {
981
+ p.tsType = match.name;
982
+ }
983
+ else {
984
+ // Generate synthetic union type with intersected fields
985
+ const synName = arms.join("Or");
986
+ if (!typeDeclMap.has(synName)) {
987
+ const fields = common.map(name => {
988
+ const f = armDecls[0].fields.find(f => f.name === name);
989
+ return { name: f.name, tsType: f.tsType };
990
+ });
991
+ typeDecls.push({ name: synName, kind: "record", fields });
992
+ typeDeclMap.set(synName, typeDecls[typeDecls.length - 1]);
993
+ }
994
+ p.tsType = synName;
995
+ }
996
+ }
997
+ }
665
998
  // 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
999
  if (hasVerifyDirective) {
668
1000
  const referencedNames = new Set();
669
1001
  function collectNames(stmts) {
670
1002
  for (const s of stmts) {
671
1003
  if (s.kind === "let") {
1004
+ referencedNames.add(s.tsType);
672
1005
  collectNamesExpr(s.init);
673
1006
  }
674
1007
  if (s.kind === "assign") {
@@ -743,6 +1076,26 @@ export function extractModule(sourceFile) {
743
1076
  }
744
1077
  }
745
1078
  constants.splice(0, constants.length, ...constants.filter(c => referencedNames.has(c.name)));
1079
+ // Filter types to only those referenced by verified functions (transitive)
1080
+ const neededTypes = new Set();
1081
+ function markType(name) {
1082
+ if (neededTypes.has(name))
1083
+ return;
1084
+ const d = typeDecls.find(t => t.name === name);
1085
+ if (!d)
1086
+ return;
1087
+ neededTypes.add(name);
1088
+ for (const f of d.fields ?? [])
1089
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1090
+ markType(m[1]);
1091
+ for (const v of d.variants ?? [])
1092
+ for (const f of v.fields)
1093
+ for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
1094
+ markType(m[1]);
1095
+ }
1096
+ for (const name of referencedNames)
1097
+ markType(name);
1098
+ typeDecls.splice(0, typeDecls.length, ...typeDecls.filter(d => neededTypes.has(d.name) || declaredNames.has(d.name)));
746
1099
  }
747
1100
  // Resolve imported types: extract types referenced in function signatures but not in this file
748
1101
  const knownTypes = new Set(typeDecls.map(d => d.name));
@@ -753,15 +1106,53 @@ export function extractModule(sourceFile) {
753
1106
  resolveType(t.getArrayElementTypeOrThrow(), locationNode);
754
1107
  return;
755
1108
  }
1109
+ // Resolve type aliases (e.g. string unions imported from other files)
1110
+ const alias = t.getAliasSymbol();
1111
+ if (alias) {
1112
+ const aliasName = alias.getName();
1113
+ if (!knownTypes.has(aliasName) && !builtins.has(aliasName) && !aliasName.startsWith("__")) {
1114
+ const decls = alias.getDeclarations();
1115
+ if (decls.length > 0 && Node.isTypeAliasDeclaration(decls[0])) {
1116
+ const extra = [];
1117
+ const info = extractTypeDecl(decls[0], extra);
1118
+ if (info) {
1119
+ typeDecls.push(...extra);
1120
+ typeDecls.push(info);
1121
+ knownTypes.add(aliasName);
1122
+ }
1123
+ }
1124
+ else if (t.getProperties().length > 0) {
1125
+ // Alias declaration not available (e.g. intersection type) — extract from properties
1126
+ const extra = [];
1127
+ const info = extractRecord(aliasName, t, locationNode, undefined, extra);
1128
+ if (info) {
1129
+ typeDecls.push(...extra);
1130
+ typeDecls.push(info);
1131
+ knownTypes.add(aliasName);
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+ if (t.isUnion()) {
1137
+ for (const u of t.getUnionTypes())
1138
+ resolveType(u, locationNode);
1139
+ return;
1140
+ }
756
1141
  for (const arg of t.getTypeArguments())
757
1142
  resolveType(arg, locationNode);
758
1143
  const sym = t.getSymbol() ?? t.getAliasSymbol();
759
1144
  const name = sym?.getName();
760
- if (name && !knownTypes.has(name) && !builtins.has(name) && t.isObject()) {
761
- const info = extractRecord(name, t, locationNode);
1145
+ if (name && !name.startsWith("__") && !knownTypes.has(name) && !builtins.has(name) && (t.isObject() || t.isIntersection())) {
1146
+ const extra = [];
1147
+ const info = extractRecord(name, t, locationNode, undefined, extra);
762
1148
  if (info) {
1149
+ typeDecls.push(...extra);
763
1150
  typeDecls.push(info);
764
1151
  knownTypes.add(name);
1152
+ // Recursively resolve types referenced in this type's fields
1153
+ for (const prop of t.getProperties()) {
1154
+ resolveType(prop.getTypeAtLocation(locationNode), locationNode);
1155
+ }
765
1156
  }
766
1157
  }
767
1158
  }
@@ -769,6 +1160,45 @@ export function extractModule(sourceFile) {
769
1160
  for (const p of f.node.getParameters())
770
1161
  resolveType(p.getType(), p);
771
1162
  }
1163
+ // Resolve anonymous object return types into synthetic named types
1164
+ for (let i = 0; i < fnsToExtract.length; i++) {
1165
+ const f = fnsToExtract[i];
1166
+ const fn = functions[i];
1167
+ const retType = f.node.getReturnType();
1168
+ // Prefer alias symbol (named type aliases) over underlying object symbol (__type)
1169
+ const aliasSym = retType.getAliasSymbol();
1170
+ if (aliasSym && !aliasSym.getName().startsWith("__")) {
1171
+ // Named type alias — resolve it instead of generating a synthetic name
1172
+ resolveType(retType, f.node);
1173
+ const aliasName = aliasSym.getName();
1174
+ if (knownTypes.has(aliasName)) {
1175
+ // Preserve type arguments: Result<Model, Err> not just Result
1176
+ const typeArgs = retType.getAliasTypeArguments();
1177
+ fn.returnType = typeArgs.length > 0
1178
+ ? `${aliasName}<${typeArgs.map(t => typeToString(t)).join(", ")}>`
1179
+ : aliasName;
1180
+ }
1181
+ continue;
1182
+ }
1183
+ const sym = retType.getSymbol();
1184
+ if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
1185
+ const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
1186
+ if (!knownTypes.has(synName)) {
1187
+ const extra = [];
1188
+ const info = extractRecord(synName, retType, f.node, undefined, extra);
1189
+ if (info) {
1190
+ typeDecls.push(...extra);
1191
+ typeDecls.push(info);
1192
+ knownTypes.add(synName);
1193
+ }
1194
+ }
1195
+ fn.returnType = synName;
1196
+ // Also resolve imported types referenced in the return type's fields
1197
+ for (const prop of retType.getProperties()) {
1198
+ resolveType(prop.getTypeAtLocation(f.node), f.node);
1199
+ }
1200
+ }
1201
+ }
772
1202
  // Extract classes with //@ verify methods
773
1203
  const classes = [];
774
1204
  for (const cls of sourceFile.getClasses()) {