lemmascript 0.5.7 → 0.5.9

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.
@@ -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);
@@ -4,6 +4,7 @@
4
4
  * Consumes resolved types and classifications.
5
5
  * No type lookups, no string parsing, no re-inference.
6
6
  */
7
+ import { anyExprInStmts } from "./ir.js";
7
8
  import { parseTsType } from "./types.js";
8
9
  // ── Generic IR walkers ──────────────────────────────────────
9
10
  /**
@@ -23,7 +24,8 @@ function mapExpr(e, f) {
23
24
  case "str":
24
25
  case "emptyMap":
25
26
  case "emptySet":
26
- case "havoc": return e;
27
+ case "havoc":
28
+ case "default": return e;
27
29
  case "mapLiteral": return { ...e, entries: e.entries.map(en => ({ key: r(en.key), value: r(en.value) })) };
28
30
  case "constructor": return e.args ? { ...e, args: e.args.map(r) } : e;
29
31
  case "binop": return { ...e, left: r(e.left), right: r(e.right) };
@@ -630,6 +632,17 @@ function lowerExpr(e, binds) {
630
632
  return { kind: "field", obj: transformExpr(e.obj), field: "true_?" };
631
633
  }
632
634
  }
635
+ // Union destructor: `x.field` where x is a discriminated union and `field`
636
+ // is a data field of one of its variants. Dafny reads the destructor
637
+ // directly; Lean has no field projection on a multi-ctor inductive, so tag
638
+ // the node with the union's base name and let the Lean emitter `match`.
639
+ if (e.obj.ty.kind === "user") {
640
+ const baseName = e.obj.ty.name.includes("<") ? e.obj.ty.name.slice(0, e.obj.ty.name.indexOf("<")) : e.obj.ty.name;
641
+ const decl = _typeDecls.find(d => d.name === baseName && d.kind === "discriminated-union");
642
+ if (decl?.variants?.some(v => v.fields.some(f => f.name === e.field))) {
643
+ return { kind: "field", obj: transformExpr(e.obj), field: e.field, fromUnion: baseName };
644
+ }
645
+ }
633
646
  return { kind: "field", obj: transformExpr(e.obj), field: e.field };
634
647
  case "index": {
635
648
  const idx = transformExpr(e.idx);
@@ -1106,6 +1119,188 @@ function buildPopLowering(arrName, arrTy) {
1106
1119
  const guardedTrunc = { kind: "if", cond: isNonEmpty, then: truncated, else: arrVar };
1107
1120
  return { optValue, guardedTrunc };
1108
1121
  }
1122
+ /**
1123
+ * Velvet (Lean backend) rejects `return` inside a loop. For a function shaped as
1124
+ * `…; while (…) { … early returns … }; return fallthrough`, hoist a mutable
1125
+ * result variable: each in-loop `return e` becomes `_loopRet := e; break`, then
1126
+ * `return _loopRet` after the loop. The trailing fallthrough return seeds
1127
+ * `_loopRet`, so a normal loop exit returns it unchanged. Dafny is untouched —
1128
+ * it keeps the native early returns.
1129
+ */
1130
+ function stmtsContainReturn(stmts) {
1131
+ for (const s of stmts) {
1132
+ if (s.kind === "return")
1133
+ return true;
1134
+ if (s.kind === "if" && (stmtsContainReturn(s.then) || stmtsContainReturn(s.else)))
1135
+ return true;
1136
+ if (s.kind === "match" && s.arms.some(a => stmtsContainReturn(a.body)))
1137
+ return true;
1138
+ // Don't descend into a nested while — its returns would target that loop.
1139
+ }
1140
+ return false;
1141
+ }
1142
+ function replaceReturnsWithBreak(stmts, retVar) {
1143
+ return stmts.flatMap((s) => {
1144
+ if (s.kind === "return")
1145
+ return [{ kind: "assign", target: retVar, value: s.value }, { kind: "break" }];
1146
+ if (s.kind === "if")
1147
+ return [{ ...s, then: replaceReturnsWithBreak(s.then, retVar), else: replaceReturnsWithBreak(s.else, retVar) }];
1148
+ if (s.kind === "match")
1149
+ return [{ ...s, arms: s.arms.map(a => ({ ...a, body: replaceReturnsWithBreak(a.body, retVar) })) }];
1150
+ return [s];
1151
+ });
1152
+ }
1153
+ // Seed value for `_loopRet` when a return-in-loop function has no trailing
1154
+ // fallthrough return. Readable literals for the primitives; everything else
1155
+ // (user types, arrays, maps, optionals) gets a typed `default : T` rather than a
1156
+ // type-incorrect `0` — these types derive `Inhabited`, so the default exists.
1157
+ function defaultExprForTy(ty) {
1158
+ switch (ty.kind) {
1159
+ case "bool": return { kind: "bool", value: false };
1160
+ case "string": return { kind: "str", value: "" };
1161
+ case "nat":
1162
+ case "int":
1163
+ case "real": return { kind: "num", value: 0 };
1164
+ default: return { kind: "default", type: ty };
1165
+ }
1166
+ }
1167
+ function eliminateReturnInLoops(stmts, retTy, resultInvariants) {
1168
+ const out = [];
1169
+ for (let i = 0; i < stmts.length; i++) {
1170
+ const s = stmts[i];
1171
+ if (s.kind === "while" && stmtsContainReturn(s.body)) {
1172
+ const retVar = "_loopRet";
1173
+ const next = stmts[i + 1];
1174
+ const init = next && next.kind === "return" ? next.value : defaultExprForTy(retTy);
1175
+ out.push({ kind: "let", name: retVar, type: retTy, mutable: true, value: init });
1176
+ // The result variable carries the postcondition (the function's `ensures`
1177
+ // with `\result` → `_loopRet`) as a loop invariant: it holds initially (the
1178
+ // fallthrough seed) and after each `_loopRet := e; break`, so the
1179
+ // postcondition is re-established from the invariant when the loop exits.
1180
+ // Where Dafny discharged the postcondition at each `return` site, the
1181
+ // break-rewrite discharges it once, after the loop.
1182
+ out.push({ ...s, invariants: [...s.invariants, ...resultInvariants], body: replaceReturnsWithBreak(s.body, retVar) });
1183
+ out.push({ kind: "return", value: { kind: "var", name: retVar } });
1184
+ if (next && next.kind === "return")
1185
+ i++; // consume the seeded fallthrough return
1186
+ }
1187
+ else {
1188
+ out.push(s);
1189
+ }
1190
+ }
1191
+ return out;
1192
+ }
1193
+ /** True if `name` is referenced as a variable anywhere in `stmts`. Conservative:
1194
+ * counts every occurrence and ignores shadowing — a spurious hit only costs an
1195
+ * unused `let`, whereas a miss would leave a binder unbound. */
1196
+ function stmtsUseVar(stmts, name) {
1197
+ return anyExprInStmts(stmts, e => e.kind === "var" && e.name === name);
1198
+ }
1199
+ /**
1200
+ * Velvet (Lean backend) cannot synthesize a `WPGen` for a monadic statement
1201
+ * `match` on a user inductive in a method body (loom's matcher-WPGen drops a
1202
+ * `sorry`). loom *can* handle method-body `if`s (cf. examples/arrayEquals), so
1203
+ * lower such matches to discriminator `if`-chains: `match x with | .C f.. => B`
1204
+ * becomes `if x.C? then (let f := x.f-destructor; B) else …`. Constructor field
1205
+ * binders become `let`s bound to the (already-supported) union destructor. Option
1206
+ * matches and other non-user matches are left alone (loom handles those). Dafny
1207
+ * is untouched — it keeps the native match.
1208
+ */
1209
+ function matchToIfChains(stmts) {
1210
+ return stmts.flatMap((s) => {
1211
+ if (s.kind === "if")
1212
+ return [{ ...s, then: matchToIfChains(s.then), else: matchToIfChains(s.else) }];
1213
+ if (s.kind === "while")
1214
+ return [{ ...s, body: matchToIfChains(s.body) }];
1215
+ if (s.kind === "forin")
1216
+ return [{ ...s, body: matchToIfChains(s.body) }];
1217
+ if (s.kind !== "match")
1218
+ return [s];
1219
+ const arms = s.arms.map(a => ({ ...a, body: matchToIfChains(a.body) }));
1220
+ const ctorArms = arms.filter(a => a.pattern.trim() !== "_");
1221
+ const firstCtor = ctorArms[0]?.pattern.trim().split(/\s+/)[0].replace(/^\./, "");
1222
+ const decl = firstCtor
1223
+ ? _typeDecls.find(d => (d.kind === "discriminated-union" || d.kind === "string-union") &&
1224
+ ((d.variants?.some(v => v.name === firstCtor)) || (d.values?.includes(firstCtor))))
1225
+ : undefined;
1226
+ if (!decl)
1227
+ return [{ ...s, arms }]; // not a user union (e.g. Option) — leave as match
1228
+ const scrutExpr = typeof s.scrutinee === "string" ? { kind: "var", name: s.scrutinee } : s.scrutinee;
1229
+ const defaultArm = arms.find(a => a.pattern.trim() === "_");
1230
+ let elseBranch = defaultArm ? defaultArm.body : [];
1231
+ for (let k = ctorArms.length - 1; k >= 0; k--) {
1232
+ const armBody = ctorArms[k].body;
1233
+ if (armBody.length === 0)
1234
+ continue; // empty arm (no-op) — let it fall through to `else`
1235
+ const toks = ctorArms[k].pattern.trim().split(/\s+/);
1236
+ const ctor = toks[0].replace(/^\./, "");
1237
+ const binders = toks.slice(1);
1238
+ const variant = decl.variants?.find(v => v.name === ctor);
1239
+ // Discriminator condition. A nullary discriminated-union constructor would
1240
+ // need `DecidableEq` for `x = .Ctor` (which such unions don't derive), so
1241
+ // use a match-bool instead. Multi-field ctors already lower to a match-bool;
1242
+ // string-unions derive DecidableEq, so `=` is fine there.
1243
+ const cond = decl.kind === "discriminated-union" && binders.length === 0
1244
+ ? { kind: "match", scrutinee: scrutExpr, arms: [
1245
+ { pattern: `.${ctor}`, body: { kind: "bool", value: true } },
1246
+ { pattern: "_", body: { kind: "bool", value: false } }
1247
+ ] }
1248
+ : { kind: "binop", op: "=", left: scrutExpr, right: { kind: "constructor", name: ctor, type: decl.name } };
1249
+ // Bind only the constructor-field binders the body actually uses, pinning the
1250
+ // owning ctor so the destructor doesn't guess (variants share field names).
1251
+ const lets = [];
1252
+ binders.forEach((b, i) => {
1253
+ const f = variant?.fields[i];
1254
+ if (b !== "_" && f && stmtsUseVar(armBody, b))
1255
+ lets.push({
1256
+ kind: "let", name: b, type: f.type ?? { kind: "unknown" }, mutable: false,
1257
+ value: { kind: "field", obj: scrutExpr, field: f.name, fromUnion: decl.name, ctor },
1258
+ });
1259
+ });
1260
+ elseBranch = [{ kind: "if", cond, then: [...lets, ...armBody], else: elseBranch }];
1261
+ }
1262
+ return elseBranch;
1263
+ });
1264
+ }
1265
+ /** True if `stmts` contain a `break` not nested inside another while. */
1266
+ function stmtsContainBreak(stmts) {
1267
+ for (const s of stmts) {
1268
+ if (s.kind === "break")
1269
+ return true;
1270
+ if (s.kind === "if" && (stmtsContainBreak(s.then) || stmtsContainBreak(s.else)))
1271
+ return true;
1272
+ if (s.kind === "match" && s.arms.some(a => stmtsContainBreak(a.body)))
1273
+ return true;
1274
+ }
1275
+ return false;
1276
+ }
1277
+ /**
1278
+ * loom's default loop-exit fact is `¬guard`, which does not hold for a loop that
1279
+ * `break`s (a break exits with the guard still true). Such loops need an explicit
1280
+ * `//@ done_with` in the TS source (`//@ done_with true` when the loop invariant
1281
+ * alone carries the exit facts). Rather than silently supplying one, reject —
1282
+ * the exit fact is part of the spec and belongs beside the invariants.
1283
+ * Lean-only; Dafny derives loop-exit facts from the break sites themselves.
1284
+ */
1285
+ function requireDoneWithForBreaks(stmts, fnName) {
1286
+ for (const s of stmts) {
1287
+ if (s.kind === "if") {
1288
+ requireDoneWithForBreaks(s.then, fnName);
1289
+ requireDoneWithForBreaks(s.else, fnName);
1290
+ }
1291
+ if (s.kind === "match")
1292
+ for (const a of s.arms)
1293
+ requireDoneWithForBreaks(a.body, fnName);
1294
+ if (s.kind === "while") {
1295
+ if (!s.doneWith && stmtsContainBreak(s.body)) {
1296
+ throw new Error(`${fnName}: loop with break needs //@ done_with on the Lean backend ` +
1297
+ `(an early return in a loop also lowers to a break). ` +
1298
+ `Use //@ done_with true when the loop invariants carry the exit facts.`);
1299
+ }
1300
+ requireDoneWithForBreaks(s.body, fnName);
1301
+ }
1302
+ }
1303
+ }
1109
1304
  function eliminateTopLevelContinue(stmts) {
1110
1305
  const out = [];
1111
1306
  for (let i = 0; i < stmts.length; i++) {
@@ -1359,7 +1554,7 @@ function transformStmt(s, typeDecls) {
1359
1554
  const recv = s.expr.fn.obj;
1360
1555
  const f = s.expr.fn.field;
1361
1556
  const isMutating = ((recv.ty.kind === "map" || recv.ty.kind === "set") && (f === "set" || f === "add" || f === "delete")) ||
1362
- (recv.ty.kind === "array" && f === "push");
1557
+ (recv.ty.kind === "array" && (f === "push" || f === "unshift" || f === "sort"));
1363
1558
  if (isMutating && recv.kind === "var") {
1364
1559
  const { binds, expr } = liftMethodCalls(s.expr);
1365
1560
  return [...binds, { kind: "assign", target: recv.name, value: expr }];
@@ -1836,6 +2031,7 @@ function transformTypeDecl(d) {
1836
2031
  else {
1837
2032
  return {
1838
2033
  kind: "structure", name: d.name,
2034
+ typeParams: d.typeParams,
1839
2035
  fields: d.fields.map(f => ({ name: f.name, type: f.type })),
1840
2036
  deriving: ["Repr", "Inhabited", "DecidableEq"],
1841
2037
  };
@@ -1854,7 +2050,7 @@ function findReassignedNames(stmts, names) {
1854
2050
  // Mutating collection calls: s.add(x), m.set(k,v), s.delete(x), arr.push(x)
1855
2051
  if (s.kind === "expr" && s.expr.kind === "call" && s.expr.fn.kind === "field" &&
1856
2052
  s.expr.fn.obj.kind === "var" && names.has(s.expr.fn.obj.name) &&
1857
- ["add", "set", "delete", "push"].includes(s.expr.fn.field)) {
2053
+ ["add", "set", "delete", "push", "unshift"].includes(s.expr.fn.field)) {
1858
2054
  found.add(s.expr.fn.obj.name);
1859
2055
  }
1860
2056
  if (s.kind === "if") {
@@ -1913,11 +2109,11 @@ function replaceVar(e, name, replacement, narrowing) {
1913
2109
  }
1914
2110
  // ── Top-level transform ──────────────────────────────────────
1915
2111
  /** Transform for Lean backend — same logic, Lean options. */
1916
- export function transformModuleLean(mod, specImport) {
2112
+ export function transformModuleLean(mod, specImport, moduleBase) {
1917
2113
  const prev = _opts;
1918
2114
  _opts = LEAN_OPTIONS;
1919
2115
  try {
1920
- return transformModule(mod, specImport);
2116
+ return transformModule(mod, specImport, moduleBase);
1921
2117
  }
1922
2118
  finally {
1923
2119
  _opts = prev;
@@ -1934,7 +2130,7 @@ export function transformModuleDafny(mod) {
1934
2130
  _opts = prev;
1935
2131
  }
1936
2132
  }
1937
- export function transformModule(mod, specImport) {
2133
+ export function transformModule(mod, specImport, moduleBaseOverride) {
1938
2134
  _forofCounters.clear();
1939
2135
  _liftCounter = 0;
1940
2136
  _typeDecls = mod.typeDecls;
@@ -1987,6 +2183,9 @@ export function transformModule(mod, specImport) {
1987
2183
  }
1988
2184
  }
1989
2185
  const base = mod.file.split("/").pop()?.replace(/\.ts$/, "") ?? "module";
2186
+ // Lean module base — overridable via `//@ lean-module` (see lsc.ts). Only the
2187
+ // def→types import below reads it; Dafny never passes an override.
2188
+ const moduleBase = moduleBaseOverride ?? base;
1990
2189
  // Externs: emit as top-of-file `function {:axiom}` (Dafny) declarations.
1991
2190
  // Any `requires`/`ensures` from the source declaration come along so callers
1992
2191
  // see the same spec the source itself verified. Substitute `\result` with the
@@ -2010,12 +2209,18 @@ export function transformModule(mod, specImport) {
2010
2209
  ? [{ kind: "namespace", name: "Pure", decls: pureDefs }]
2011
2210
  : [];
2012
2211
  if (typeDecls.length > 0 || pureDefs.length > 0 || externDecls.length > 0) {
2212
+ // Declaration order differs by backend. Dafny allows forward references, so
2213
+ // externs go first to be in scope everywhere. Lean requires definition-before-use:
2214
+ // an extern's signature may reference a declared type (e.g. `estimateTokens(m: AgentMessage)`),
2215
+ // so types must precede externs, which in turn precede the pure mirrors that may call them.
2216
+ const decls = _opts.backend === "lean"
2217
+ ? [...typeDecls, ...externDecls, ...pureNamespace]
2218
+ : [...externDecls, ...typeDecls, ...pureNamespace];
2013
2219
  typesFile = {
2014
2220
  comment: " Generated by lsc — Lean types and pure function mirrors.",
2015
2221
  imports: typesImports,
2016
2222
  options: [],
2017
- // Externs come first so they're in scope for every later declaration.
2018
- decls: [...externDecls, ...typeDecls, ...pureNamespace],
2223
+ decls,
2019
2224
  };
2020
2225
  }
2021
2226
  // Def file: Velvet methods
@@ -2035,6 +2240,19 @@ export function transformModule(mod, specImport) {
2035
2240
  let body = pureDefNames.has(fn.name)
2036
2241
  ? [{ kind: "return", value: { kind: "app", fn: `Pure.${fn.name}`, args: fn.params.map(p => ({ kind: "var", name: p.name })) } }]
2037
2242
  : promoteAssignedLets(transformStmts(fn.body, mod.typeDecls));
2243
+ // Lean-only method-body rewrites (Velvet can't WP-synthesize monadic matches
2244
+ // and forbids `return` in loops):
2245
+ // 1. monadic statement-`match` on a user union → discriminator `if`-chains
2246
+ // 2. `return` inside a loop → result var + break (postcondition as invariant)
2247
+ // Dafny keeps the native forms. Breaking loops (including those synthesized
2248
+ // by rewrite 2) must carry a `//@ done_with` in the TS source — enforced here,
2249
+ // since loom's default loop-exit fact `¬guard` does not hold across a break.
2250
+ if (_opts.backend === "lean" && !pureDefNames.has(fn.name)) {
2251
+ body = matchToIfChains(body);
2252
+ const resultInvariants = fn.ensures.map(e => replaceVar(transformExpr(e), "\\result", { kind: "var", name: "_loopRet" }));
2253
+ body = eliminateReturnInLoops(body, fn.returnTy, resultInvariants);
2254
+ requireDoneWithForBreaks(body, fn.name);
2255
+ }
2038
2256
  // Shadow reassigned parameters with mutable locals
2039
2257
  const paramNames = new Set(fn.params.map(p => p.name));
2040
2258
  const reassigned = findReassignedNames(fn.body, paramNames);
@@ -2081,7 +2299,7 @@ export function transformModule(mod, specImport) {
2081
2299
  methods: classMethods,
2082
2300
  };
2083
2301
  });
2084
- const defImport = specImport ?? (typesFile ? `«${base}.types»` : null);
2302
+ const defImport = specImport ?? (typesFile ? `«${moduleBase}.types»` : null);
2085
2303
  const defBaseImports = defImport ? [defImport] : ["LemmaScript"];
2086
2304
  const defFile = {
2087
2305
  comment: " Generated by lsc from " + (mod.file.split("/").pop() ?? "") + "\n Do not edit — re-run `lsc gen` to regenerate.",
@@ -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);