lemmascript 0.1.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.
@@ -4,13 +4,63 @@
4
4
  * Produces structured AST nodes, not strings.
5
5
  * The only strings are //@ annotation expressions (parsed later by specparser).
6
6
  */
7
- import { Project, Node, SyntaxKind } from "ts-morph";
7
+ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
8
8
  // ── Expression extraction ────────────────────────────────────
9
+ /** When set, calls whose function/method name matches this key are replaced with havoc. */
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
+ }
9
26
  function extractExpr(node) {
27
+ // Havoc key matching: replace matching calls with havoc expression
28
+ if (_havocKey && Node.isCallExpression(node)) {
29
+ const fnExpr = node.getExpression();
30
+ const name = Node.isPropertyAccessExpression(fnExpr) ? fnExpr.getName()
31
+ : Node.isIdentifier(fnExpr) ? fnExpr.getText()
32
+ : null;
33
+ if (name === _havocKey) {
34
+ return { kind: "havoc", tsType: typeToString(node.getType()) };
35
+ }
36
+ }
10
37
  // Numeric literal
11
38
  if (Node.isNumericLiteral(node)) {
12
39
  return { kind: "num", value: Number(node.getLiteralValue()) };
13
40
  }
41
+ // BigInt literal (e.g. 32n, 0xffffn) — treat as integer
42
+ if (Node.isBigIntLiteral(node)) {
43
+ const text = node.getText().replace(/n$/, '');
44
+ return { kind: "num", value: Number(text) };
45
+ }
46
+ // Template literal: `foo${x}bar` → "foo" + x + "bar"
47
+ if (Node.isTemplateExpression(node)) {
48
+ const parts = [];
49
+ const head = node.getHead().getLiteralText();
50
+ if (head)
51
+ parts.push({ kind: "str", value: head });
52
+ for (const span of node.getTemplateSpans()) {
53
+ parts.push(extractExpr(span.getExpression()));
54
+ const text = span.getLiteral().getLiteralText();
55
+ if (text)
56
+ parts.push({ kind: "str", value: text });
57
+ }
58
+ return parts.reduce((left, right) => ({ kind: "binop", op: "+", left, right }));
59
+ }
60
+ // No-substitution template literal: `hello` → "hello"
61
+ if (Node.isNoSubstitutionTemplateLiteral(node)) {
62
+ return { kind: "str", value: node.getLiteralText() };
63
+ }
14
64
  // String literal
15
65
  if (Node.isStringLiteral(node)) {
16
66
  return { kind: "str", value: node.getLiteralValue() };
@@ -24,9 +74,28 @@ function extractExpr(node) {
24
74
  if (Node.isIdentifier(node)) {
25
75
  return { kind: "var", name: node.getText() };
26
76
  }
27
- // Property access: x.foo
77
+ // this
78
+ if (node.getKind() === SyntaxKind.ThisKeyword) {
79
+ return { kind: "var", name: "this" };
80
+ }
81
+ // Property access: x.foo or x?.foo
28
82
  if (Node.isPropertyAccessExpression(node)) {
29
- 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 };
30
99
  }
31
100
  // Element access: arr[i]
32
101
  if (Node.isElementAccessExpression(node)) {
@@ -90,15 +159,38 @@ function extractExpr(node) {
90
159
  }
91
160
  throw new Error(`Unsupported arrow function body: ${node.getText().slice(0, 80)}`);
92
161
  }
93
- // Array literal: [a, b, c] → arrayLiteral, [...arr, elem]push(arr, elem)
162
+ // Array literal: [a, b, c] → arrayLiteral, with spreadsconcatenation
94
163
  if (Node.isArrayLiteralExpression(node)) {
95
164
  const elems = node.getElements();
96
- // [...arr, elem] push(arr, elem)
97
- if (elems.length === 2 && Node.isSpreadElement(elems[0])) {
98
- 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)) };
99
168
  }
100
- // [a, b, c] or [] arrayLiteral
101
- return { kind: "arrayLiteral", elems: elems.map(e => extractExpr(e)) };
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
+ }
184
+ }
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;
102
194
  }
