lemmascript 0.5.6 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,6 +31,7 @@
31
31
  "type": "git",
32
32
  "url": "https://github.com/midspiral/LemmaScript"
33
33
  },
34
+ "homepage": "https://lemmascript.com",
34
35
  "keywords": [
35
36
  "lemmascript",
36
37
  "verification",
@@ -19,7 +19,11 @@ function tyToDafny(ty) {
19
19
  }
20
20
  case "user": return ty.name;
21
21
  case "fn": return `(${ty.params.map(tyToDafny).join(", ")}) -> ${tyToDafny(ty.result)}`;
22
- case "unknown": return "int";
22
+ // Out-of-subset (`any`/`unknown`); opaque so real ops on it fail loudly
23
+ // rather than silently verify as `int`. Mirrors the Lean backend's `_`.
24
+ case "unknown":
25
+ needPreamble("UnknownType");
26
+ return "Unknown";
23
27
  }
24
28
  }
25
29
  // ── Dafny keyword escaping ──────────────────────────────────
@@ -159,8 +163,14 @@ function emitExpr(e) {
159
163
  }
160
164
  if (e.method === "push")
161
165
  return `(${obj} + [${args.join(", ")}])`;
166
+ if (e.method === "unshift")
167
+ return `([${args.join(", ")}] + ${obj})`;
162
168
  if (e.method === "concat")
163
169
  return `(${obj} + [${args.join(", ")}])`;
170
+ if (e.method === "sort") {
171
+ needPreamble("SeqSortBy");
172
+ return `SeqSortBy(${obj}, ${args[0]})`;
173
+ }
164
174
  // No-arg slice is a full copy; Dafny seq is an immutable value type, so
165
175
  // the copy is just the seq itself (the idiom for "copy then mutate").
166
176
  if (e.method === "slice" && args.length === 0)
@@ -671,7 +681,8 @@ function emitDecl(d) {
671
681
  return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
672
682
  }
673
683
  case "structure": {
674
- return `datatype ${d.name} = ${d.name}(${paramList(d.fields)})`;
684
+ const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
685
+ return `datatype ${d.name}${tp} = ${d.name}(${paramList(d.fields)})`;
675
686
  }
