lemmascript 0.4.0 → 0.5.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.
- package/README.md +17 -12
- package/package.json +4 -1
- package/tools/dist/dafny-emit.js +294 -12
- package/tools/dist/extract.js +1093 -165
- package/tools/dist/info-command.js +38 -0
- package/tools/dist/lean-emit.js +28 -2
- package/tools/dist/lsc.js +16 -6
- package/tools/dist/narrow.js +211 -16
- package/tools/dist/peephole.js +5 -2
- package/tools/dist/resolve.js +416 -46
- package/tools/dist/specparser.js +6 -0
- package/tools/dist/transform.js +400 -42
- package/tools/dist/types.js +128 -69
package/tools/dist/extract.js
CHANGED
|
@@ -5,9 +5,133 @@
|
|
|
5
5
|
* The only strings are //@ annotation expressions (parsed later by specparser).
|
|
6
6
|
*/
|
|
7
7
|
import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
|
|
8
|
+
import { initTypeParser } from "./types.js";
|
|
8
9
|
// ── Expression extraction ────────────────────────────────────
|
|
9
10
|
/** When set, calls whose function/method name matches this key are replaced with havoc. */
|
|
10
11
|
let _havocKey = null;
|
|
12
|
+
/** Auto-detected cross-file calls. Populated by `extractExpr` whenever it sees
|
|
13
|
+
* a call `Obj.method(...)` or `foo(...)` whose ts-morph symbol resolves to a
|
|
14
|
+
* different `.ts` source file. Emitted in Dafny as `function {:axiom} <flat>`.
|
|
15
|
+
* Cleared at the start of every `extractModule`. */
|
|
16
|
+
const _externs = new Map();
|
|
17
|
+
let _currentSourceFile = null;
|
|
18
|
+
/** True only while extracting a function body. Module-level constants that
|
|
19
|
+
* reference cross-file callees (e.g., `BusEvent.define(...)` inside a
|
|
20
|
+
* module-level record) would otherwise pollute the output with externs
|
|
21
|
+
* that no verified function actually calls — and whose TS return types
|
|
22
|
+
* often don't translate to valid Dafny. */
|
|
23
|
+
let _inFunctionExtraction = false;
|
|
24
|
+
/** Counter for synthetic names used by let-statement array destructuring
|
|
25
|
+
* when the initializer isn't a bare variable (single-eval temp). */
|
|
26
|
+
let _destrCounter = 0;
|
|
27
|
+
/** Register the call's callee as a cross-file extern if applicable, then
|
|
28
|
+
* walk the source declaration's body for nested cross-file calls (so the
|
|
29
|
+
* lifted `requires`/`ensures` see all the symbols they reference). Idempotent
|
|
30
|
+
* via the `_externs` dedup. */
|
|
31
|
+
function registerExternIfCrossFile(callee, sourceFile) {
|
|
32
|
+
const ext = detectCrossFileExtern(callee, sourceFile);
|
|
33
|
+
if (!ext || _externs.has(ext.qualified))
|
|
34
|
+
return;
|
|
35
|
+
_externs.set(ext.qualified, ext);
|
|
36
|
+
// Recurse: scan the source decl's body for nested cross-file calls so any
|
|
37
|
+
// symbol referenced by the copied spec is itself declared in the output.
|
|
38
|
+
let symbol = callee.getSymbol();
|
|
39
|
+
if (!symbol)
|
|
40
|
+
return;
|
|
41
|
+
const aliased = symbol.getAliasedSymbol();
|
|
42
|
+
if (aliased)
|
|
43
|
+
symbol = aliased;
|
|
44
|
+
const sourceDecl = symbol.getDeclarations().find(d => d.getSourceFile().getFilePath() !== sourceFile.getFilePath());
|
|
45
|
+
if (!sourceDecl)
|
|
46
|
+
return;
|
|
47
|
+
const sourceSF = sourceDecl.getSourceFile();
|
|
48
|
+
const body = sourceDecl.getBody?.();
|
|
49
|
+
if (!body)
|
|
50
|
+
return;
|
|
51
|
+
for (const inner of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
52
|
+
const innerCallee = inner.getExpression();
|
|
53
|
+
if (Node.isPropertyAccessExpression(innerCallee) || Node.isIdentifier(innerCallee)) {
|
|
54
|
+
registerExternIfCrossFile(innerCallee, sourceSF);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function detectCrossFileExtern(callee, sourceFile) {
|
|
59
|
+
let symbol = callee.getSymbol();
|
|
60
|
+
if (!symbol)
|
|
61
|
+
return null;
|
|
62
|
+
// For bare imports `import { foo } from "..."`, the call-site symbol is the
|
|
63
|
+
// local ImportSpecifier — declared in the current file. Follow the alias to
|
|
64
|
+
// the original `export function` declaration.
|
|
65
|
+
const aliased = symbol.getAliasedSymbol();
|
|
66
|
+
if (aliased)
|
|
67
|
+
symbol = aliased;
|
|
68
|
+
const decls = symbol.getDeclarations();
|
|
69
|
+
if (decls.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const currentPath = sourceFile.getFilePath();
|
|
72
|
+
const externalDecl = decls.find(d => d.getSourceFile().getFilePath() !== currentPath);
|
|
73
|
+
if (!externalDecl)
|
|
74
|
+
return null;
|
|
75
|
+
// Skip stdlib / typings — those have built-in dispatch elsewhere or are
|
|
76
|
+
// genuinely out of LS's verification model.
|
|
77
|
+
if (externalDecl.getSourceFile().getFilePath().endsWith(".d.ts"))
|
|
78
|
+
return null;
|
|
79
|
+
const sig = callee.getType().getCallSignatures()[0];
|
|
80
|
+
if (!sig)
|
|
81
|
+
return null;
|
|
82
|
+
// Generic type parameters (e.g. `step<S, A>`). ts-morph reports param/return
|
|
83
|
+
// types in the callee's own type-parameter namespace, so these names match
|
|
84
|
+
// what `params`/`returnType` reference — declare them on the emitted axiom.
|
|
85
|
+
const typeParams = sig.getTypeParameters().map(tp => tp.getText());
|
|
86
|
+
const params = sig.getParameters().map(p => ({
|
|
87
|
+
name: p.getName(),
|
|
88
|
+
tsType: p.getTypeAtLocation(callee).getText(),
|
|
89
|
+
}));
|
|
90
|
+
const returnType = sig.getReturnType().getText();
|
|
91
|
+
let qualified;
|
|
92
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
93
|
+
qualified = `${callee.getExpression().getText()}.${callee.getName()}`;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
qualified = callee.getText();
|
|
97
|
+
}
|
|
98
|
+
const flat = qualified.replace(/\./g, "_");
|
|
99
|
+
// Lift `//@ requires`/`//@ ensures` from the source declaration so callers
|
|
100
|
+
// reason against the source's verified contract, not an unconstrained axiom.
|
|
101
|
+
const annots = collectFunctionAnnotations(externalDecl);
|
|
102
|
+
const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
|
|
103
|
+
const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
|
|
104
|
+
return { qualified, flat, typeParams, params, returnType, requires, ensures };
|
|
105
|
+
}
|
|
106
|
+
/** Build a concat-tree from a mixed list of literal and SpreadElement nodes.
|
|
107
|
+
* Literals collapse into arrayLiteral segments; spreads become bare expressions;
|
|
108
|
+
* segments are joined with `arrayConcat`. Used by array-literal and Math.max/min
|
|
109
|
+
* call-arg spread.
|
|
110
|
+
* Precondition: at least one element. */
|
|
111
|
+
function buildSpreadConcat(elems) {
|
|
112
|
+
const segments = [];
|
|
113
|
+
let currentLiterals = [];
|
|
114
|
+
for (const e of elems) {
|
|
115
|
+
if (Node.isSpreadElement(e)) {
|
|
116
|
+
if (currentLiterals.length > 0) {
|
|
117
|
+
segments.push({ kind: "arrayLiteral", elems: currentLiterals });
|
|
118
|
+
currentLiterals = [];
|
|
119
|
+
}
|
|
120
|
+
segments.push(extractExpr(e.getExpression()));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
currentLiterals.push(extractExpr(e));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (currentLiterals.length > 0) {
|
|
127
|
+
segments.push({ kind: "arrayLiteral", elems: currentLiterals });
|
|
128
|
+
}
|
|
129
|
+
let result = segments[0];
|
|
130
|
+
for (let i = 1; i < segments.length; i++) {
|
|
131
|
+
result = { kind: "binop", op: "arrayConcat", left: result, right: segments[i] };
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
11
135
|
/**
|
|
12
136
|
* Maps property-name fingerprints to type alias names for collapsed single-variant unions.
|
|
13
137
|
* TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
|
|
@@ -15,8 +139,116 @@ let _havocKey = null;
|
|
|
15
139
|
* Populated in extractModule before type extraction; used by typeToString.
|
|
16
140
|
*/
|
|
17
141
|
let _collapsedUnionMap = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* Accumulator for synthesized `T[] | U` array-union datatypes.
|
|
144
|
+
*
|
|
145
|
+
* Plain TS unions like `string | Part[]` have no backend image (LemmaScript
|
|
146
|
+
* models tagged unions only). When typeToString encounters a binary union
|
|
147
|
+
* where one member is an array and the other is not array/undefined/null,
|
|
148
|
+
* it synthesizes a discriminated-union TypeDeclInfo with variants
|
|
149
|
+
* ArrayBranch(arr: T[]) and NonArrayBranch(val: U) and returns the synthetic
|
|
150
|
+
* name in place of "T[] | U". The runtime discriminator is `Array.isArray`,
|
|
151
|
+
* lowered to a tag predicate by narrow/transform.
|
|
152
|
+
*
|
|
153
|
+
* Set in extractModule to the module's typeDecls; cleared at end. When null,
|
|
154
|
+
* typeToString falls through to the existing union path.
|
|
155
|
+
*/
|
|
156
|
+
let _synthArrayUnions = null;
|
|
157
|
+
/** Sanitize an arbitrary type-string fragment for use inside a generated identifier. */
|
|
158
|
+
function _synthName(elemName, otherName) {
|
|
159
|
+
const sanitize = (s) => s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
160
|
+
return `ArrayOf_${sanitize(elemName)}_Or_${sanitize(otherName)}`;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* String-level fallback for synth detection on a `T[] | U` shape, used by
|
|
164
|
+
* declare-type field parsing (where no ts-morph TypeNode is available). The
|
|
165
|
+
* format inside declare-type is user-controlled and structurally simple, so
|
|
166
|
+
* a split-on-` | ` is acceptable in that bounded context. Returns the synth
|
|
167
|
+
* name if matched, registers a TypeDeclInfo into the accumulator, else null.
|
|
168
|
+
*/
|
|
169
|
+
function _synthFromTsTypeString(ts) {
|
|
170
|
+
if (_synthArrayUnions === null)
|
|
171
|
+
return null;
|
|
172
|
+
if (!ts.includes(" | "))
|
|
173
|
+
return null;
|
|
174
|
+
const arms = ts.split(" | ").map(s => s.trim());
|
|
175
|
+
if (arms.length !== 2)
|
|
176
|
+
return null;
|
|
177
|
+
if (arms.some(a => a === "undefined" || a === "null"))
|
|
178
|
+
return null;
|
|
179
|
+
const arrIdx = arms.findIndex(a => a.endsWith("[]"));
|
|
180
|
+
if (arrIdx === -1)
|
|
181
|
+
return null;
|
|
182
|
+
const otherIdx = 1 - arrIdx;
|
|
183
|
+
if (arms[otherIdx].endsWith("[]"))
|
|
184
|
+
return null;
|
|
185
|
+
const elem = arms[arrIdx].slice(0, -2);
|
|
186
|
+
const other = arms[otherIdx];
|
|
187
|
+
const synthName = _synthName(elem, other);
|
|
188
|
+
if (!_synthArrayUnions.some(d => d.name === synthName)) {
|
|
189
|
+
_synthArrayUnions.push({
|
|
190
|
+
name: synthName,
|
|
191
|
+
kind: "discriminated-union",
|
|
192
|
+
discriminant: "__isArray__",
|
|
193
|
+
variants: [
|
|
194
|
+
{ name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elem}[]` }] },
|
|
195
|
+
{ name: "NonArrayBranch", fields: [{ name: "val", tsType: other }] },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return synthName;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Compute a tsType string from a syntactic union TypeNode (`T | U`), preserving
|
|
203
|
+
* the member nodes' source text (so `type ListId = number` stays `ListId`,
|
|
204
|
+
* which ts-morph erases when you read the resolved Type). When the union matches
|
|
205
|
+
* the `T[] | U` synth shape (U not array/undefined/null), registers a synth-
|
|
206
|
+
* array-union TypeDeclInfo and returns its synthetic name. Otherwise returns
|
|
207
|
+
* the syntactic join (so `ListId | undefined` stays a recognizable union for
|
|
208
|
+
* parseTsType to wrap as Option<ListId>).
|
|
209
|
+
*
|
|
210
|
+
* This is the param/return-type counterpart of the typeToString synthesis hook,
|
|
211
|
+
* which handles record/interface field types via the Type-driven path.
|
|
212
|
+
*/
|
|
213
|
+
function _tsTypeFromUnionNode(tn) {
|
|
214
|
+
if (!Node.isUnionTypeNode(tn))
|
|
215
|
+
return tn.getText();
|
|
216
|
+
const members = tn.getTypeNodes();
|
|
217
|
+
if (_synthArrayUnions !== null && members.length === 2) {
|
|
218
|
+
const arrIdx = members.findIndex(m => m.getType().isArray());
|
|
219
|
+
if (arrIdx !== -1) {
|
|
220
|
+
const other = members[1 - arrIdx];
|
|
221
|
+
const ot = other.getType();
|
|
222
|
+
if (!ot.isArray() && !ot.isUndefined() && !ot.isNull()) {
|
|
223
|
+
const arrNode = members[arrIdx];
|
|
224
|
+
const elemName = Node.isArrayTypeNode(arrNode)
|
|
225
|
+
? arrNode.getElementTypeNode().getText()
|
|
226
|
+
: typeToString(arrNode.getType().getArrayElementTypeOrThrow());
|
|
227
|
+
const otherName = other.getText();
|
|
228
|
+
const synthName = _synthName(elemName, otherName);
|
|
229
|
+
if (!_synthArrayUnions.some(d => d.name === synthName)) {
|
|
230
|
+
_synthArrayUnions.push({
|
|
231
|
+
name: synthName,
|
|
232
|
+
kind: "discriminated-union",
|
|
233
|
+
discriminant: "__isArray__",
|
|
234
|
+
variants: [
|
|
235
|
+
{ name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elemName}[]` }] },
|
|
236
|
+
{ name: "NonArrayBranch", fields: [{ name: "val", tsType: otherName }] },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return synthName;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return members.map(m => m.getText()).join(" | ");
|
|
245
|
+
}
|
|
18
246
|
/** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
|
|
19
247
|
let _typeParamMap = new Map();
|
|
248
|
+
// Fresh for-counter names allocated in the current function — so two sibling
|
|
249
|
+
// loops that both need renaming don't independently pick the same `<name>_N`
|
|
250
|
+
// (detection is read-only, so the source still shows the original names).
|
|
251
|
+
let _reservedForCounterNames = new Set();
|
|
20
252
|
function _eraseGenerics(tsType) {
|
|
21
253
|
if (_typeParamMap.size === 0)
|
|
22
254
|
return tsType;
|
|
@@ -125,6 +357,28 @@ function extractExpr(node) {
|
|
|
125
357
|
node.getArguments().length === 1) {
|
|
126
358
|
return extractExpr(node.getArguments()[0]);
|
|
127
359
|
}
|
|
360
|
+
// Math.max(...) / Math.min(...) with spread args → MaxOfSeq(seq) / MinOfSeq(seq)
|
|
361
|
+
// The spread is desugared at extract time; resolve and downstream passes
|
|
362
|
+
// see an ordinary function call.
|
|
363
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
364
|
+
callee.getExpression().getText() === "Math" &&
|
|
365
|
+
(callee.getName() === "max" || callee.getName() === "min")) {
|
|
366
|
+
const argNodes = node.getArguments();
|
|
367
|
+
if (argNodes.some(a => Node.isSpreadElement(a))) {
|
|
368
|
+
const combined = buildSpreadConcat(argNodes);
|
|
369
|
+
const fnName = callee.getName() === "max" ? "MaxOfSeq" : "MinOfSeq";
|
|
370
|
+
return { kind: "call", fn: { kind: "var", name: fnName }, args: [combined] };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Auto-extern: if the callee resolves (via ts-morph) to a symbol declared
|
|
374
|
+
// in a different `.ts` file, register it as an opaque extern. Covers both
|
|
375
|
+
// `Obj.method(...)` and bare `foo(...)` imports. Skipped for stdlib/.d.ts
|
|
376
|
+
// declarations — those are either built-in methods (handled in dafny-emit)
|
|
377
|
+
// or genuinely out of scope.
|
|
378
|
+
if (_currentSourceFile && _inFunctionExtraction &&
|
|
379
|
+
(Node.isPropertyAccessExpression(callee) || Node.isIdentifier(callee))) {
|
|
380
|
+
registerExternIfCrossFile(callee, _currentSourceFile);
|
|
381
|
+
}
|
|
128
382
|
const fn = extractExpr(callee);
|
|
129
383
|
const args = node.getArguments().map(a => extractExpr(a));
|
|
130
384
|
if (node.hasQuestionDotToken()) {
|
|
@@ -193,30 +447,8 @@ function extractExpr(node) {
|
|
|
193
447
|
if (!hasSpread) {
|
|
194
448
|
return { kind: "arrayLiteral", elems: elems.map(e => extractExpr(e)) };
|
|
195
449
|
}
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
const segments = [];
|
|
199
|
-
let currentLiterals = [];
|
|
200
|
-
for (const e of elems) {
|
|
201
|
-
if (Node.isSpreadElement(e)) {
|
|
202
|
-
if (currentLiterals.length > 0) {
|
|
203
|
-
segments.push({ kind: "arrayLiteral", elems: currentLiterals });
|
|
204
|
-
currentLiterals = [];
|
|
205
|
-
}
|
|
206
|
-
segments.push(extractExpr(e.getExpression()));
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
currentLiterals.push(extractExpr(e));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (currentLiterals.length > 0) {
|
|
213
|
-
segments.push({ kind: "arrayLiteral", elems: currentLiterals });
|
|
214
|
-
}
|
|
215
|
-
// Fold segments with arrayConcat
|
|
216
|
-
let result = segments[0];
|
|
217
|
-
for (let i = 1; i < segments.length; i++) {
|
|
218
|
-
result = { kind: "binop", op: "arrayConcat", left: result, right: segments[i] };
|
|
219
|
-
}
|
|
450
|
+
// [a, ...b, c] → [a] + b + [c] via shared helper
|
|
451
|
+
const result = buildSpreadConcat(elems);
|
|
220
452
|
return result;
|
|
221
453
|
}
|
|
222
454
|
// Object literal: { res: true, done: false } or { ...obj, res: true }
|
|
@@ -239,7 +471,10 @@ function extractExpr(node) {
|
|
|
239
471
|
computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
|
|
240
472
|
}
|
|
241
473
|
else if (init) {
|
|
242
|
-
|
|
474
|
+
// String-literal keys (e.g. `"bun run": 3`) — use the unquoted literal
|
|
475
|
+
// value; otherwise `prop.getName()` may include surrounding quotes.
|
|
476
|
+
const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName();
|
|
477
|
+
fields.push({ name, value: extractExpr(init) });
|
|
243
478
|
}
|
|
244
479
|
}
|
|
245
480
|
}
|
|
@@ -345,6 +580,27 @@ function collectAnnotations(node, body) {
|
|
|
345
580
|
return [...own, ...parseAnnotations(body[0])];
|
|
346
581
|
return own;
|
|
347
582
|
}
|
|
583
|
+
// A loop's `//@ invariant`/`decreases`/`done_with` annotations live as leading
|
|
584
|
+
// comments of its first body statement — never on the loop node itself. (The
|
|
585
|
+
// loop node's own leading comments belong to whatever precedes it; when the
|
|
586
|
+
// loop is the first statement of an *enclosing* loop, those comments are the
|
|
587
|
+
// enclosing loop's invariants, which must not leak in.) So collect from the
|
|
588
|
+
// body alone, unlike `collectAnnotations`, which also reads the node (needed for
|
|
589
|
+
// functions, whose specs may precede the declaration).
|
|
590
|
+
function collectLoopAnnotations(body) {
|
|
591
|
+
return body.length > 0 ? parseAnnotations(body[0]) : [];
|
|
592
|
+
}
|
|
593
|
+
/** All `//@ ` annotations for a function-like node, regardless of whether its
|
|
594
|
+
* body is a block (annotations on the first statement) or an expression-body
|
|
595
|
+
* arrow (annotations only on the declaration). Used both for in-file function
|
|
596
|
+
* extraction and for pulling specs off cross-file externs. */
|
|
597
|
+
function collectFunctionAnnotations(fn) {
|
|
598
|
+
const body = fn.getBody?.();
|
|
599
|
+
if (body && Node.isBlock(body)) {
|
|
600
|
+
return collectAnnotations(fn, body.getStatements());
|
|
601
|
+
}
|
|
602
|
+
return collectAnnotations(fn);
|
|
603
|
+
}
|
|
348
604
|
/** Check for bare `//@ pure` annotation (no expression). */
|
|
349
605
|
function hasPureAnnotation(node, body) {
|
|
350
606
|
const nodes = body && body.length > 0 ? [node, body[0]] : [node];
|
|
@@ -362,6 +618,12 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
362
618
|
const type = decl.getType();
|
|
363
619
|
const typeParams = decl.getTypeParameters().map(tp => tp.getName());
|
|
364
620
|
const tpField = typeParams.length > 0 ? typeParams : undefined;
|
|
621
|
+
// Leading `//@ type <ty>` overrides extraction — the declared TS type is
|
|
622
|
+
// replaced by the annotated backend type. Used to coerce literal unions
|
|
623
|
+
// (`5 | 15 | 30` → `nat`) and other types LS can't model precisely.
|
|
624
|
+
const override = parseAnnotations(decl).find(a => a.kind === "type");
|
|
625
|
+
if (override)
|
|
626
|
+
return { name, typeParams: tpField, kind: "alias", aliasOf: override.expr };
|
|
365
627
|
if (type.isUnion()) {
|
|
366
628
|
const members = type.getUnionTypes();
|
|
367
629
|
if (members.every(m => m.isStringLiteral())) {
|
|
@@ -379,7 +641,12 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
379
641
|
for (const prop of m.getProperties()) {
|
|
380
642
|
if (prop.getName() === discriminant)
|
|
381
643
|
continue;
|
|
382
|
-
|
|
644
|
+
let tsType = typeToString(prop.getTypeAtLocation(decl));
|
|
645
|
+
const propDecl = prop.getDeclarations()[0];
|
|
646
|
+
if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
|
|
647
|
+
tsType = `${tsType} | undefined`;
|
|
648
|
+
}
|
|
649
|
+
fields.push({ name: prop.getName(), tsType });
|
|
383
650
|
}
|
|
384
651
|
return { name: tag, fields };
|
|
385
652
|
});
|
|
@@ -411,6 +678,29 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
411
678
|
}
|
|
412
679
|
}
|
|
413
680
|
}
|
|
681
|
+
// Function-type alias: `type Comparator = (a: T, b: T) => boolean` —
|
|
682
|
+
// ts-morph reports these as object-typed with a call signature and no
|
|
683
|
+
// user-visible properties. Emit as a Dafny `type X = (...) -> R` alias.
|
|
684
|
+
if (type.isObject()) {
|
|
685
|
+
const sig = type.getCallSignatures()[0];
|
|
686
|
+
const hasProps = type.getProperties().length > 0;
|
|
687
|
+
if (sig && !hasProps) {
|
|
688
|
+
// Synthesize fresh param names — the ts-morph parser used downstream
|
|
689
|
+
// needs `name: T` syntax; bare `T` is read as a param name with `any`.
|
|
690
|
+
const params = sig.getParameters().map((p, i) => `_p${i}: ${typeToString(p.getTypeAtLocation(decl))}`);
|
|
691
|
+
const ret = typeToString(sig.getReturnType());
|
|
692
|
+
return { name, kind: "alias", aliasOf: `(${params.join(", ")}) => ${ret}` };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Array-type alias: `type Board = number[]` → alias to the seq type, not a
|
|
696
|
+
// record. Arrays report as object types, so this must precede the record
|
|
697
|
+
// branch, which would otherwise enumerate the Array prototype as fields.
|
|
698
|
+
// Build the element type directly: typeToString(type) would return the
|
|
699
|
+
// alias's own name (via getAliasSymbol), yielding a self-referential alias.
|
|
700
|
+
if (type.isArray()) {
|
|
701
|
+
const elem = type.getArrayElementTypeOrThrow();
|
|
702
|
+
return { name, typeParams: tpField, kind: "alias", aliasOf: `${typeToString(elem)}[]` };
|
|
703
|
+
}
|
|
414
704
|
if (type.isObject() || type.isIntersection())
|
|
415
705
|
return extractRecord(name, type, decl, undefined, extraDecls);
|
|
416
706
|
// Primitive type alias: type TaskId = number → alias
|
|
@@ -445,6 +735,13 @@ function extractRecord(name, type, locationNode, overrides, extraDecls) {
|
|
|
445
735
|
}
|
|
446
736
|
const propType = prop.getTypeAtLocation(locationNode);
|
|
447
737
|
let tsType = typeToString(propType);
|
|
738
|
+
// Optional property: `foo?: T` reports as `T` (ts-morph strips the
|
|
739
|
+
// `| undefined` from a question-token type). Add it back so the field
|
|
740
|
+
// resolves to `Optional<T>`.
|
|
741
|
+
const propDecl = prop.getDeclarations()[0];
|
|
742
|
+
if (propDecl && propDecl.hasQuestionToken?.() && !tsType.includes(" | undefined")) {
|
|
743
|
+
tsType = `${tsType} | undefined`;
|
|
744
|
+
}
|
|
448
745
|
// Inline anonymous object types: ts-morph names them __type.
|
|
449
746
|
// Generate a synthetic named record and reference it by name instead.
|
|
450
747
|
if (extraDecls && tsType.includes("__type")) {
|
|
@@ -510,7 +807,37 @@ function typeToString(type) {
|
|
|
510
807
|
return name;
|
|
511
808
|
}
|
|
512
809
|
if (type.isUnion()) {
|
|
513
|
-
const
|
|
810
|
+
const unionTypes = type.getUnionTypes();
|
|
811
|
+
// `T[] | U` synthesis: exactly two members, one array, the other not
|
|
812
|
+
// array/undefined/null. Detected via ts-morph type predicates (no string
|
|
813
|
+
// parsing). Registers a discriminated-union TypeDeclInfo with variants
|
|
814
|
+
// ArrayBranch(arr: T[]) and NonArrayBranch(val: U); returns the synthetic
|
|
815
|
+
// name so all downstream tsType slots agree. `T[] | undefined` and
|
|
816
|
+
// `T[] | T[]` fall through to the existing path unchanged.
|
|
817
|
+
if (_synthArrayUnions !== null && unionTypes.length === 2) {
|
|
818
|
+
const [m0, m1] = unionTypes;
|
|
819
|
+
const arrayMember = m0.isArray() ? m0 : (m1.isArray() ? m1 : null);
|
|
820
|
+
const otherMember = arrayMember === m0 ? m1 : m0;
|
|
821
|
+
if (arrayMember && otherMember && !otherMember.isArray()
|
|
822
|
+
&& !otherMember.isUndefined() && !otherMember.isNull()) {
|
|
823
|
+
const elemName = typeToString(arrayMember.getArrayElementTypeOrThrow());
|
|
824
|
+
const otherName = typeToString(otherMember);
|
|
825
|
+
const synthName = _synthName(elemName, otherName);
|
|
826
|
+
if (!_synthArrayUnions.some(d => d.name === synthName)) {
|
|
827
|
+
_synthArrayUnions.push({
|
|
828
|
+
name: synthName,
|
|
829
|
+
kind: "discriminated-union",
|
|
830
|
+
discriminant: "__isArray__",
|
|
831
|
+
variants: [
|
|
832
|
+
{ name: "ArrayBranch", fields: [{ name: "arr", tsType: `${elemName}[]` }] },
|
|
833
|
+
{ name: "NonArrayBranch", fields: [{ name: "val", tsType: otherName }] },
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return synthName;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const parts = [...new Set(unionTypes.map(typeToString))];
|
|
514
841
|
return parts.join(" | ");
|
|
515
842
|
}
|
|
516
843
|
if (type.isTuple()) {
|
|
@@ -540,7 +867,171 @@ function typeToString(type) {
|
|
|
540
867
|
}
|
|
541
868
|
const COMPOUND_OPS = {
|
|
542
869
|
"+=": "+", "-=": "-", "*=": "*", "/=": "/", "%=": "%",
|
|
870
|
+
"<<=": "<<", ">>=": ">>", "|=": "|", "&=": "&", "^=": "^", "**=": "**",
|
|
543
871
|
};
|
|
872
|
+
/** Desugar a statement-position side-effecting expression — `x = e`, `x += e`,
|
|
873
|
+
* `i++`, `arr[i] = v`, etc. — into a `RawAssign`. Returns null when no shape
|
|
874
|
+
* match (caller emits a plain `{kind: "expr"}` or errors). Called by both
|
|
875
|
+
* `ExpressionStatement` extraction (wrapped) and the C-style for-loop
|
|
876
|
+
* incrementor (bare Expression — same shape, no `;` wrapper). */
|
|
877
|
+
function desugarStmtExpr(expr, line) {
|
|
878
|
+
if (Node.isBinaryExpression(expr)) {
|
|
879
|
+
const opText = expr.getOperatorToken().getText();
|
|
880
|
+
const left = expr.getLeft();
|
|
881
|
+
if (opText === "=" && Node.isElementAccessExpression(left)) {
|
|
882
|
+
const obj = extractExpr(left.getExpression());
|
|
883
|
+
const idx = extractExpr(left.getArgumentExpression());
|
|
884
|
+
const val = extractExpr(expr.getRight());
|
|
885
|
+
const target = left.getExpression().getText();
|
|
886
|
+
const withCall = { kind: "call", fn: { kind: "field", obj, field: "with" }, args: [idx, val] };
|
|
887
|
+
return { kind: "assign", target, value: withCall, line };
|
|
888
|
+
}
|
|
889
|
+
if (opText === "=") {
|
|
890
|
+
return { kind: "assign", target: left.getText(), value: extractExpr(expr.getRight()), line };
|
|
891
|
+
}
|
|
892
|
+
const compound = COMPOUND_OPS[opText];
|
|
893
|
+
if (compound) {
|
|
894
|
+
const target = left.getText();
|
|
895
|
+
return {
|
|
896
|
+
kind: "assign", target,
|
|
897
|
+
value: { kind: "binop", op: compound, left: { kind: "var", name: target }, right: extractExpr(expr.getRight()) },
|
|
898
|
+
line,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if ((Node.isPostfixUnaryExpression(expr) || Node.isPrefixUnaryExpression(expr)) &&
|
|
903
|
+
(expr.getOperatorToken() === SyntaxKind.PlusPlusToken || expr.getOperatorToken() === SyntaxKind.MinusMinusToken)) {
|
|
904
|
+
const target = expr.getOperand().getText();
|
|
905
|
+
const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
|
|
906
|
+
return {
|
|
907
|
+
kind: "assign", target,
|
|
908
|
+
value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } },
|
|
909
|
+
line,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
// A C-style `for (let i …)` counter is hoisted out of its loop scope into the
|
|
915
|
+
// enclosing block (the desugar runs `init; while (cond) { body; update }`). If
|
|
916
|
+
// another binding of that name shares the hoisted scope — a sibling `let i`, or
|
|
917
|
+
// an enclosing for-loop whose own `i` stays live where the inner counter and the
|
|
918
|
+
// outer update sit — the two collapse to one Dafny `var i` ("Duplicate
|
|
919
|
+
// local-variable name", or a silently-misbound update). When that happens we
|
|
920
|
+
// rename this counter to a fresh `<name>_N` in the loop's own desugared pieces.
|
|
921
|
+
//
|
|
922
|
+
// `forCounterRename` is a read-only scope check (no AST mutation): it returns the
|
|
923
|
+
// fresh name when this counter would collide, else null — so a non-conflicting
|
|
924
|
+
// loop keeps its name and output is byte-identical. The rename itself is applied
|
|
925
|
+
// to the extracted Raw IR by `renameRawStmts` / `renameRawExpr` (code) and
|
|
926
|
+
// `renameSpec` (the `//@` strings, still unparsed at this phase).
|
|
927
|
+
function forCounterRename(decl, forStmt) {
|
|
928
|
+
const name = decl.getName();
|
|
929
|
+
const fnLike = forStmt.getFirstAncestor(a => Node.isFunctionDeclaration(a) || Node.isArrowFunction(a) ||
|
|
930
|
+
Node.isFunctionExpression(a) || Node.isMethodDeclaration(a));
|
|
931
|
+
const scopeRoot = fnLike ?? forStmt.getSourceFile();
|
|
932
|
+
const isScope = (a) => Node.isBlock(a) || Node.isSourceFile(a) ||
|
|
933
|
+
Node.isForStatement(a) || Node.isForOfStatement(a) || Node.isForInStatement(a);
|
|
934
|
+
// Scopes that enclose this loop — its counter, once hoisted, lives in one of
|
|
935
|
+
// these, so a same-named binding scoped here would collide.
|
|
936
|
+
const enclosing = new Set(forStmt.getAncestors());
|
|
937
|
+
const paramClash = fnLike?.getParameters?.().some((p) => p.getName() === name) ?? false;
|
|
938
|
+
const declClash = scopeRoot.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some(d => {
|
|
939
|
+
if (d === decl || d.getName() !== name)
|
|
940
|
+
return false;
|
|
941
|
+
const scope = d.getFirstAncestor(isScope);
|
|
942
|
+
return !!scope && enclosing.has(scope);
|
|
943
|
+
});
|
|
944
|
+
if (!paramClash && !declClash)
|
|
945
|
+
return null;
|
|
946
|
+
const used = new Set(scopeRoot.getDescendantsOfKind(SyntaxKind.Identifier).map(i => i.getText()));
|
|
947
|
+
let n = 2;
|
|
948
|
+
while (used.has(`${name}_${n}`) || _reservedForCounterNames.has(`${name}_${n}`))
|
|
949
|
+
n++;
|
|
950
|
+
const fresh = `${name}_${n}`;
|
|
951
|
+
_reservedForCounterNames.add(fresh);
|
|
952
|
+
return fresh;
|
|
953
|
+
}
|
|
954
|
+
/** Whole-word rename of an identifier inside an unparsed `//@` spec string. */
|
|
955
|
+
function renameSpec(s, from, to) {
|
|
956
|
+
return s.replace(new RegExp(`\\b${from}\\b`, "g"), to);
|
|
957
|
+
}
|
|
958
|
+
/** Rename free references to `from` → `to` in a Raw expression, stopping under a
|
|
959
|
+
* lambda / quantifier that re-binds the name (it shadows). */
|
|
960
|
+
function renameRawExpr(e, from, to) {
|
|
961
|
+
const r = (x) => renameRawExpr(x, from, to);
|
|
962
|
+
switch (e.kind) {
|
|
963
|
+
case "var": return e.name === from ? { kind: "var", name: to } : e;
|
|
964
|
+
case "num":
|
|
965
|
+
case "str":
|
|
966
|
+
case "bool":
|
|
967
|
+
case "result":
|
|
968
|
+
case "havoc":
|
|
969
|
+
case "emptyCollection": return e;
|
|
970
|
+
case "binop": return { ...e, left: r(e.left), right: r(e.right) };
|
|
971
|
+
case "unop": return { ...e, expr: r(e.expr) };
|
|
972
|
+
case "call": return { ...e, fn: r(e.fn), args: e.args.map(r) };
|
|
973
|
+
case "index": return { ...e, obj: r(e.obj), idx: r(e.idx) };
|
|
974
|
+
case "field": return { ...e, obj: r(e.obj) };
|
|
975
|
+
case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(f => ({ ...f, value: r(f.value) })) };
|
|
976
|
+
case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
|
|
977
|
+
case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
|
|
978
|
+
case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
|
|
979
|
+
case "nonNull": return { ...e, expr: r(e.expr) };
|
|
980
|
+
case "optChain": return { ...e, obj: r(e.obj), chain: e.chain.map(c => c.kind === "call" ? { ...c, args: c.args.map(r) } : c.kind === "index" ? { ...c, idx: r(c.idx) } : c) };
|
|
981
|
+
case "lambda": return e.params.some(p => p.name === from) ? e
|
|
982
|
+
: { ...e, body: Array.isArray(e.body) ? renameRawStmts(e.body, from, to) : r(e.body) };
|
|
983
|
+
case "forall":
|
|
984
|
+
case "exists": return e.var === from ? e : { ...e, body: r(e.body) };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/** Rename references to `from` → `to` across a Raw statement (code refs via
|
|
988
|
+
* `renameRawExpr`, `//@` strings via `renameSpec`). Statement lists stop
|
|
989
|
+
* renaming once a `let from` re-declares the name (it shadows). */
|
|
990
|
+
function renameRawStmt(s, from, to) {
|
|
991
|
+
const r = (x) => renameRawExpr(x, from, to);
|
|
992
|
+
const sp = (x) => renameSpec(x, from, to);
|
|
993
|
+
switch (s.kind) {
|
|
994
|
+
case "let": return { ...s, init: r(s.init) }; // name kept: a shadowing binding is a different variable
|
|
995
|
+
case "assign": return { ...s, target: s.target === from ? to : s.target, value: r(s.value) };
|
|
996
|
+
case "return": return { ...s, value: r(s.value) };
|
|
997
|
+
case "expr": return { ...s, expr: r(s.expr) };
|
|
998
|
+
case "if": return { ...s, cond: r(s.cond), then: renameRawStmts(s.then, from, to), else: renameRawStmts(s.else, from, to) };
|
|
999
|
+
case "while": return { ...s, cond: r(s.cond), invariants: s.invariants.map(sp),
|
|
1000
|
+
decreases: s.decreases ? sp(s.decreases) : null, doneWith: s.doneWith ? sp(s.doneWith) : null,
|
|
1001
|
+
body: renameRawStmts(s.body, from, to) };
|
|
1002
|
+
case "forof": return e_forof(s);
|
|
1003
|
+
case "switch": return { ...s, expr: r(s.expr), cases: s.cases.map(c => ({ ...c, body: renameRawStmts(c.body, from, to) })),
|
|
1004
|
+
defaultBody: renameRawStmts(s.defaultBody, from, to) };
|
|
1005
|
+
case "ghostLet": return { ...s, init: sp(s.init) };
|
|
1006
|
+
case "ghostAssign": return { ...s, target: s.target === from ? to : s.target, value: sp(s.value) };
|
|
1007
|
+
case "assert": return { ...s, expr: sp(s.expr) };
|
|
1008
|
+
case "break":
|
|
1009
|
+
case "continue":
|
|
1010
|
+
case "throw": return s;
|
|
1011
|
+
}
|
|
1012
|
+
function e_forof(f) {
|
|
1013
|
+
// for-of names bind in the body; if one shadows `from`, leave the body alone.
|
|
1014
|
+
const body = f.names.includes(from) ? f.body : renameRawStmts(f.body, from, to);
|
|
1015
|
+
return { ...f, iterable: r(f.iterable), invariants: f.invariants.map(sp),
|
|
1016
|
+
doneWith: f.doneWith ? sp(f.doneWith) : null, body };
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** Rename `from` → `to` through a statement list, stopping after a `let from`
|
|
1020
|
+
* shadows it (subsequent references are a different variable). */
|
|
1021
|
+
function renameRawStmts(stmts, from, to) {
|
|
1022
|
+
const out = [];
|
|
1023
|
+
let active = true;
|
|
1024
|
+
for (const s of stmts) {
|
|
1025
|
+
if (!active) {
|
|
1026
|
+
out.push(s);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
out.push(renameRawStmt(s, from, to));
|
|
1030
|
+
if (s.kind === "let" && s.name === from)
|
|
1031
|
+
active = false;
|
|
1032
|
+
}
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
544
1035
|
// ── Statement extraction ─────────────────────────────────────
|
|
545
1036
|
/** Parse ghost and assert annotations from comment ranges. */
|
|
546
1037
|
function parseSpecComments(ranges, line) {
|
|
@@ -555,13 +1046,19 @@ function parseSpecComments(ranges, line) {
|
|
|
555
1046
|
result.push({ kind: "assert", expr: content.slice(7).trim(), line });
|
|
556
1047
|
continue;
|
|
557
1048
|
}
|
|
1049
|
+
// assume expr — trusted form of assert; emitted as `assume P;` in Dafny.
|
|
1050
|
+
if (content.startsWith("assume ")) {
|
|
1051
|
+
result.push({ kind: "assert", expr: content.slice(7).trim(), line, assumed: true });
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
558
1054
|
if (!content.startsWith("ghost "))
|
|
559
1055
|
continue;
|
|
560
1056
|
const ghostBody = content.slice(6).trim();
|
|
561
1057
|
// ghost let varName: type = expr OR ghost let varName = expr
|
|
562
|
-
|
|
1058
|
+
// Type segment accepts compound forms like `number[]`, `Map<K,V>`, etc.
|
|
1059
|
+
const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*([^=]+?))?\s*=\s*(.+)$/);
|
|
563
1060
|
if (letMatch) {
|
|
564
|
-
result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
|
|
1061
|
+
result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2]?.trim() ?? null, init: letMatch[3].trim(), line });
|
|
565
1062
|
continue;
|
|
566
1063
|
}
|
|
567
1064
|
// ghost varName = expr
|
|
@@ -572,6 +1069,34 @@ function parseSpecComments(ranges, line) {
|
|
|
572
1069
|
}
|
|
573
1070
|
return result;
|
|
574
1071
|
}
|
|
1072
|
+
/** Splice `update` before each `continue` in the same loop scope. Recurses
|
|
1073
|
+
* into `if`/`switch` (same scope) but not nested `while`/`forof` (they own
|
|
1074
|
+
* their own continue). Used by the C-style `for` desugar so a `continue`
|
|
1075
|
+
* doesn't skip the loop update. The `update` node is shared by reference —
|
|
1076
|
+
* the IR is transformed functionally downstream (no in-place mutation), so
|
|
1077
|
+
* aliasing it across the body is safe. */
|
|
1078
|
+
function insertUpdateBeforeContinue(stmts, update) {
|
|
1079
|
+
const out = [];
|
|
1080
|
+
for (const s of stmts) {
|
|
1081
|
+
if (s.kind === "continue") {
|
|
1082
|
+
out.push(update, s);
|
|
1083
|
+
}
|
|
1084
|
+
else if (s.kind === "if") {
|
|
1085
|
+
out.push({ ...s, then: insertUpdateBeforeContinue(s.then, update), else: insertUpdateBeforeContinue(s.else, update) });
|
|
1086
|
+
}
|
|
1087
|
+
else if (s.kind === "switch") {
|
|
1088
|
+
out.push({
|
|
1089
|
+
...s,
|
|
1090
|
+
cases: s.cases.map(c => ({ ...c, body: insertUpdateBeforeContinue(c.body, update) })),
|
|
1091
|
+
defaultBody: insertUpdateBeforeContinue(s.defaultBody, update),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
out.push(s);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
575
1100
|
function extractStmts(stmts) {
|
|
576
1101
|
const result = [];
|
|
577
1102
|
for (const s of stmts) {
|
|
@@ -609,6 +1134,89 @@ function extractStmts(stmts) {
|
|
|
609
1134
|
}
|
|
610
1135
|
continue;
|
|
611
1136
|
}
|
|
1137
|
+
// Array destructuring: const [a, , c, ...rest] = arr → individual lets,
|
|
1138
|
+
// each picking from `arr` by position. Rest (if present) must be last
|
|
1139
|
+
// and emits as `arr.slice(N)`. Omitted slots (`,,`) are skipped. Nested
|
|
1140
|
+
// binding patterns throw — extend the helper here when a case study
|
|
1141
|
+
// hits them.
|
|
1142
|
+
if (!isHavoc && Node.isArrayBindingPattern(nameNode)) {
|
|
1143
|
+
const elements = nameNode.getElements();
|
|
1144
|
+
const initializer = d.getInitializer();
|
|
1145
|
+
if (initializer) {
|
|
1146
|
+
let initExpr = extractExpr(initializer);
|
|
1147
|
+
let initVar = initExpr;
|
|
1148
|
+
if (initExpr.kind !== "var") {
|
|
1149
|
+
const tempName = `_destr${_destrCounter++}`;
|
|
1150
|
+
const initTs = _eraseGenerics(typeToString(initializer.getType()));
|
|
1151
|
+
result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
|
|
1152
|
+
initVar = { kind: "var", name: tempName };
|
|
1153
|
+
}
|
|
1154
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1155
|
+
const el = elements[i];
|
|
1156
|
+
if (Node.isOmittedExpression(el))
|
|
1157
|
+
continue;
|
|
1158
|
+
if (!Node.isBindingElement(el))
|
|
1159
|
+
continue;
|
|
1160
|
+
const inner = el.getNameNode();
|
|
1161
|
+
if (!Node.isIdentifier(inner)) {
|
|
1162
|
+
throw new Error(`nested binding pattern in array destructuring not yet supported: ${el.getText()}`);
|
|
1163
|
+
}
|
|
1164
|
+
const name = inner.getText();
|
|
1165
|
+
const isRest = !!el.getDotDotDotToken();
|
|
1166
|
+
const elTs = _eraseGenerics(typeToString(el.getType()));
|
|
1167
|
+
const init = isRest
|
|
1168
|
+
? { kind: "call",
|
|
1169
|
+
fn: { kind: "field", obj: initVar, field: "slice" },
|
|
1170
|
+
args: [{ kind: "num", value: i }] }
|
|
1171
|
+
: { kind: "index", obj: initVar, idx: { kind: "num", value: i } };
|
|
1172
|
+
result.push({ kind: "let", name, mutable: s.getDeclarationKind() === "let", tsType: elTs, init, line });
|
|
1173
|
+
}
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// Plain object destructuring: const { a, b, c } = obj → field access lets.
|
|
1178
|
+
// Skipped if any element has a computed property (handled by the rest+
|
|
1179
|
+
// computed branch below) or a rest element (also handled below).
|
|
1180
|
+
if (!isHavoc && Node.isObjectBindingPattern(nameNode)) {
|
|
1181
|
+
const elements = nameNode.getElements();
|
|
1182
|
+
const hasRest = elements.some(el => el.getDotDotDotToken());
|
|
1183
|
+
const hasComputed = elements.some(el => {
|
|
1184
|
+
const pn = el.getPropertyNameNode();
|
|
1185
|
+
return pn && Node.isComputedPropertyName(pn);
|
|
1186
|
+
});
|
|
1187
|
+
if (!hasRest && !hasComputed) {
|
|
1188
|
+
const initializer = d.getInitializer();
|
|
1189
|
+
if (initializer) {
|
|
1190
|
+
let initExpr = extractExpr(initializer);
|
|
1191
|
+
let initVar = initExpr;
|
|
1192
|
+
if (initExpr.kind !== "var") {
|
|
1193
|
+
const tempName = `_destr${_destrCounter++}`;
|
|
1194
|
+
const initTs = _eraseGenerics(typeToString(initializer.getType()));
|
|
1195
|
+
result.push({ kind: "let", name: tempName, mutable: false, tsType: initTs, init: initExpr, line });
|
|
1196
|
+
initVar = { kind: "var", name: tempName };
|
|
1197
|
+
}
|
|
1198
|
+
for (const el of elements) {
|
|
1199
|
+
const inner = el.getNameNode();
|
|
1200
|
+
if (!Node.isIdentifier(inner)) {
|
|
1201
|
+
throw new Error(`nested binding pattern in object destructuring not yet supported: ${el.getText()}`);
|
|
1202
|
+
}
|
|
1203
|
+
const localName = inner.getText();
|
|
1204
|
+
const propNode = el.getPropertyNameNode();
|
|
1205
|
+
const fieldName = propNode ? propNode.getText() : localName;
|
|
1206
|
+
const elTs = _eraseGenerics(typeToString(el.getType()));
|
|
1207
|
+
result.push({
|
|
1208
|
+
kind: "let",
|
|
1209
|
+
name: localName,
|
|
1210
|
+
mutable: s.getDeclarationKind() === "let",
|
|
1211
|
+
tsType: elTs,
|
|
1212
|
+
init: { kind: "field", obj: initVar, field: fieldName },
|
|
1213
|
+
line,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
612
1220
|
// Destructuring rest: const { [k]: _, ...rest } = map → let rest = map.delete(k)
|
|
613
1221
|
if (!isHavoc && Node.isObjectBindingPattern(nameNode)) {
|
|
614
1222
|
const elements = nameNode.getElements();
|
|
@@ -651,14 +1259,56 @@ function extractStmts(stmts) {
|
|
|
651
1259
|
else {
|
|
652
1260
|
const initializer = d.getInitializer();
|
|
653
1261
|
_havocKey = havocKey;
|
|
654
|
-
|
|
1262
|
+
if (initializer) {
|
|
1263
|
+
init = extractExpr(initializer);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
// No initializer — emit a type-appropriate default so the emitted
|
|
1267
|
+
// Dafny binding `var x: T := <default>;` typechecks. The empty-
|
|
1268
|
+
// collection cases are picked up by dafny-emit's let case, which
|
|
1269
|
+
// adds an explicit `: T` annotation for inference.
|
|
1270
|
+
const tsType = _eraseGenerics(d.getTypeNode()?.getText() ?? typeToString(declType));
|
|
1271
|
+
const isOptional = / \| (null|undefined)\b/.test(tsType)
|
|
1272
|
+
|| /^(null|undefined) \| /.test(tsType)
|
|
1273
|
+
|| tsType.endsWith(" | undefined") || tsType.endsWith(" | null");
|
|
1274
|
+
const isArray = tsType.endsWith("[]") || /^Array</.test(tsType) || /^readonly /.test(tsType);
|
|
1275
|
+
const isMap = /^Map</.test(tsType);
|
|
1276
|
+
const isSet = /^Set</.test(tsType);
|
|
1277
|
+
if (isOptional)
|
|
1278
|
+
init = { kind: "var", name: "undefined" };
|
|
1279
|
+
else if (isArray)
|
|
1280
|
+
init = { kind: "arrayLiteral", elems: [] };
|
|
1281
|
+
else if (isMap)
|
|
1282
|
+
init = { kind: "emptyCollection", collectionType: "Map", tsType };
|
|
1283
|
+
else if (isSet)
|
|
1284
|
+
init = { kind: "emptyCollection", collectionType: "Set", tsType };
|
|
1285
|
+
else if (tsType === "number")
|
|
1286
|
+
init = { kind: "num", value: 0 };
|
|
1287
|
+
else if (tsType === "boolean")
|
|
1288
|
+
init = { kind: "bool", value: false };
|
|
1289
|
+
else if (tsType === "string")
|
|
1290
|
+
init = { kind: "str", value: "" };
|
|
1291
|
+
else
|
|
1292
|
+
init = { kind: "var", name: "default" };
|
|
1293
|
+
}
|
|
655
1294
|
_havocKey = null;
|
|
656
1295
|
}
|
|
1296
|
+
// Use the source-level type annotation if present — ts-morph's
|
|
1297
|
+
// `d.getType()` strips `| undefined` from optional annotations.
|
|
1298
|
+
// When no annotation, fall back to ts-morph's inferred type — except
|
|
1299
|
+
// when the inference collapses to `any` (e.g. brownfield imports
|
|
1300
|
+
// where the imported declaration's shape is opaque to LS): in that
|
|
1301
|
+
// case leave null so resolve infers from the initializer's IR type
|
|
1302
|
+
// (which sees the declare-type stubs).
|
|
1303
|
+
const annotatedText = d.getTypeNode()?.getText();
|
|
1304
|
+
const inferred = annotatedText ? null : _eraseGenerics(typeToString(declType));
|
|
1305
|
+
const tsType = havocType
|
|
1306
|
+
?? (annotatedText ? _eraseGenerics(annotatedText) : (inferred === "any" ? null : inferred));
|
|
657
1307
|
result.push({
|
|
658
1308
|
kind: "let",
|
|
659
1309
|
name: d.getName(),
|
|
660
1310
|
mutable: s.getDeclarationKind() === "let",
|
|
661
|
-
tsType
|
|
1311
|
+
tsType,
|
|
662
1312
|
init,
|
|
663
1313
|
line,
|
|
664
1314
|
});
|
|
@@ -667,8 +1317,10 @@ function extractStmts(stmts) {
|
|
|
667
1317
|
}
|
|
668
1318
|
if (Node.isWhileStatement(s)) {
|
|
669
1319
|
const bodyNode = s.getStatement();
|
|
670
|
-
|
|
671
|
-
|
|
1320
|
+
// A braceless body (`while (c) stmt`) is a single statement, not a Block;
|
|
1321
|
+
// wrap it so it isn't dropped (mirrors the for / for-of / if handlers).
|
|
1322
|
+
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
1323
|
+
const annots = collectLoopAnnotations(bodyStmts);
|
|
672
1324
|
result.push({
|
|
673
1325
|
kind: "while",
|
|
674
1326
|
cond: extractExpr(s.getExpression()),
|
|
@@ -719,7 +1371,7 @@ function extractStmts(stmts) {
|
|
|
719
1371
|
}
|
|
720
1372
|
const bodyNode = s.getStatement();
|
|
721
1373
|
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
722
|
-
const annots =
|
|
1374
|
+
const annots = collectLoopAnnotations(bodyStmts);
|
|
723
1375
|
result.push({
|
|
724
1376
|
kind: "forof",
|
|
725
1377
|
names,
|
|
@@ -731,6 +1383,83 @@ function extractStmts(stmts) {
|
|
|
731
1383
|
});
|
|
732
1384
|
continue;
|
|
733
1385
|
}
|
|
1386
|
+
// C-style for(init; cond; update) — desugar to:
|
|
1387
|
+
// init;
|
|
1388
|
+
// while (cond) { body; update }
|
|
1389
|
+
// The init's binding is forced mutable (update mutates it). The update is
|
|
1390
|
+
// a bare Expression in ts-morph (not wrapped in an ExpressionStatement),
|
|
1391
|
+
// so we route it through the same `desugarStmtExpr` helper that the
|
|
1392
|
+
// ExpressionStatement branch above uses — `i++` etc. end up as RawAssign
|
|
1393
|
+
// exactly as they would if written as their own statement.
|
|
1394
|
+
if (Node.isForStatement(s)) {
|
|
1395
|
+
const init = s.getInitializer();
|
|
1396
|
+
const cond = s.getCondition();
|
|
1397
|
+
const incrementor = s.getIncrementor();
|
|
1398
|
+
const bodyNode = s.getStatement();
|
|
1399
|
+
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
1400
|
+
const annots = collectLoopAnnotations(bodyStmts);
|
|
1401
|
+
if (!init || !Node.isVariableDeclarationList(init))
|
|
1402
|
+
throw new Error(`for(...) at line ${line}: only variable-declaration init supported`);
|
|
1403
|
+
// Hoist the counter declarations, renaming any that would collide once
|
|
1404
|
+
// lifted out of the loop scope (see forCounterRename). The renames are
|
|
1405
|
+
// then applied to the loop's own desugared pieces below.
|
|
1406
|
+
const hoisted = [];
|
|
1407
|
+
const renames = [];
|
|
1408
|
+
for (const decl of init.getDeclarations()) {
|
|
1409
|
+
const fresh = forCounterRename(decl, s);
|
|
1410
|
+
const name = fresh ?? decl.getName();
|
|
1411
|
+
if (fresh)
|
|
1412
|
+
renames.push([decl.getName(), fresh]);
|
|
1413
|
+
const tsType = decl.getTypeNode()?.getText() ?? typeToString(decl.getType());
|
|
1414
|
+
const initExpr = decl.getInitializer();
|
|
1415
|
+
if (!initExpr)
|
|
1416
|
+
throw new Error(`for(...) at line ${line}: missing initializer for ${name}`);
|
|
1417
|
+
hoisted.push({
|
|
1418
|
+
kind: "let", name, mutable: true, tsType,
|
|
1419
|
+
init: extractExpr(initExpr),
|
|
1420
|
+
line: decl.getStartLineNumber(),
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
let extractedBody = extractStmts(bodyStmts);
|
|
1424
|
+
if (incrementor) {
|
|
1425
|
+
const incLine = incrementor.getStartLineNumber();
|
|
1426
|
+
const asStmt = desugarStmtExpr(incrementor, incLine);
|
|
1427
|
+
if (!asStmt)
|
|
1428
|
+
throw new Error(`for(...) at line ${line}: incrementor must be an assignment, compound assignment, or ++/--`);
|
|
1429
|
+
// The loop variable update runs at the bottom of every iteration. A
|
|
1430
|
+
// `continue` in the body would skip it, so emit a copy of the update
|
|
1431
|
+
// immediately before each same-scope `continue` (transform's
|
|
1432
|
+
// eliminateTopLevelContinue then turns `if (X) { update; continue }`
|
|
1433
|
+
// into `if (X) { update } else { rest }`). Nested `while`/`for-of`
|
|
1434
|
+
// loops own their continue scope and are left untouched.
|
|
1435
|
+
extractedBody = insertUpdateBeforeContinue(extractedBody, asStmt);
|
|
1436
|
+
extractedBody.push(asStmt);
|
|
1437
|
+
}
|
|
1438
|
+
let condExpr = cond ? extractExpr(cond) : { kind: "bool", value: true };
|
|
1439
|
+
let invariants = annots.filter(a => a.kind === "invariant").map(a => a.expr);
|
|
1440
|
+
let decreases = annots.find(a => a.kind === "decreases")?.expr ?? null;
|
|
1441
|
+
let doneWith = annots.find(a => a.kind === "done_with")?.expr ?? null;
|
|
1442
|
+
for (const [from, to] of renames) {
|
|
1443
|
+
for (let k = 0; k < hoisted.length; k++)
|
|
1444
|
+
hoisted[k] = renameRawStmt(hoisted[k], from, to);
|
|
1445
|
+
condExpr = renameRawExpr(condExpr, from, to);
|
|
1446
|
+
extractedBody = renameRawStmts(extractedBody, from, to);
|
|
1447
|
+
invariants = invariants.map(inv => renameSpec(inv, from, to));
|
|
1448
|
+
decreases = decreases ? renameSpec(decreases, from, to) : null;
|
|
1449
|
+
doneWith = doneWith ? renameSpec(doneWith, from, to) : null;
|
|
1450
|
+
}
|
|
1451
|
+
result.push(...hoisted);
|
|
1452
|
+
result.push({
|
|
1453
|
+
kind: "while",
|
|
1454
|
+
cond: condExpr,
|
|
1455
|
+
invariants,
|
|
1456
|
+
decreases,
|
|
1457
|
+
doneWith,
|
|
1458
|
+
body: extractedBody,
|
|
1459
|
+
line,
|
|
1460
|
+
});
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
734
1463
|
// for...in: for (const k in obj) → treat as forof with single key name
|
|
735
1464
|
if (Node.isForInStatement(s)) {
|
|
736
1465
|
const init = s.getInitializer();
|
|
@@ -740,7 +1469,7 @@ function extractStmts(stmts) {
|
|
|
740
1469
|
}
|
|
741
1470
|
const bodyNode = s.getStatement();
|
|
742
1471
|
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
743
|
-
const annots =
|
|
1472
|
+
const annots = collectLoopAnnotations(bodyStmts);
|
|
744
1473
|
result.push({
|
|
745
1474
|
kind: "forof",
|
|
746
1475
|
names: [name],
|
|
@@ -773,23 +1502,50 @@ function extractStmts(stmts) {
|
|
|
773
1502
|
const switchExpr = exprAst.kind === "field" ? exprAst.obj : exprAst;
|
|
774
1503
|
const cases = [];
|
|
775
1504
|
let defaultBody = [];
|
|
1505
|
+
// Two JS-`switch` faithfulness concerns the Dafny `match` doesn't share:
|
|
1506
|
+
// (1) Fall-through: stacked `case A: case B: body` is several clauses
|
|
1507
|
+
// where the leading ones have no statements; those labels share the
|
|
1508
|
+
// next clause's body (we duplicate it per label).
|
|
1509
|
+
// (2) `break` is the switch exit, not a loop break. We extract the full
|
|
1510
|
+
// body (extractStmts flattens `{ }` blocks but keeps loop bodies
|
|
1511
|
+
// nested) and strip the *top-level* breaks — so a `break` written
|
|
1512
|
+
// inside a `{ }` case block is stripped, while a `break` inside a
|
|
1513
|
+
// nested loop stays put.
|
|
1514
|
+
const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
|
|
1515
|
+
let fallthrough = [];
|
|
776
1516
|
for (const clause of s.getClauses()) {
|
|
777
1517
|
if (Node.isCaseClause(clause)) {
|
|
778
1518
|
const label = clause.getExpression().getText().replace(/^["']|["']$/g, "");
|
|
779
|
-
|
|
780
|
-
|
|
1519
|
+
if (clause.getStatements().length === 0) {
|
|
1520
|
+
fallthrough.push(label);
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
const body = stripExitBreaks(extractStmts(clause.getStatements()));
|
|
1524
|
+
for (const l of fallthrough)
|
|
1525
|
+
cases.push({ label: l, body });
|
|
1526
|
+
cases.push({ label, body });
|
|
1527
|
+
fallthrough = [];
|
|
781
1528
|
}
|
|
782
1529
|
else {
|
|
783
|
-
|
|
784
|
-
|
|
1530
|
+
defaultBody = stripExitBreaks(extractStmts(clause.getStatements()));
|
|
1531
|
+
for (const l of fallthrough)
|
|
1532
|
+
cases.push({ label: l, body: defaultBody });
|
|
1533
|
+
fallthrough = [];
|
|
785
1534
|
}
|
|
786
1535
|
}
|
|
1536
|
+
for (const l of fallthrough)
|
|
1537
|
+
cases.push({ label: l, body: [] });
|
|
787
1538
|
result.push({ kind: "switch", expr: switchExpr, discriminant, cases, defaultBody, line });
|
|
788
1539
|
continue;
|
|
789
1540
|
}
|
|
790
1541
|
if (Node.isReturnStatement(s)) {
|
|
791
1542
|
const expr = s.getExpression();
|
|
792
|
-
|
|
1543
|
+
// Bare `return;` in a `T | undefined` function → emit `return None;`
|
|
1544
|
+
// ("undefined" is mapped to None by dafny-emit). For void-returning
|
|
1545
|
+
// functions this would emit the wrong shape, but lsc has no current
|
|
1546
|
+
// examples of explicit bare return in void functions; revisit if one
|
|
1547
|
+
// appears.
|
|
1548
|
+
result.push({ kind: "return", value: expr ? extractExpr(expr) : { kind: "var", name: "undefined" }, line });
|
|
793
1549
|
continue;
|
|
794
1550
|
}
|
|
795
1551
|
if (Node.isBreakStatement(s)) {
|
|
@@ -802,41 +1558,22 @@ function extractStmts(stmts) {
|
|
|
802
1558
|
}
|
|
803
1559
|
if (Node.isExpressionStatement(s)) {
|
|
804
1560
|
const expr = s.getExpression();
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
// x = e
|
|
815
|
-
}
|
|
816
|
-
else if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === "=") {
|
|
817
|
-
result.push({ kind: "assign", target: expr.getLeft().getText(), value: extractExpr(expr.getRight()), line });
|
|
818
|
-
// x += e, x -= e, etc.
|
|
819
|
-
}
|
|
820
|
-
else if (Node.isBinaryExpression(expr) && COMPOUND_OPS[expr.getOperatorToken().getText()]) {
|
|
821
|
-
const op = COMPOUND_OPS[expr.getOperatorToken().getText()];
|
|
1561
|
+
// //@ havoc before `x = e` — discard the RHS, assign a nondeterministic
|
|
1562
|
+
// value of x's type. Only applies to plain `=` with an identifier LHS;
|
|
1563
|
+
// compound assigns, `arr[i] = v`, and `x++` fall through to desugaring.
|
|
1564
|
+
const havocMatch = s.getLeadingCommentRanges()
|
|
1565
|
+
.map(r => r.getText().trim().match(/^\/\/@ havoc(?:\s*:\s*(.+))?$/))
|
|
1566
|
+
.find(m => m !== null);
|
|
1567
|
+
if (havocMatch && Node.isBinaryExpression(expr)
|
|
1568
|
+
&& expr.getOperatorToken().getText() === "="
|
|
1569
|
+
&& Node.isIdentifier(expr.getLeft())) {
|
|
822
1570
|
const target = expr.getLeft().getText();
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
else if (Node.isPostfixUnaryExpression(expr)) {
|
|
827
|
-
const target = expr.getOperand().getText();
|
|
828
|
-
const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
|
|
829
|
-
result.push({ kind: "assign", target, value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } }, line });
|
|
830
|
-
// ++i, --i
|
|
831
|
-
}
|
|
832
|
-
else if (Node.isPrefixUnaryExpression(expr) && (expr.getOperatorToken() === SyntaxKind.PlusPlusToken || expr.getOperatorToken() === SyntaxKind.MinusMinusToken)) {
|
|
833
|
-
const target = expr.getOperand().getText();
|
|
834
|
-
const op = expr.getOperatorToken() === SyntaxKind.PlusPlusToken ? "+" : "-";
|
|
835
|
-
result.push({ kind: "assign", target, value: { kind: "binop", op, left: { kind: "var", name: target }, right: { kind: "num", value: 1 } }, line });
|
|
836
|
-
}
|
|
837
|
-
else {
|
|
838
|
-
result.push({ kind: "expr", expr: extractExpr(expr), line });
|
|
1571
|
+
const tsType = havocMatch[1]?.trim() ?? _eraseGenerics(typeToString(expr.getLeft().getType()));
|
|
1572
|
+
result.push({ kind: "assign", target, value: { kind: "havoc", tsType }, line });
|
|
1573
|
+
continue;
|
|
839
1574
|
}
|
|
1575
|
+
const asAssign = desugarStmtExpr(expr, line);
|
|
1576
|
+
result.push(asAssign ?? { kind: "expr", expr: extractExpr(expr), line });
|
|
840
1577
|
continue;
|
|
841
1578
|
}
|
|
842
1579
|
if (Node.isThrowStatement(s)) {
|
|
@@ -864,12 +1601,17 @@ function extractStmts(stmts) {
|
|
|
864
1601
|
result.push({ kind: "assert", expr: content.slice(7).trim(), line });
|
|
865
1602
|
continue;
|
|
866
1603
|
}
|
|
1604
|
+
// assume expr — trusted form of assert; emitted as `assume P;` in Dafny.
|
|
1605
|
+
if (content.startsWith("assume ")) {
|
|
1606
|
+
result.push({ kind: "assert", expr: content.slice(7).trim(), line, assumed: true });
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
867
1609
|
if (!content.startsWith("ghost "))
|
|
868
1610
|
continue;
|
|
869
1611
|
const ghostBody = content.slice(6).trim();
|
|
870
|
-
const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*(
|
|
1612
|
+
const letMatch = ghostBody.match(/^let\s+(\w+)(?:\s*:\s*([^=]+?))?\s*=\s*(.+)$/);
|
|
871
1613
|
if (letMatch) {
|
|
872
|
-
result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2] ?? null, init: letMatch[3].trim(), line });
|
|
1614
|
+
result.push({ kind: "ghostLet", name: letMatch[1], tsType: letMatch[2]?.trim() ?? null, init: letMatch[3].trim(), line });
|
|
873
1615
|
continue;
|
|
874
1616
|
}
|
|
875
1617
|
const assignMatch = ghostBody.match(/^(\w+)\s*=\s*(.+)$/);
|
|
@@ -882,9 +1624,20 @@ function extractStmts(stmts) {
|
|
|
882
1624
|
}
|
|
883
1625
|
// ── Function extraction ──────────────────────────────────────
|
|
884
1626
|
function extractFunction(fn, parentAnnotations) {
|
|
1627
|
+
const prevInFn = _inFunctionExtraction;
|
|
1628
|
+
_inFunctionExtraction = true;
|
|
1629
|
+
try {
|
|
1630
|
+
return extractFunctionInner(fn, parentAnnotations);
|
|
1631
|
+
}
|
|
1632
|
+
finally {
|
|
1633
|
+
_inFunctionExtraction = prevInFn;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
function extractFunctionInner(fn, parentAnnotations) {
|
|
885
1637
|
// Generic bounds erasure: <T extends Base> → substitute T with Base everywhere
|
|
886
1638
|
// Unbounded type params are preserved as Dafny type parameters
|
|
887
1639
|
_typeParamMap = new Map();
|
|
1640
|
+
_reservedForCounterNames = new Set();
|
|
888
1641
|
const unboundedTypeParams = [];
|
|
889
1642
|
for (const tp of fn.getTypeParameters?.() ?? []) {
|
|
890
1643
|
const constraint = tp.getConstraint();
|
|
@@ -900,12 +1653,11 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
900
1653
|
if (body && !Node.isBlock(body)) {
|
|
901
1654
|
const expr = extractExpr(body);
|
|
902
1655
|
extractedBody = [{ kind: "return", value: expr, line: body.getStartLineNumber() }];
|
|
903
|
-
annots = parentAnnotations ??
|
|
1656
|
+
annots = parentAnnotations ?? collectFunctionAnnotations(fn);
|
|
904
1657
|
}
|
|
905
1658
|
else if (body && Node.isBlock(body)) {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
annots = collectAnnotations(fn, bodyStmts);
|
|
1659
|
+
extractedBody = extractStmts(body.getStatements());
|
|
1660
|
+
annots = collectFunctionAnnotations(fn);
|
|
909
1661
|
}
|
|
910
1662
|
else {
|
|
911
1663
|
throw new Error(`${fn.getName?.() ?? "arrow"}: function has no body`);
|
|
@@ -932,14 +1684,29 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
932
1684
|
return { name, tsType: propType ? typeToString(propType) : "unknown" };
|
|
933
1685
|
});
|
|
934
1686
|
}
|
|
935
|
-
|
|
936
|
-
//
|
|
1687
|
+
// Syntactic union nodes go through _tsTypeFromUnionNode so synth fires
|
|
1688
|
+
// and aliases are preserved. Non-union nodes use the syntactic text;
|
|
1689
|
+
// when no annotation is present, fall back to the computed type
|
|
1690
|
+
// (e.g., `eof = false` infers `boolean` from the default value).
|
|
1691
|
+
const tn = p.getTypeNode();
|
|
1692
|
+
let tsType;
|
|
1693
|
+
if (tn && Node.isUnionTypeNode(tn)) {
|
|
1694
|
+
tsType = _eraseGenerics(_tsTypeFromUnionNode(tn));
|
|
1695
|
+
}
|
|
1696
|
+
else if (tn) {
|
|
1697
|
+
tsType = _eraseGenerics(tn.getText());
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
tsType = _eraseGenerics(typeToString(p.getType()));
|
|
1701
|
+
}
|
|
937
1702
|
if (p.hasQuestionToken())
|
|
938
1703
|
tsType = `${tsType} | undefined`;
|
|
939
1704
|
return [{ name: p.getName(), tsType }];
|
|
940
1705
|
}),
|
|
941
1706
|
returnType: (() => {
|
|
942
1707
|
const node = fn.getReturnTypeNode();
|
|
1708
|
+
if (node && Node.isUnionTypeNode(node))
|
|
1709
|
+
return _eraseGenerics(_tsTypeFromUnionNode(node));
|
|
943
1710
|
if (node)
|
|
944
1711
|
return _eraseGenerics(node.getText());
|
|
945
1712
|
const inferred = fn.getReturnType();
|
|
@@ -959,41 +1726,65 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
959
1726
|
// ── Module extraction ────────────────────────────────────────
|
|
960
1727
|
export function extractModule(sourceFile) {
|
|
961
1728
|
const typeDecls = [];
|
|
962
|
-
//
|
|
1729
|
+
// Cross-file calls are auto-externed: ts-morph resolves the call's symbol;
|
|
1730
|
+
// if it's defined in a different source file we treat the symbol as opaque
|
|
1731
|
+
// and emit a body-less `function {:axiom}` in Dafny. Populated by
|
|
1732
|
+
// `extractExpr` during call extraction (only symbols *actually used* end up
|
|
1733
|
+
// here), deduped by qualified name.
|
|
1734
|
+
_externs.clear();
|
|
1735
|
+
// Share the module's ts-morph Project with parseTsType (scratch source file
|
|
1736
|
+
// for type-string parsing). Done before declare-type parsing so any
|
|
1737
|
+
// parseTsType call downstream uses the same Project.
|
|
1738
|
+
initTypeParser(sourceFile.getProject());
|
|
1739
|
+
// Activate the synthesized-array-union accumulator. typeToString registers
|
|
1740
|
+
// a discriminated-union TypeDeclInfo for any `T[] | U` shape it encounters,
|
|
1741
|
+
// pushing into `typeDecls` so resolve sees the synth as a regular user type.
|
|
1742
|
+
// Set before declare-type parsing so declare-type field types can also synth.
|
|
1743
|
+
_synthArrayUnions = typeDecls;
|
|
1744
|
+
// `//@ declare-type Name { f1: T1, ... }` — record form.
|
|
1745
|
+
// `//@ declare-type Name = TsType` — alias form (e.g. `Ruleset = Rule[]`).
|
|
1746
|
+
function parseDeclareType(body) {
|
|
1747
|
+
const recordMatch = body.match(/^(\w+)\s*\{(.+)\}$/);
|
|
1748
|
+
if (recordMatch) {
|
|
1749
|
+
const name = recordMatch[1];
|
|
1750
|
+
const fields = recordMatch[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
|
|
1751
|
+
const [fname, ftype] = f.split(":").map(s => s.trim());
|
|
1752
|
+
const synth = _synthFromTsTypeString(ftype);
|
|
1753
|
+
return { name: fname, tsType: synth ?? ftype };
|
|
1754
|
+
});
|
|
1755
|
+
typeDecls.push({ name, kind: "record", fields });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
|
|
1759
|
+
if (aliasMatch) {
|
|
1760
|
+
const rhs = aliasMatch[2].trim();
|
|
1761
|
+
// A string-literal union (`= "a" | "b" | …`) becomes an enum datatype —
|
|
1762
|
+
// the same shape a real string-union alias resolves to. Dafny has no
|
|
1763
|
+
// string-literal type, so a plain alias (`type X = "a" | "b"`) would be
|
|
1764
|
+
// invalid. Other RHS forms (`Rule[]`, `number`, `A | B`) fall through.
|
|
1765
|
+
const parts = rhs.split("|").map(s => s.trim());
|
|
1766
|
+
const lits = parts.map(p => p.match(/^["'](.+)["']$/));
|
|
1767
|
+
if (parts.length >= 2 && lits.every(m => m !== null)) {
|
|
1768
|
+
typeDecls.push({ name: aliasMatch[1], kind: "string-union", values: lits.map(m => m[1]) });
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
typeDecls.push({ name: aliasMatch[1], kind: "alias", aliasOf: rhs });
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
963
1775
|
for (const range of sourceFile.getLeadingCommentRanges()) {
|
|
964
1776
|
const text = range.getText().trim();
|
|
965
|
-
if (
|
|
966
|
-
|
|
967
|
-
const body = text.slice("//@ declare-type ".length);
|
|
968
|
-
const match = body.match(/^(\w+)\s*\{(.+)\}$/);
|
|
969
|
-
if (!match)
|
|
970
|
-
continue;
|
|
971
|
-
const name = match[1];
|
|
972
|
-
const fieldsStr = match[2];
|
|
973
|
-
const fields = fieldsStr.split(",").map(f => f.trim()).filter(Boolean).map(f => {
|
|
974
|
-
const [fname, ftype] = f.split(":").map(s => s.trim());
|
|
975
|
-
return { name: fname, tsType: ftype };
|
|
976
|
-
});
|
|
977
|
-
typeDecls.push({ name, kind: "record", fields });
|
|
1777
|
+
if (text.startsWith("//@ declare-type "))
|
|
1778
|
+
parseDeclareType(text.slice("//@ declare-type ".length));
|
|
978
1779
|
}
|
|
979
|
-
// Also scan statement-level comments for declare-type
|
|
980
1780
|
for (const stmt of sourceFile.getStatements()) {
|
|
981
1781
|
for (const range of stmt.getLeadingCommentRanges()) {
|
|
982
1782
|
const text = range.getText().trim();
|
|
983
|
-
if (
|
|
984
|
-
|
|
985
|
-
const body = text.slice("//@ declare-type ".length);
|
|
986
|
-
const match = body.match(/^(\w+)\s*\{(.+)\}$/);
|
|
987
|
-
if (!match)
|
|
988
|
-
continue;
|
|
989
|
-
const name = match[1];
|
|
990
|
-
const fields = match[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
|
|
991
|
-
const [fname, ftype] = f.split(":").map(s => s.trim());
|
|
992
|
-
return { name: fname, tsType: ftype };
|
|
993
|
-
});
|
|
994
|
-
typeDecls.push({ name, kind: "record", fields });
|
|
1783
|
+
if (text.startsWith("//@ declare-type "))
|
|
1784
|
+
parseDeclareType(text.slice("//@ declare-type ".length));
|
|
995
1785
|
}
|
|
996
1786
|
}
|
|
1787
|
+
_currentSourceFile = sourceFile;
|
|
997
1788
|
// Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
|
|
998
1789
|
// TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
|
|
999
1790
|
// the alias. We record a fingerprint (sorted property names) → alias name mapping.
|
|
@@ -1040,11 +1831,17 @@ export function extractModule(sourceFile) {
|
|
|
1040
1831
|
// Skip huge string constants — they crash the verifier and have no verification value
|
|
1041
1832
|
const initType = decl.getType();
|
|
1042
1833
|
const isHugeString = (initType.isString() || initType.isStringLiteral()) && init.getText().length > 200;
|
|
1043
|
-
|
|
1834
|
+
// Skip anonymous-object consts (e.g., `const Util = { dotMatch(s, p) { ... } }`).
|
|
1835
|
+
// ts-morph names these `__type` / `__object`; Dafny has no model for
|
|
1836
|
+
// object-namespace-with-methods. The methods themselves should be
|
|
1837
|
+
// extracted via the function path if marked `//@ verify`.
|
|
1838
|
+
const declTsType = typeToString(decl.getType());
|
|
1839
|
+
const isAnonObject = declTsType.startsWith("__");
|
|
1840
|
+
if (init && !isHugeString && !isAnonObject && !Node.isArrowFunction(init)) {
|
|
1044
1841
|
try {
|
|
1045
1842
|
constants.push({
|
|
1046
1843
|
name: decl.getName(),
|
|
1047
|
-
tsType:
|
|
1844
|
+
tsType: declTsType,
|
|
1048
1845
|
value: extractExpr(init),
|
|
1049
1846
|
});
|
|
1050
1847
|
}
|
|
@@ -1072,6 +1869,42 @@ export function extractModule(sourceFile) {
|
|
|
1072
1869
|
}
|
|
1073
1870
|
}
|
|
1074
1871
|
}
|
|
1872
|
+
// `//@ extern` on a same-file declaration: register the function as an
|
|
1873
|
+
// opaque axiom (signature + any //@ requires/ensures), skip its body. Use
|
|
1874
|
+
// when the function is outside LS's verification model — e.g., wraps a
|
|
1875
|
+
// regex — but its callers should still be verifiable against an
|
|
1876
|
+
// uninterpreted predicate. Parallel to auto-extern for cross-file calls,
|
|
1877
|
+
// and emitted the same way (`function {:axiom} foo(...)` in Dafny).
|
|
1878
|
+
function hasExtern(f) {
|
|
1879
|
+
if (f.node.getFullText().includes('//@ extern'))
|
|
1880
|
+
return true;
|
|
1881
|
+
if (f.parentStmt) {
|
|
1882
|
+
for (const r of f.parentStmt.getLeadingCommentRanges()) {
|
|
1883
|
+
if (r.getText().includes('//@ extern'))
|
|
1884
|
+
return true;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return false;
|
|
1888
|
+
}
|
|
1889
|
+
for (const f of allFns) {
|
|
1890
|
+
if (!hasExtern(f))
|
|
1891
|
+
continue;
|
|
1892
|
+
if (_externs.has(f.name))
|
|
1893
|
+
continue;
|
|
1894
|
+
const sig = f.node.getType().getCallSignatures()[0];
|
|
1895
|
+
if (!sig)
|
|
1896
|
+
continue;
|
|
1897
|
+
const typeParams = sig.getTypeParameters().map(tp => tp.getText());
|
|
1898
|
+
const params = sig.getParameters().map(p => ({
|
|
1899
|
+
name: p.getName(),
|
|
1900
|
+
tsType: p.getTypeAtLocation(f.node).getText(),
|
|
1901
|
+
}));
|
|
1902
|
+
const returnType = sig.getReturnType().getText();
|
|
1903
|
+
const annots = collectFunctionAnnotations(f.node);
|
|
1904
|
+
const requires = annots.filter(a => a.kind === "requires").map(a => a.expr);
|
|
1905
|
+
const ensures = annots.filter(a => a.kind === "ensures").map(a => a.expr);
|
|
1906
|
+
_externs.set(f.name, { qualified: f.name, flat: f.name, typeParams, params, returnType, requires, ensures });
|
|
1907
|
+
}
|
|
1075
1908
|
// If any function has //@ verify, only extract those (brownfield mode).
|
|
1076
1909
|
// For expression-body arrows, //@ verify may be on the parent variable statement.
|
|
1077
1910
|
function hasVerify(f) {
|
|
@@ -1086,7 +1919,8 @@ export function extractModule(sourceFile) {
|
|
|
1086
1919
|
return false;
|
|
1087
1920
|
}
|
|
1088
1921
|
const hasVerifyDirective = sourceFile.getFullText().includes('//@ verify');
|
|
1089
|
-
const
|
|
1922
|
+
const nonExternFns = allFns.filter(f => !hasExtern(f));
|
|
1923
|
+
const fnsToExtract = hasVerifyDirective ? nonExternFns.filter(hasVerify) : nonExternFns;
|
|
1090
1924
|
const functions = fnsToExtract.map(f => {
|
|
1091
1925
|
// For expression-body arrows, annotations come from the parent variable statement
|
|
1092
1926
|
const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
|
|
@@ -1094,61 +1928,97 @@ export function extractModule(sourceFile) {
|
|
|
1094
1928
|
raw.name = f.name; // use the const name, not "<anonymous>"
|
|
1095
1929
|
return raw;
|
|
1096
1930
|
});
|
|
1097
|
-
// Resolve
|
|
1931
|
+
// Resolve type references in function signatures via ts-morph's type
|
|
1932
|
+
// checker — walk the TypeNode tree, resolve each TypeReferenceNode to its
|
|
1933
|
+
// declaration through symbol resolution, recurse. This is the principled
|
|
1934
|
+
// replacement for an earlier regex-based walker that extracted identifier
|
|
1935
|
+
// names from tsType strings and searched the whole project by name — that
|
|
1936
|
+
// approach had ambiguous resolution (name collisions in generated files
|
|
1937
|
+
// could shadow what the user actually imported).
|
|
1098
1938
|
const knownTypeNames = new Set(typeDecls.map(d => d.name));
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
function resolveTypeName(name) {
|
|
1102
|
-
if (knownTypeNames.has(name) || primitives.has(name) || builtinTypes.has(name))
|
|
1939
|
+
function resolveTypeNodeRefs(tn) {
|
|
1940
|
+
if (!tn)
|
|
1103
1941
|
return;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1942
|
+
if (Node.isTypeReference(tn)) {
|
|
1943
|
+
const sym = tn.getTypeName().getSymbol();
|
|
1944
|
+
if (sym) {
|
|
1945
|
+
for (const d of sym.getDeclarations()) {
|
|
1946
|
+
// Skip ambient/built-in declarations: `.d.ts` files (lib.dom.d.ts,
|
|
1947
|
+
// node_modules typings) describe runtime/host types, not user code
|
|
1948
|
+
// — backends map these directly (`Map<K,V>` → `map<K,V>`) without
|
|
1949
|
+
// needing the interface dump.
|
|
1950
|
+
if (d.getSourceFile().getFilePath().endsWith(".d.ts"))
|
|
1951
|
+
continue;
|
|
1952
|
+
let added = null;
|
|
1953
|
+
if (Node.isTypeAliasDeclaration(d) && !knownTypeNames.has(d.getName())) {
|
|
1954
|
+
const extra = [];
|
|
1955
|
+
added = extractTypeDecl(d, extra);
|
|
1956
|
+
typeDecls.push(...extra);
|
|
1957
|
+
if (added) {
|
|
1958
|
+
typeDecls.push(added);
|
|
1959
|
+
knownTypeNames.add(d.getName());
|
|
1960
|
+
}
|
|
1113
1961
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1962
|
+
else if (Node.isInterfaceDeclaration(d) && !knownTypeNames.has(d.getName())) {
|
|
1963
|
+
const extra = [];
|
|
1964
|
+
added = extractInterface(d, extra);
|
|
1965
|
+
typeDecls.push(...extra);
|
|
1966
|
+
if (added) {
|
|
1967
|
+
typeDecls.push(added);
|
|
1968
|
+
knownTypeNames.add(d.getName());
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
// Recurse into the newly-added declaration's TypeNodes — preferring
|
|
1972
|
+
// the actual AST over re-parsing tsType strings.
|
|
1973
|
+
if (added) {
|
|
1974
|
+
if (Node.isTypeAliasDeclaration(d))
|
|
1975
|
+
resolveTypeNodeRefs(d.getTypeNode());
|
|
1976
|
+
else if (Node.isInterfaceDeclaration(d)) {
|
|
1977
|
+
for (const m of d.getProperties())
|
|
1978
|
+
resolveTypeNodeRefs(m.getTypeNode());
|
|
1979
|
+
}
|
|
1122
1980
|
}
|
|
1123
1981
|
}
|
|
1124
1982
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1983
|
+
for (const a of tn.getTypeArguments())
|
|
1984
|
+
resolveTypeNodeRefs(a);
|
|
1985
|
+
return;
|
|
1127
1986
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
|
|
1133
|
-
resolveTypeName(m[1]);
|
|
1134
|
-
}
|
|
1987
|
+
if (Node.isUnionTypeNode(tn) || Node.isIntersectionTypeNode(tn)) {
|
|
1988
|
+
for (const arm of tn.getTypeNodes())
|
|
1989
|
+
resolveTypeNodeRefs(arm);
|
|
1990
|
+
return;
|
|
1135
1991
|
}
|
|
1136
|
-
if (
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
for (const m of f.tsType.matchAll(/\b([A-Z]\w*)\b/g))
|
|
1140
|
-
resolveTypeName(m[1]);
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1992
|
+
if (Node.isArrayTypeNode(tn)) {
|
|
1993
|
+
resolveTypeNodeRefs(tn.getElementTypeNode());
|
|
1994
|
+
return;
|
|
1143
1995
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1996
|
+
if (Node.isTupleTypeNode(tn)) {
|
|
1997
|
+
for (const el of tn.getElements())
|
|
1998
|
+
resolveTypeNodeRefs(el);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (Node.isParenthesizedTypeNode(tn)) {
|
|
2002
|
+
resolveTypeNodeRefs(tn.getTypeNode());
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
if (Node.isFunctionTypeNode(tn)) {
|
|
2006
|
+
for (const p of tn.getParameters())
|
|
2007
|
+
resolveTypeNodeRefs(p.getTypeNode());
|
|
2008
|
+
resolveTypeNodeRefs(tn.getReturnTypeNode());
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (Node.isTypeLiteral(tn)) {
|
|
2012
|
+
for (const m of tn.getProperties())
|
|
2013
|
+
resolveTypeNodeRefs(m.getTypeNode());
|
|
2014
|
+
return;
|
|
1150
2015
|
}
|
|
1151
2016
|
}
|
|
2017
|
+
for (const f of fnsToExtract) {
|
|
2018
|
+
for (const p of f.node.getParameters())
|
|
2019
|
+
resolveTypeNodeRefs(p.getTypeNode());
|
|
2020
|
+
resolveTypeNodeRefs(f.node.getReturnTypeNode());
|
|
2021
|
+
}
|
|
1152
2022
|
// Resolve union param types: A | B → intersection of fields
|
|
1153
2023
|
const typeDeclMap = new Map(typeDecls.map(d => [d.name, d]));
|
|
1154
2024
|
for (const fn of functions) {
|
|
@@ -1188,7 +2058,8 @@ export function extractModule(sourceFile) {
|
|
|
1188
2058
|
function collectNames(stmts) {
|
|
1189
2059
|
for (const s of stmts) {
|
|
1190
2060
|
if (s.kind === "let") {
|
|
1191
|
-
|
|
2061
|
+
if (s.tsType)
|
|
2062
|
+
referencedNames.add(s.tsType);
|
|
1192
2063
|
collectNamesExpr(s.init);
|
|
1193
2064
|
}
|
|
1194
2065
|
if (s.kind === "assign") {
|
|
@@ -1297,6 +2168,12 @@ export function extractModule(sourceFile) {
|
|
|
1297
2168
|
const alias = t.getAliasSymbol();
|
|
1298
2169
|
if (alias) {
|
|
1299
2170
|
const aliasName = alias.getName();
|
|
2171
|
+
// A //@ declare-type already defines the verification surface for this
|
|
2172
|
+
// alias. Don't walk the imported structure — its union members would
|
|
2173
|
+
// leak unrelated variant datatypes (and the types those reference, which
|
|
2174
|
+
// we don't model) into the output. See examples/declareTypeShadow.ts.
|
|
2175
|
+
if (declaredNames.has(aliasName))
|
|
2176
|
+
return;
|
|
1300
2177
|
if (!knownTypes.has(aliasName) && !builtins.has(aliasName) && !aliasName.startsWith("__")) {
|
|
1301
2178
|
const decls = alias.getDeclarations();
|
|
1302
2179
|
if (decls.length > 0 && Node.isTypeAliasDeclaration(decls[0])) {
|
|
@@ -1343,9 +2220,19 @@ export function extractModule(sourceFile) {
|
|
|
1343
2220
|
}
|
|
1344
2221
|
}
|
|
1345
2222
|
}
|
|
1346
|
-
for (
|
|
1347
|
-
|
|
2223
|
+
for (let i = 0; i < fnsToExtract.length; i++) {
|
|
2224
|
+
const f = fnsToExtract[i];
|
|
2225
|
+
const fn = functions[i];
|
|
2226
|
+
// Skip params whose TS type was overridden by `//@ type <param> <Override>`
|
|
2227
|
+
// — the verification works against the override, so cross-file resolving
|
|
2228
|
+
// the original type pulls in unused datatypes (often with unsupported
|
|
2229
|
+
// shapes the override exists precisely to avoid).
|
|
2230
|
+
const overriddenNames = new Set(fn.typeAnnotations.map(a => a.name));
|
|
2231
|
+
for (const p of f.node.getParameters()) {
|
|
2232
|
+
if (overriddenNames.has(p.getName()))
|
|
2233
|
+
continue;
|
|
1348
2234
|
resolveType(p.getType(), p);
|
|
2235
|
+
}
|
|
1349
2236
|
}
|
|
1350
2237
|
// Resolve anonymous object return types into synthetic named types
|
|
1351
2238
|
for (let i = 0; i < fnsToExtract.length; i++) {
|
|
@@ -1355,9 +2242,15 @@ export function extractModule(sourceFile) {
|
|
|
1355
2242
|
// Prefer alias symbol (named type aliases) over underlying object symbol (__type)
|
|
1356
2243
|
const aliasSym = retType.getAliasSymbol();
|
|
1357
2244
|
if (aliasSym && !aliasSym.getName().startsWith("__")) {
|
|
1358
|
-
// Named type alias — resolve it instead of generating a synthetic name
|
|
1359
|
-
resolveType(retType, f.node);
|
|
1360
2245
|
const aliasName = aliasSym.getName();
|
|
2246
|
+
// If the alias name is already locally declared (e.g. via `//@ declare-type
|
|
2247
|
+
// Ruleset = Rule[]`), don't unwrap further — the declared shape is the
|
|
2248
|
+
// verification surface, and walking the original cross-file alias pulls
|
|
2249
|
+
// unused datatypes (often with unsupported shapes) into the gen.
|
|
2250
|
+
if (!knownTypes.has(aliasName)) {
|
|
2251
|
+
// Named type alias — resolve it instead of generating a synthetic name
|
|
2252
|
+
resolveType(retType, f.node);
|
|
2253
|
+
}
|
|
1361
2254
|
if (knownTypes.has(aliasName)) {
|
|
1362
2255
|
// Preserve type arguments: Result<Model, Err> not just Result
|
|
1363
2256
|
const typeArgs = retType.getAliasTypeArguments();
|
|
@@ -1367,27 +2260,58 @@ export function extractModule(sourceFile) {
|
|
|
1367
2260
|
}
|
|
1368
2261
|
continue;
|
|
1369
2262
|
}
|
|
2263
|
+
// Inline-anon return type — bare `{...}` (no union wrapping).
|
|
2264
|
+
//
|
|
2265
|
+
// ts-morph's `fn.getReturnType()` returns the COMPUTED type, which strips
|
|
2266
|
+
// `| null` in non-strict mode (and sometimes `| undefined`). The source
|
|
2267
|
+
// annotation, however, encodes the user's actual intent. Check the
|
|
2268
|
+
// already-extracted `fn.returnType` string for nullish suffixes to detect
|
|
2269
|
+
// the wrap-in-Optional case.
|
|
2270
|
+
let innerType = null;
|
|
2271
|
+
let wrapOptional = false;
|
|
2272
|
+
const sourceReturnText = fn.returnType ?? "";
|
|
2273
|
+
const sourceHadNullish = / \| (null|undefined)$/.test(sourceReturnText)
|
|
2274
|
+
|| sourceReturnText.includes(" | null ") || sourceReturnText.includes(" | undefined ")
|
|
2275
|
+
|| sourceReturnText.includes(" | null|") || sourceReturnText.includes(" | undefined|");
|
|
1370
2276
|
const sym = retType.getSymbol();
|
|
1371
2277
|
if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
|
|
2278
|
+
innerType = retType;
|
|
2279
|
+
if (sourceHadNullish)
|
|
2280
|
+
wrapOptional = true;
|
|
2281
|
+
}
|
|
2282
|
+
else if (retType.isUnion()) {
|
|
2283
|
+
const arms = retType.getUnionTypes();
|
|
2284
|
+
const nullish = arms.filter(t => t.isNull() || t.isUndefined());
|
|
2285
|
+
const others = arms.filter(t => !t.isNull() && !t.isUndefined());
|
|
2286
|
+
if (nullish.length >= 1 && others.length === 1) {
|
|
2287
|
+
const onlyOther = others[0];
|
|
2288
|
+
const otherSym = onlyOther.getSymbol();
|
|
2289
|
+
if (otherSym?.getName() === "__type" && onlyOther.isObject() && !onlyOther.isArray()) {
|
|
2290
|
+
innerType = onlyOther;
|
|
2291
|
+
wrapOptional = true;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
if (innerType) {
|
|
1372
2296
|
// Try typeToString first — it resolves collapsed single-variant unions
|
|
1373
|
-
const resolved = typeToString(
|
|
2297
|
+
const resolved = typeToString(innerType);
|
|
1374
2298
|
if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
|
|
1375
|
-
fn.returnType = resolved;
|
|
2299
|
+
fn.returnType = wrapOptional ? `${resolved} | undefined` : resolved;
|
|
1376
2300
|
continue;
|
|
1377
2301
|
}
|
|
1378
2302
|
const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
|
|
1379
2303
|
if (!knownTypes.has(synName)) {
|
|
1380
2304
|
const extra = [];
|
|
1381
|
-
const info = extractRecord(synName,
|
|
2305
|
+
const info = extractRecord(synName, innerType, f.node, undefined, extra);
|
|
1382
2306
|
if (info) {
|
|
1383
2307
|
typeDecls.push(...extra);
|
|
1384
2308
|
typeDecls.push(info);
|
|
1385
2309
|
knownTypes.add(synName);
|
|
1386
2310
|
}
|
|
1387
2311
|
}
|
|
1388
|
-
fn.returnType = synName;
|
|
2312
|
+
fn.returnType = wrapOptional ? `${synName} | undefined` : synName;
|
|
1389
2313
|
// Also resolve imported types referenced in the return type's fields
|
|
1390
|
-
for (const prop of
|
|
2314
|
+
for (const prop of innerType.getProperties()) {
|
|
1391
2315
|
resolveType(prop.getTypeAtLocation(f.node), f.node);
|
|
1392
2316
|
}
|
|
1393
2317
|
}
|
|
@@ -1409,9 +2333,13 @@ export function extractModule(sourceFile) {
|
|
|
1409
2333
|
}
|
|
1410
2334
|
classes.push({ name: cls.getName() ?? "Anonymous", fields, methods });
|
|
1411
2335
|
}
|
|
2336
|
+
// Clear the synth-union accumulator so typeToString reverts to plain
|
|
2337
|
+
// union stringification outside of an extractModule call.
|
|
2338
|
+
_synthArrayUnions = null;
|
|
1412
2339
|
return {
|
|
1413
2340
|
file: sourceFile.getFilePath(),
|
|
1414
2341
|
typeDecls,
|
|
2342
|
+
externs: Array.from(_externs.values()),
|
|
1415
2343
|
constants,
|
|
1416
2344
|
functions,
|
|
1417
2345
|
classes,
|