103
195
  // Object literal: { res: true, done: false } or { ...obj, res: true }
104
196
  if (Node.isObjectLiteralExpression(node)) {
@@ -108,6 +200,10 @@ function extractExpr(node) {
108
200
  if (Node.isSpreadAssignment(prop)) {
109
201
  spread = extractExpr(prop.getExpression());
110
202
  }
203
+ else if (Node.isShorthandPropertyAssignment(prop)) {
204
+ const name = prop.getName();
205
+ fields.push({ name, value: { kind: "var", name } });
206
+ }
111
207
  else if (Node.isPropertyAssignment(prop)) {
112
208
  const init = prop.getInitializer();
113
209
  if (init)
@@ -120,6 +216,51 @@ function extractExpr(node) {
120
216
  if (Node.isConditionalExpression(node)) {
121
217
  return { kind: "conditional", cond: extractExpr(node.getCondition()), then: extractExpr(node.getWhenTrue()), else: extractExpr(node.getWhenFalse()) };
122
218
  }
219
+ // Non-null assertion: expr!
220
+ if (Node.isNonNullExpression(node)) {
221
+ return { kind: "nonNull", expr: extractExpr(node.getExpression()) };
222
+ }
223
+ // new Map<K,V>() / new Set<T>() — with or without initializer
224
+ if (Node.isNewExpression(node)) {
225
+ const name = node.getExpression().getText();
226
+ if (name === "Map" || name === "Set") {
227
+ const typeArgs = node.getTypeArguments();
228
+ // Use explicit type args if present, otherwise infer from TS type system
229
+ const tsType = typeArgs && typeArgs.length > 0
230
+ ? `${name}<${typeArgs.map(t => t.getText()).join(", ")}>`
231
+ : _eraseGenerics(typeToString(node.getType()));
232
+ const args = node.getArguments();
233
+ // new Map(source) — clone existing map or build from entries
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
242
+ return { kind: "call", fn: { kind: "var", name: "__mapFromArray" }, args: [extractExpr(args[0])] };
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
+ }
253
+ return { kind: "emptyCollection", collectionType: name, tsType };
254
+ }
255
+ }
256
+ // As-expression: expr as T — strip the type assertion
257
+ if (Node.isAsExpression(node)) {
258
+ return extractExpr(node.getExpression());
259
+ }
260
+ // null → undefined (both map to None in backends)
261
+ if (Node.isNullLiteral(node)) {
262
+ return { kind: "var", name: "undefined" };
263
+ }
123
264
  throw new Error(`Unsupported expression: ${node.getText()}`);
124
265
  }
125
266
  // ── Annotation parsing ───────────────────────────────────────
@@ -149,13 +290,15 @@ function collectAnnotations(node, body) {
149
290
  return own;
150
291
  }
151
292
  // ── Type declaration extraction ──────────────────────────────
152
- function extractTypeDecl(decl) {
293
+ function extractTypeDecl(decl, extraDecls) {
153
294
  const name = decl.getName();
154
295
  const type = decl.getType();
296
+ const typeParams = decl.getTypeParameters().map(tp => tp.getName());
297
+ const tpField = typeParams.length > 0 ? typeParams : undefined;
155
298
  if (type.isUnion()) {
156
299
  const members = type.getUnionTypes();
157
300
  if (members.every(m => m.isStringLiteral())) {
158
- 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()) };
159
302
  }
160
303
  if (members.every(m => m.isObject())) {
161
304
  const discriminant = findDiscriminant(members);
@@ -163,7 +306,8 @@ function extractTypeDecl(decl) {
163
306
  const variants = members.map(m => {
164
307
  const tagProp = m.getProperty(discriminant);
165
308
  const tagType = tagProp?.getTypeAtLocation(decl);
166
- const tag = tagType?.getLiteralValue();
309
+ const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue())
310
+ : tagType?.getText() ?? "unknown";
167
311
  const fields = [];
168
312
  for (const prop of m.getProperties()) {
169
313
  if (prop.getName() === discriminant)
@@ -172,15 +316,19 @@ function extractTypeDecl(decl) {
172
316
  }
173
317
  return { name: tag, fields };
174
318
  });
175
- return { name, kind: "discriminated-union", discriminant, variants };
319
+ return { name, typeParams: tpField, kind: "discriminated-union", discriminant, variants };
176
320
  }
177
321
  }
178
322
  }
179
- if (type.isObject())
180
- 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 };
181
329
  return null;
182
330
  }
