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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ See the external case studies:
29
29
  - **[xyflow-lemmascript](https://github.com/midspiral/xyflow-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [xyflow](https://github.com/xyflow/xyflow)'s core edge and geometry utilities. 9 functions verified: `addEdge` (dedup — never loses edges, adds at most one), `reconnectEdge` (semantic: under a unique-id precondition, the result is in-place — `|result| ≤ |edges|`, no insertion — *and* when a matching edge existed with non-empty new endpoints, the output contains an edge with those endpoints. Uses `//@ assume` to characterize destructuring, `find`, and the constructed edge), `connectionExists`, `getEdgeCenter` (midpoint correctness), `clamp` (bounds), `rectToBox`/`boxToRect` (field arithmetic), `getBoundsOfBoxes` (enclosure), `getOverlappingArea` (non-negative), `areSetsEqual` (subset + same size). 14 Dafny proof obligations. Also adds a new verified feature — a **DAG connection gate**: `canReach` decides reachability soundly *and* completely, and `wouldCreateCycle` gates both new connections and edge reconnections so the graph is proven to stay acyclic, with `isAcyclic` (sound + complete) establishing the base case and a topological-rank witness giving a safe evaluation order (+29 obligations), shown live in a React Flow demo that refuses cycle-closing edges — extending the case study from verifying existing code to adding a verified feature. Dafny only.
30
30
  - **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
31
31
  - **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
32
- - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. Dafny only.
32
+ - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. **Dafny + Lean**: the no-orphan theorem, the changelog semver core, and both tool-output truncators also carry Lean 4 (Velvet/Loom) proofs from the same annotated source, zero `sorry`; the Lean port drove the backend's brownfield batch (cross-file externs, union destructor lowering, Bool-vs-Prop contexts, `return`-in-loop elimination).
33
33
  - **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
34
34
  - **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
35
35
  - **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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)
@@ -523,6 +533,9 @@ function emitExpr(e) {
523
533
  case "exists": return emitQuantifier(e, "exists");
524
534
  case "let": return `(var ${escapeName(e.name)} := ${emitExpr(e.value)}; ${emitExpr(e.body)})`;
525
535
  case "havoc": return "*";
536
+ // No Dafny producer: the only `default` source is the Lean return-in-loop
537
+ // rewrite, which never runs on the Dafny path (native in-loop returns).
538
+ case "default": throw new Error("default-value expression not produced on the Dafny path");
526
539
  }
527
540
  }
528
541
  /** Emit a pure expression with indentation for if/match/let. */
@@ -671,7 +684,8 @@ function emitDecl(d) {
671
684
  return `datatype ${d.name}${tp} = ${ctors.join(" | ")}`;
672
685
  }
673
686
  case "structure": {
674
- return `datatype ${d.name} = ${d.name}(${paramList(d.fields)})`;
687
+ const tp = d.typeParams?.length ? `<${d.typeParams.join(", ")}>` : "";
688
+ return `datatype ${d.name}${tp} = ${d.name}(${paramList(d.fields)})`;
675
689
  }
