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