astral-sh/ruff · error

storage-free owned constraint sets must have terminal roots

Error message

storage-free owned constraint sets must have terminal roots

What it means

When merging another `OwnedConstraintSet` into a builder, the incoming set's root is checked first: a terminal root needs no data, but a non-terminal root must carry its own inner storage (`other.inner`). The expect fires when an owned set was constructed with a non-terminal node but no inner storage - an inconsistent construction that the merge refuses to handle silently.

Source

Thrown at crates/ty_python_semantic/src/types/constraints.rs:1687

            if index < split {
                let compacted_index = compacted.retained_node_index(node);
                return Some(compacted.node_supports[compacted_index]);
            }
            return Some(self.node_supports[NodeId::from_usize(index - split)]);
        }
        Some(self.node_supports[node])
    }

    fn node_support(&self, node: NodeId) -> Option<&Support> {
        self.node_support_id(node)
            .map(|support| self.support_data(support))
    }

    /// Loads an [`OwnedConstraintSet`] into this storage.
    fn load(
        &mut self,
        db: &'db dyn Db,
        env: &ProgramEnvironment<'db>,
        other: &OwnedConstraintSet<'db>,
    ) -> (NodeId, Option<SourceOrderId>) {
        fn rebuild_node<'db>(
            storage: &mut ConstraintSetStorage<'db>,
            inner: &OwnedConstraintSetInner<'db>,
            constraints: &[(NodeId, Option<SourceOrderId>)],
            cache: &mut FxHashMap<NodeId, NodeId>,
            old_node: NodeId,
        ) -> NodeId {
            if old_node.is_terminal() {
                return old_node;
            }
            if let Some(remapped) = cache.get(&old_node) {
                return *remapped;
            }

            let old_node_index = inner.retained_node_index(old_node);
            let old_interior = inner.nodes[old_node_index];

View on GitHub (pinned to 15f3fe6b15)

Solutions

  1. Audit all `OwnedConstraintSet` constructors: non-terminal root requires Some(inner); terminal root requires None.
  2. Add a debug assertion in the constructor so misuse fails at creation, with context, instead of at merge time.
  3. Write a unit test that merges sets produced by every public combine operation.
  4. Report upstream with the reproducer if stock ty panics.
Defensive patterns

Strategy: try-catch

Try / catch

let merged = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    builder.extend(db, env, other)
}))
.unwrap_or_else(|_| builder.clone_set()); // skip the poisoned merge

Prevention

When it happens

Trigger: Merging an `OwnedConstraintSet` whose `node` is non-terminal while `inner` is None - typically a constructor path or optimization that strips inner storage without also collapsing the root to a terminal node.

Common situations: Refactoring `OwnedConstraintSet` construction (caching, serialization, quantifier results); tests that hand-build sets; usually a compile-time-adjacent bug caught by constraint mdtests, not user Python code.

Related errors


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