From 0f67c953325bfebb02216829a5e9d4659fe06caa Mon Sep 17 00:00:00 2001 From: Shyamsundar Gitte Date: Thu, 27 Aug 2026 12:07:59 +0530 Subject: [PATCH 1/2] fix(compiler): handle nested mutual recursion --- .../src/frontend/lowering/lower-calls.ts | 8 ++++++-- tests/corpus/606-nested-mutual-recursion.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/606-nested-mutual-recursion.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 96b336a4e..3cc58e2b8 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5507,13 +5507,17 @@ const inliningPredicates = new Set(); * to the top of the enclosing function — calling one before this statement * is a compile error here, not a silent divergence). Self-references inside * the body lower to `selfRef`, not a capture: a box holding its own - * closure would be an RC cycle. */ + * closure would be an RC cycle. Reserve the box before lowering the body so + * mutually recursive declarations can capture each other's live boxes. */ export function lowerNestedFunctionDecl(lowerer: Lowerer, stmt: ts.FunctionDeclaration): IrStmt { if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous function declarations"); const { funcType } = lowerer.lambdaSignature(stmt); const local = lowerer.declareLocal(stmt.name, stmt.name.text, funcType, false); + local.mutable = true; + const active = [...lowerer.activeStmtLists].reverse().find((entry) => entry.stmts.includes(stmt)); + active?.out.push({ kind: "varDecl", localId: local.id, init: null, loc: locOf(stmt) }); const init = lowerer.lowerLambda(stmt); - return { kind: "varDecl", localId: local.id, init, loc: locOf(stmt) }; + return { kind: "assign", localId: local.id, value: init, loc: locOf(stmt) }; } /** Signature checks + param shapes + IR func type for any lambda-like diff --git a/tests/corpus/606-nested-mutual-recursion.ts b/tests/corpus/606-nested-mutual-recursion.ts new file mode 100644 index 000000000..c3dd8488b --- /dev/null +++ b/tests/corpus/606-nested-mutual-recursion.ts @@ -0,0 +1,17 @@ +export {} + +function outer(n: number): number { + function even(k: number): number { + if (k === 0) return 1 + return odd(k - 1) + } + + function odd(k: number): number { + if (k === 0) return 0 + return even(k - 1) + } + + return even(n) +} + +console.log(`r=${outer(4)}`) From 67c8753e96223730a74198b78079dc076dbfb122 Mon Sep 17 00:00:00 2001 From: Shyamsundar Gitte Date: Thu, 27 Aug 2026 12:33:45 +0530 Subject: [PATCH 2/2] fix(compiler): address review feedback --- .../src/frontend/lowering/lower-calls.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 3cc58e2b8..e2729e4f7 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3,6 +3,7 @@ * function/lambda lowering and signature collection, and monomorphizing * generic instantiation (bounded by MAX_GENERIC_INSTANCES). */ import * as ts from "../ts7/adapter.js"; +import { InternalCompilerError } from "../../errors.js"; import type { Lowerer } from "./lowerer.js"; import { lowerGenMethodCall } from "./lower-generators.js"; import { BOOL, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, ffiClassType, ffiSourceParamTypes, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js"; @@ -5502,20 +5503,19 @@ const inliningPredicates = new Set(); ); } -/** Nested `function name(...) {...}`: lowered as `const name = ` - * at the declaration's statement position (JS hoists function declarations - * to the top of the enclosing function — calling one before this statement - * is a compile error here, not a silent divergence). Self-references inside - * the body lower to `selfRef`, not a capture: a box holding its own - * closure would be an RC cycle. Reserve the box before lowering the body so - * mutually recursive declarations can capture each other's live boxes. */ +/** Nested `function name(...) {...}` reserves its binding first, then lowers + * the lambda and assigns the resulting closure to that binding. The local is + * mutable in IR because this two-phase form lets mutually recursive + * declarations capture each other's live boxes before either closure is + * initialized. Self-references inside the body lower to `selfRef`, not a + * capture: a box holding its own closure would be an RC cycle. */ export function lowerNestedFunctionDecl(lowerer: Lowerer, stmt: ts.FunctionDeclaration): IrStmt { if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous function declarations"); const { funcType } = lowerer.lambdaSignature(stmt); - const local = lowerer.declareLocal(stmt.name, stmt.name.text, funcType, false); - local.mutable = true; + const local = lowerer.declareLocal(stmt.name, stmt.name.text, funcType, true); const active = [...lowerer.activeStmtLists].reverse().find((entry) => entry.stmts.includes(stmt)); - active?.out.push({ kind: "varDecl", localId: local.id, init: null, loc: locOf(stmt) }); + if (!active) throw new InternalCompilerError("lowerer bug: nested function has no owning statement list"); + active.out.push({ kind: "varDecl", localId: local.id, init: null, loc: locOf(stmt) }); const init = lowerer.lowerLambda(stmt); return { kind: "assign", localId: local.id, value: init, loc: locOf(stmt) }; }