Skip to content

Type the remaining 13 mixins, closing the *Ops attribute gap entirely - #407

Merged
dovvnloading merged 1 commit into
mainfrom
refactor/type-the-remaining-mixins
Sep 4, 2026
Merged

Type the remaining 13 mixins, closing the *Ops attribute gap entirely#407
dovvnloading merged 1 commit into
mainfrom
refactor/type-the-remaining-mixins

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

#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 while the real error count grew.

This finishes the other two families.

Change

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.

SceneDocument MRO tail  : ['CommandOps', 'SceneDocumentParts', 'object']
AgentDispatcher MRO tail: ['ContentDispatchOps', 'DispatcherParts', 'object']
SceneDocumentParts adds : []
DispatcherParts adds    : []

What this does not fix

327 of the remaining 666 union-attr errors are the NodeState | None gap — SceneNode.state typed against a field-less marker class. That is not a declaration problem, and it is not tractable here: tests/test_node_state_migration.py requires every migrated field to be read as X.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

  • 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 precisely the hole these close.
  • Full suite: 3219 passed, 19 skipped. ruff and mypy both clean.

One process note for the record: the first attempt inserted the new imports by a line heuristic and landed one between a @dataclass decorator 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

#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>
@dovvnloading
dovvnloading merged commit 22a94fb into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the refactor/type-the-remaining-mixins branch September 4, 2026 15:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant