Type the remaining 13 mixins, closing the *Ops attribute gap entirely - #407
Merged
Conversation
#397 did this for settings_store and named the rest as still outstanding: 23 mixin classes across three families, each using attributes that only exist on the class composing it. Correct at runtime, invisible to a type checker reading one mixin on its own - and a large part of why [tool.mypy].files has stayed at four entries. This finishes the other two families. backend/domain/_composed.py declares what SceneDocument provides its four mixins (nodes, edges, command_log, redo_stack, and the handful of methods they call across the composition); backend/agent_dispatch/_composed.py declares what AgentDispatcher provides its nine (_runs, _settings_manager, _dispatch, _run_node_blocking_action). mypy backend: 1,059 errors in 68 files -> 844 in 52 "*Ops has no attribute" errors: 354 -> 0 Both bases are TYPE_CHECKING-only: at runtime they are empty, add no attributes, no methods and no __init__, and land immediately before object in their composed class's MRO where they can never take precedence over a real mixin. What this does NOT fix, stated plainly: 327 of the remaining 666 union-attr errors are the `NodeState | None` gap - SceneNode.state typed against a field-less marker class. That one is not a declaration problem. tests/test_node_state_migration.py requires every migrated field to be read as X.state.<field> and rejects the alias a narrowing accessor needs, so it belongs with the ADR-002 stage 2.5 work rather than here. mypy's scope therefore does not widen in this change; it becomes possible to widen later. Test plan: - 12 new parametrised gate tests covering all three bases together: no runtime body, no __init__, last in the MRO, and every *Ops mixin in each family actually inheriting its base - that last one catches a new sibling landing without one, which is the hole these close. - Full suite: 3219 passed, 19 skipped. ruff and mypy both clean. Worth recording: the first attempt at this inserted the new imports by a line heuristic and landed one between a @DataClass decorator and its class, breaking the module. Redone with an AST-located insertion point after the real end of each import block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 4, 2026
dovvnloading
added a commit
that referenced
this pull request
Sep 4, 2026
…#410) The QA audit called this blocked, and #397 and #407 both repeated the claim: a typed accessor for SceneNode.state was said to be impossible because tests/test_node_state_migration.py rejects the alias one needs. That was wrong, and re-reading the gate is what showed it. Its own _is_via_state checks only that a migrated field sits on something whose attribute is `state` - it constrains the ACCESS SHAPE, `<anything>.state.<field>`, and says nothing about how the node was obtained. So narrowing the NODE is permitted where aliasing the STATE is not, and `node.state.code_review_pr_url` satisfies the gate and the type checker at once. require_node(nodes, node_id, kind, StateClass) returns the node with its state type narrowed, and does the two existing SceneError raises verbatim. optional_node is its silent sibling for the fail_*_run methods. Eighteen copies of the same five-line preamble collapse to one call each. mypy backend: 845 errors -> 659 nodes_code_review.py + nodes_gitlink.py: 93 -> 0 Both now check clean, so [tool.mypy].files gains its first three backend/domain/ entries. The rest of the package follows as its per-kind groups are extracted the same way (#409). TWO DELIBERATE BEHAVIOUR CHANGES, both narrowing a crash into a handled error. Five methods had no kind check at all: three gitlink ones that raised only on a missing node, and two fail_*_run ones that returned None only for a missing node. Passing a wrong-kind node id to any of them reached `node.state.<kind>_<field>` on a state class without that field and raised AttributeError - which the WS layer does not translate, unlike SceneError. They now behave like every sibling: raise SceneError, or (for fail_*_run, whose documented contract is already "silent when the node has gone") return None. Test plan: - 2 new tests pinning both changes: a wrong-kind id raises SceneError from store/fetch/append, and fail_*_run is a quiet no-op for it. - tests/test_node_state_migration.py and test_domain_purity.py green - the access shape is unchanged and node_access.py imports nothing the domain layer may not. - Full suite: 3226 passed, 19 skipped. ruff clean, mypy clean across 33 source files (was 30). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
#397 did this for
settings_storeand named the rest as still outstanding: 23 mixin classes across three families, each using attributes that only exist on the class composing it. Correct at runtime, invisible to a type checker reading one mixin on its own — and a large part of why[tool.mypy].fileshas stayed at four entries while the real error count grew.This finishes the other two families.
Change
backend/domain/_composed.pydeclares whatSceneDocumentprovides its four mixins (nodes,edges,command_log,redo_stack, and the handful of methods they call across the composition).backend/agent_dispatch/_composed.pydeclares whatAgentDispatcherprovides its nine (_runs,_settings_manager,_dispatch,_run_node_blocking_action).Both bases are
TYPE_CHECKING-only: at runtime they are empty, add no attributes, no methods and no__init__, and land immediately beforeobjectin their composed class's MRO where they can never take precedence over a real mixin.What this does not fix
327 of the remaining 666
union-attrerrors are theNodeState | Nonegap —SceneNode.statetyped against a field-less marker class. That is not a declaration problem, and it is not tractable here:tests/test_node_state_migration.pyrequires every migrated field to be read asX.state.<field>and rejects the local alias a narrowing accessor would need. It belongs with the ADR-002 stage 2.5 work.So mypy's configured scope does not widen in this change. It becomes possible to widen later, which it was not before.
Test plan
__init__, last in the MRO, and every*Opsmixin in each family actually inheriting its base. That last one catches a new sibling landing without one, which is precisely the hole these close.ruffandmypyboth clean.One process note for the record: the first attempt inserted the new imports by a line heuristic and landed one between a
@dataclassdecorator and its class, breaking the module on import. Redone with an AST-located insertion point after the real end of each import block, and the 13 files reverted cleanly in between.🤖 Generated with Claude Code