676
690
  case "type-alias": {
677
691
  return `type ${d.name} = ${tyToDafny(d.target)}`;
@@ -964,6 +978,17 @@ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<s
964
978
  ensures |StringSplit(s, d)| >= 1
965
979
  ensures |StringSplit(s, d)| <= |s| + 1
966
980
  ensures forall k :: 0 <= k < |StringSplit(s, d)| ==> |StringSplit(s, d)[k]| <= |s|`;
981
+ // `xs.sort(cmp)` in TS sorts in place by a comparator (negative ⟺ a before b).
982
+ // Modeled here as an axiom returning a permutation sorted by cmp. The `requires`
983
+ // is the soundness condition — cmp must be a total preorder, otherwise no sorted
984
+ // permutation exists and the axiom would be vacuous. Callers discharge it (e.g.
985
+ // `(a,b) => a.k - b.k` is total + transitive by linear arithmetic).
986
+ const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T, T) -> int): seq<T>
987
+ requires forall a: T, b: T :: cmp(a, b) <= 0 || cmp(b, a) <= 0
988
+ requires forall a: T, b: T, c: T :: cmp(a, b) <= 0 && cmp(b, c) <= 0 ==> cmp(a, c) <= 0
989
+ ensures multiset(SeqSortBy(s, cmp)) == multiset(s)
990
+ ensures |SeqSortBy(s, cmp)| == |s|
991
+ ensures forall i: int, j: int :: 0 <= i <= j < |SeqSortBy(s, cmp)| ==> cmp(SeqSortBy(s, cmp)[i], SeqSortBy(s, cmp)[j]) <= 0`;
967
992
  const STRING_TRIM = `function StringTrimLeft(s: string): string
968
993
  ensures |StringTrimLeft(s)| <= |s|
969
994
  ensures StringTrimLeft(s) == "" || (|StringTrimLeft(s)| > 0 && StringTrimLeft(s)[0] != ' ')
@@ -1095,6 +1120,9 @@ const SET_TO_SEQ = `method SetToSeq<T>(s: set<T>) returns (res: seq<T>)
1095
1120
  /** Preamble code keyed by name. Emitted in this order when needed. */
1096
1121
  const PREAMBLE_CODE = [
1097
1122
  ["OptionType", "datatype Option<T> = None | Some(value: T)"],
1123
+ // Opaque carrier for `unknown`-typed values. `(==)` for compare/map-key/match;
1124
+ // `(0)` (auto-init ⇒ nonempty) so `havoc` (`:= *`) is well-formed.
1125
+ ["UnknownType", "type Unknown(==, 0)"],
1098
1126
  ["SetToSeq", SET_TO_SEQ],
1099
1127
  ["Pow2", POW2],
1100
1128
  ["BitAnd", BIT_AND],
@@ -1114,6 +1142,7 @@ const PREAMBLE_CODE = [
1114
1142
  ["SafeSlice", SAFE_SLICE],
1115
1143
  ["StringIndexOf", STRING_INDEX_OF],
1116
1144
  ["StringSplit", STRING_SPLIT],
1145
+ ["SeqSortBy", SEQ_SORT_BY],
1117
1146
  ["StringTrim", STRING_TRIM],
1118
1147
  ["StringToLower", STRING_TO_LOWER],
1119
1148
  ["StringToUpper", STRING_TO_UPPER],
@@ -1202,6 +1231,23 @@ export function emitDafnyFile(file, tsFileName, opts) {
1202
1231
  // Track successfully emitted pure defs — method wrappers are only
1203
1232
  // skipped when the corresponding pure def was actually emitted.
1204
1233
  const emittedPureDefs = new Set();
1234
+ // Emit a decl, rolling back any preamble requirements it registered if it
1235
+ // throws. A skipped decl must contribute neither text nor preambles — else a
1236
+ // side-effecting `needPreamble` from a half-emitted decl leaves an unused
1237
+ // preamble (e.g. `type Unknown` from a skipped const whose head is
1238
+ // `unknown`-typed but whose value expr is unsupported).
1239
+ const emitDeclTx = (d) => {
1240
+ const saved = new Set(_neededPreambles);
1241
+ try {
1242
+ return emitDecl(d);
1243
+ }
1244
+ catch (e) {
1245
+ _neededPreambles.clear();
1246
+ for (const k of saved)
1247
+ _neededPreambles.add(k);
1248
+ throw e;
1249
+ }
1250
+ };
1205
1251
  // Emit declarations
1206
1252
  const declLines = [];
1207
1253
  const skipped = [];
@@ -1214,7 +1260,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
1214
1260
  for (const inner of decl.decls) {
1215
1261
  try {
1216
1262
  declLines.push("");
1217
- declLines.push(emitDecl(inner));
1263
+ declLines.push(emitDeclTx(inner));
1218
1264
  if (inner.kind === "def")
1219
1265
  emittedPureDefs.add(inner.name);
1220
1266
  }
@@ -1230,7 +1276,7 @@ export function emitDafnyFile(file, tsFileName, opts) {
1230
1276
  }
1231
1277
  try {
1232
1278
  declLines.push("");
1233
- declLines.push(emitDecl(decl));
1279
+ declLines.push(emitDeclTx(decl));
1234
1280
  if (decl.kind === "def-by-method")
1235
1281
  emittedPureDefs.add(decl.name);
1236
1282
  }
@@ -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();
@@ -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
@@ -1731,7 +1756,7 @@ function extractFunctionInner(fn, parentAnnotations) {
1731
1756
  return {
1732
1757
  name: fn.getName?.() ?? "<anonymous>",
1733
1758
  exported: false, // set in extractModule against the source file's export surface
1734
- typeParams: unboundedTypeParams,
1759
+ typeParams,
1735
1760
  // Original TS parameter grouping, before the flatten below loses it. `defaults` carries
1736
1761
  // each bound name's default initializer text (omitted when none) for TS-targeting consumers.
1737
1762
  tsParams: fn.getParameters().map(p => {
@@ -1759,10 +1784,20 @@ function extractFunctionInner(fn, parentAnnotations) {
1759
1784
  const nameNode = p.getNameNode();
1760
1785
  if (Node.isObjectBindingPattern(nameNode)) {
1761
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;
1762
1794
  return nameNode.getElements().map(el => {
1763
1795
  const name = el.getName();
1764
1796
  const propType = type.getProperty(name)?.getTypeAtLocation(p);
1765
- 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" };
1766
1801
  });
1767
1802
  }
1768
1803
  // Syntactic union nodes go through _tsTypeFromUnionNode so synth fires
@@ -1843,15 +1878,17 @@ export function extractModule(sourceFile) {
1843
1878
  // `//@ declare-type Name { f1: T1, ... }` — record form.
1844
1879
  // `//@ declare-type Name = TsType` — alias form (e.g. `Ruleset = Rule[]`).
1845
1880
  function parseDeclareType(body) {
1846
- const recordMatch = body.match(/^(\w+)\s*\{(.+)\}$/);
1881
+ const recordMatch = body.match(/^(\w+)\s*(?:<([^>]+)>)?\s*\{(.+)\}$/);
1847
1882
  if (recordMatch) {
1848
1883
  const name = recordMatch[1];
1849
- 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 => {
1850
1887
  const [fname, ftype] = f.split(":").map(s => s.trim());
1851
1888
  const synth = _synthFromTsTypeString(ftype);
1852
1889
  return { name: fname, tsType: synth ?? ftype };
1853
1890
  });
1854
- typeDecls.push({ name, kind: "record", fields });
1891
+ typeDecls.push({ name, kind: "record", fields, ...(typeParams?.length ? { typeParams } : {}) });
1855
1892
  return;
1856
1893
  }
1857
1894
  const aliasMatch = body.match(/^(\w+)\s*=\s*(.+)$/);
@@ -2052,7 +2089,8 @@ export function extractModule(sourceFile) {
2052
2089
  const sig = f.node.getType().getCallSignatures()[0];
2053
2090
  if (!sig)
2054
2091
  continue;
2055
- 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());
2056
2094
  // Normalize via typeToString (not raw getText): resolves declare-type
2057
2095
  // shadows and yields bare names, so a param typed by an unreachable import
2058
2096
  // becomes `AgentMessage`, not `import("/abs/path").AgentMessage`.
@@ -2297,6 +2335,10 @@ export function extractModule(sourceFile) {
2297
2335
  collectNamesExpr(e.spread);
2298
2336
  e.fields.forEach(f => collectNamesExpr(f.value));
2299
2337
  }
2338
+ if (e.kind === "recordMerge") {
2339
+ collectNamesExpr(e.base);
2340
+ collectNamesExpr(e.override);
2341
+ }
2300
2342
  if (e.kind === "arrayLiteral") {
2301
2343
  e.elems.forEach(collectNamesExpr);
2302
2344
  }
@@ -2305,6 +2347,31 @@ export function extractModule(sourceFile) {
2305
2347
  collectNamesExpr(e.then);
2306
2348
  collectNamesExpr(e.else);
2307
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
+ }
2308
2375
  }
2309
2376
  // Signature types (params + return) get base-name stripping below; body /
2310
2377
  // spec references stay exact-match (so a body `let xs: Hunk[]` doesn't pull
package/tools/dist/ir.js CHANGED
@@ -4,4 +4,59 @@
4
4
  * The transform phase produces these types.
5
5
  * The emit phase pretty-prints them to backend syntax (Lean or Dafny).
6
6
  */
7
- export {};
7
+ export function anyExpr(e, pred) {
8
+ if (pred(e))
9
+ return true;
10
+ switch (e.kind) {
11
+ case "var":
12
+ case "num":
13
+ case "bool":
14
+ case "str":
15
+ case "emptyMap":
16
+ case "emptySet":
17
+ case "havoc":
18
+ case "default": return false;
19
+ case "constructor": return (e.args ?? []).some(a => anyExpr(a, pred));
20
+ case "binop": return anyExpr(e.left, pred) || anyExpr(e.right, pred);
21
+ case "unop":
22
+ case "toNat":
23
+ case "toReal": return anyExpr(e.expr, pred);
24
+ case "app": return e.args.some(a => anyExpr(a, pred));
25
+ case "field": return anyExpr(e.obj, pred);
26
+ case "index": return anyExpr(e.arr, pred) || anyExpr(e.idx, pred);
27
+ case "implies": return e.premises.some(p => anyExpr(p, pred)) || anyExpr(e.conclusion, pred);
28
+ case "record": return (e.spread ? anyExpr(e.spread, pred) : false) || e.fields.some(f => anyExpr(f.value, pred));
29
+ case "arrayLiteral": return e.elems.some(x => anyExpr(x, pred));
30
+ case "mapLiteral": return e.entries.some(en => anyExpr(en.key, pred) || anyExpr(en.value, pred));
31
+ case "methodCall": return anyExpr(e.obj, pred) || e.args.some(a => anyExpr(a, pred));
32
+ case "lambda": return e.body.some(s => anyExprInStmt(s, pred));
33
+ case "if": return anyExpr(e.cond, pred) || anyExpr(e.then, pred) || anyExpr(e.else, pred);
34
+ case "match": return (typeof e.scrutinee !== "string" && anyExpr(e.scrutinee, pred)) || e.arms.some(a => anyExpr(a.body, pred));
35
+ case "forall":
36
+ case "exists": return anyExpr(e.body, pred);
37
+ case "let": return anyExpr(e.value, pred) || anyExpr(e.body, pred);
38
+ }
39
+ }
40
+ export function anyExprInStmt(s, pred) {
41
+ switch (s.kind) {
42
+ case "let":
43
+ case "assign":
44
+ case "bind":
45
+ case "let-bind":
46
+ case "return":
47
+ case "ghostLet":
48
+ case "ghostAssign": return anyExpr(s.value, pred);
49
+ case "assert": return anyExpr(s.expr, pred);
50
+ case "break":
51
+ case "continue": return false;
52
+ case "if": return anyExpr(s.cond, pred) || anyExprInStmts(s.then, pred) || anyExprInStmts(s.else, pred);
53
+ case "match": return (typeof s.scrutinee !== "string" && anyExpr(s.scrutinee, pred)) || s.arms.some(a => anyExprInStmts(a.body, pred));
54
+ case "while": return anyExpr(s.cond, pred) || s.invariants.some(i => anyExpr(i, pred))
55
+ || (s.decreasing ? anyExpr(s.decreasing, pred) : false) || (s.doneWith ? anyExpr(s.doneWith, pred) : false)
56
+ || anyExprInStmts(s.body, pred);
57
+ case "forin": return anyExpr(s.bound, pred) || s.invariants.some(i => anyExpr(i, pred)) || anyExprInStmts(s.body, pred);
58
+ }
59
+ }
60
+ export function anyExprInStmts(stmts, pred) {
61
+ return stmts.some(s => anyExprInStmt(s, pred));
62
+ }