astral-sh/ruff · error
anchor should not be NodeIndex::NONE
Error message
anchor should not be NodeIndex::NONE
What it means
For dangling dynamic-class calls (a call with no assignment), ty stores a `DynamicClassScopeOffset` relative to the enclosing scope's AST node index. Resolving the header range converts the scope's node index to a raw u32; this expect fires if that index is the `NodeIndex::NONE` sentinel, meaning the scope's node has no usable index in the parsed module. Without it the offset arithmetic `NodeIndex::from(anchor_u32 + offset)` cannot be trusted.
Source
Thrown at crates/ty_python_semantic/src/types/class.rs:126
anchor: DynamicClassHeaderAnchor<'db>,
) -> TextRange {
let module = parsed_module(db, scope.python_file(db)).load(db);
match anchor {
DynamicClassHeaderAnchor::Definition(definition) => definition
.kind(db)
.value(&module)
.expect("dynamic class definitions should only be used for assignments")
.range(),
DynamicClassHeaderAnchor::ScopeOffset(offset) => {
let (offset, relative_range) = match offset {
DynamicClassScopeOffset::Node(offset) => (offset, None),
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()
}
}
}
View on GitHub (pinned to 15f3fe6b15)
Solutions
- At anchor construction, only build `DynamicClassAnchor::ScopeOffset` for scopes whose `node_index()` is Some and not the NONE sentinel; otherwise fall back to storing the absolute range.
- Check that the module used for resolution is the scope's own `python_file`, and that the anchor has a Salsa dependency on that parse.
- Add an mdtest with a dangling call in the same scope shape that panicked and run the ty_python_semantic mdtests.
- If reachable on stock ty, file an issue with the reproducer.
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
- Treat any dynamic-class range panic as a ty bug: reduce the input to the smallest file that still panics before reporting.
- In editor integrations, wrap per-file checking so one file's panic does not take down the LSP session.
- Keep ty updated; anchoring fixes land regularly.
When it happens
Trigger: Resolving a `DynamicClassScopeOffset` anchor where `scope.node(db).node_index()` is missing or is the NONE sentinel - e.g. a synthetic scope, or a scope whose node lives in a different module than the one loaded via `scope.python_file(db)`.
Common situations: Modifying how dangling calls get anchored (new scope kinds, lambda/comprehension bodies); LSP incremental edits where the scope's node index shifts after reparse but the anchor was not recomputed; a refactor that lets scopes without AST nodes own dynamic classes.
Related errors
- scope offset should point to ExprCall
- dynamic class definitions should only be used for assignment
- string annotation offset should point to ExprStringLiteral
- DynamicClassAnchor::Definition should only be used for assig
- Definition value should be a call expression
AI-assisted analysis of astral-sh/ruff@15f3fe6b15 (2026-08-20).
Data as JSON: /api/errors/fe0db3029f720c99.
Report an issue: GitHub.