lemmascript 0.3.1 → 0.3.3
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 +1 -0
- package/package.json +3 -2
- package/tools/dist/dafny-emit.js +70 -12
- package/tools/dist/extract.js +182 -3
- package/tools/dist/lean-emit.js +2 -0
- package/tools/dist/resolve.js +128 -37
- package/tools/dist/transform.js +307 -58
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Each example and case study is verified in Lean 4 and/or Dafny from the same ann
|
|
|
13
13
|
See the internal [examples](examples).
|
|
14
14
|
|
|
15
15
|
See the external case studies:
|
|
16
|
+
- **[collab-todo-lemmascript](https://github.com/midspiral/collab-todo-lemmascript/)** — collaborative task management web app (React + Supabase) with a verified domain model. Single `domain.ts` imported directly by the UI, hooks, and edge functions — no adapter layer. 123 Dafny lemmas (120 in a separate `domain.proofs.dfy`): 16-conjunct invariant preserved across 25 single-project + 3 cross-project actions, NoOp completeness/soundness, initialization. Dafny only.
|
|
16
17
|
- **[colorwheel-lemmascript](https://github.com/midspiral/colorwheel-lemmascript/)** — verified color palette generator with mood + harmony constraints. 31 Lean proofs + 18 behavioral properties, 115 Dafny lemmas (invariant preservation, commutativity, NoOp completeness).
|
|
17
18
|
- **[clear-split-lemmascript](https://github.com/midspiral/clear-split-lemmascript/)** — greenfield verified expense splitting web app. Conservation theorem, invariant preservation, delta laws — all proven in both Lean (no sorry) and Dafny (56 lemmas).
|
|
18
19
|
- **[node-casbin-lemmascript](https://github.com/midspiral/node-casbin-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [node-casbin](https://github.com/casbin/node-casbin). 5 functions verified, 217 existing tests pass. End-to-end correctness and order independence for all 4 effect modes in both Lean and Dafny (39 lemmas).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lemmascript",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc -p tools/tsconfig.json",
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
|
-
"typecheck": "tsc -p tools/tsconfig.json --noEmit"
|
|
15
|
+
"typecheck": "tsc -p tools/tsconfig.json --noEmit",
|
|
16
|
+
"typecheck:examples": "tsc -p examples/tsconfig.json"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"ts-morph": "^25.0.0"
|
package/tools/dist/dafny-emit.js
CHANGED
|
@@ -94,6 +94,10 @@ function emitExpr(e) {
|
|
|
94
94
|
return `${obj}[${args[0]} := ${args[1]}]`;
|
|
95
95
|
if (e.method === "includes")
|
|
96
96
|
return `(${args[0]} in ${obj})`;
|
|
97
|
+
if (e.method === "indexOf") {
|
|
98
|
+
needPreamble("SeqIndexOf");
|
|
99
|
+
return `SeqIndexOf(${obj}, ${args[0]})`;
|
|
100
|
+
}
|
|
97
101
|
if (e.method === "push")
|
|
98
102
|
return `(${obj} + [${args[0]}])`;
|
|
99
103
|
if (e.method === "concat")
|
|
@@ -321,6 +325,8 @@ function emitExpr(e) {
|
|
|
321
325
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
322
326
|
return `${ctorName}(${vals.join(", ")})`;
|
|
323
327
|
}
|
|
328
|
+
if (e.fields.length === 0)
|
|
329
|
+
return `map[]`;
|
|
324
330
|
const vals = e.fields.map(f => emitExpr(f.value));
|
|
325
331
|
return `(${vals.join(", ")})`;
|
|
326
332
|
}
|
|
@@ -469,6 +475,8 @@ function emitDecl(d) {
|
|
|
469
475
|
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
470
476
|
for (const r of d.requires)
|
|
471
477
|
lines.push(` requires ${emitExpr(r)}`);
|
|
478
|
+
if (d.decreases)
|
|
479
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
472
480
|
lines.push(`{`);
|
|
473
481
|
lines.push(emitPureExpr(d.body, 1));
|
|
474
482
|
lines.push(`}`);
|
|
@@ -487,6 +495,20 @@ function emitDecl(d) {
|
|
|
487
495
|
}
|
|
488
496
|
return lines.join("\n");
|
|
489
497
|
}
|
|
498
|
+
case "def-by-method": {
|
|
499
|
+
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
500
|
+
const lines = [`function ${d.name}${tp}(${paramList(d.params)}): ${tyToDafny(d.returnType)}`];
|
|
501
|
+
for (const r of d.requires)
|
|
502
|
+
lines.push(` requires ${emitExpr(r)}`);
|
|
503
|
+
if (d.decreases)
|
|
504
|
+
lines.push(` decreases ${emitExpr(d.decreases)}`);
|
|
505
|
+
lines.push(`{`);
|
|
506
|
+
lines.push(`}`);
|
|
507
|
+
lines.push(`by method {`);
|
|
508
|
+
lines.push(emitStmts(d.methodBody, 1));
|
|
509
|
+
lines.push(`}`);
|
|
510
|
+
return lines.join("\n");
|
|
511
|
+
}
|
|
490
512
|
case "method": {
|
|
491
513
|
const tp = d.typeParams.length > 0 ? `<${d.typeParams.join(", ")}>` : "";
|
|
492
514
|
const lines = [`method ${d.name}${tp}(${paramList(d.params)}) returns (res: ${tyToDafny(d.returnType)})`];
|
|
@@ -565,6 +587,25 @@ const CEIL_REAL = `function CeilReal(x: real): int
|
|
|
565
587
|
if x == (x.Floor as real) then x.Floor
|
|
566
588
|
else x.Floor + 1
|
|
567
589
|
}`;
|
|
590
|
+
const SEQ_INDEX_OF = `function SeqIndexOf<T(==)>(s: seq<T>, x: T): int
|
|
591
|
+
ensures -1 <= SeqIndexOf(s, x) < |s|
|
|
592
|
+
ensures SeqIndexOf(s, x) >= 0 ==> s[SeqIndexOf(s, x)] == x
|
|
593
|
+
ensures SeqIndexOf(s, x) == -1 ==> x !in s
|
|
594
|
+
{
|
|
595
|
+
SeqIndexOfFrom(s, x, 0)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function SeqIndexOfFrom<T(==)>(s: seq<T>, x: T, from: nat): int
|
|
599
|
+
requires from <= |s|
|
|
600
|
+
ensures -1 <= SeqIndexOfFrom(s, x, from) < |s|
|
|
601
|
+
ensures SeqIndexOfFrom(s, x, from) >= 0 ==> s[SeqIndexOfFrom(s, x, from)] == x
|
|
602
|
+
ensures SeqIndexOfFrom(s, x, from) == -1 ==> forall i :: from <= i < |s| ==> s[i] != x
|
|
603
|
+
decreases |s| - from
|
|
604
|
+
{
|
|
605
|
+
if from == |s| then -1
|
|
606
|
+
else if s[from] == x then from as int
|
|
607
|
+
else SeqIndexOfFrom(s, x, from + 1)
|
|
608
|
+
}`;
|
|
568
609
|
const STRING_INDEX_OF = `function StringIndexOf(s: string, sub: string): int
|
|
569
610
|
{
|
|
570
611
|
StringIndexOfFrom(s, sub, 0)
|
|
@@ -633,6 +674,7 @@ const MATH_ABS = `function MathAbs(x: int): nat { if x >= 0 then x else -x }`;
|
|
|
633
674
|
const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
634
675
|
ensures forall x :: x in s <==> x in res
|
|
635
676
|
ensures |res| == |s|
|
|
677
|
+
ensures forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
636
678
|
{
|
|
637
679
|
var remaining := s;
|
|
638
680
|
res := [];
|
|
@@ -640,6 +682,7 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
|
|
|
640
682
|
invariant remaining <= s
|
|
641
683
|
invariant forall x :: x in res <==> (x in s && x !in remaining)
|
|
642
684
|
invariant |res| + |remaining| == |s|
|
|
685
|
+
invariant forall i, j :: 0 <= i < j < |res| ==> res[i] != res[j]
|
|
643
686
|
decreases remaining
|
|
644
687
|
{
|
|
645
688
|
var x :| x in remaining;
|
|
@@ -656,6 +699,7 @@ const PREAMBLE_CODE = [
|
|
|
656
699
|
["JSFloorDiv", JS_FLOOR_DIV],
|
|
657
700
|
["CeilReal", CEIL_REAL],
|
|
658
701
|
["FloorReal", FLOOR_REAL],
|
|
702
|
+
["SeqIndexOf", SEQ_INDEX_OF],
|
|
659
703
|
["StringIndexOf", STRING_INDEX_OF],
|
|
660
704
|
["StringTrim", STRING_TRIM],
|
|
661
705
|
["StringToLower", STRING_TO_LOWER],
|
|
@@ -736,26 +780,40 @@ function translatePattern(pattern) {
|
|
|
736
780
|
export function emitDafnyFile(file, tsFileName) {
|
|
737
781
|
buildRecordCtorMap(file.decls);
|
|
738
782
|
_neededPreambles.clear();
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (d.kind === "namespace") {
|
|
743
|
-
for (const inner of d.decls)
|
|
744
|
-
if (inner.kind === "def")
|
|
745
|
-
pureDefs.add(inner.name);
|
|
746
|
-
}
|
|
747
|
-
if (d.kind === "def")
|
|
748
|
-
pureDefs.add(d.name);
|
|
749
|
-
}
|
|
783
|
+
// Track successfully emitted pure defs — method wrappers are only
|
|
784
|
+
// skipped when the corresponding pure def was actually emitted.
|
|
785
|
+
const emittedPureDefs = new Set();
|
|
750
786
|
// Emit declarations
|
|
751
787
|
const declLines = [];
|
|
752
788
|
const skipped = [];
|
|
753
789
|
for (const decl of file.decls) {
|
|
754
|
-
if (decl.kind === "method" &&
|
|
790
|
+
if (decl.kind === "method" && emittedPureDefs.has(decl.name))
|
|
791
|
+
continue;
|
|
792
|
+
if (decl.kind === "namespace") {
|
|
793
|
+
// Emit each inner decl individually — if one fails, the rest survive
|
|
794
|
+
// and failed defs fall back to their method wrappers
|
|
795
|
+
for (const inner of decl.decls) {
|
|
796
|
+
try {
|
|
797
|
+
declLines.push("");
|
|
798
|
+
declLines.push(emitDecl(inner));
|
|
799
|
+
if (inner.kind === "def")
|
|
800
|
+
emittedPureDefs.add(inner.name);
|
|
801
|
+
}
|
|
802
|
+
catch (e) {
|
|
803
|
+
const name = "name" in inner ? inner.name : "unknown";
|
|
804
|
+
const msg = e.message;
|
|
805
|
+
console.error(`WARNING: skipping pure '${name}': ${msg}`);
|
|
806
|
+
declLines.push(`\n// LemmaScript: skipped pure ${name}`);
|
|
807
|
+
skipped.push(name);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
755
810
|
continue;
|
|
811
|
+
}
|
|
756
812
|
try {
|
|
757
813
|
declLines.push("");
|
|
758
814
|
declLines.push(emitDecl(decl));
|
|
815
|
+
if (decl.kind === "def-by-method")
|
|
816
|
+
emittedPureDefs.add(decl.name);
|
|
759
817
|
}
|
|
760
818
|
catch (e) {
|
|
761
819
|
const name = "name" in decl ? decl.name : "unknown";
|
package/tools/dist/extract.js
CHANGED
|
@@ -8,6 +8,13 @@ import { Project, Node, SyntaxKind, ScriptTarget } from "ts-morph";
|
|
|
8
8
|
// ── Expression extraction ────────────────────────────────────
|
|
9
9
|
/** When set, calls whose function/method name matches this key are replaced with havoc. */
|
|
10
10
|
let _havocKey = null;
|
|
11
|
+
/**
|
|
12
|
+
* Maps property-name fingerprints to type alias names for collapsed single-variant unions.
|
|
13
|
+
* TypeScript collapses `type X = | { kind: 'A'; ... }` to the underlying object type,
|
|
14
|
+
* causing getAliasSymbol() to return null. This map lets typeToString recover the alias.
|
|
15
|
+
* Populated in extractModule before type extraction; used by typeToString.
|
|
16
|
+
*/
|
|
17
|
+
let _collapsedUnionMap = new Map();
|
|
11
18
|
/** Generic bounds erasure map — set during extractFunction, applied in extractStmts. */
|
|
12
19
|
let _typeParamMap = new Map();
|
|
13
20
|
function _eraseGenerics(tsType) {
|
|
@@ -106,6 +113,14 @@ function extractExpr(node) {
|
|
|
106
113
|
}
|
|
107
114
|
// Call expression: f(a, b)
|
|
108
115
|
if (Node.isCallExpression(node)) {
|
|
116
|
+
// Object.fromEntries(map) → identity (Map IS Record in Dafny)
|
|
117
|
+
const callee = node.getExpression();
|
|
118
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
119
|
+
callee.getExpression().getText() === "Object" &&
|
|
120
|
+
callee.getName() === "fromEntries" &&
|
|
121
|
+
node.getArguments().length === 1) {
|
|
122
|
+
return extractExpr(node.getArguments()[0]);
|
|
123
|
+
}
|
|
109
124
|
return {
|
|
110
125
|
kind: "call",
|
|
111
126
|
fn: extractExpr(node.getExpression()),
|
|
@@ -196,6 +211,7 @@ function extractExpr(node) {
|
|
|
196
211
|
if (Node.isObjectLiteralExpression(node)) {
|
|
197
212
|
let spread = null;
|
|
198
213
|
const fields = [];
|
|
214
|
+
const computedFields = [];
|
|
199
215
|
for (const prop of node.getProperties()) {
|
|
200
216
|
if (Node.isSpreadAssignment(prop)) {
|
|
201
217
|
spread = extractExpr(prop.getExpression());
|
|
@@ -205,10 +221,26 @@ function extractExpr(node) {
|
|
|
205
221
|
fields.push({ name, value: { kind: "var", name } });
|
|
206
222
|
}
|
|
207
223
|
else if (Node.isPropertyAssignment(prop)) {
|
|
224
|
+
const nameNode = prop.getNameNode();
|
|
208
225
|
const init = prop.getInitializer();
|
|
209
|
-
if (init)
|
|
226
|
+
if (init && Node.isComputedPropertyName(nameNode)) {
|
|
227
|
+
computedFields.push({ key: extractExpr(nameNode.getExpression()), value: extractExpr(init) });
|
|
228
|
+
}
|
|
229
|
+
else if (init) {
|
|
210
230
|
fields.push({ name: prop.getName(), value: extractExpr(init) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Desugar computed keys: { ...base, [k]: v } → base.set(k, v)
|
|
235
|
+
// No spread: { [k]: v } → {}.set(k, v) (empty map base)
|
|
236
|
+
if (computedFields.length > 0) {
|
|
237
|
+
let result = spread ?? { kind: "record", spread: null, fields: [] };
|
|
238
|
+
for (const cf of computedFields) {
|
|
239
|
+
result = { kind: "call",
|
|
240
|
+
fn: { kind: "field", obj: result, field: "set" },
|
|
241
|
+
args: [cf.key, cf.value] };
|
|
211
242
|
}
|
|
243
|
+
return result;
|
|
212
244
|
}
|
|
213
245
|
return { kind: "record", spread, fields };
|
|
214
246
|
}
|
|
@@ -234,8 +266,9 @@ function extractExpr(node) {
|
|
|
234
266
|
if (name === "Map" && args && args.length === 1) {
|
|
235
267
|
const argType = args[0].getType();
|
|
236
268
|
const argSymbol = argType.getSymbol()?.getName() ?? argType.getAliasSymbol()?.getName();
|
|
237
|
-
|
|
238
|
-
|
|
269
|
+
const argTypeText = _eraseGenerics(typeToString(argType));
|
|
270
|
+
if (argSymbol === "Map" || argTypeText.startsWith("Record<")) {
|
|
271
|
+
// new Map(existingMap) or new Map(record) — identity (Dafny maps are value types)
|
|
239
272
|
return extractExpr(args[0]);
|
|
240
273
|
}
|
|
241
274
|
// new Map(entries) — map-from-array constructor
|
|
@@ -257,6 +290,17 @@ function extractExpr(node) {
|
|
|
257
290
|
if (Node.isAsExpression(node)) {
|
|
258
291
|
return extractExpr(node.getExpression());
|
|
259
292
|
}
|
|
293
|
+
// delete obj[key] → map delete expression
|
|
294
|
+
if (Node.isDeleteExpression(node)) {
|
|
295
|
+
const expr = node.getExpression();
|
|
296
|
+
if (Node.isElementAccessExpression(expr)) {
|
|
297
|
+
return {
|
|
298
|
+
kind: "call",
|
|
299
|
+
fn: { kind: "field", obj: extractExpr(expr.getExpression()), field: "delete" },
|
|
300
|
+
args: [extractExpr(expr.getArgumentExpression())],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
260
304
|
// null → undefined (both map to None in backends)
|
|
261
305
|
if (Node.isNullLiteral(node)) {
|
|
262
306
|
return { kind: "var", name: "undefined" };
|
|
@@ -289,6 +333,17 @@ function collectAnnotations(node, body) {
|
|
|
289
333
|
return [...own, ...parseAnnotations(body[0])];
|
|
290
334
|
return own;
|
|
291
335
|
}
|
|
336
|
+
/** Check for bare `//@ pure` annotation (no expression). */
|
|
337
|
+
function hasPureAnnotation(node, body) {
|
|
338
|
+
const nodes = body && body.length > 0 ? [node, body[0]] : [node];
|
|
339
|
+
for (const n of nodes) {
|
|
340
|
+
for (const range of n.getLeadingCommentRanges()) {
|
|
341
|
+
if (range.getText().trim() === "//@ pure")
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
292
347
|
// ── Type declaration extraction ──────────────────────────────
|
|
293
348
|
function extractTypeDecl(decl, extraDecls) {
|
|
294
349
|
const name = decl.getName();
|
|
@@ -320,6 +375,30 @@ function extractTypeDecl(decl, extraDecls) {
|
|
|
320
375
|
}
|
|
321
376
|
}
|
|
322
377
|
}
|
|
378
|
+
// Single-variant discriminated union: type X = | { kind: 'Foo', ... }
|
|
379
|
+
// TypeScript collapses single-member unions to their member type,
|
|
380
|
+
// so type.isUnion() returns false. Detect by checking the source text
|
|
381
|
+
// for union syntax '|' AND a string-literal discriminant field.
|
|
382
|
+
if (type.isObject() && !type.isIntersection()) {
|
|
383
|
+
const srcText = decl.getTypeNode()?.getText() ?? "";
|
|
384
|
+
if (srcText.includes("|")) {
|
|
385
|
+
const disc = findDiscriminant([type]);
|
|
386
|
+
if (disc) {
|
|
387
|
+
const tagProp = type.getProperty(disc);
|
|
388
|
+
const tagType = tagProp?.getTypeAtLocation(decl);
|
|
389
|
+
const tag = tagType?.isStringLiteral() ? String(tagType.getLiteralValue()) : null;
|
|
390
|
+
if (tag) {
|
|
391
|
+
const fields = [];
|
|
392
|
+
for (const prop of type.getProperties()) {
|
|
393
|
+
if (prop.getName() === disc)
|
|
394
|
+
continue;
|
|
395
|
+
fields.push({ name: prop.getName(), tsType: typeToString(prop.getTypeAtLocation(decl)) });
|
|
396
|
+
}
|
|
397
|
+
return { name, typeParams: tpField, kind: "discriminated-union", discriminant: disc, variants: [{ name: tag, fields }] };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
323
402
|
if (type.isObject() || type.isIntersection())
|
|
324
403
|
return extractRecord(name, type, decl, undefined, extraDecls);
|
|
325
404
|
// Primitive type alias: type TaskId = number → alias
|
|
@@ -432,6 +511,13 @@ function typeToString(type) {
|
|
|
432
511
|
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
|
433
512
|
if (symbol) {
|
|
434
513
|
const name = symbol.getName();
|
|
514
|
+
// Recover collapsed single-variant union alias via property fingerprint
|
|
515
|
+
if (name === "__type" && _collapsedUnionMap.size > 0) {
|
|
516
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
517
|
+
const alias = _collapsedUnionMap.get(props);
|
|
518
|
+
if (alias)
|
|
519
|
+
return alias;
|
|
520
|
+
}
|
|
435
521
|
const typeArgs = type.getTypeArguments();
|
|
436
522
|
if (typeArgs.length > 0) {
|
|
437
523
|
return `${name}<${typeArgs.map(t => typeToString(t)).join(", ")}>`;
|
|
@@ -511,6 +597,38 @@ function extractStmts(stmts) {
|
|
|
511
597
|
}
|
|
512
598
|
continue;
|
|
513
599
|
}
|
|
600
|
+
// Destructuring rest: const { [k]: _, ...rest } = map → let rest = map.delete(k)
|
|
601
|
+
if (!isHavoc && Node.isObjectBindingPattern(nameNode)) {
|
|
602
|
+
const elements = nameNode.getElements();
|
|
603
|
+
const restEl = elements.find(el => el.getDotDotDotToken());
|
|
604
|
+
const computedEls = elements.filter(el => {
|
|
605
|
+
const pn = el.getPropertyNameNode();
|
|
606
|
+
return pn && Node.isComputedPropertyName(pn);
|
|
607
|
+
});
|
|
608
|
+
if (restEl && computedEls.length > 0) {
|
|
609
|
+
const initializer = d.getInitializer();
|
|
610
|
+
if (initializer) {
|
|
611
|
+
let deleteInit = extractExpr(initializer);
|
|
612
|
+
for (const cel of computedEls) {
|
|
613
|
+
const pn = cel.getPropertyNameNode();
|
|
614
|
+
const keyExpr = extractExpr(pn.getExpression());
|
|
615
|
+
deleteInit = { kind: "call",
|
|
616
|
+
fn: { kind: "field", obj: deleteInit, field: "delete" },
|
|
617
|
+
args: [keyExpr] };
|
|
618
|
+
}
|
|
619
|
+
const declType = d.getType();
|
|
620
|
+
result.push({
|
|
621
|
+
kind: "let",
|
|
622
|
+
name: restEl.getName(),
|
|
623
|
+
mutable: s.getDeclarationKind() === "let",
|
|
624
|
+
tsType: _eraseGenerics(typeToString(declType)),
|
|
625
|
+
init: deleteInit,
|
|
626
|
+
line,
|
|
627
|
+
});
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
514
632
|
const declType = d.getType();
|
|
515
633
|
let init;
|
|
516
634
|
if (isHavoc && !havocKey) {
|
|
@@ -571,12 +689,49 @@ function extractStmts(stmts) {
|
|
|
571
689
|
else {
|
|
572
690
|
names.push("_");
|
|
573
691
|
}
|
|
692
|
+
// Unwrap Object.entries(expr) / Object.values(expr) to bare map iteration
|
|
693
|
+
let iterableExpr = s.getExpression();
|
|
694
|
+
if (Node.isCallExpression(iterableExpr)) {
|
|
695
|
+
const callee = iterableExpr.getExpression();
|
|
696
|
+
if (Node.isPropertyAccessExpression(callee) &&
|
|
697
|
+
callee.getExpression().getText() === "Object") {
|
|
698
|
+
const method = callee.getName();
|
|
699
|
+
if ((method === "entries" || method === "values") && iterableExpr.getArguments().length === 1) {
|
|
700
|
+
iterableExpr = iterableExpr.getArguments()[0];
|
|
701
|
+
// Object.values with single name → prepend "_" so it looks like [_, v] destructuring
|
|
702
|
+
if (method === "values" && names.length === 1) {
|
|
703
|
+
names.unshift("_");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
574
708
|
const bodyNode = s.getStatement();
|
|
575
709
|
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
576
710
|
const annots = collectAnnotations(s, bodyStmts);
|
|
577
711
|
result.push({
|
|
578
712
|
kind: "forof",
|
|
579
713
|
names,
|
|
714
|
+
iterable: extractExpr(iterableExpr),
|
|
715
|
+
invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
|
|
716
|
+
doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
|
|
717
|
+
body: extractStmts(bodyStmts),
|
|
718
|
+
line,
|
|
719
|
+
});
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
// for...in: for (const k in obj) → treat as forof with single key name
|
|
723
|
+
if (Node.isForInStatement(s)) {
|
|
724
|
+
const init = s.getInitializer();
|
|
725
|
+
let name = "_";
|
|
726
|
+
if (Node.isVariableDeclarationList(init)) {
|
|
727
|
+
name = init.getDeclarations()[0]?.getName() ?? "_";
|
|
728
|
+
}
|
|
729
|
+
const bodyNode = s.getStatement();
|
|
730
|
+
const bodyStmts = Node.isBlock(bodyNode) ? bodyNode.getStatements() : [bodyNode];
|
|
731
|
+
const annots = collectAnnotations(s, bodyStmts);
|
|
732
|
+
result.push({
|
|
733
|
+
kind: "forof",
|
|
734
|
+
names: [name],
|
|
580
735
|
iterable: extractExpr(s.getExpression()),
|
|
581
736
|
invariants: annots.filter(a => a.kind === "invariant").map(a => a.expr),
|
|
582
737
|
doneWith: annots.find(a => a.kind === "done_with")?.expr ?? null,
|
|
@@ -782,6 +937,8 @@ function extractFunction(fn, parentAnnotations) {
|
|
|
782
937
|
})(),
|
|
783
938
|
requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
|
|
784
939
|
ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
|
|
940
|
+
decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
|
|
941
|
+
pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
|
|
785
942
|
typeAnnotations,
|
|
786
943
|
body: extractedBody,
|
|
787
944
|
line: fn.getStartLineNumber(),
|
|
@@ -825,6 +982,22 @@ export function extractModule(sourceFile) {
|
|
|
825
982
|
typeDecls.push({ name, kind: "record", fields });
|
|
826
983
|
}
|
|
827
984
|
}
|
|
985
|
+
// Pre-scan for collapsed single-variant unions so typeToString can recover alias names.
|
|
986
|
+
// TypeScript collapses `type X = | { kind: 'A'; ... }` to a plain object type, losing
|
|
987
|
+
// the alias. We record a fingerprint (sorted property names) → alias name mapping.
|
|
988
|
+
_collapsedUnionMap = new Map();
|
|
989
|
+
for (const stmt of sourceFile.getStatements()) {
|
|
990
|
+
if (Node.isTypeAliasDeclaration(stmt)) {
|
|
991
|
+
const type = stmt.getType();
|
|
992
|
+
if (!type.isUnion() && type.isObject() && !type.isIntersection()) {
|
|
993
|
+
const srcText = stmt.getTypeNode()?.getText() ?? "";
|
|
994
|
+
if (srcText.includes("|") && findDiscriminant([type])) {
|
|
995
|
+
const props = type.getProperties().map(p => p.getName()).sort().join(",");
|
|
996
|
+
_collapsedUnionMap.set(props, stmt.getName());
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
828
1001
|
// Extract type declarations in source order to respect dependencies
|
|
829
1002
|
// Skip types already declared via //@ declare-type
|
|
830
1003
|
const declaredNames = new Set(typeDecls.map(d => d.name));
|
|
@@ -1184,6 +1357,12 @@ export function extractModule(sourceFile) {
|
|
|
1184
1357
|
}
|
|
1185
1358
|
const sym = retType.getSymbol();
|
|
1186
1359
|
if (sym?.getName() === "__type" && retType.isObject() && !retType.isArray()) {
|
|
1360
|
+
// Try typeToString first — it resolves collapsed single-variant unions
|
|
1361
|
+
const resolved = typeToString(retType);
|
|
1362
|
+
if (resolved !== "__type" && !resolved.includes("__type") && knownTypes.has(resolved)) {
|
|
1363
|
+
fn.returnType = resolved;
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1187
1366
|
const synName = fn.name.charAt(0).toUpperCase() + fn.name.slice(1) + "Result";
|
|
1188
1367
|
if (!knownTypes.has(synName)) {
|
|
1189
1368
|
const extra = [];
|
package/tools/dist/lean-emit.js
CHANGED
|
@@ -325,6 +325,8 @@ function emitDecl(d) {
|
|
|
325
325
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
326
326
|
return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
|
|
327
327
|
}
|
|
328
|
+
case "def-by-method":
|
|
329
|
+
throw new Error("function by method is not supported for Lean backend");
|
|
328
330
|
case "method": {
|
|
329
331
|
const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
|
|
330
332
|
const lines = [`method ${d.name} ${params} return (res : ${tyToLean(d.returnType)})`];
|