183
- function extractInterface(decl) {
331
+ function extractInterface(decl, extraDecls) {
184
332
  // Collect field type overrides from trailing //@ type annotations
185
333
  const overrides = new Map();
186
334
  for (const member of decl.getMembers()) {
@@ -191,16 +339,44 @@ function extractInterface(decl) {
191
339
  overrides.set(member.getName(), match[1]);
192
340
  }
193
341
  }
194
- return extractRecord(decl.getName(), decl.getType(), decl, overrides);
342
+ return extractRecord(decl.getName(), decl.getType(), decl, overrides, extraDecls);
195
343
  }
196
- function extractRecord(name, type, locationNode, overrides) {
344
+ function extractRecord(name, type, locationNode, overrides, extraDecls) {
197
345
  const props = type.getProperties();
198
346
  if (props.length === 0)
199
347
  return null;
200
348
  const fields = [];
201
349
  for (const prop of props) {
202
350
  const override = overrides?.get(prop.getName());
203
- 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
+ }
204
380
  fields.push({ name: prop.getName(), tsType });
205
381
  }
206
382
  return { name, kind: "record", fields };
@@ -216,7 +392,7 @@ function findDiscriminant(members) {
216
392
  if (!p)
217
393
  return false;
218
394
  const t = p.getDeclarations()[0] ? p.getTypeAtLocation(p.getDeclarations()[0]) : null;
219
- return t?.isStringLiteral() ?? false;
395
+ return (t?.isStringLiteral() || t?.isBooleanLiteral()) ?? false;
220
396
  });
221
397
  if (allHave)
222
398
  return name;
@@ -224,39 +400,136 @@ function findDiscriminant(members) {
224
400
  return null;
225
401
  }
226
402
  function typeToString(type) {
227
- if (type.isNumber())
403
+ if (type.isUndefined())
404
+ return "undefined";
405
+ if (type.isNumber() || type.isNumberLiteral())
228
406
  return "number";
229
- if (type.isString())
407
+ if (type.isBigInt() || type.isBigIntLiteral())
408
+ return "bigint";
409
+ if (type.isString() || type.isStringLiteral())
230
410
  return "string";
231
411
  if (type.isBoolean())
232
412
  return "boolean";
413
+ // Named type alias (e.g. Priority = "low" | "medium" | "high") — use the alias name
414
+ if (type.getAliasSymbol()) {
415
+ const name = type.getAliasSymbol().getName();
416
+ const args = type.getAliasTypeArguments();
417
+ if (args.length > 0)
418
+ return `${name}<${args.map(t => typeToString(t)).join(", ")}>`;
419
+ return name;
420
+ }
421
+ if (type.isUnion()) {
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(", ")}]`;
427
+ }
233
428
  if (type.isArray()) {
234
429
  const elem = type.getArrayElementTypeOrThrow();
235
430
  return `${typeToString(elem)}[]`;
236
431
  }
237
432
  const symbol = type.getSymbol() ?? type.getAliasSymbol();
238
- if (symbol)
239
- return symbol.getName();
433
+ if (symbol) {
434
+ const name = symbol.getName();
435
+ const typeArgs = type.getTypeArguments();
436
+ if (typeArgs.length > 0) {
437
+ return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
438
+ }
439
+ return name;
440
+ }
240
441
  return type.getText();
241
442
  }
242
443
  const COMPOUND_OPS = {
243
444
  "+=": "+", "-=": "-", "*=": "*", "/=": "/", "%=": "%",
244
445
  };
245
446
  // ── Statement extraction ─────────────────────────────────────
447
+ /** Parse ghost and assert annotations from comment ranges. */
448
+ function parseSpecComments(ranges, line) {
449
+ const result = [];
450
+ for (const range of ranges) {
451
+ const text = range.getText().trim();
452
+ if (!text.startsWith(PREFIX))
453
+ continue;
454
+ const content = text.slice(PREFIX.length);
455
+ // assert expr
456
+ if (content.startsWith("assert ")) {
457
+ result.push({ kind: "assert", expr: content.slice(7).trim(), line });
458
+ continue;
459
+ }
460
+ if (!content.startsWith("ghost "))
461
+ continue;
462
+ const ghostBody = content.slice(6).trim();
463
+ // ghost let varName: type = expr OR ghost let varName = expr
464
+ const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*(\w+))?\s*=\s*(.+)$/);
465
+ if (letMatch) {
466
+ result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
467
+ continue;
468
+ }
469
+ // ghost varName = expr
470
+ const assignMatch = ghostBody.match(/^(\w+)\s*=\s*(.+)$/);
471
+ if (assignMatch) {
472
+ result.push({ kind: "ghostAssign", target: assignMatch[1], value: assignMatch[2].trim(), line });
473
+ }
474
+ }
475
+ return result;
476
+ }
246
477
  function extractStmts(stmts) {
247
478
  const result = [];
248
479
  for (const s of stmts) {
249
480
  const line = s.getStartLineNumber();
481
+ // Ghost annotations from leading comments → inject before this statement
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
+ }
250
488
  if (Node.isVariableStatement(s)) {
489
+ const havocMatch = s.getLeadingCommentRanges()
490
+ .map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+)|(?:\s+(\S+)))?$/))
491
+ .find(m => m !== null);
492
+ const havocType = havocMatch?.[1]?.trim() ?? null; // //@ havoc : Type
493
+ const havocKey = havocMatch?.[2] ?? null; // //@ havoc key
494
+ const isHavoc = !!havocMatch;
251
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
+ }
252
514
  const declType = d.getType();
253
- const init = d.getInitializer();
515
+ let init;
516
+ if (isHavoc && !havocKey) {
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) };
520
+ }
521
+ else {
522
+ const initializer = d.getInitializer();
523
+ _havocKey = havocKey;
524
+ init = initializer ? extractExpr(initializer) : { kind: "var", name: "default" };
525
+ _havocKey = null;
526
+ }
254
527
  result.push({
255
528
  kind: "let",
256
529
  name: d.getName(),
257
530
  mutable: s.getDeclarationKind() === "let",
258
- tsType: typeToString(declType),
259
- init: init ? extractExpr(init) : { kind: "var", name: "default" },
531
+ tsType: havocType ?? _eraseGenerics(typeToString(declType)),
532
+ init,
260
533
  line,
261
534
  });
262
535
  }
@@ -279,13 +552,29 @@ function extractStmts(stmts) {
279
552
  }
280
553
  if (Node.isForOfStatement(s)) {
281
554
  const init = s.getInitializer();
282
- const varName = Node.isVariableDeclarationList(init) ? init.getDeclarations()[0]?.getName() ?? "_" : "_";
555
+ const names = [];
556
+ if (Node.isVariableDeclarationList(init)) {
557
+ const decl = init.getDeclarations()[0];
558
+ const nameNode = decl?.getNameNode();
559
+ if (nameNode && Node.isArrayBindingPattern(nameNode)) {
560
+ for (const elem of nameNode.getElements()) {
561
+ if (Node.isBindingElement(elem))
562
+ names.push(elem.getNameNode().getText());
563
+ }
564
+ }
565
+ else {
566
+ names.push(decl?.getName() ?? "_");
567
+ }
568
+ }
569
+ else {
570
+ names.push("_");
571
+ }
283
572
  const bodyNode = s.getStatement();
284
573
  const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
285
574
  const annots = collectAnnotations(s, bodyStmts);
286
575
  result.push({
287
576
  kind: "forof",
288
- varName,
577
+ names,
289
578
  iterable: extractExpr(s.getExpression()),
290
579
  invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
291
580
  doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
@@ -344,8 +633,18 @@ function extractStmts(stmts) {
344
633
  }
345
634
  if (Node.isExpressionStatement(s)) {
346
635
  const expr = s.getExpression();
347
- // x = e
348
- if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=") {
636
+ // arr[i] = v → arr = arr.with(i, v)
637
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=" && Node.isElementAccessExpression(expr.getLeft())) {
638
+ const left = expr.getLeft();
639
+ const obj = extractExpr(left.getExpression());
640
+ const idx = extractExpr(left.getArgumentExpression());
641
+ const val = extractExpr(expr.getRight());
642
+ const target = left.getExpression().getText();
643
+ const withCall = { kind: "call", fn: { kind: "field", obj, field: "with" }, args: [idx, val] };
644
+ result.push({ kind: "assign", target, value: withCall, line });
645
+ // x = e
646
+ }
647
+ else if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=") {
349
648
  result.push({ kind: "assign", target: expr.getLeft().getText(), value: extractExpr(expr.getRight()), line });
350
649
  // x += e, x -= e, etc.
351
650
  }
@@ -371,17 +670,77 @@ function extractStmts(stmts) {
371
670
  }
372
671
  continue;
373
672
  }
673
+ if (Node.isThrowStatement(s)) {
674
+ result.push({ kind: "throw", line });
675
+ continue;
676
+ }
677
+ // Block statement: { ... } — flatten into parent
678
+ if (Node.isBlock(s)) {
679
+ result.push(...extractStmts(s.getStatements()));
680
+ continue;
681
+ }
374
682
  throw new Error(`Unsupported statement at line ${line}: ${s.getText().slice(0, 80)}`);
375
683
  }
684
+ // Ghost comments after the last statement (before closing brace) appear as sibling trivia nodes
685
+ if (stmts.length > 0) {
686
+ const last = stmts[stmts.length - 1];
687
+ const line = last.getStartLineNumber();
688
+ for (const sib of last.getNextSiblings()) {
689
+ const text = sib.getText().trim();
690
+ if (!text.startsWith(PREFIX))
691
+ continue;
692
+ const content = text.slice(PREFIX.length);
693
+ // assert expr
694
+ if (content.startsWith("assert ")) {
695
+ result.push({ kind: "assert", expr: content.slice(7).trim(), line });
696
+ continue;
697
+ }
698
+ if (!content.startsWith("ghost "))
699
+ continue;
700
+ const ghostBody = content.slice(6).trim();
701
+ const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*(\w+))?\s*=\s*(.+)$/);
702
+ if (letMatch) {
703
+ result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
704
+ continue;
705
+ }
706
+ const assignMatch = ghostBody.match(/^(\w+)\s*=\s*(.+)$/);
707
+ if (assignMatch) {
708
+ result.push({ kind: "ghostAssign", target: assignMatch[1], value: assignMatch[2].trim(), line });
709
+ }
710
+ }
711
+ }
376
712
  return result;
377
713
  }
378
714
  // ── Function extraction ──────────────────────────────────────
379
- 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
+ }
380
727
  const body = fn.getBody();
381
- if (!body || !Node.isBlock(body))
382
- throw new Error(`${fn.getName()}: function body is not a block`);
383
- const bodyStmts = body.getStatements();
384
- 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
+ }
385
744
  const typeAnnotations = [];
386
745
  for (const a of annots) {
387
746
  if (a.kind === "type") {
@@ -391,36 +750,478 @@ function extractFunction(fn) {
391
750
  }
392
751
  }
393
752
  return {
394
- name: fn.getName() ?? "<anonymous>",
395
- params: fn.getParameters().map(p => ({ name: p.getName(), tsType: p.getTypeNode()?.getText() ?? "unknown" })),
396
- returnType: fn.getReturnTypeNode()?.getText() ?? "unknown",
753
+ name: fn.getName?.() ?? "<anonymous>",
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
+ })(),
397
781
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
398
782
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
399
783
  typeAnnotations,
400
- body: extractStmts(bodyStmts),
784
+ body: extractedBody,
401
785
  line: fn.getStartLineNumber(),
402
786
  };
403
787
  }
404
788
  // ── Module extraction ────────────────────────────────────────
405
789
  export function extractModule(sourceFile) {
406
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
+ }
407
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));
408
829
  for (const stmt of sourceFile.getStatements()) {
409
- if (Node.isTypeAliasDeclaration(stmt)) {
410
- 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);
411
834
  if (info)
412
835
  typeDecls.push(info);
413
836
  }
414
- else if (Node.isInterfaceDeclaration(stmt)) {
415
- 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);
416
842
  if (info)
417
843
  typeDecls.push(info);
418
844
  }
419
845
  }
846
+ // Extract module-level const declarations
847
+ const constants = [];
848
+ for (const stmt of sourceFile.getStatements()) {
849
+ if (Node.isVariableStatement(stmt)) {
850
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
851
+ if (stmt.getDeclarationList().getFlags() & 2 /* const */) {
852
+ const init = decl.getInitializer();
853
+ // Skip huge string constants — they crash the verifier and have no verification value
854
+ const initType = decl.getType();
855
+ const isHugeString = (initType.isString() || initType.isStringLiteral()) && init.getText().length > 200;
856
+ if (init && !isHugeString && !Node.isArrowFunction(init)) {
857
+ try {
858
+ constants.push({
859
+ name: decl.getName(),
860
+ tsType: typeToString(decl.getType()),
861
+ value: extractExpr(init),
862
+ });
863
+ }
864
+ catch (e) {
865
+ console.error(`WARNING: skipping const '${decl.getName()}': ${e.message}`);
866
+ }
867
+ }
868
+ }
869
+ }
870
+ }
871
+ }
872
+ // Collect all function-like declarations: function declarations + const arrow functions
873
+ const allFns = [];
874
+ for (const fn of sourceFile.getFunctions()) {
875
+ allFns.push({ name: fn.getName() ?? "<anonymous>", node: fn });
876
+ }
877
+ // const f = (...) => expr OR const f = (...) => { ... }
878
+ for (const stmt of sourceFile.getStatements()) {
879
+ if (Node.isVariableStatement(stmt)) {
880
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
881
+ const init = decl.getInitializer();
882
+ if (init && Node.isArrowFunction(init)) {
883
+ allFns.push({ name: decl.getName(), node: init, parentStmt: stmt });
884
+ }
885
+ }
886
+ }
887
+ }
888
+ // If any function has //@ verify, only extract those (brownfield mode).
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;
903
+ const functions = fnsToExtract.map(f => {
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);
907
+ raw.name = f.name; // use the const name, not "<anonymous>"
908
+ return raw;
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
+ }
998
+ // In brownfield mode, filter consts to only those referenced by verified functions.
999
+ if (hasVerifyDirective) {
1000
+ const referencedNames = new Set();
1001
+ function collectNames(stmts) {
1002
+ for (const s of stmts) {
1003
+ if (s.kind === "let") {
1004
+ referencedNames.add(s.tsType);
1005
+ collectNamesExpr(s.init);
1006
+ }
1007
+ if (s.kind === "assign") {
1008
+ collectNamesExpr(s.value);
1009
+ }
1010
+ if (s.kind === "return") {
1011
+ collectNamesExpr(s.value);
1012
+ }
1013
+ if (s.kind === "if") {
1014
+ collectNamesExpr(s.cond);
1015
+ collectNames(s.then);
1016
+ collectNames(s.else);
1017
+ }
1018
+ if (s.kind === "while") {
1019
+ collectNamesExpr(s.cond);
1020
+ collectNames(s.body);
1021
+ }
1022
+ if (s.kind === "forof") {
1023
+ collectNamesExpr(s.iterable);
1024
+ collectNames(s.body);
1025
+ }
1026
+ if (s.kind === "expr") {
1027
+ collectNamesExpr(s.expr);
1028
+ }
1029
+ }
1030
+ }
1031
+ function collectNamesExpr(e) {
1032
+ if (e.kind === "var")
1033
+ referencedNames.add(e.name);
1034
+ if (e.kind === "binop") {
1035
+ collectNamesExpr(e.left);
1036
+ collectNamesExpr(e.right);
1037
+ }
1038
+ if (e.kind === "unop") {
1039
+ collectNamesExpr(e.expr);
1040
+ }
1041
+ if (e.kind === "call") {
1042
+ collectNamesExpr(e.fn);
1043
+ e.args.forEach(collectNamesExpr);
1044
+ }
1045
+ if (e.kind === "field") {
1046
+ collectNamesExpr(e.obj);
1047
+ }
1048
+ if (e.kind === "index") {
1049
+ collectNamesExpr(e.obj);
1050
+ collectNamesExpr(e.idx);
1051
+ }
1052
+ if (e.kind === "record") {
1053
+ if (e.spread)
1054
+ collectNamesExpr(e.spread);
1055
+ e.fields.forEach(f => collectNamesExpr(f.value));
1056
+ }
1057
+ if (e.kind === "arrayLiteral") {
1058
+ e.elems.forEach(collectNamesExpr);
1059
+ }
1060
+ if (e.kind === "conditional") {
1061
+ collectNamesExpr(e.cond);
1062
+ collectNamesExpr(e.then);
1063
+ collectNamesExpr(e.else);
1064
+ }
1065
+ }
1066
+ for (const fn of functions) {
1067
+ for (const p of fn.params)
1068
+ referencedNames.add(p.tsType);
1069
+ referencedNames.add(fn.returnType);
1070
+ collectNames(fn.body);
1071
+ // Also scan spec annotations for identifier references
1072
+ for (const spec of [...fn.requires, ...fn.ensures]) {
1073
+ for (const m of spec.matchAll(/\b([a-zA-Z_]\w*)\b/g)) {
1074
+ referencedNames.add(m[1]);
1075
+ }
1076
+ }
1077
+ }
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)));
1099
+ }
1100
+ // Resolve imported types: extract types referenced in function signatures but not in this file
1101
+ const knownTypes = new Set(typeDecls.map(d => d.name));
1102
+ const builtins = new Set(["Map", "Set", "Array", "String", "Number", "Boolean", "Promise", "Date", "RegExp", "Error"]);
1103
+ function resolveType(t, locationNode) {
1104
+ // Unwrap arrays and generics to find user-defined types
1105
+ if (t.isArray()) {
1106
+ resolveType(t.getArrayElementTypeOrThrow(), locationNode);
1107
+ return;
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
+ }
1141
+ for (const arg of t.getTypeArguments())
1142
+ resolveType(arg, locationNode);
1143
+ const sym = t.getSymbol() ?? t.getAliasSymbol();
1144
+ const name = sym?.getName();
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);
1148
+ if (info) {
1149
+ typeDecls.push(...extra);
1150
+ typeDecls.push(info);
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
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ for (const f of fnsToExtract) {
1160
+ for (const p of f.node.getParameters())
1161
+ resolveType(p.getType(), p);
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
+ }
1202
+ // Extract classes with //@ verify methods
1203
+ const classes = [];
1204
+ for (const cls of sourceFile.getClasses()) {
1205
+ const methods = [];
1206
+ for (const method of cls.getMethods()) {
1207
+ if (!method.getFullText().includes('//@ verify'))
1208
+ continue;
1209
+ methods.push(extractFunction(method));
1210
+ }
1211
+ if (methods.length === 0)
1212
+ continue;
1213
+ const fields = [];
1214
+ for (const prop of cls.getProperties()) {
1215
+ fields.push({ name: prop.getName(), tsType: typeToString(prop.getType()) });
1216
+ }
1217
+ classes.push({ name: cls.getName() ?? "Anonymous", fields, methods });
1218
+ }
420
1219
  return {
421
1220
  file: sourceFile.getFilePath(),
422
1221
  typeDecls,
423
- functions: sourceFile.getFunctions().map(extractFunction),
1222
+ constants,
1223
+ functions,
1224
+ classes,
424
1225
  };
425
1226
  }
426
1227
  // ── Main ─────────────────────────────────────────────────────
@@ -430,6 +1231,6 @@ if (process.argv[1]?.match(/extract\.(ts|js)$/)) {
430
1231
  console.error("Usage: extract <file.ts>");
431
1232
  process.exit(1);
432
1233
  }
433
- const proj = new Project({ compilerOptions: { strict: true } });
1234
+ const proj = new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
434
1235
  console.log(JSON.stringify(extractModule(proj.addSourceFileAtPath(file)), null, 2));
435
1236
  }