astral-sh/ruff · error

scope offset should point to ExprCall

Error message

scope offset should point to ExprCall

What it means

The non-annotation counterpart of the string-literal check: for a dangling dynamic-class call in a scope body, the stored scope-relative offset must land on the `ast::ExprCall` node (the `make_class`-style call). The `try_into().expect(...)` fires when the node resolved by `NodeIndex::from(anchor_u32 + offset)` is any other expression kind.

Source

Thrown at crates/ty_python_semantic/src/types/class.rs:138

                DynamicClassScopeOffset::StringAnnotation { offset, range } => {
                    (offset, Some(range))
                }
            };
            let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0));
            let anchor_u32 = scope_anchor
                .as_u32()
                .expect("anchor should not be NodeIndex::NONE");
            let absolute_index = NodeIndex::from(anchor_u32 + offset);
            if let Some(relative_range) = relative_range {
                let string: &ast::ExprStringLiteral = module
                    .get_by_index(absolute_index)
                    .try_into()
                    .expect("string annotation offset should point to ExprStringLiteral");
                return relative_range + string.start();
            }
            let node: &ast::ExprCall = module
                .get_by_index(absolute_index)
                .try_into()
                .expect("scope offset should point to ExprCall");
            node.range()
        }
    }
}

bitflags::bitflags! {
    /// Properties shared by all instances of a class.
    ///
    /// This combines properties derived from the MRO into the existing class-classification
    /// query, avoiding a separate cached query for each property.
    #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
    pub(super) struct ClassInstanceFlags: u8 {
        /// The class is, or inherits from, a `TypedDict` specification.
        const TYPED_DICT = 1 << 0;
        /// The class directly or indirectly inherits from an explicit `Any` base.
        const INHERITS_FROM_EXPLICIT_ANY = 1 << 1;
        /// The class may define or inherit a custom `__getattribute__` method.

View on GitHub (pinned to 15f3fe6b15)

Solutions

  1. Recompute or invalidate offsets together with `parsed_module` for the file so index arithmetic always runs on the parse the offset came from.
  2. Write a failing mdtest with the minimal dangling call (e.g. a bare `Enum('X', 'A')` expression statement) and fix anchoring until it passes.
  3. Double-check the base used at creation (`scope.node(db).node_index()`) matches the base used in `dynamic_class_header_range`.
  4. Report upstream if reachable without source changes.
Defensive patterns

Strategy: try-catch

Try / catch

let range = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    dynamic_class_header_range(db, scope, anchor)
}))
.unwrap_or_else(|_| file.range());

Prevention

When it happens

Trigger: Resolving a `DynamicClassScopeOffset::Node(offset)` anchor where offset + scope node index points at a non-call node: index drift after a reparse, an offset computed from the wrong base node, or a scope whose node index changed between anchor creation and resolution.

Common situations: Editing ty's dangling-call detection to anchor calls in new positions (decorators, subscripts, nested calls); incremental LSP edits where the AST shifts under a cached anchor; test fixtures generated from a differently-shaped module.

Related errors


AI-assisted analysis of astral-sh/ruff@15f3fe6b15 (2026-08-20). Data as JSON: /api/errors/46450845f22de36d. Report an issue: GitHub.