lemmascript 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,7 +51,7 @@ function escapeName(name) {
51
51
  const PREC = {
52
52
  "→": 1, "∨": 2, "∧": 3,
53
53
  "=": 4, "≠": 4, "≥": 4, "≤": 4, ">": 4, "<": 4,
54
- "+": 5, "-": 5, "*": 6, "/": 6, "%": 6,
54
+ "+": 5, "-": 5, "++": 5, "arrayConcat": 5, "*": 6, "/": 6, "%": 6,
55
55
  };
56
56
  function prec(op) { return PREC[op] ?? 10; }
57
57
  // ── Method call → Lean syntax ───────────────────────────────
@@ -143,7 +143,8 @@ function emitExpr(e, parentPrec) {
143
143
  return `-${e.expr.value}`;
144
144
  return `(-${emitExpr(e.expr)})`;
145
145
  case "binop": {
146
- const s = `${emitExpr(e.left, prec(e.op))} ${e.op} ${emitExpr(e.right, prec(e.op))}`;
146
+ const op = e.op === "arrayConcat" ? "++" : e.op;
147
+ const s = `${emitExpr(e.left, prec(e.op))} ${op} ${emitExpr(e.right, prec(e.op))}`;
147
148
  return (parentPrec !== undefined && prec(e.op) < parentPrec) ? `(${s})` : s;
148
149
  }
149
150
  case "implies": {
@@ -317,6 +318,9 @@ function emitDecl(d) {
317
318
  lines.push(`deriving ${d.deriving.join(", ")}`);
318
319
  return lines.join("\n");
319
320
  }
321
+ case "type-alias": {
322
+ return `abbrev ${d.name} := ${tyToLean(d.target)}`;
323
+ }
320
324
  case "def": {
321
325
  const params = d.params.map(p => `(${escapeName(p.name)} : ${tyToLean(p.type)})`).join(" ");
322
326
  return `def ${d.name} ${params} : ${tyToLean(d.returnType)} :=\n${emitPureExpr(d.body, 1)}`;
package/tools/dist/lsc.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync } from "fs";
9
9
  import path from "path";
10
10
  import { extractModule } from "./extract.js";
11
11
  import { resolveModule } from "./resolve.js";
12
- import { transformModule, transformModuleDafny } from "./transform.js";
12
+ import { transformModuleLean, transformModuleDafny } from "./transform.js";
13
13
  import { emitLeanFile } from "./lean-emit.js";
14
14
  import { emitDafnyFile } from "./dafny-emit.js";
15
15
  import { dafnyGen, dafnyCheckDiff, dafnyVerify, dafnyRegen } from "./dafny-commands.js";
@@ -17,7 +17,7 @@ import { leanGen, leanCheck } from "./lean-commands.js";
17
17
  function main() {
18
18
  const args = process.argv.slice(2);
19
19
  const backendIdx = args.findIndex(a => a.startsWith("--backend="));
20
- let backend = "lean";
20
+ let backend = "dafny";
21
21
  if (backendIdx >= 0) {
22
22
  const val = args[backendIdx].split("=")[1];
23
23
  if (val !== "lean" && val !== "dafny") {
@@ -43,8 +43,25 @@ function main() {
43
43
  console.error(`File not found: ${absPath}`);
44
44
  process.exit(1);
45
45
  }
46
- const project = new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
46
+ // Find nearest tsconfig.json for import resolution; fall back to bare options
47
+ function findTsConfig(from) {
48
+ let dir = path.dirname(from);
49
+ while (true) {
50
+ const candidate = path.join(dir, "tsconfig.json");
51
+ if (existsSync(candidate))
52
+ return candidate;
53
+ const parent = path.dirname(dir);
54
+ if (parent === dir)
55
+ return undefined;
56
+ dir = parent;
57
+ }
58
+ }
59
+ const tsConfigFilePath = findTsConfig(absPath);
60
+ const project = tsConfigFilePath
61
+ ? new Project({ tsConfigFilePath })
62
+ : new Project({ compilerOptions: { strict: true, target: ScriptTarget.ESNext, lib: ["lib.esnext.d.ts"] } });
47
63
  const sourceFile = project.addSourceFileAtPath(absPath);
64
+ project.resolveSourceFileDependencies();
48
65
  // Check //@ backend directive — skip if backend doesn't match
49
66
  const backendDirective = sourceFile.getFullText().match(/\/\/@ backend (\w+)/);
50
67
  if (backendDirective && backendDirective[1] !== backend) {
@@ -98,7 +115,7 @@ function main() {
98
115
  // ── Lean backend ──────────────────────────────────────────
99
116
  const specPath = path.join(dir, `${base}.spec.lean`);
100
117
  const specImport = existsSync(specPath) ? `«${base}.spec»` : undefined;
101
- const { typesFile, defFile } = transformModule(typed, specImport);
118
+ const { typesFile, defFile } = transformModuleLean(typed, specImport);
102
119
  const typesPath = typesFile ? path.join(dir, `${base}.types.lean`) : null;
103
120
  const typesText = typesFile ? emitLeanFile(typesFile) : null;
104
121
  const defPath = path.join(dir, `${base}.def.lean`);
@@ -6,6 +6,72 @@
6
6
  */
7
7
  import { parseTsType } from "./types.js";
8
8
  import { parseExpr } from "./specparser.js";
9
+ // ── Raw expression substitution ─────────────────────────────
10
+ let _synVarCounter = 0;
11
+ /**
12
+ * Structural equality for raw field-access chains (var and field nodes only).
13
+ * Exact within a single expression scope — raw IR has no bindings that could
14
+ * cause name collisions (those are introduced by resolve, which runs after).
15
+ */
16
+ function rawExprEquals(a, b) {
17
+ if (a.kind === "var" && b.kind === "var")
18
+ return a.name === b.name;
19
+ if (a.kind === "field" && b.kind === "field")
20
+ return a.field === b.field && rawExprEquals(a.obj, b.obj);
21
+ if (a.kind === "call" && b.kind === "call")
22
+ return rawExprEquals(a.fn, b.fn) && a.args.length === b.args.length && a.args.every((arg, i) => rawExprEquals(arg, b.args[i]));
23
+ if (a.kind === "index" && b.kind === "index")
24
+ return rawExprEquals(a.obj, b.obj) && rawExprEquals(a.idx, b.idx);
25
+ return false;
26
+ }
27
+ /** Return the root variable name of a field-access chain, or null. */
28
+ function rawChainRoot(e) {
29
+ if (e.kind === "var")
30
+ return e.name;
31
+ if (e.kind === "field")
32
+ return rawChainRoot(e.obj);
33
+ return null;
34
+ }
35
+ /**
36
+ * Replace all occurrences of `target` in `expr` with `replacement`.
37
+ * Only matches field-access chains (see rawExprEquals). Stops at lambda
38
+ * boundaries that shadow the chain's root variable.
39
+ */
40
+ function substituteRawExpr(expr, target, replacement) {
41
+ if (rawExprEquals(expr, target))
42
+ return replacement;
43
+ const root = rawChainRoot(target);
44
+ const sub = (e) => substituteRawExpr(e, target, replacement);
45
+ switch (expr.kind) {
46
+ case "var":
47
+ case "num":
48
+ case "str":
49
+ case "bool":
50
+ case "result":
51
+ case "havoc":
52
+ case "emptyCollection":
53
+ return expr;
54
+ case "binop": return { ...expr, left: sub(expr.left), right: sub(expr.right) };
55
+ case "unop": return { ...expr, expr: sub(expr.expr) };
56
+ case "call": return { ...expr, fn: sub(expr.fn), args: expr.args.map(sub) };
57
+ case "field": return { ...expr, obj: sub(expr.obj) };
58
+ case "index": return { ...expr, obj: sub(expr.obj), idx: sub(expr.idx) };
59
+ case "record":
60
+ return { ...expr, spread: expr.spread ? sub(expr.spread) : null,
61
+ fields: expr.fields.map(f => ({ ...f, value: sub(f.value) })) };
62
+ case "arrayLiteral": return { ...expr, elems: expr.elems.map(sub) };
63
+ case "conditional": return { ...expr, cond: sub(expr.cond), then: sub(expr.then), else: sub(expr.else) };
64
+ case "nonNull": return { ...expr, expr: sub(expr.expr) };
65
+ case "forall":
66
+ case "exists":
67
+ return { ...expr, body: sub(expr.body) };
68
+ case "lambda":
69
+ // Don't cross lambda boundaries that shadow the chain's root variable
70
+ if (root && expr.params.some(p => p.name === root))
71
+ return expr;
72
+ return { ...expr, body: Array.isArray(expr.body) ? expr.body : sub(expr.body) };
73
+ }
74
+ }
9
75
  function lookup(env, name) {
10
76
  if (!env)
11
77
  return undefined;
@@ -162,18 +228,28 @@ function resolveExpr(e, ctx) {
162
228
  }
163
229
  case "binop": {
164
230
  let left = resolveExpr(e.left, ctx);
165
- let right = resolveExpr(e.right, ctx);
231
+ // && narrowing: if left is "x !== undefined", narrow x for right side
232
+ let rightCtx = ctx;
233
+ if (e.op === "&&") {
234
+ const narrowed = narrowOptional(e.left, ctx.env);
235
+ if (narrowed && narrowed.inThen) {
236
+ rightCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
237
+ }
238
+ }
239
+ let right = resolveExpr(e.right, rightCtx);
166
240
  if (e.op === "===" || e.op === "!==") {
167
241
  left = coerceStr(left, right.ty);
168
242
  right = coerceStr(right, left.ty);
169
243
  }
170
244
  let ty = { kind: "unknown" };
171
- if (["===", "!==", ">=", "<=", ">", "<"].includes(e.op))
245
+ if (["===", "!==", ">=", "<=", ">", "<", "in"].includes(e.op))
172
246
  ty = { kind: "bool" };
173
247
  else if (e.op === "&&")
174
248
  ty = right.ty;
175
- else if (e.op === "||" && left.ty.kind === "optional")
176
- ty = left.ty.inner;
249
+ else if (e.op === "||" && left.ty.kind === "optional") {
250
+ // || undefined is identity for optionals — keep the optional type
251
+ ty = (e.right.kind === "var" && e.right.name === "undefined") ? left.ty : left.ty.inner;
252
+ }
177
253
  else if (e.op === "||")
178
254
  ty = right.ty;
179
255
  else if (["+", "-", "*", "/", "%"].includes(e.op)) {
@@ -187,12 +263,41 @@ function resolveExpr(e, ctx) {
187
263
  }
188
264
  case "call": {
189
265
  const fn = resolveExpr(e.fn, ctx);
190
- let args = e.args.map(a => resolveExpr(a, ctx));
191
- // Coerce non-optional args to Option when callee expects optional param: wrap in Some
266
+ // Infer lambda param types from array method context (map, filter, etc.)
267
+ let rawArgs = e.args;
268
+ if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
269
+ ["map", "filter", "every", "some", "find"].includes(fn.field) &&
270
+ rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
271
+ rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
272
+ const elemTy = fn.obj.ty.elem;
273
+ const tsType = elemTy.kind === "user" ? elemTy.name
274
+ : elemTy.kind === "string" ? "string"
275
+ : elemTy.kind === "int" || elemTy.kind === "nat" ? "number"
276
+ : elemTy.kind === "bool" ? "boolean" : undefined;
277
+ if (tsType) {
278
+ const lam = rawArgs[0];
279
+ const updatedParams = [{ ...lam.params[0], tsType }, ...lam.params.slice(1)];
280
+ rawArgs = [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
281
+ }
282
+ }
283
+ // For .push() on a typed array, resolve the argument with element type context
284
+ // so record expressions can match fields and coerce types
285
+ let argCtx = ctx;
286
+ if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "push" &&
287
+ fn.obj.ty.elem.kind === "user") {
288
+ argCtx = { ...ctx, returnTy: fn.obj.ty.elem };
289
+ }
290
+ let args = rawArgs.map(a => resolveExpr(a, argCtx));
291
+ // Coerce args: string literals to user types, non-optional to Option, pad missing optional args
192
292
  if (fn.kind === "var" && ctx.fnParams.has(fn.name)) {
193
293
  const paramTys = ctx.fnParams.get(fn.name);
194
294
  args = args.map((a, i) => {
195
- if (i < paramTys.length && a.ty.kind !== "optional" && paramTys[i].kind === "optional") {
295
+ if (i >= paramTys.length)
296
+ return a;
297
+ // Coerce string literal to user type (e.g., 'MissingList' → Err constructor)
298
+ a = coerceStr(a, paramTys[i]);
299
+ // Wrap non-optional in Some when callee expects optional param
300
+ if (a.ty.kind !== "optional" && paramTys[i].kind === "optional") {
196
301
  return {
197
302
  kind: "call",
198
303
  fn: { kind: "var", name: "Some", ty: paramTys[i] },
@@ -203,6 +308,12 @@ function resolveExpr(e, ctx) {
203
308
  }
204
309
  return a;
205
310
  });
311
+ // Pad missing optional args with None
312
+ for (let i = args.length; i < paramTys.length; i++) {
313
+ if (paramTys[i].kind === "optional") {
314
+ args.push({ kind: "var", name: "undefined", ty: paramTys[i] });
315
+ }
316
+ }
206
317
  }
207
318
  let ty = { kind: "unknown" };
208
319
  // Infer return types for collection methods
@@ -213,6 +324,8 @@ function resolveExpr(e, ctx) {
213
324
  ty = { kind: "bool" };
214
325
  else if (fn.field === "set")
215
326
  ty = fn.obj.ty;
327
+ else if (fn.field === "delete")
328
+ ty = fn.obj.ty;
216
329
  }
217
330
  else if (fn.kind === "field" && fn.obj.ty.kind === "set") {
218
331
  if (fn.field === "has")
@@ -229,6 +342,17 @@ function resolveExpr(e, ctx) {
229
342
  ty = fn.obj.ty.elem;
230
343
  else if (fn.field === "push")
231
344
  ty = fn.obj.ty;
345
+ else if (fn.field === "concat")
346
+ ty = fn.obj.ty;
347
+ else if (fn.field === "map" && args.length >= 1 && args[0].kind === "lambda") {
348
+ const retTy = args[0].body.length > 0 && args[0].body[0].kind === "return"
349
+ ? args[0].body[0].value.ty : { kind: "unknown" };
350
+ ty = { kind: "array", elem: retTy };
351
+ }
352
+ else if (fn.field === "filter")
353
+ ty = fn.obj.ty;
354
+ else if (fn.field === "every" || fn.field === "some")
355
+ ty = { kind: "bool" };
232
356
  }
233
357
  else if (fn.kind === "field" && fn.obj.ty.kind === "string") {
234
358
  if (fn.field === "trim")
@@ -245,7 +369,10 @@ function resolveExpr(e, ctx) {
245
369
  case "index": {
246
370
  const obj = resolveExpr(e.obj, ctx);
247
371
  const idx = resolveExpr(e.idx, ctx);
248
- return { kind: "index", obj, idx, ty: obj.ty.kind === "array" ? obj.ty.elem : { kind: "unknown" } };
372
+ const idxTy = obj.ty.kind === "array" ? obj.ty.elem
373
+ : obj.ty.kind === "map" ? obj.ty.value
374
+ : { kind: "unknown" };
375
+ return { kind: "index", obj, idx, ty: idxTy };
249
376
  }
250
377
  case "field": {
251
378
  const obj = resolveExpr(e.obj, ctx);
@@ -275,11 +402,23 @@ function resolveExpr(e, ctx) {
275
402
  // Infer record type: from spread, or from return type context
276
403
  const recordTy = ty.kind === "user" ? ty : ctx.returnTy.kind === "user" ? ctx.returnTy : null;
277
404
  const decl = recordTy ? ctx.typeDecls.find(d => d.name === recordTy.name && d.kind === "record") : undefined;
405
+ // Clear returnTy for field values — it applies to THIS record, not nested ones
406
+ const fieldCtx = recordTy ? { ...ctx, returnTy: { kind: "unknown" } } : ctx;
278
407
  const fields = e.fields.map(f => {
279
- let value = resolveExpr(f.value, ctx);
408
+ let value = resolveExpr(f.value, fieldCtx);
280
409
  const fieldDecl = decl?.fields?.find(df => df.name === f.name);
281
- if (fieldDecl)
282
- value = coerceStr(value, parseTsType(fieldDecl.tsType));
410
+ if (fieldDecl) {
411
+ const declTy = parseTsType(fieldDecl.tsType);
412
+ value = coerceStr(value, declTy);
413
+ // Coerce non-optional to optional: wrap in Some (only when value type is concrete)
414
+ if (declTy.kind === "optional" && value.ty.kind !== "optional" && value.ty.kind !== "void" && value.ty.kind !== "unknown") {
415
+ value = {
416
+ kind: "call",
417
+ fn: { kind: "var", name: "Some", ty: declTy },
418
+ args: [value], ty: declTy, callKind: "pure",
419
+ };
420
+ }
421
+ }
283
422
  return { name: f.name, value };
284
423
  });
285
424
  return { kind: "record", spread, fields, ty: recordTy ?? ty };
@@ -289,12 +428,12 @@ function resolveExpr(e, ctx) {
289
428
  throw new Error("\\result is only valid in ensures");
290
429
  return { kind: "result", ty: ctx.returnTy };
291
430
  case "forall": {
292
- const varTy = e.varType === "nat" ? { kind: "nat" }
431
+ const varTy = e.varType !== "int" ? parseTsType(e.varType)
293
432
  : inferQuantVarType(e.var, e.body, ctx) ?? { kind: "int" };
294
433
  return { kind: "forall", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
295
434
  }
296
435
  case "exists": {
297
- const varTy = e.varType === "nat" ? { kind: "nat" }
436
+ const varTy = e.varType !== "int" ? parseTsType(e.varType)
298
437
  : inferQuantVarType(e.var, e.body, ctx) ?? { kind: "int" };
299
438
  return { kind: "exists", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
300
439
  }
@@ -322,16 +461,61 @@ function resolveExpr(e, ctx) {
322
461
  }
323
462
  case "conditional": {
324
463
  const cond = resolveExpr(e.cond, ctx);
325
- let then_ = resolveExpr(e.then, ctx);
464
+ // Optional truthiness: opt ? X : Y
465
+ // Narrow the optional to its inner type in the then-branch so that
466
+ // field accesses resolve correctly (e.g. entry.decision.field).
467
+ let narrowedVar;
468
+ let narrowedExprResolved;
469
+ let thenCtx = ctx;
470
+ let rawThen = e.then;
471
+ if (cond.ty.kind === "optional") {
472
+ const innerTy = cond.ty.inner;
473
+ if (e.cond.kind === "var") {
474
+ narrowedVar = e.cond.name;
475
+ thenCtx = withEnv(ctx, extend(ctx.env, e.cond.name, innerTy));
476
+ }
477
+ else {
478
+ narrowedVar = `_opt${_synVarCounter++}`;
479
+ rawThen = substituteRawExpr(e.then, e.cond, { kind: "var", name: narrowedVar });
480
+ thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, innerTy));
481
+ }
482
+ }
483
+ // Explicit optional check: x !== undefined ? expr(x) : undefined
484
+ // Narrow x to its inner type in the then-branch.
485
+ if (!narrowedVar) {
486
+ const narrowed = narrowOptional(e.cond, ctx.env);
487
+ if (narrowed && narrowed.inThen) {
488
+ narrowedVar = narrowed.varName;
489
+ thenCtx = withEnv(ctx, extend(ctx.env, narrowed.varName, narrowed.innerTy));
490
+ }
491
+ // Handle complex optional expressions: f() !== undefined ? f().field : undefined
492
+ if (!narrowedVar && e.cond.kind === "binop" && e.cond.op === "!==" &&
493
+ e.cond.right.kind === "var" && e.cond.right.name === "undefined") {
494
+ const optExpr = e.cond.left;
495
+ const resolvedOpt = resolveExpr(optExpr, ctx);
496
+ if (resolvedOpt.ty.kind === "optional") {
497
+ narrowedVar = `_opt${_synVarCounter++}`;
498
+ narrowedExprResolved = resolvedOpt;
499
+ rawThen = substituteRawExpr(e.then, optExpr, { kind: "var", name: narrowedVar });
500
+ thenCtx = withEnv(ctx, extend(ctx.env, narrowedVar, resolvedOpt.ty.inner));
501
+ }
502
+ }
503
+ }
504
+ let then_ = resolveExpr(rawThen, thenCtx);
326
505
  let else_ = resolveExpr(e.else, ctx);
327
506
  then_ = coerceStr(then_, else_.ty);
328
507
  else_ = coerceStr(else_, then_.ty);
329
- const ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
330
- return { kind: "conditional", cond, then: then_, else: else_, ty };
508
+ let ty = then_.ty.kind !== "unknown" ? then_.ty : else_.ty;
509
+ // When narrowedExpr is set, the transform will emit a match producing Optional
510
+ if (narrowedExprResolved && ty.kind !== "optional") {
511
+ ty = { kind: "optional", inner: ty };
512
+ }
513
+ return { kind: "conditional", cond, then: then_, else: else_, ty, narrowedVar, narrowedExpr: narrowedExprResolved };
331
514
  }
332
515
  case "emptyCollection": {
333
516
  const ty = parseTsType(e.tsType);
334
- return { kind: "arrayLiteral", elems: [], ty };
517
+ const elems = e.initElems ? e.initElems.map(el => resolveExpr(el, ctx)) : [];
518
+ return { kind: "arrayLiteral", elems, ty };
335
519
  }
336
520
  case "havoc":
337
521
  return { kind: "havoc", ty: resolveTsType(e.tsType, ctx.overrides) };
@@ -379,8 +563,20 @@ function resolveStmt(s, ctx) {
379
563
  const targetTy = lookup(ctx.env, s.target) ?? { kind: "unknown" };
380
564
  return [{ kind: "assign", target: s.target, value: coerceStr(resolveExpr(s.value, ctx), targetTy) }, ctx.env];
381
565
  }
382
- case "return":
383
- return [{ kind: "return", value: coerceStr(resolveExpr(s.value, ctx), ctx.returnTy) }, ctx.env];
566
+ case "return": {
567
+ let value = coerceStr(resolveExpr(s.value, ctx), ctx.returnTy);
568
+ // Wrap non-optional return value in Some when function returns optional
569
+ // Skip if already optional, void, or undefined (which maps to None)
570
+ const isUndef = value.kind === "var" && value.name === "undefined";
571
+ if (ctx.returnTy.kind === "optional" && value.ty.kind !== "optional" && !isUndef) {
572
+ value = {
573
+ kind: "call",
574
+ fn: { kind: "var", name: "Some", ty: ctx.returnTy },
575
+ args: [value], ty: ctx.returnTy, callKind: "pure",
576
+ };
577
+ }
578
+ return [{ kind: "return", value }, ctx.env];
579
+ }
384
580
  case "break":
385
581
  return [{ kind: "break" }, ctx.env];
386
582
  case "continue":
@@ -398,6 +594,13 @@ function resolveStmt(s, ctx) {
398
594
  else
399
595
  elseCtx = withEnv(ctx, env);
400
596
  }
597
+ // Also narrow from left side of && condition: if (x !== undefined && ...) { ... }
598
+ if (!narrowed && s.cond.kind === "binop" && s.cond.op === "&&") {
599
+ const leftNarrowed = narrowOptional(s.cond.left, ctx.env);
600
+ if (leftNarrowed && leftNarrowed.inThen) {
601
+ thenCtx = withEnv(ctx, extend(ctx.env, leftNarrowed.varName, leftNarrowed.innerTy));
602
+ }
603
+ }
401
604
  return [{ kind: "if", cond: resolveExpr(s.cond, ctx), then: resolveBlock(s.then, thenCtx), else: resolveBlock(s.else, elseCtx) }, ctx.env];
402
605
  }
403
606
  case "while": {
@@ -483,7 +686,7 @@ function isSyntacticallyPure(stmts) {
483
686
  case "while":
484
687
  case "forof": return false;
485
688
  case "let":
486
- if (s.mutable)
689
+ if (s.mutable || s.init.kind === "havoc")
487
690
  return false;
488
691
  break;
489
692
  case "if":
@@ -645,9 +848,6 @@ function containsReturn(stmts) {
645
848
  }
646
849
  // ── Resolve function / module ────────────────────────────────
647
850
  function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map()) {
648
- if (hasReturnInLoop(fn.body)) {
649
- throw new Error(`${fn.name}: return inside a loop is not supported.`);
650
- }
651
851
  const overrides = new Map(fn.typeAnnotations.map(a => [a.name, a.type]));
652
852
  const params = fn.params.map(p => ({ name: p.name, ty: resolveTsType(p.tsType, overrides, p.name) }));
653
853
  const returnTy = resolveTsType(fn.returnType, overrides, "\\result");
@@ -658,7 +858,7 @@ function resolveFunction(fn, typeDecls, pureFns, fnParams = new Map()) {
658
858
  const requiresCtx = { ...baseCtx, inSpec: true };
659
859
  const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
660
860
  return {
661
- name: fn.name, params, returnTy,
861
+ name: fn.name, typeParams: fn.typeParams, params, returnTy,
662
862
  requires: resolveSpecs(fn.requires, requiresCtx),
663
863
  ensures: resolveSpecs(fn.ensures, ensuresCtx),
664
864
  isPure: pureFns.has(fn.name),
@@ -684,7 +884,7 @@ function resolveClass(cls, typeDecls, pureFns, fnParams = new Map()) {
684
884
  const requiresCtx = { ...baseCtx, inSpec: true };
685
885
  const ensuresCtx = { ...baseCtx, allowResult: true, inSpec: true };
686
886
  return {
687
- name: fn.name, params, returnTy,
887
+ name: fn.name, typeParams: fn.typeParams, params, returnTy,
688
888
  requires: resolveSpecs(fn.requires, requiresCtx),
689
889
  ensures: resolveSpecs(fn.ensures, ensuresCtx),
690
890
  isPure: false, // class methods are never pure (they access this)
@@ -129,6 +129,11 @@ class Parser {
129
129
  parseCmp() {
130
130
  const left = this.parseAdd();
131
131
  const t = this.peek();
132
+ // 'in' as infix membership operator (set/seq/map): x in S
133
+ if (t?.type === "ident" && t.value === "in") {
134
+ this.advance();
135
+ return { kind: "binop", op: "in", left, right: this.parseAdd() };
136
+ }
132
137
  if (t?.type === "op" && ["===", "!==", "==", "!=", ">=", "<=", ">", "<"].includes(t.value)) {
133
138
  this.advance();
134
139
  // Normalize == to ===, != to !== so downstream sees one spelling
@@ -252,8 +257,6 @@ class Parser {
252
257
  let varType = "int";
253
258
  if (this.match("punc", ":")) {
254
259
  const ty = this.expect("ident").value;
255
- if (ty !== "nat" && ty !== "int")
256
- throw new Error(`Unknown type '${ty}'`);
257
260
  varType = ty;
258
261
  }
259
262
  this.expect("punc", ",");