oxc-project/oxc · error

function and catch parameter scopes always have a parent

Error message

function and catch parameter scopes always have a parent

What it means

This is an internal invariant panic in oxc_semantic's scope builder. `resolve_references_for_current_scope` early-resolves references collected while visiting a function or catch parameter scope by looking up that scope's parent via `scope_parent_id(...).expect(...)`. The library asserts the scope current at that point must be a function or catch parameter scope, which by construction is always nested inside another scope, so the parent must exist. If the parent is `None`, the builder entered the function with a malformed/missing scope tree, so it panics instead of silently mis-resolving references.

Source

Thrown at crates/oxc_semantic/src/builder.rs:751

    /// so a nested function parameter can still resolve to a later declaration in an enclosing
    /// function body while skipping declarations in its own body.
    ///
    /// This is a workaround until function bodies have separate scopes:
    /// <https://github.com/oxc-project/backlog/issues/176>.
    ///
    /// Resolved references are removed. Unresolved references stay in the flat
    /// list for later resolution by `resolve_all_references` (which handles
    /// forward references to declarations not yet visited).
    fn resolve_references_for_current_scope(&mut self, unresolved_start: usize) {
        if self.unresolved_references.len() == unresolved_start {
            return;
        }

        let current_scope_id = self.current_scope_id;
        let parent_scope_id = self
            .scoping
            .scope_parent_id(current_scope_id)
            .expect("function and catch parameter scopes always have a parent");

        // Take the list out of `self` while resolving, so the closure can call `&mut self`
        // methods. Resolution never pushes new unresolved references, so nothing is lost.
        let mut unresolved_references = mem::take(&mut self.unresolved_references);
        unresolved_references.retain_from(unresolved_start, |unresolved| {
            // Parameter decorators are visited in an outer class scope. Leave those references
            // for final resolution because the current function is not on their scope chain.
            let lookup_scope_id = unresolved.lookup_scope_id;
            if lookup_scope_id != current_scope_id
                && !self.scoping.scope_is_descendant_of(lookup_scope_id, current_scope_id)
            {
                return true;
            }
            if self.walk_up_resolve_reference(*unresolved, current_scope_id) {
                return false;
            }
            // Skip this function body during final resolution. An enclosing parameter resolution
            // may still resolve the reference before advancing the boundary again.

View on GitHub (pinned to a3d33dda7c)

Solutions

  1. Ensure every function/arrow/catch visit is wrapped in matching enter_scope/leave_scope calls so the parameter scope is a child of an enclosing scope
  2. Verify you are not invoking the builder's internal visit methods on a manually built scope tree; use the standard `SemanticBuilder::build` entry point
  3. Update oxc to the latest version — this invariant was added while function-body scoping is being reworked (oxc-project/backlog#176), and internal scope construction has changed across versions
  4. If you hit this on a reproducible input, file an oxc issue with the snippet; it indicates a scope-tree construction bug, not a recoverable error

Example fix

// before (external code driving the builder)
let mut builder = SemanticBuilder::new();
builder.visit_program_without_scoping(&program); // current_scope_id stays root
// after
let semantic = SemanticBuilder::new().build(&program); // standard entry, scopes pushed correctly
Defensive patterns

Strategy: validation

Validate before calling

// Before resolving, confirm the scope has a parent and unresolved refs exist
if builder.unresolved_references.len() == unresolved_start {
    return; // nothing to resolve, no panic path
}
debug_assert!(
    scoping.scope_parent_id(current_scope_id).is_some(),
    "function/catch parameter scope must have a parent before resolution"
);

Type guard

fn has_parent_scope(scoping: &Scoping, scope_id: ScopeId) -> bool {
    scoping.scope_parent_id(scope_id).is_some()
}

Prevention

When it happens

Trigger: Calling `resolve_references_for_current_scope` (from visit_function, visit_arrow_function_expression, or visit_catch_parameter) while `self.current_scope_id` refers to a scope with no parent in `Scoping` — i.e. the root/global scope, or a scope whose parent edge was never pushed. Only reachable when `unresolved_references.len() != unresolved_start`, meaning parameter-scope unresolved references exist when the function/catch parameter visit ends.

Common situations: Custom AST visits or embedding code that calls `SemanticBuilder` visit methods out of order and leaves `current_scope_id` at the program root; builder refactors that skip the `enter_scope`/`leave_scope` pairs for function or catch clauses; external crates constructing a `Scoping` scope tree manually and calling resolution on a parentless scope.


AI-assisted analysis of oxc-project/oxc@a3d33dda7c (2026-08-31). Data as JSON: /api/errors/c563c360807e2999. Report an issue: GitHub.