676
687
  case "type-alias": {
677
688
  return `type ${d.name} = ${tyToDafny(d.target)}`;
@@ -964,6 +975,17 @@ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<s
964
975
  ensures |StringSplit(s, d)| >= 1
965
976
  ensures |StringSplit(s, d)| <= |s| + 1
966
977
  ensures forall k :: 0 <= k < |StringSplit(s, d)| ==> |StringSplit(s, d)[k]| <= |s|`;
978
+ // `xs.sort(cmp)` in TS sorts in place by a comparator (negative ⟺ a before b).
979
+ // Modeled here as an axiom returning a permutation sorted by cmp. The `requires`
980
+ // is the soundness condition — cmp must be a total preorder, otherwise no sorted
981
+ // permutation exists and the axiom would be vacuous. Callers discharge it (e.g.
982
+ // `(a,b) => a.k - b.k` is total + transitive by linear arithmetic).
983
+ const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T, T) -> int): seq<T>
984
+ requires forall a: T, b: T :: cmp(a, b) <= 0 || cmp(b, a) <= 0
985
+ requires forall a: T, b: T, c: T :: cmp(a, b) <= 0 && cmp(b, c) <= 0 ==> cmp(a, c) <= 0
986
+ ensures multiset(SeqSortBy(s, cmp)) == multiset(s)
987
+ ensures |SeqSortBy(s, cmp)| == |s|
988
+ ensures forall i: int, j: int :: 0 <= i <= j < |SeqSortBy(s, cmp)| ==> cmp(SeqSortBy(s, cmp)[i], SeqSortBy(s, cmp)[j]) <= 0`;
967
989
  const STRING_TRIM = `function StringTrimLeft(s: string): string
968
990
  ensures |StringTrimLeft(s)| <= |s|
969
991
  ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
@@ -1095,6 +1117,9 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
1095
1117
  /** Preamble code keyed by name. Emitted in this order when needed. */
1096
1118
  const PREAMBLE_CODE = [
1097
1119
  ["OptionType", "datatype Option<T> = None | Some(value: T)"],
1120
+ // Opaque carrier for `unknown`-typed values. `(==)` for compare/map-key/match;
1121
+ // `(0)` (auto-init ⇒ nonempty) so `havoc` (`:= *`) is well-formed.
1122
+ ["UnknownType", "type Unknown(==, 0)"],
1098
1123
  ["SetToSeq", SET_TO_SEQ],
1099
1124
  ["Pow2", POW2],
1100
1125
  ["BitAnd", BIT_AND],
@@ -1114,6 +1139,7 @@ const PREAMBLE_CODE = [
1114
1139
  ["SafeSlice", SAFE_SLICE],
1115
1140
  ["StringIndexOf", STRING_INDEX_OF],
1116
1141
  ["StringSplit", STRING_SPLIT],
1142
+ ["SeqSortBy", SEQ_SORT_BY],
1117
1143
  ["StringTrim", STRING_TRIM],
1118
1144
  ["StringToLower", STRING_TO_LOWER],
1119
1145
  ["StringToUpper", STRING_TO_UPPER],
@@ -1202,6 +1228,23 @@ export function emitDafnyFile(file, tsFileName, opts) {
1202
1228
  // Track successfully emitted pure defs — method wrappers are only
1203
1229
  // skipped when the corresponding pure def was actually emitted.
1204
1230
  const emittedPureDefs = new Set();
1231
+ // Emit a decl, rolling back any preamble requirements it registered if it
1232
+ // throws. A skipped decl must contribute neither text nor preambles — else a
1233
+ // side-effecting `needPreamble` from a half-emitted decl leaves an unused
1234
+ // preamble (e.g. `type Unknown` from a skipped const whose head is
1235
+ // `unknown`-typed but whose value expr is unsupported).
1236
+ const emitDeclTx = (d) => {
1237
+ const saved = new Set(_neededPreambles);
1238
+ try {
1239
+ return emitDecl(d);
1240
+ }
1241
+ catch (e) {
1242
+ _neededPreambles.clear();
1243
+ for (const k of saved)
1244
+ _neededPreambles.add(k);
1245
+ throw e;
1246
+ }
1247
+ };
1205
1248
  // Emit declarations
1206
1249
  const declLines = [];
1207
1250
  const skipped = [];
@@ -1214,7 +1257,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
1214
1257
  for (const inner of decl.decls) {
1215
1258
  try {
1216
1259
  declLines.push("");
1217
- declLines.push(emitDecl(inner));
1260
+ declLines.push(emitDeclTx(inner));
1218
1261
  if (inner.kind === "def")
1219
1262
  emittedPureDefs.add(inner.name);
1220
1263
  }
@@ -1230,7 +1273,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
1230
1273
  }
1231
1274
  try {
1232
1275
  declLines.push("");
1233
- declLines.push(emitDecl(decl));
1276
+ declLines.push(emitDeclTx(decl));
1234
1277
  if (decl.kind === "def-by-method")
1235
1278
  emittedPureDefs.add(decl.name);
1236
1279
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Lean IR → text. Trivial pretty-printer.
3
+ * No logic, no type decisions — just serialization.
4
+ */
5
+ // ── Lean keyword escaping ────────────────────────────────────
6
+ const LEAN_KEYWORDS = new Set([
7
+ "def", "theorem", "lemma", "example", "structure", "class", "instance",
8
+ "inductive", "where", "match", "with", "if", "then", "else", "do",
9
+ "let", "mut", "return", "for", "in", "while", "break", "continue",
10
+ "import", "open", "section", "namespace", "end", "set_option",
11
+ "variable", "axiom", "constant", "private", "protected", "noncomputable",
12
+ "partial", "unsafe", "macro", "syntax", "by", "fun", "have", "show",
13
+ "at", "from", "to", "deriving", "extends", "true", "false",
14
+ ]);
15
+ function escapeName(name) {
16
+ return LEAN_KEYWORDS.has(name) ? `«${name}»` : name;
17
+ }
18
+ // ── Operator precedence (for parenthesization) ──────────────
19
+ const PREC = {
20
+ "→": 1, "∨": 2, "∧": 3,
21
+ "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
22
+ "+": 5, "-": 5, "*": 6, "/": 6, "%": 6,
23
+ };
24
+ function prec(op) { return PREC[op] ?? 10; }
25
+ // ── Expression emission ─────────────────────────────────────
26
+ function emitExpr(e, parentPrec) {
27
+ switch (e.kind) {
28
+ case "var": return escapeName(e.name);
29
+ case "num": return `${e.value}`;
30
+ case "bool": return e.value ? "true" : "false";
31
+ case "str": return `"${e.value}"`;
32
+ case "constructor": return `.${e.name}`;
33
+ case "arrayLiteral":
34
+ if (e.elems.length === 0)
35
+ return `#[]`;
36
+ return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
37
+ case "dotCall": {
38
+ const obj = emitExpr(e.obj);
39
+ const wrap = e.obj.kind === "binop" || e.obj.kind === "app" || e.obj.kind === "dotCall";
40
+ const receiver = wrap ? `(${obj})` : obj;
41
+ const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
42
+ return args.length > 0 ? `${receiver}.${e.method} ${args.join(" ")}` : `${receiver}.${e.method}`;
43
+ }
44
+ case "lambda": {
45
+ const params = e.params.map(p => p.name).join(" ");
46
+ // Single return statement → expression lambda
47
+ if (e.body.length === 1 && e.body[0].kind === "return") {
48
+ return `(fun ${params} => ${emitExpr(e.body[0].value)})`;
49
+ }
50
+ // Multi-statement → do block
51
+ return `(fun ${params} => do\n${emitStmts(e.body, 2)})`;
52
+ }
53
+ case "unop":
54
+ if (e.op === "¬")
55
+ return `¬(${emitExpr(e.expr)})`;
56
+ if (e.op === "-" && e.expr.kind === "num")
57
+ return `-${e.expr.value}`;
58
+ return `(-${emitExpr(e.expr)})`;
59
+ case "binop": {
60
+ const s = `${emitExpr(e.left, prec(e.op))} ${e.op} ${emitExpr(e.right, prec(e.op))}`;
61
+ return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
62
+ }
63
+ case "implies": {
64
+ const parts = [...e.premises.map(p => emitExpr(p)), emitExpr(e.conclusion)];
65
+ const s = parts.join(" → ");
66
+ return parentPrec !== undefined ? `(${s})` : s;
67
+ }
68
+ case "app": {
69
+ const args = e.args.map(a => (a.kind === "binop" || a.kind === "unop" || a.kind === "implies" || a.kind === "app") ? `(${emitExpr(a)})` : emitExpr(a));
70
+ return `${e.fn} ${args.join(" ")}`;
71
+ }
72
+ case "field": {
73
+ const obj = emitExpr(e.obj);
74
+ const wrap = e.obj.kind !== "var" && e.obj.kind !== "num" && e.obj.kind !== "bool";
75
+ return wrap ? `(${obj}).${escapeName(e.field)}` : `${obj}.${escapeName(e.field)}`;
76
+ }
77
+ case "toNat": {
78
+ const inner = emitExpr(e.expr);
79
+ const wrap = e.expr.kind !== "var" && e.expr.kind !== "num";
80
+ return wrap ? `(${inner}).toNat` : `${inner}.toNat`;
81
+ }
82
+ case "index":
83
+ return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]!`;
84
+ case "record": {
85
+ const fields = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
86
+ if (e.spread)
87
+ return `{ ${emitExpr(e.spread)} with ${fields.join(", ")} }`;
88
+ return `{ ${fields.join(", ")} }`;
89
+ }
90
+ case "if":
91
+ return `if ${emitExpr(e.cond)} then ${emitExpr(e.then)} else ${emitExpr(e.else)}`;
92
+ case "match": {
93
+ const arms = e.arms.map(a => `| ${a.pattern} => ${emitExpr(a.body)}`);
94
+ return `match ${e.scrutinee} with ${arms.join(" ")}`;
95
+ }
96
+ case "forall": return `∀ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
97
+ case "exists": return `∃ ${e.var} : ${e.type}, ${emitExpr(e.body)}`;
98
+ case "let": return `let ${e.name} := ${emitExpr(e.value)}\n${emitExpr(e.body)}`;
99
+ }
100
+ }
101
+ // ── Statement emission ──────────────────────────────────────
102
+ function emitStmts(stmts, indent) {
103
+ const pad = " ".repeat(indent);
104
+ return stmts.map(s => emitStmt(s, indent)).join("\n");
105
+ }
106
+ function emitStmt(s, indent) {
107
+ const pad = " ".repeat(indent);
108
+ switch (s.kind) {
109
+ case "let":
110
+ return s.mutable
111
+ ? `${pad}let mut ${escapeName(s.name)} : ${s.type} := ${emitExpr(s.value)}`
112
+ : `${pad}let ${escapeName(s.name)} := ${emitExpr(s.value)}`;
113
+ case "assign": return `${pad}${escapeName(s.target)} := ${emitExpr(s.value)}`;
114
+ case "bind": return `${pad}${escapeName(s.target)} ← ${emitExpr(s.value)}`;
115
+ case "let-bind": return `${pad}let ${s.name} ← ${emitExpr(s.value)}`;
116
+ case "return": return `${pad}return ${emitExpr(s.value)}`;
117
+ case "break": return `${pad}break`;
118
+ case "continue": return `${pad}continue`;
119
+ case "if": {
120
+ let out = `${pad}if ${emitExpr(s.cond)} then\n${emitStmts(s.then, indent + 1)}`;
121
+ if (s.else.length > 0) {
122
+ if (s.else.length === 1 && s.else[0].kind === "if") {
123
+ const ei = s.else[0];
124
+ out += `\n${pad}else if ${emitExpr(ei.cond)} then\n${emitStmts(ei.then, indent + 1)}`;
125
+ if (ei.else.length > 0)
126
+ out += `\n${pad}else\n${emitStmts(ei.else, indent + 1)}`;
127
+ }
128
+ else {
129
+ out += `\n${pad}else\n${emitStmts(s.else, indent + 1)}`;
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ case "match": {
135
+ const lines = [`${pad}match ${s.scrutinee} with`];
136
+ for (const arm of s.arms) {
137
+ lines.push(`${pad}| ${arm.pattern} =>`);
138
+ lines.push(emitStmts(arm.body, indent + 1));
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+ case "while": {
143
+ const lines = [`${pad}while ${emitExpr(s.cond)}`];
144
+ for (const inv of s.invariants)
145
+ lines.push(`${pad} invariant ${emitExpr(inv)}`);
146
+ if (s.doneWith)
147
+ lines.push(`${pad} done_with ${emitExpr(s.doneWith)}`);
148
+ if (s.decreasing)
149
+ lines.push(`${pad} decreasing ${emitExpr(s.decreasing)}`);
150
+ lines.push(`${pad}do`);
151
+ lines.push(emitStmts(s.body, indent + 1));
152
+ return lines.join("\n");
153
+ }
154
+ case "forin": {
155
+ const lines = [`${pad}for ${s.idx} in [:${emitExpr(s.bound)}]`];
156
+ for (const inv of s.invariants)
157
+ lines.push(`${pad} invariant ${emitExpr(inv)}`);
158
+ lines.push(`${pad}do`);
159
+ lines.push(emitStmts(s.body, indent + 1));
160
+ return lines.join("\n");
161
+ }
162
+ }
163
+ }
164
+ // ── Declaration emission ─────────────────────────────────────
165
+ function emitDecl(d) {
166
+ switch (d.kind) {
167
+ case "inductive": {
168
+ const lines = [`inductive ${d.name} where`];
169
+ for (const c of d.constructors) {
170
+ if (c.fields.length === 0) {
171
+ lines.push(` | ${c.name} : ${d.name}`);
172
+ }
173
+ else {
174
+ const params = c.fields.map(f => `(${escapeName(f.name)} : ${f.type})`).join(" ");
175
+ lines.push(` | ${c.name} ${params} : ${d.name}`);
176
+ }
177
+ }
178
+ if (d.deriving.length > 0)
179
+ lines.push(`deriving ${d.deriving.join(", ")}`);
180
+ return lines.join("\n");
181
+ }
182
+ case "structure": {
183
+ const lines = [`structure ${d.name} where`];
184
+ for (const f of d.fields)
185
+ lines.push(` ${escapeName(f.name)} : ${f.type}`);
186
+ if (d.deriving.length > 0)
187
+ lines.push(`deriving ${d.deriving.join(", ")}`);
188
+ return lines.join("\n");
189
+ }
190
+ case "def": {
191
+ const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
192
+ return `def ${d.name} ${params} : ${d.returnType} :=\n${emitPureExpr(d.body, 1)}`;
193
+ }
194
+ case "method": {
195
+ const params = d.params.map(p => `(${escapeName(p.name)} : ${p.type})`).join(" ");
196
+ const lines = [`method ${d.name} ${params} return (res : ${d.returnType})`];
197
+ for (const r of d.requires)
198
+ lines.push(` require ${emitExpr(r)}`);
199
+ for (const e of d.ensures)
200
+ lines.push(` ensures ${emitExpr(e)}`);
201
+ lines.push(" do");
202
+ lines.push(emitStmts(d.body, 2));
203
+ return lines.join("\n");
204
+ }
205
+ case "namespace": {
206
+ const lines = [`namespace ${d.name}`];
207
+ for (const inner of d.decls)
208
+ lines.push("", emitDecl(inner));
209
+ lines.push("", `end ${d.name}`);
210
+ return lines.join("\n");
211
+ }
212
+ }
213
+ }
214
+ /** Emit a pure expression with indented if/match blocks. */
215
+ function emitPureExpr(e, indent) {
216
+ const pad = " ".repeat(indent);
217
+ switch (e.kind) {
218
+ case "if":
219
+ return `${pad}if ${emitExpr(e.cond)} then\n${emitPureExpr(e.then, indent + 1)}\n${pad}else\n${emitPureExpr(e.else, indent + 1)}`;
220
+ case "match": {
221
+ const lines = [`${pad}match ${e.scrutinee} with`];
222
+ for (const arm of e.arms) {
223
+ lines.push(`${pad}| ${arm.pattern} =>`);
224
+ lines.push(emitPureExpr(arm.body, indent + 1));
225
+ }
226
+ return lines.join("\n");
227
+ }
228
+ case "let":
229
+ return `${pad}let ${e.name} := ${emitExpr(e.value)}\n${emitPureExpr(e.body, indent)}`;
230
+ default:
231
+ return `${pad}${emitExpr(e)}`;
232
+ }
233
+ }
234
+ // ── File emission ────────────────────────────────────────────
235
+ export function emitFile(file) {
236
+ const lines = [];
237
+ if (file.comment) {
238
+ lines.push("/-");
239
+ lines.push(file.comment);
240
+ lines.push("-/");
241
+ }
242
+ for (const imp of file.imports)
243
+ lines.push(`import ${imp}`);
244
+ if (file.options.length > 0)
245
+ lines.push("");
246
+ for (const opt of file.options)
247
+ lines.push(`set_option ${opt.key} ${opt.value}`);
248
+ for (const decl of file.decls) {
249
+ lines.push("");
250
+ lines.push(emitDecl(decl));
251
+ }
252
+ return lines.join("\n") + "\n";
253
+ }
@@ -487,9 +487,9 @@ function extractExpr(node) {
487
487
  }
488
488
  // Object literal: { res: true, done: false } or { ...obj, res: true }
489
489
  if (Node.isObjectLiteralExpression(node)) {
490
- // Fold properties in source order: a later spread overrides everything before
491
- // it, a named field is a record-update on the accumulator, a computed key is a
492
- // map `.set`. Order matters: if `a` has `k`, `{ k: v, ...a }` is `a`, not `a.(k := v)`.
490
+ // Fold properties in source order: a spread on top of existing content is a
491
+ // field-wise merge (resolve expands it against the result type), a named field
492
+ // is a record-update on the accumulator, a computed key is a map `.set`.
493
493
  let acc = null;
494
494
  const update = (name, value) => {
495
495
  acc = acc && acc.kind === "record"
@@ -498,7 +498,8 @@ function extractExpr(node) {
498
498
  };
499
499
  for (const prop of node.getProperties()) {
500
500
  if (Node.isSpreadAssignment(prop)) {
501
- acc = extractExpr(prop.getExpression());
501
+ const s = extractExpr(prop.getExpression());
502
+ acc = acc === null ? s : { kind: "recordMerge", base: acc, override: s };
502
503
  }
503
504
  else if (Node.isShorthandPropertyAssignment(prop)) {
504
505
  const name = prop.getName();
@@ -599,7 +600,7 @@ function extractExpr(node) {
599
600
  }
600
601
  // ── Annotation parsing ───────────────────────────────────────
601
602
  const PREFIX = "//@ ";
602
- const KEYWORDS = ["requires", "ensures", "invariant", "decreases", "done_with", "type"];
603
+ const KEYWORDS = ["requires", "ensures", "contract", "invariant", "decreases", "done_with", "type"];
603
604
  function parseAnnotations(node) {
604
605
  const result = [];
605
606
  for (const range of node.getLeadingCommentRanges()) {
@@ -985,6 +986,14 @@ function forCounterRename(decl, forStmt) {
985
986
  const scopeRoot = fnLike ?? forStmt.getSourceFile();
986
987
  const isScope = (a) => Node.isBlock(a) || Node.isSourceFile(a) ||
987
988
  Node.isForStatement(a) || Node.isForOfStatement(a) || Node.isForInStatement(a);
989
+ // A counting-`for` counter is hoisted out of its `for`, so its real scope is
990
+ // the nearest enclosing block, not the `for` itself.
991
+ const hoistScope = (d) => {
992
+ const owner = d.getParent()?.getParent();
993
+ return owner && Node.isForStatement(owner) && owner.getInitializer() === d.getParent()
994
+ ? d.getFirstAncestor(a => Node.isBlock(a) || Node.isSourceFile(a))
995
+ : undefined;
996
+ };
988
997
  // Scopes that enclose this loop — its counter, once hoisted, lives in one of
989
998
  // these, so a same-named binding scoped here would collide.
990
999
  const enclosing = new Set(forStmt.getAncestors());
@@ -992,6 +1001,11 @@ function forCounterRename(decl, forStmt) {
992
1001
  const declClash = scopeRoot.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some(d => {
993
1002
  if (d === decl || d.getName() !== name)
994
1003
  return false;
1004
+ // Two sibling for-counters hoisting into the same block: rename only the
1005
+ // later loop, so the first keeps its name. Any other clash (e.g. a `let`) yields.
1006
+ const counterScope = hoistScope(d);
1007
+ if (counterScope)
1008
+ return enclosing.has(counterScope) && d.getStart() < decl.getStart();
995
1009
  const scope = d.getFirstAncestor(isScope);
996
1010
  return !!scope && enclosing.has(scope);
997
1011
  });
@@ -1027,6 +1041,7 @@ function renameRawExpr(e, from, to) {
1027
1041
  case "index": return { ...e, obj: r(e.obj), idx: r(e.idx) };
1028
1042
  case "field": return { ...e, obj: r(e.obj) };
1029
1043
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(f => ({ ...f, value: r(f.value) })) };
1044
+ case "recordMerge": return { ...e, base: r(e.base), override: r(e.override) };
1030
1045
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
1031
1046
  case "conditional": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
1032
1047
  case "nullish": return { ...e, left: r(e.left), right: r(e.right) };
@@ -1567,32 +1582,38 @@ function extractStmts(stmts) {
1567
1582
  // nested loop stays put.
1568
1583
  const stripExitBreaks = (b) => b.filter(st => st.kind !== "break");
1569
1584
  const isExit = (st) => !!st && ["break", "return", "throw", "continue"].includes(st.kind);
1570
- let fallthrough = [];
1571
- for (const clause of s.getClauses()) {
1572
- if (Node.isCaseClause(clause)) {
1573
- const label = clause.getExpression().getText().replace(/^["']|["']$/g, "");
1574
- if (clause.getStatements().length === 0) {
1575
- fallthrough.push(label);
1576
- continue;
1577
- }
1578
- const raw = extractStmts(clause.getStatements());
1579
- if (!isExit(raw[raw.length - 1]))
1580
- throw new Error(`switch case "${label}" at line ${line}: a non-empty case must end with break/return/throw; fall-through into the next case is not supported`);
1581
- const body = stripExitBreaks(raw);
1582
- for (const l of fallthrough)
1583
- cases.push({ label: l, body });
1584
- cases.push({ label, body });
1585
- fallthrough = [];
1586
- }
1587
- else {
1588
- defaultBody = stripExitBreaks(extractStmts(clause.getStatements()));
1589
- for (const l of fallthrough)
1590
- cases.push({ label: l, body: defaultBody });
1591
- fallthrough = [];
1585
+ // Resolve JS fall-through positionally: a clause's effective body is its
1586
+ // own statements concatenated with each following clause's statements up
1587
+ // to and including the first clause that ends in break/return/throw (or
1588
+ // the switch end). This covers both empty stacked labels (`case A: case
1589
+ // B: body`) and a *non-empty* case that falls through (`case A: sA; case
1590
+ // B: ...`) — the stripped breaks are the switch exits.
1591
+ const clauseInfos = s.getClauses().map(clause => {
1592
+ const stmts = extractStmts(clause.getStatements());
1593
+ return {
1594
+ label: Node.isCaseClause(clause)
1595
+ ? clause.getExpression().getText().replace(/^["']|["']$/g, "")
1596
+ : null,
1597
+ stmts,
1598
+ exits: isExit(stmts[stmts.length - 1]),
1599
+ };
1600
+ });
1601
+ const fallThroughBody = (start) => {
1602
+ let body = [];
1603
+ for (let j = start; j < clauseInfos.length; j++) {
1604
+ body = body.concat(clauseInfos[j].stmts);
1605
+ if (clauseInfos[j].exits)
1606
+ break;
1592
1607
  }
1608
+ return stripExitBreaks(body);
1609
+ };
1610
+ for (let i = 0; i < clauseInfos.length; i++) {
1611
+ const c = clauseInfos[i];
1612
+ if (c.label === null)
1613
+ defaultBody = fallThroughBody(i);
1614
+ else
1615
+ cases.push({ label: c.label, body: fallThroughBody(i) });
1593
1616
  }
1594
- for (const l of fallthrough)
1595
- cases.push({ label: l, body: [] });
1596
1617
  result.push({ kind: "switch", expr: switchExpr, discriminant, cases, defaultBody, line });
1597
1618
  continue;
1598
1619
  }
@@ -1692,17 +1713,21 @@ function extractFunction(fn, parentAnnotations) {
1692
1713
  }
1693
1714
  }
1694
1715
  function extractFunctionInner(fn, parentAnnotations) {
1695
- // Generic bounds erasure: <T extends Base> substitute T with Base everywhere
1696
- // Unbounded type params are preserved as Dafny type parameters
1716
+ // A `<T extends B>` bound is handled one of two ways. When B is a modelable
1717
+ // type (a record/nominal), substitute T with B — a body that reads T's fields
1718
+ // (e.g. `x.id`) then typechecks against B. When B is a union/intersection we
1719
+ // can't model as one Dafny type (e.g. `string & {}` tricks), keep T as a Dafny
1720
+ // type param instead; such a T must be phantom (any field read won't typecheck).
1697
1721
  _typeParamMap = new Map();
1698
1722
  _reservedForCounterNames = new Set();
1699
- const unboundedTypeParams = [];
1723
+ const typeParams = [];
1700
1724
  for (const tp of fn.getTypeParameters?.() ?? []) {
1701
1725
  const constraint = tp.getConstraint();
1702
- if (constraint)
1726
+ const ct = constraint?.getType();
1727
+ if (constraint && !ct?.isUnion() && !ct?.isIntersection())
1703
1728
  _typeParamMap.set(tp.getName(), constraint.getText());
1704
1729
  else
1705
- unboundedTypeParams.push(tp.getName());
1730
+ typeParams.push(tp.getName());
1706
1731
  }
1707
1732
  const body = fn.getBody();
1708
1733
  // Expression-body arrow: wrap in implicit return
@@ -1730,16 +1755,49 @@ function extractFunctionInner(fn, parentAnnotations) {
1730
1755
  }
1731
1756
  return {
1732
1757
  name: fn.getName?.() ?? "<anonymous>",
1733
- typeParams: unboundedTypeParams,
1758
+ exported: false, // set in extractModule against the source file's export surface
1759
+ typeParams,
1760
+ // Original TS parameter grouping, before the flatten below loses it. `defaults` carries
1761
+ // each bound name's default initializer text (omitted when none) for TS-targeting consumers.
1762
+ tsParams: fn.getParameters().map(p => {
1763
+ const nameNode = p.getNameNode();
1764
+ if (Node.isObjectBindingPattern(nameNode)) {
1765
+ const els = nameNode.getElements();
1766
+ const defaults = {};
1767
+ for (const el of els) {
1768
+ const init = el.getInitializer();
1769
+ if (init)
1770
+ defaults[el.getName()] = init.getText();
1771
+ }
1772
+ const binds = els.map(el => el.getName());
1773
+ return Object.keys(defaults).length ? { kind: "object", binds, defaults } : { kind: "object", binds };
1774
+ }
1775
+ if (p.isRestParameter())
1776
+ return { kind: "rest", binds: [p.getName()] };
1777
+ const init = p.getInitializer();
1778
+ return init
1779
+ ? { kind: "simple", binds: [p.getName()], defaults: { [p.getName()]: init.getText() } }
1780
+ : { kind: "simple", binds: [p.getName()] };
1781
+ }),
1734
1782
  params: fn.getParameters().flatMap(p => {
1735
1783
  // Flatten destructured object params into individual params
1736
1784
  const nameNode = p.getNameNode();
1737
1785
  if (Node.isObjectBindingPattern(nameNode)) {
1738
1786
  const type = p.getType();
1787
+ // `//@ declare-type` types are invisible to the TS checker, so
1788
+ // `type.getProperty` finds nothing for them and the bindings would
1789
+ // collapse to `unknown`. Fall back to the declared record's field types.
1790
+ const declTypeName = p.getTypeNode()?.getText();
1791
+ const declFields = declTypeName
1792
+ ? _synthArrayUnions?.find(d => d.name === declTypeName && d.kind === "record")?.fields
1793
+ : undefined;
1739
1794
  return nameNode.getElements().map(el => {
1740
1795
  const name = el.getName();
1741
1796
  const propType = type.getProperty(name)?.getTypeAtLocation(p);
1742
- return { name, tsType: propType ? typeToString(propType) : "unknown" };
1797
+ if (propType)
1798
+ return { name, tsType: typeToString(propType) };
1799
+ const declTy = declFields?.find(f => f.name === name)?.tsType;
1800
+ return { name, tsType: declTy ?? "unknown" };
1743
1801
  });
1744
1802
  }
1745
1803
  // Syntactic union nodes go through _tsTypeFromUnionNode so synth fires
@@ -1790,6 +1848,7 @@ function extractFunctionInner(fn, parentAnnotations) {
1790
1848
  })(),
1791
1849
  requires: annots.filter(a => a.kind === "requires").map(a => a.expr),
1792
1850
  ensures: annots.filter(a => a.kind === "ensures").map(a => a.expr),
1851
+ contract: annots.filter(a => a.kind === "contract").map(a => a.expr),
1793
1852
  decreases: annots.find(a => a.kind === "decreases")?.expr ?? null,
1794
1853
  pure: hasPureAnnotation(fn, body && Node.isBlock(body) ? body.getStatements() : undefined),
1795
1854
  autohavoc: false, // set in extractModule (file-level directive or per-function)
@@ -1819,15 +1878,17 @@ export function extractModule(sourceFile) {
1819
1878
  // `//@ declare-type Name { f1: T1, ... }` — record form.
1820
1879
  // `//@ declare-type Name = TsType` — alias form (e.g. `Ruleset = Rule[]`).
1821
1880
  function parseDeclareType(body) {
1822
- const recordMatch = body.match(/^(\w+)\s*\{(.+)\}$/);
1881
+ const recordMatch = body.match(/^(\w+)\s*(?:<([^>]+)>)?\s*\{(.+)\}$/);
1823
1882
  if (recordMatch) {
1824
1883
  const name = recordMatch[1];
1825
- const fields = recordMatch[2].split(",").map(f => f.trim()).filter(Boolean).map(f => {
1884
+ // `<T extends B>` → bare `T`: a Dafny type param, like the def path.
1885
+ const typeParams = recordMatch[2]?.split(",").map(s => s.trim().split(/\s+extends\s+/)[0].trim()).filter(Boolean);
1886
+ const fields = recordMatch[3].split(",").map(f => f.trim()).filter(Boolean).map(f => {
1826
1887
  const [fname, ftype] = f.split(":").map(s => s.trim());
1827
1888
  const synth = _synthFromTsTypeString(ftype);
1828
1889
  return { name: fname, tsType: synth ?? ftype };
1829
1890
  });
1830
- typeDecls.push({ name, kind: "record", fields });
1891
+ typeDecls.push({ name, kind: "record", fields, ...(typeParams?.length ? { typeParams } : {}) });
1831
1892
  return;
1832
1893
  }
1833
1894
  const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
@@ -2028,7 +2089,8 @@ export function extractModule(sourceFile) {
2028
2089
  const sig = f.node.getType().getCallSignatures()[0];
2029
2090
  if (!sig)
2030
2091
  continue;
2031
- const typeParams = sig.getTypeParameters().map(tp => tp.getText());
2092
+ // Bare names, dropping `extends B` — same as the def path.
2093
+ const typeParams = sig.getTypeParameters().map(tp => tp.getText().split(/\s+extends\s+/)[0].trim());
2032
2094
  // Normalize via typeToString (not raw getText): resolves declare-type
2033
2095
  // shadows and yields bare names, so a param typed by an unreachable import
2034
2096
  // becomes `AgentMessage`, not `import("/abs/path").AgentMessage`.
@@ -2076,11 +2138,16 @@ export function extractModule(sourceFile) {
2076
2138
  }
2077
2139
  return false;
2078
2140
  }
2141
+ // The module's export surface, by name — covers inline `export function`,
2142
+ // `export { a, b }`, re-exports, and `export const`. Consumers (e.g. the guard
2143
+ // plugin) use this to wrap only the boundary, not internal helpers.
2144
+ const exportedNames = new Set(sourceFile.getExportedDeclarations().keys());
2079
2145
  const functions = fnsToExtract.map(f => {
2080
2146
  // For expression-body arrows, annotations come from the parent variable statement
2081
2147
  const parentAnnots = f.parentStmt ? parseAnnotations(f.parentStmt) : undefined;
2082
2148
  const raw = extractFunction(f.node, parentAnnots);
2083
2149
  raw.name = f.name; // use the const name, not "<anonymous>"
2150
+ raw.exported = exportedNames.has(f.name);
2084
2151
  raw.autohavoc = hasAutohavoc(f);
2085
2152
  return raw;
2086
2153
  });
@@ -2268,6 +2335,10 @@ export function extractModule(sourceFile) {
2268
2335
  collectNamesExpr(e.spread);
2269
2336
  e.fields.forEach(f => collectNamesExpr(f.value));
2270
2337
  }
2338
+ if (e.kind === "recordMerge") {
2339
+ collectNamesExpr(e.base);
2340
+ collectNamesExpr(e.override);
2341
+ }
2271
2342
  if (e.kind === "arrayLiteral") {
2272
2343
  e.elems.forEach(collectNamesExpr);
2273
2344
  }
@@ -2276,6 +2347,31 @@ export function extractModule(sourceFile) {
2276
2347
  collectNamesExpr(e.then);
2277
2348
  collectNamesExpr(e.else);
2278
2349
  }
2350
+ if (e.kind === "nullish") {
2351
+ collectNamesExpr(e.left);
2352
+ collectNamesExpr(e.right);
2353
+ }
2354
+ if (e.kind === "nonNull") {
2355
+ collectNamesExpr(e.expr);
2356
+ }
2357
+ if (e.kind === "optChain") {
2358
+ collectNamesExpr(e.obj);
2359
+ for (const c of e.chain) {
2360
+ if (c.kind === "call")
2361
+ c.args.forEach(collectNamesExpr);
2362
+ if (c.kind === "index")
2363
+ collectNamesExpr(c.idx);
2364
+ }
2365
+ }
2366
+ if (e.kind === "lambda") {
2367
+ if (Array.isArray(e.body))
2368
+ collectNames(e.body);
2369
+ else
2370
+ collectNamesExpr(e.body);
2371
+ }
2372
+ if (e.kind === "forall" || e.kind === "exists") {
2373
+ collectNamesExpr(e.body);
2374
+ }
2279
2375
  }
2280
2376
  // Signature types (params + return) get base-name stripping below; body /
2281
2377
  // spec references stay exact-match (so a body `let xs: Hunk[]` doesn't pull
@@ -100,7 +100,7 @@ const parseSimpleOptionalCheck = parseOptionalCheck;
100
100
  // ── Walkers ──────────────────────────────────────────────────
101
101
  function walkExpr(e) {
102
102
  const r = recurseExpr(e);
103
- return ruleNullish(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
103
+ return ruleNullish(r) ?? ruleNullishIndex(r) ?? ruleOptChainIndex(r) ?? ruleOptChain(r) ?? ruleImplOptional(r) ?? ruleImplArrayIsArray(r) ?? ruleConditionalArrayIsArray(r) ?? ruleConditionalAndArrayIsArray(r) ?? ruleConditionalAndOptional(r) ?? ruleConditionalOptionalSimple(r) ?? ruleConditionalInMap(r) ?? ruleConditionalOptionalTruthy(r) ?? r;
104
104
  }
105
105
  function recurseExpr(e) {
106
106
  const re = walkExpr;
@@ -434,6 +434,61 @@ function ruleNullish(e) {
434
434
  ty: e.ty,
435
435
  };
436
436
  }
437
+ /** Rule (expression): `arr[i] ?? right` — nullish coalescing on an array index.
438
+ * Under noUncheckedIndexedAccess `arr[i]` is `T | undefined`, undefined exactly
439
+ * when out of bounds, so → `(0 <= i && i < arr.length) ? arr[i] : right`. The
440
+ * guarded `then` keeps the seq index in bounds for the backend. (Map index is
441
+ * already optional-typed and handled by ruleNullish above; this is the array
442
+ * case, whose element type stays non-optional in expression position.) */
443
+ function ruleNullishIndex(e) {
444
+ if (e.kind !== "nullish")
445
+ return null;
446
+ if (e.left.kind !== "index")
447
+ return null;
448
+ if (e.left.obj.ty.kind !== "array")
449
+ return null;
450
+ const idx = e.left.idx;
451
+ const len = { kind: "field", obj: e.left.obj, field: "length", ty: { kind: "int" } };
452
+ const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
453
+ const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
454
+ const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
455
+ return { kind: "conditional", cond, then: e.left, else: e.right, ty: e.ty };
456
+ }
457
+ /** Rule (expression): `arr[i]?.<chain>` — optional chaining on an array index,
458
+ * the optChain sibling of ruleNullishIndex. `arr[i]` is `T | undefined`,
459
+ * undefined exactly out of bounds, so → `(0 <= i && i < arr.length) ? <chain on
460
+ * arr[i]> : undefined`. The conditional's optional type makes transform wrap the
461
+ * in-bounds chain result in Some and the OOB branch in None — the same Option<…>
462
+ * a directly-optional scrutinee yields via ruleOptChain, just bounds-guarded.
463
+ * (ruleOptChain itself bails here: an array index is typed as the non-optional
464
+ * element type, so its `?.` never reaches that rule.) */
465
+ function ruleOptChainIndex(e) {
466
+ if (e.kind !== "optChain")
467
+ return null;
468
+ if (e.obj.kind !== "index")
469
+ return null;
470
+ if (e.obj.obj.ty.kind !== "array")
471
+ return null;
472
+ const idx = e.obj.idx;
473
+ const len = { kind: "field", obj: e.obj.obj, field: "length", ty: { kind: "int" } };
474
+ const lo = { kind: "binop", op: "<=", left: { kind: "num", value: 0, ty: { kind: "int" } }, right: idx, ty: { kind: "bool" } };
475
+ const hi = { kind: "binop", op: "<", left: idx, right: len, ty: { kind: "bool" } };
476
+ const cond = { kind: "binop", op: "&&", left: lo, right: hi, ty: { kind: "bool" } };
477
+ let body = e.obj; // arr[i] — in bounds under `cond`
478
+ for (const step of e.chain) {
479
+ if (step.kind === "field") {
480
+ body = { kind: "field", obj: body, field: step.name, ty: step.ty };
481
+ }
482
+ else if (step.kind === "index") {
483
+ body = { kind: "index", obj: body, idx: step.idx, ty: step.ty };
484
+ }
485
+ else {
486
+ body = { kind: "call", fn: body, args: step.args, ty: step.ty, callKind: step.callKind };
487
+ }
488
+ }
489
+ const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
490
+ return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
491
+ }
437
492
  /** Rule (expression): `obj?.<chain>` — single-eval optional chain.
438
493
  * → `someMatch obj { Some(_oc{N}_val) => apply(chain, _oc{N}_val), None => undefined }`.
439
494
  * The someBody applies the chain to the binder directly (field/call/index),
@@ -5,7 +5,7 @@
5
5
  * No mutation — each let extends the chain, lookup walks it.
6
6
  */
7
7
  import { isBigInt } from "./typedir.js";
8
- import { parseTsType } from "./types.js";
8
+ import { parseTsType, tyToCanonical } from "./types.js";
9
9
  import { parseExpr } from "./specparser.js";
10
10
  function lookup(env, name) {
11
11
  if (!env)
@@ -349,6 +349,14 @@ function isUnmodeledTy(ty, typeDecls) {
349
349
  }
350
350
  return false;
351
351
  }
352
+ /** A `user` type that resolves to a string-union declare-type — runs as a plain
353
+ * string at runtime, so it's a refinement of `string`, not an opaque blob. */
354
+ function isStringUnionTy(ty, typeDecls) {
355
+ if (ty.kind !== "user")
356
+ return false;
357
+ const base = ty.name.includes("<") ? ty.name.slice(0, ty.name.indexOf("<")) : ty.name;
358
+ return typeDecls.some(d => d.name === base && d.kind === "string-union");
359
+ }
352
360
  /** Infer quantifier variable type from usage in body.
353
361
  * If the variable is used as a map/set key (e.g. map.has(k), map.get(k)),
354
362
  * return the collection's key type. Otherwise return null (default to int). */
@@ -457,6 +465,16 @@ function tyToTsStr(ty) {
457
465
  return undefined;
458
466
  }
459
467
  function inferLambdaParamTypes(fn, rawArgs, ctx) {
468
+ // sort's comparator takes two params, both the element type.
469
+ if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "sort" &&
470
+ rawArgs.length >= 1 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 1) {
471
+ const tsType = tyToTsStr(fn.obj.ty.elem);
472
+ if (tsType) {
473
+ const lam = rawArgs[0];
474
+ const updatedParams = lam.params.map(p => (p.tsType ? p : { ...p, tsType }));
475
+ return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
476
+ }
477
+ }
460
478
  if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
461
479
  ["map", "filter", "every", "some", "find", "findLast", "findIndex"].includes(fn.field) &&
462
480
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
@@ -573,7 +591,9 @@ function inferMethodReturnTy(fn, args, ctx) {
573
591
  return objTy.elem;
574
592
  if (fn.field === "pop")
575
593
  return { kind: "optional", inner: objTy.elem };
576
- if (fn.field === "push" || fn.field === "concat")
594
+ if (fn.field === "push" || fn.field === "unshift" || fn.field === "concat")
595
+ return objTy;
596
+ if (fn.field === "sort")
577
597
  return objTy;
578
598
  if (fn.field === "filter")
579
599
  return objTy;
@@ -639,6 +659,106 @@ function lookupFieldTy(objTy, field, ctx) {
639
659
  return { ty: { kind: "unknown" }, isDiscriminant: false };
640
660
  }
641
661
  // ── Resolve expressions ──────────────────────────────────────
662
+ // Fresh-binder counter for the someMatches synthesized by object-spread merge.
663
+ let mergeBinder = 0;
664
+ /** Expand `{ ...base, ...override }` into a faithful field-wise merge. Driven by
665
+ * the result record type's fields: an override field wins when present, else the
666
+ * base's field shows through. Optional fields decide presence at runtime
667
+ * (`Some?`); an `Option`-typed override is the whole merge guarded by its tag. */
668
+ function resolveRecordMerge(base, override, ctx) {
669
+ const tbase = resolveExpr(base, ctx);
670
+ const tover = resolveExpr(override, ctx);
671
+ const overInner = tover.ty.kind === "optional" ? tover.ty.inner : tover.ty;
672
+ // Result record type: prefer the override's (an optional override still merges
673
+ // into its inner type), else the base's.
674
+ const rTy = overInner.kind === "user" ? overInner
675
+ : tbase.ty.kind === "user" ? tbase.ty : null;
676
+ const decl = rTy ? ctx.typeDecls.find(d => d.name === rTy.name && d.kind === "record") : undefined;
677
+ if (!rTy || !decl?.fields) {
678
+ throw new Error(`object spread merge { ...a, ...b } needs a known record type for both operands ` +
679
+ `(base: ${tyToCanonical(tbase.ty)}, override: ${tyToCanonical(tover.ty)})`);
680
+ }
681
+ if (tbase.ty.kind === "optional") {
682
+ throw new Error(`object spread merge with an optional base operand is not supported (base: ${tyToCanonical(tbase.ty)})`);
683
+ }
684
+ const userTy = rTy;
685
+ const fields = decl.fields;
686
+ // Build the merged literal from concrete base/override values, both : userTy.
687
+ const merged = (bv, ov) => ({
688
+ kind: "record", spread: null, ty: userTy,
689
+ fields: fields.map(f => {
690
+ const ft = f.type;
691
+ const ovf = { kind: "field", obj: ov, field: f.name, ty: ft };
692
+ if (ft.kind !== "optional")
693
+ return { name: f.name, value: ovf }; // required: override always provides
694
+ // optional: override field wins iff present, else base's field
695
+ const bvf = { kind: "field", obj: bv, field: f.name, ty: ft };
696
+ const binder = `_m${mergeBinder++}`;
697
+ // someBody is the unwrapped present value; transform re-wraps each arm in
698
+ // the backend's Some constructor (Dafny `Some`, Lean `Option.some`).
699
+ return { name: f.name, value: {
700
+ kind: "someMatch", scrutinee: ovf, binder, binderTy: ft.inner,
701
+ someBody: { kind: "var", name: binder, ty: ft.inner }, noneBody: bvf, ty: ft,
702
+ } };
703
+ }),
704
+ });
705
+ if (tover.ty.kind === "optional") {
706
+ // override may be absent (undefined spreads nothing) → base unchanged
707
+ const binder = `_mo${mergeBinder++}`;
708
+ return {
709
+ kind: "someMatch", scrutinee: tover, binder, binderTy: userTy,
710
+ someBody: merged(tbase, { kind: "var", name: binder, ty: userTy }), noneBody: tbase, ty: userTy,
711
+ };
712
+ }
713
+ return merged(tbase, tover);
714
+ }
715
+ /** `rec[k]` where `rec` is a record and `k` an enum of its field names. A *named*
716
+ * string-union key is a datatype → `match k { case f => rec.f }`; an *inline*
717
+ * union (`"a" | "b"`, a bare string carrying its members) → an equality chain
718
+ * `if k === "a" then rec.a else …`. Either way the chain/match covers exactly
719
+ * the key's values, so a subset key stays sound. Returns null if the shape
720
+ * doesn't apply (caller falls back to plain index). */
721
+ function tryRecordIndexByEnum(obj, idx, ctx) {
722
+ const objTy = obj.ty, keyTy = idx.ty;
723
+ if (objTy.kind !== "user")
724
+ return null;
725
+ const rec = ctx.typeDecls.find(d => d.name === objTy.name && d.kind === "record");
726
+ if (!rec?.fields)
727
+ return null;
728
+ const fieldByName = new Map(rec.fields.map(f => [f.name, f]));
729
+ const fieldTy = (v) => fieldByName.get(v).type ?? { kind: "unknown" };
730
+ const field = (v) => ({ kind: "field", obj, field: v, ty: fieldTy(v) });
731
+ // The key's members, and whether it's a datatype (named) or a bare string (inline).
732
+ let values = null;
733
+ let datatype = null;
734
+ if (keyTy.kind === "user") {
735
+ const keyEnum = ctx.typeDecls.find(d => d.name === keyTy.name && d.kind === "string-union");
736
+ if (keyEnum?.values?.length) {
737
+ values = keyEnum.values;
738
+ datatype = keyEnum.name;
739
+ }
740
+ }
741
+ else if (keyTy.kind === "string" && keyTy.values?.length) {
742
+ values = keyTy.values;
743
+ }
744
+ if (!values || !values.every(v => fieldByName.has(v)))
745
+ return null; // key isn't a subset of fields
746
+ if (datatype) {
747
+ return {
748
+ kind: "tagMatch", scrutinee: idx, typeName: datatype,
749
+ cases: values.map(v => ({ variant: v, body: field(v) })), fallthrough: null, ty: fieldTy(values[0]),
750
+ };
751
+ }
752
+ // Inline union: fold right into an equality chain, last member as the bare else.
753
+ let expr = field(values[values.length - 1]);
754
+ for (let i = values.length - 2; i >= 0; i--) {
755
+ expr = {
756
+ kind: "conditional", ty: fieldTy(values[i]), then: field(values[i]), else: expr,
757
+ cond: { kind: "binop", op: "===", left: idx, right: { kind: "str", value: values[i], ty: { kind: "string" } }, ty: { kind: "bool" } },
758
+ };
759
+ }
760
+ return expr;
761
+ }
642
762
  function resolveExpr(e, ctx) {
643
763
  switch (e.kind) {
644
764
  case "var":
@@ -812,6 +932,9 @@ function resolveExpr(e, ctx) {
812
932
  idxTy = narrowed ? obj.ty.value : { kind: "optional", inner: obj.ty.value };
813
933
  }
814
934
  else {
935
+ const recIdx = tryRecordIndexByEnum(obj, idx, ctx);
936
+ if (recIdx)
937
+ return recIdx;
815
938
  idxTy = { kind: "unknown" };
816
939
  }
817
940
  return { kind: "index", obj, idx, ty: idxTy };
@@ -842,8 +965,10 @@ function resolveExpr(e, ctx) {
842
965
  // left ?? right — result type is left's inner (when left is optional)
843
966
  // or just left's type, unified with right's type.
844
967
  const left = resolveExpr(e.left, ctx);
845
- const right = resolveExpr(e.right, ctx);
846
968
  const ty = left.ty.kind === "optional" ? left.ty.inner : left.ty;
969
+ // The default shares the result type, so coerce a string literal to a
970
+ // string-union enum (e.g. `availableLevels[0] ?? "off"`).
971
+ const right = coerceStr(resolveExpr(e.right, ctx), ty);
847
972
  return { kind: "nullish", left, right, ty };
848
973
  }
849
974
  case "optChain": {
@@ -944,6 +1069,8 @@ function resolveExpr(e, ctx) {
944
1069
  });
945
1070
  return { kind: "record", spread, fields, ty: recordTy ?? ty };
946
1071
  }
1072
+ case "recordMerge":
1073
+ return resolveRecordMerge(e.base, e.override, ctx);
947
1074
  case "result":
948
1075
  // \result desugars to a regular var so all the variable-narrowing
949
1076
  // machinery (env lookup, optional checks, path matching) just works.
@@ -967,8 +1094,14 @@ function resolveExpr(e, ctx) {
967
1094
  // anonymous tuple (mirrors return-position and call-argument records, which
968
1095
  // get their type via ctx.returnTy). Only narrow when the context type is an
969
1096
  // array; otherwise leave ctx untouched.
970
- const elemCtx = ctx.returnTy.kind === "array" ? { ...ctx, returnTy: ctx.returnTy.elem } : ctx;
971
- const elems = e.elems.map(el => resolveExpr(el, elemCtx));
1097
+ const expectedElem = ctx.returnTy.kind === "array" ? ctx.returnTy.elem : null;
1098
+ const elemCtx = expectedElem ? { ...ctx, returnTy: expectedElem } : ctx;
1099
+ const elems = e.elems.map(el => {
1100
+ const r = resolveExpr(el, elemCtx);
1101
+ // Coerce a bare string-literal element to a string-union enum (e.g.
1102
+ // `["off", …]: ModelThinkingLevel[]`), like return/arg positions.
1103
+ return expectedElem ? coerceStr(r, expectedElem) : r;
1104
+ });
972
1105
  const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
973
1106
  return { kind: "arrayLiteral", elems, ty: { kind: "array", elem: elemTy } };
974
1107
  }
@@ -1140,6 +1273,11 @@ function resolveStmt(s, ctx) {
1140
1273
  ? { kind: "optional", inner: init.ty }
1141
1274
  : init.ty;
1142
1275
  }
1276
+ else if (declTy.kind === "string" && isStringUnionTy(init.ty, ctx.typeDecls) && !ctx.overrides.has(s.name)) {
1277
+ // ts-morph widened a string-union to `string`; keep the initializer's
1278
+ // datatype so `local === "lit"` lowers to a discriminant test.
1279
+ ty = init.ty;
1280
+ }
1143
1281
  else if ((declTy.kind === "int" || declTy.kind === "nat") && init.ty.kind === "real" && !ctx.overrides.has(s.name)) {
1144
1282
  // TS infers `number` (→ int/nat) for an expression LS computes as `real`
1145
1283
  // (e.g. `a / b`, now real division). `number` can't tell them apart, so
@@ -1332,6 +1470,10 @@ function collectCallsExpr(e, fns, out) {
1332
1470
  for (const f of e.fields)
1333
1471
  collectCallsExpr(f.value, fns, out);
1334
1472
  return;
1473
+ case "recordMerge":
1474
+ collectCallsExpr(e.base, fns, out);
1475
+ collectCallsExpr(e.override, fns, out);
1476
+ return;
1335
1477
  case "arrayLiteral":
1336
1478
  for (const el of e.elems)
1337
1479
  collectCallsExpr(el, fns, out);
@@ -1359,7 +1359,7 @@ function transformStmt(s, typeDecls) {
1359
1359
  const recv = s.expr.fn.obj;
1360
1360
  const f = s.expr.fn.field;
1361
1361
  const isMutating = ((recv.ty.kind === "map" || recv.ty.kind === "set") && (f === "set" || f === "add" || f === "delete")) ||
1362
- (recv.ty.kind === "array" && f === "push");
1362
+ (recv.ty.kind === "array" && (f === "push" || f === "unshift" || f === "sort"));
1363
1363
  if (isMutating && recv.kind === "var") {
1364
1364
  const { binds, expr } = liftMethodCalls(s.expr);
1365
1365
  return [...binds, { kind: "assign", target: recv.name, value: expr }];
@@ -1836,6 +1836,7 @@ function transformTypeDecl(d) {
1836
1836
  else {
1837
1837
  return {
1838
1838
  kind: "structure", name: d.name,
1839
+ typeParams: d.typeParams,
1839
1840
  fields: d.fields.map(f => ({ name: f.name, type: f.type })),
1840
1841
  deriving: ["Repr", "Inhabited", "DecidableEq"],
1841
1842
  };
@@ -1854,7 +1855,7 @@ function findReassignedNames(stmts, names) {
1854
1855
  // Mutating collection calls: s.add(x), m.set(k,v), s.delete(x), arr.push(x)
1855
1856
  if (s.kind === "expr" && s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1856
1857
  s.expr.fn.obj.kind === "var" && names.has(s.expr.fn.obj.name) &&
1857
- ["add", "set", "delete", "push"].includes(s.expr.fn.field)) {
1858
+ ["add", "set", "delete", "push", "unshift"].includes(s.expr.fn.field)) {
1858
1859
  found.add(s.expr.fn.obj.name);
1859
1860
  }
1860
1861
  if (s.kind === "if") {
@@ -55,6 +55,20 @@ function tyFromTypeNode(tn) {
55
55
  if (normalized.length === 1 && "syntheticBool" in normalized[0])
56
56
  return { kind: "bool" };
57
57
  const nonNullish = normalized.filter(a => "syntheticBool" in a || !isNullish(a.node));
58
+ // Inline string-literal union (`"a" | "b"`, not a //@ declare-type): no datatype
59
+ // to resolve against, so lower to plain string (the arms are strings; == holds).
60
+ const isStrLit = (a) => !("syntheticBool" in a) && Node.isLiteralTypeNode(a.node) && a.node.getLiteral().getKind() === SyntaxKind.StringLiteral;
61
+ if (nonNullish.length >= 2 && nonNullish.every(isStrLit)) {
62
+ // Keep the literal members so `rec[k]` can lower to an equality chain.
63
+ const values = nonNullish.map(a => {
64
+ const lit = a.node;
65
+ const inner = Node.isLiteralTypeNode(lit) ? lit.getLiteral() : lit;
66
+ return Node.isStringLiteral(inner) ? inner.getLiteralValue() : inner.getText();
67
+ });
68
+ return normalized.some(a => !("syntheticBool" in a) && isNullish(a.node))
69
+ ? { kind: "optional", inner: { kind: "string", values } }
70
+ : { kind: "string", values };
71
+ }
58
72
  if (nonNullish.length === 1 && normalized.length >= 2) {
59
73
  const sole = nonNullish[0];
60
74
  const inner = "syntheticBool" in sole ? { kind: "bool" } : tyFromTypeNode(sole.node);