SFEP-0042
Nested / Local Function Declarations (non-capturing static `fn` items)
- Status
- Accepted
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- #1609, #1922, #1935, #1940, #1950
SFEP-0042 — Nested / Local Function Declarations (non-capturing static fn items)
1. Summary
Allow a fn name(params) -> T ![effects] { body } declaration to appear in
statement position inside another function or lambda body. A nested fn is
a lexically-scoped static function (Rust’s model): its name is visible
across the whole enclosing block (block-hoisted, so siblings may mutually
recurse and a nested fn may recurse into itself), and its body may reference
only its own parameters, module-level items, and other in-scope nested fns —
never an enclosing local or parameter. Referencing an enclosing local is a
hard error (E0421) with a fix-it pointing at let f = fn(...) => ..., which
is Sailfin’s existing capturing-lambda form. Nested fns lift to plain
top-level functions with scope-qualified mangled names and are called by name
via ordinary static dispatch, so native-IR and LLVM lowering need no new code
for the by-name path. This SFEP is a slice of epic #1609 (first-class
function values); using a nested fn’s bare name as a first-class value reuses
#1609’s {fnptr, null-env} lowering and is gated on that item.
2. Motivation
Today, statement-position fn name(...) does not parse. parse_block_statement
(compiler/src/parser/statements.sfn:198) has arms for let, for, loop,
routine, if, match, with, unsafe, return, throw, try, assert,
and a while reserved-guard — but no fn arm. A fn helper() { ... }
inside a function body therefore falls through to parse_expression_statement
and hits the E0818 “unstructured expression” backstop. The anonymous-lambda
parser (parse_lambda_expression, parser/expressions.sfn:1877) only matches
fn( with no name, so it does not catch this either.
This is a real ergonomic and readability gap. Programmers reach for a named
local helper to factor a multi-step body without polluting the module namespace
or dropping to a let-bound lambda (which reads as data, not as a definition,
and — per #1921 — could not self-recurse until recently because a let binding
is not in scope inside its own initializer). Every mainstream systems language
the target audience knows offers some form of this, and LLMs emit it
reflexively (see the AI-agents-are-users principle) — so the current failure is
a systematic E0818 papercut in generated code.
Go vs. Rust: why Sailfin picks Rust’s model
There are two established designs:
-
Go has no named nested function declarations. You write a closure literal bound to a variable:
helper := func(x int) int { ... }. Closures capture the enclosing environment by reference, and self-recursion is awkward: you must pre-declarevar helper func(int) intand then assign, because the name is not in scope inside the literal. The capture-by-reference default is also a well-known footgun (loop-variable capture). -
Rust has non-capturing nested
fnitems and separate closures. A nestedfnis an ordinary static function that merely happens to be name-scoped to the enclosing block; it cannot see the enclosing dynamic environment, and referencing an enclosing local from a nestedfnis a hard error (E0434, “can’t capture dynamic environment in a fn item; use the || { … } closure form instead”). Capture is the closure’s job. Nestedfnitems are block-hoisted, so they self- and mutually recurse with no forward-declaration ritual.
Sailfin already has the closure half of Rust’s split: let f = fn(x) => expr
and the block form let f = fn(x) -> T { ... } capture enclosing variables
(SFEP-0029; capturing-lambda lifting in
llvm/expression_lowering/native/lambda_lowering.sfn). What it lacks is the
named static half. Adopting Go’s capturing-closure-only model would either
duplicate what let-lambdas already do or silently make fn name capture —
reintroducing the by-reference footgun and forcing a heap-allocated environment
behind a construct that reads like a plain function. Rust’s split is the honest
fit: let-lambda when you need capture, nested fn when you need a named,
recursive, zero-overhead static helper. It also sidesteps the #1921 recursion
limitation structurally — a block-hoisted fn name is in scope inside its own
body, so fn fib(n: int) -> int { ... fib(n - 1) ... } just works, where the
let-lambda equivalent needs the (now-shipped but heavier) recursive-let
machinery.
3. Design
3.1 Semantics
- Syntax.
fn name(p1: T1, p2: T2, ...) -> R ![e1, e2] { body }in statement position, identical to a top-level function declaration. Return type and effect row are optional exactly as at top level. - Static, non-capturing. The body’s free names must resolve to one of:
(a) the nested
fn’s own parameters, (b) module-level items (imports, top-level functions, types, module constants), (c) sibling nestedfns in the same or an enclosing block, or (d) the nestedfn’s own name (self-recursion). Any name that resolves to an enclosing local or parameter is rejected withE0421(§3.3). - Block-hoisted visibility. All sibling nested
fnnames in a block are in scope across the entire block regardless of declaration order. Two siblings may mutually recurse. This is the item-hoisting rule, in deliberate contrast to the sequentialletregistration threaded throughwalk_block_expressions(typecheck.sfn:1628-1640, the #1921 model): aletis visible only after its declaration; a nestedfnis visible throughout its block. - Nesting depth. A nested
fnmay itself contain nestedfns. Names at each depth are block-scoped to that depth; an inner nestedfnsees its enclosing nestedfns’ names (they are module-level-equivalent static items from its perspective) but still not their locals. - Shadowing. A nested
fnname may shadow a module-level function of the same name within its block; resolution prefers the nearest enclosing block’s nestedfn. Two nestedfns with the same name in the same block are a duplicate-symbol error (E0001, viamake_duplicate_symbol_diagnostic—register_local_symbolruns the dedup check for the"function"kind, unlike"variable"), just like two top-level functions.
fn classify(values: int[]) -> string ![io] { // Block-hoisted: `is_even` is visible here even though it is // declared below, and the two siblings mutually recurse. fn is_even(n: int) -> boolean { if n == 0 { return true; } return is_odd(n - 1); // sibling, declared later — OK } fn is_odd(n: int) -> boolean { if n == 0 { return false; } return is_even(n - 1); // sibling — OK }
fn describe(n: int) -> string { if is_even(n) { return "even"; } // sibling nested fn — OK return "odd"; }
let mut out = ""; for v in values { out = out + describe(v) + " "; // by-name call — static dispatch } return out;}fn bump(base: int) -> int { fn helper(n: int) -> int { return n + base; // ERROR E0421: `base` is an enclosing parameter. } // fix-it: bind a closure to capture it. return helper(1);}// Fix (capture is the lambda's job):fn bump_ok(base: int) -> int { let helper = fn(n: int) => n + base; // capturing let-lambda return helper(1);}3.2 Pipeline stages
S1 — Parser (compiler/src/parser/statements.sfn)
Add an fn-name dispatch arm to parse_block_statement, placed before the
parse_expression_statement fallthrough (before statements.sfn:365). Detect
the arm with two-token lookahead: identifier_matches(token, "fn") and the
next non-trivia token is an identifier (a name), which distinguishes a nested
declaration from an anonymous lambda expression fn( (whose next token is ().
When matched, delegate to the existing parse_function machinery
(compiler/src/parser/declarations.sfn:806) so the parsed FunctionSignature +
body is byte-for-byte the top-level shape, and wrap the result in
Statement.FunctionDeclaration (ast.sfn:351) — the same node the top-level
dispatch and the lambda-lift pass already produce. No new AST node is
required.
Modifier handling in v0 (reject cleanly rather than silently mis-handle):
async fn name(...)at nested position → reject with a clear diagnostic (async fnis structural-only even at top level; nested async has no defined semantics). Recommend routing through the reserved-guard style (returnsuccess: falseso typecheck emits a targeted message).- Decorators on a nested
fn(@foo fn name) → reject in v0.parse_decorators_stubalready runs at the top ofparse_block_statement; if decorators are present on thefnarm, return the not-success sentinel and let typecheck report “decorators are not supported on nested functions (yet)”. unsafe fn name(...)→ the top-levelparse_functionalready threadsis_unsafe; a nestedunsafe fnis trivially supported (the body’sunsafe-gated operations are checked as usual), so accept it. Theunsafeblock arm (statements.sfn:290) is unaffected — that matchesunsafe {, notunsafe fn.
S2 — Typecheck + scope (compiler/src/typecheck.sfn, typecheck_captures.sfn)
Three sub-changes, all additive:
-
Body typecheck already largely works.
check_statementhas aFunctionDeclarationarm (typecheck.sfn:540-567) that registers the name into the lexicalbindingsscope viaregister_local_symbol(correct — lexically scoped, nottop_level/global) and callscheck_function_body.check_block(typecheck.sfn:908-925) recurses generically over statements. So once S1 emits the node, signature + body checking flows through unchanged. -
Block-hoisting (two-pass block walk).
check_blockandwalk_block_expressionscurrently register bindings sequentially as they iterate. Add a pre-pass over each block’s statements that registers all siblingFunctionDeclarationnames into the block scope before any body is walked. Concretely: before the per-statement loop incheck_block(typecheck.sfn:913) and inwalk_block_expressions(typecheck.sfn:1630), iterate the statements once andregister_local_symbol(..., "function", ...)everyFunctionDeclaration; then run the existing body loop. This makes a sibling declared later resolvable from an earlier sibling’s body and makes each nestedfnself-visible.lets keep their sequential registration (the pre-pass only hoistsFunctionDeclarations). -
E0420 walk recursion + capture rejection (new).
walk_statement_expressions(typecheck.sfn:1644-1730) — the walk that resolves identifiers and emitsE0420on an undefined name — has noFunctionDeclarationarm and falls through toreturn [](line 1729), so nestedfnbodies are never descended by the resolution walk. Add aFunctionDeclarationarm that walks the body in a scope that deliberately excludes enclosing locals/parameters: the arm builds a fresh binding scope containing only (a) the nestedfn’s own parameters, (b) module-level items (already inctxvia the top-level/import tables), and (c) the block-hoisted sibling nested-fnnames, then runs the block walk under that scope. Any identifier that would have resolved against an enclosing local is now unresolved in this restricted scope → the walk emits the newE0421(§3.3) instead ofE0420, so the diagnostic can carry the capture-specific message and fix-it.typecheck_captures.sfn::walk_statementalready has aFunctionDeclarationarm (typecheck_captures.sfn:277-280) that extends the scope with the fn’s parameters and walks the body — reuse itsextend_with_parametersshape to build the restricted scope.
S3 — Emit / lift (compiler/src/llvm/expression_lowering/native/lambda_lowering.sfn, called from emit_native.sfn)
Reuse the shape of lift_non_capturing_lambdas (lambda_lowering.sfn:123,
invoked from emit_native.sfn:111,166) but with a simpler target. That pass
rewrites lambdas into a Raw closure-pair marker plus a synthesized top-level
Statement.FunctionDeclaration appended to program.statements. For a nested
fn:
- Lift to a plain top-level function — no
__envparameter, no{i8*, i8*}closure-pair marker, noRawrewrite of the call site’s value. The nestedFunctionDeclarationis hoisted verbatim toprogram.statementsunder a mangled name, and by-name call sites are rewritten to call that mangled name directly. - Rewrite by-name call sites. Within the enclosing function (and inner
nested fns), a
Callwhose callee identifier resolves to a nestedfngets its callee name rewritten to the mangled name. This is a name substitution in the lift walker, keyed by the scope-qualified resolution built in S2.
Once lifted, everything downstream is flat and name-based:
native_ir.sfn and llvm/lowering/entrypoints.sfn see an ordinary top-level
function and an ordinary named call. Zero new native-IR or LLVM code for the
by-name path.
Mangling (must be collision-safe). The lambda counter sfn_lambda_<N>
(lambda_lowering.sfn:230) is module-monotonic and safe only because lambdas
are anonymous. Two different outer functions each declaring fn helper() would
collide once flattened into the single top-level symbol namespace. Scheme:
sfn_nested_<enclosing_chain>_<name>_<N><enclosing_chain>is the_-joined chain of enclosing function names from outermost to the immediate parent (e.g.classifyfor a top-level parent,classify_describefor a fn nested insidedescribeinsideclassify), giving human-readable symbols and deterministic disambiguation across different outer functions.<name>is the nested fn’s source name.<N>is a module-monotonic counter (a sibling of the lambda counter, threaded throughLiftContext) appended unconditionally. It is the actual uniqueness guarantee: it disambiguates same-name nested fns living in different blocks of the same enclosing chain (e.g. one in anifarm and one in anelsearm both namedhelper), which the chain prefix alone cannot separate. Same-name siblings in the same block are already a duplicate-symbol error in S2 (E0001), so they never reach mangling.- Deeper nesting composes naturally: an inner nested fn’s chain includes its
outer nested fn’s source name, and each level draws its own
<N>.
S4 — Lowering (compiler/src/llvm/lowering/entrypoints.sfn)
- By-name calls: free. A lifted nested fn is a top-level function; a
rewritten call is an ordinary named
Call. Nothing to add. - Name-as-value: gated on #1609. Using a nested fn’s bare name as a
first-class
fn(...)value (assigning it, passing it as an argument) is the same problem as using a top-level function name as a value, which #1609 owns: the target lowering is{fnptr, null-env}via the dispatch seam atllvm/expression_lowering/native/core_call_emission.sfnand env-struct emission inllvm/closures.sfn. #1609 lists “named fn → typedfn(...)value, lowered to{fnptr, null-env}” as unshipped — the typed-value path currently errors via the#1147E0808guard, and onlyas *u8referencing works. Therefore name-as-value for nested fns is explicitly deferred to after #1609’s named-fn-value item lands (Sub-issue B, §Decomposition). The by-name path (S1–S3 + S4-by-name) ships independently and first.
S5 — Tests, docs, graduation
Regression tests (§8), docs/status.md row, a spec/preview chapter, and
flipping this SFEP to Implemented once the checklist (§7) is met end-to-end.
3.3 New diagnostic: E0421
Code: E0421 (verified free: E04xx currently uses E0400–E0403,
E0410, E0411, E0420; grep found no E0421). E0421 is emitted from the
same resolution walk that emits E0420 (typecheck.sfn), so it is the natural
adjacent code even though the E04xx range is nominally “effect violations” —
E0420 (undefined name) and E0410/E0411 (reserved statements) already
demonstrate that the resolution/statement diagnostics live in this range. Home:
typecheck.sfn (emit) + diagnostics_render.sfn (render), mirroring E0420.
Message: nested \fn` cannot capture `. **Fix-it:** bind a closure to capture it: `let f = fn(…) => …`. This mirrors Rust E0434`.
3.4 Same-name shadowing and sibling-scope resolution (resolved)
Nested-fn resolution keys by source name in three places built during
Sub-issue A: the typecheck scope (_build_nested_fn_scope), the emit rename map
(_lookup_nested_rename), and the routine-local effect table
(_local_effect_table). Nested fns lifted to distinct mangled symbols are
collision-free, and lexical shadowing between nested fns is handled (inner
scope wins: the rename lookup scans innermost-first, and sibling scope entries
are ordered to shadow module-level names). Two classes of edge case beyond
distinct-name nested fns — a nested fn name colliding with a value binding,
or with a same-named fn in a sibling block — needed scope-aware keying. They
were closed across two PRs.
Closed by #1940 (the Sub-issue A follow-up):
- Emit rename vs. a value-binding shadow. Sailfin lets a local value binding
shadow a nested fn name (
fn helper() { … } let helper = fn(x) => x; helper(1)); typecheck resolveshelper(1)to thelet, but the emit rename — keyed only by callee name — used to rewrite the call to the lifted nested symbol. The lift walker now skips the rename when a local/param binding shadows the name at the call site (lambda_lowering.sfn, theNestedRenameshadow sentinel). - Block-scoped effect resolution for the enclosing routine. The routine’s
own requirement walk now hoists each block’s direct fns
(
_hoist_block_local_fnsincollect_effects_from_block), so a call resolves to thefnin its nearest enclosing block rather than to one flat routine-wide row.
Closed by #1950 (two narrower residuals surfaced in #1940’s review — both non-blocking: (a) unsound but contrived, (b) a spurious diagnostic only):
- (a) Deferred sibling resolution. A nested fn’s own body was still analyzed
against the flat routine-wide table, so a call to a same-named sibling in a
different scope could mis-resolve (last-write-wins). Deferred analysis now
seeds each nested fn’s body with the effect table of its lexically enclosing
scope (
ScopedFnineffect_checker.sfn), so a sibling call resolves by scope. - (b) Effect-side value-binding shadow. The effect checker now mirrors (1) on
the emit side: a
let/parameter that shadows a same-named nestedfnzeroes that fn’s effect row for calls in its scope (position-sensitive for alet, whole-body for a parameter), so the caller is not required to declare an effect it does not incur.
Distinct-name nested fns — the overwhelmingly common case — were never affected.
4. Effect & capability impact
There is a live effect-checker bug that this SFEP must fix, independent of
the parser gap. effect_checker.sfn::collect_effects_from_statement
(effect_checker.sfn:605-607) has a FunctionDeclaration arm that inlines
the nested body’s effects into the enclosing block’s requirement:
if statement.variant == "FunctionDeclaration" { return collect_effects_from_block(statement.body, imports);}This treats a declaration as if it were inline-executed — so a
declared-but-uncalled fn foo() ![io] { print.info("x"); } would wrongly force
![io] onto its parent function. That is unsound for the new construct (and is
latent today for the lambda-lifted nodes that flow through this arm).
Fix. A nested fn declaration contributes no effect requirement to its
enclosing block on its own — only a call to it does, via the ordinary
transitive call rule (a caller must declare the callee’s effects). Two coupled
changes:
- Stop inlining. The
FunctionDeclarationarm incollect_effects_from_statementmust return the empty requirement for the declaration (the nested body is effect-checked as its own deferred function, not merged into the enclosing block). The nested body still gets its own effect check — that its declared row covers what it does — via the deferred-function path below. - Register nested fn effect signatures for call-site transitivity. The
local-function effect table is built by
_merge_local_functions/analyze_statement, which iterateprogram.statementsat top level only, so a nested fn’s own effect row is absent and a call to it cannot be checked transitively. Add a recursive collection pass that walks intoFunctionDeclarationbodies and registers each nested fn’s effect signature into the local-function effect table, keyed by the same scope-qualified / mangled name S3 assigns (so the post-lift call site — which now targets the mangled name — resolves to the right row). Because S3 lifts nested fns to top-level functions, an alternative valid ordering is to run effect analysis after the lift, when the nested fns are already flat top-level entries with ordinary rows; the SFEP permits either, but the keying must agree with the name the call site ends up using.
Net effect-system behaviour after the fix:
- A nested
fn foo() ![io]that is never called imposes no effect on its parent. - A parent that calls
foo()must declare, exactly like calling any other effectful function. - Effect lists stay alphabetical per
code-style.md; no new effects, no new capability surface — this is a pure name/scope construct.
5. Self-hosting impact
Passes touched, in pipeline order: parser (statements.sfn, new dispatch
arm), typecheck (typecheck.sfn block-hoist pre-pass + E0421 walk;
typecheck_captures.sfn scope reuse), effect checker (effect_checker.sfn
inline-bug fix + nested-signature registration), emitter/lift
(lambda_lowering.sfn plain-fn lift + mangling). Native IR and LLVM lowering
are unchanged for the by-name path (the lift makes nested fns
indistinguishable from top-level fns downstream).
Each stage self-hosts on its own:
- S1 is additive — the old seed’s parser is unaffected; the new parser only
adds recognition of a form the old one rejected. The compiler source does
not need to use nested fns for the new compiler to build (they are opt-in
syntax). So
make compilebuilds the new compiler from the old pinned seed with no seed-cut required (see §Decomposition). - S2/S3/S4-by-name build on S1 in the same self-host pass — the freshly built compiler (from the old seed) is the one that would later compile any compiler source adopting the feature.
- The compiler may adopt nested fns in its own source later, but need not; a
first-class candidate is refactoring the many
_-prefixed module-private helpers that exist only to serve one caller, but that is a follow-up, not a requirement of this SFEP. Adopting them only becomes safe once the feature is in the pinned seed.
6. Alternatives considered
- Go-style capturing sugar (
fn namecaptures by reference). Rejected. It duplicates the existinglet-lambda capture path, reintroduces the by-reference-capture footgun, and hides a heap-allocated environment behind a construct that reads as a plain static function. It also muddies the effect/ownership story (a capturingfnwould need env lifetime analysis). Sailfin already has capture — vialet-lambdas — so the missing half is precisely the non-capturing named one. - Desugar nested
fnto alet-bound lambda. Rejected as the primary model because a namedfnis semantically distinct: (a) recursion — a block-hoistedfnname is in scope in its own body with zero ceremony, where alet-lambda needed the heavier recursive-letmachinery (#1921) and reads as data; (b) no hidden heap — a nestedfnlifts to a plain top-level function with a rawfnptr, never a{fnptr, env}pair or an env allocation, so it is strictly zero-overhead; (c) intent —fnannounces “a definition” wherelet f = fn ...announces “a value”. Users pick the tool by whether they need capture, not by accident of desugaring. - New AST node for nested functions. Rejected —
Statement.FunctionDeclarationalready carries everything needed (signature, body,is_unsafe, decorators), is already produced by the lambda-lift pass, and is already handled bycheck_statementandtypecheck_captures.walk_statement. Reusing it keeps the blast radius minimal. - Allow decorators /
asyncon nested fns in v0. Deferred — no defined semantics yet; rejecting cleanly (§S1) is honest and reversible.
7. Stage1 readiness mapping
- Parses — new
fn-name arm inparse_block_statement(S1). - Type-checks / effect-checks — block-hoist pre-pass, E0421 capture rejection (S2); effect-inline bug fix + nested-signature registration (S4/§4).
- Emits valid
.sfn-asm— lift to plain top-level fn; downstreamnative_ir.sfnunchanged (S3). - Lowers to LLVM IR — by-name path free; name-as-value gated on #1609 (S4).
- Regression coverage — unit (parser/typecheck/effects) + integration + e2e (§8).
- Self-hosts — additive syntax, no seed cut for Sub-issue A (§5, §Decomposition).
-
sfn fmt --checkclean — every touched.sfnfile formatted. - Documented —
docs/status.mdrow + spec/preview chapter (S5).
8. Test plan
Unit (compiler/tests/unit/):
- Parser —
fn name(...) { }in a function body producesStatement.FunctionDeclaration; anonymousfn(in the same body still parses as a lambda (dispatch disambiguation); a nestedfninside a lambda body parses; the oldE0818no longer fires for the nested-fn form. - Block-hoisting — two sibling nested fns mutually recurse (forward
reference resolves, no
E0420); a nested fn self-recurses; a nested fn is callable from an earlier sibling’s body. - Capture rejection — a nested fn body referencing an enclosing
letemitsE0421with the fix-it; referencing an enclosing parameter emitsE0421; referencing its own param / a module-level fn / a sibling nested fn does not emitE0421. - Duplicate name — two same-name nested fns in one block emit the existing duplicate-symbol diagnostic (E0001).
- Effect checker — a declared-but-uncalled nested
fn foo() ![io]does not force![io]on its parent (regression for theeffect_checker.sfn:605bug); a parent that callsfoo()without![io]gets the effect diagnostic; with![io]it passes.
Integration (compiler/tests/integration/):
- Mangling collision — two different outer functions each declaring
fn helper()both compile and dispatch to the correct body (distinct mangled symbols). - Nested-in-nested — a fn nested two levels deep lifts and calls correctly.
E2e (compiler/tests/e2e/, *_test.sfn per no-bash-e2e.md):
- Build-and-run a fixture using mutually-recursive nested fns; assert program output.
- Snapshot the emitted top-level symbol names to pin the mangling scheme.
9. References
- Epic #1609 — First-class function values (this SFEP is a slice; owns the
{fnptr, env}ABI, thecore_call_emission.sfndispatch seam,llvm/closures.sfnenv emission, and the unshipped named-fn →{fnptr, null-env}value item that Sub-issue B depends on). SFEP-0030 (0030-first-class-function-values.md). - #1917 — parameter-scope resolution gap (sibling of the block-scope work).
- #1921 — sequential
letregistration / recursive-letlimitation (contrast for the block-hoisting rule). - #1922 — related scope/resolution work.
- #1147 — the
E0808guard that currently blocks the typed named-fn-value path (relevant to Sub-issue B’s gating). - #1118 — related function-value work.
- Rust
E0434— “can’t capture dynamic environment in a fn item; use the|| { ... }closure form instead” — the prior art for the capture-rejection model and the E0421 fix-it. - SFEP-0029 (
0029-lambda-syntax.md) — the capturing-lambda short form that E0421 points users toward.
Appendix: sub-issue decomposition for /groom (epic #1609)
Per .claude/rules/seed-dependency.md, bundle a capability with its single
consumer unless there is a genuine multi-consumer or independence reason to
split. The natural cut here is by-name vs. as-value, because the as-value
path has a real external dependency (#1609’s named-fn-value item) while the
by-name path is self-contained.
Sub-issue A — nested fn called by name (ship first) — Size: M
Scope: S1 (parser arm) + S2 (block-hoist pre-pass, E0421 capture rejection,
walk recursion) + S3 (plain-fn lift + scope-qualified mangling) + S4-by-name
(free) + §4 effect-checker fix (stop inlining, register nested signatures).
Clears the E0818 backstop for statement-position fn name. Delivers the whole
user-visible feature except using a nested fn’s bare name as a value.
Bundling / seed-cut analysis. Sub-issue A is one PR, no seed cut. It
introduces a parser capability (the fn-name arm) and its own consumers
(typecheck/effects/emit) in the same change. The compiler source does not need
to use nested fns for the new compiler to build, so make compile builds the
new compiler from the old pinned seed, and that freshly built compiler
compiles the feature’s own tests in the same self-host pass. There is no
capability that a separate downstream consumer needs present in the pinned
seed, so nothing here forces a /pin-seed. Splitting S1 from S2/S3 would
manufacture exactly the seed-cut gate the rule warns against — keep them
bundled.
Sub-issue B — nested fn as a first-class value — Size: S
Scope: allow a nested fn’s bare name in value position (assignment, argument),
lowering to {fnptr, null-env} via #1609’s dispatch seam, and lifting the
E0808/#1147 guard for this case.
Dependency. ## Required in pinned seed: — blocked by #1609’s named-fn →
{fnptr, null-env} value item. Carry ## Blocked by: #1609. Because B’s
enabling capability is owned by #1609 (a separate, multi-consumer capability —
top-level and nested fn names both use it), this is the legitimate split: B is
not bundled into A, and the seed advance that B needs rides the next scheduled
cadence bump once #1609’s item lands, per the split branch of the
seed-dependency tree. B is small once the lowering exists — mostly lifting the
guard for the nested case and a resolution tweak so a nested fn name resolves in
value position.