Skip to content

Narrow the four cross-cutting domain mixins, and put mypy on the package - #412

Merged
dovvnloading merged 1 commit into
mainfrom
types/domain-cross-cutting-mixins
Sep 4, 2026
Merged

Narrow the four cross-cutting domain mixins, and put mypy on the package#412
dovvnloading merged 1 commit into
mainfrom
types/domain-cross-cutting-mixins

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

After #411, backend/domain/ still reported 124 mypy errors, all in
graph.py and the four cross-cutting mixins:

file errors
groups.py 71
branches.py 26
commands.py 10
graph.py 9
layout.py 8

Every one is the same union-attr shape the per-kind mixins had:
SceneNode.state is typed NodeState | None against a field-less marker,
so node.state.group_width cannot be verified.

require_node (#410) could not reach them. It looks a node up by id and
narrows on its kind, and this code mostly does neither — it iterates every
node in the scene and skips what it does not want, or narrows a node handed
to it as a parameter.

Change

Three additions to the narrowing vocabulary

is_node_of(node, kind, state_cls) — a TypeGuard for a node already
in hand, for the iterate-and-skip shape. TypeGuard rather than TypeIs, so
the repo's declared 3.10 floor is enough and nothing has to import
typing_extensions at runtime. It narrows only the positive branch, so a
continue-on-wrong-kind loop reads as if is_node_of(...) instead of
if not ...: continue. It also requires a non-None state — matching the
and node.state is not None conjunct that every hand-written check it
replaces already carried.

require_node/optional_node take a tuple of kinds as well as a single
one, because group geometry is frame-or-container throughout.
" or ".join of a one-element tuple is that element, so the single-kind
error message is byte-identical.

GroupSizedState — a base holding group_width/group_height, which
FrameState and ContainerState now inherit instead of each declaring.

FrameState's docstring explains at length why those two kinds are not one
class: is_locked and the group_manual_* quartet are meaningless for a
container, and forcing them onto a shared base would resurrect the "every
kind carries fields it never uses" problem the migration exists to remove.
That reasoning is about those five fields and is unchanged.
group_width/group_height are common to both, are named for both by the
wire contract, and are read for both by every piece of group geometry —
which is exactly the code no single per-kind class could describe.

Neither kind's field set changes; the two shared fields are declared one
level up. Nothing serializes by field order (there is no asdict or
fields() call anywhere in the domain package) and both construction sites
use keyword arguments.

Declarations

SceneDocumentParts gains last_chat_node_id, final_deliverable_node_id
and current_chat_id. Without them BranchOps inferred str from its own
first assignment and then rejected the = None two lines later — which is
how SceneDocument's own final_deliverable_node_id: str | None = None
declaration ended up reported as an error against itself.

Four non-narrowing fixes, none behavioural

  • _bbox_of_members initialised four accumulators to None on one line and
    tested only the first before using all four. They are set together or not
    at all; the condition now says so.
  • layout.py rebound parent — a str elsewhere in the module — to a
    SceneNode, and passed fid if fid is not None else cid to a function
    taking str after a guard that proves it is not None. Renamed, and
    hoisted into a variable the guard can narrow.
  • branches.py reused the edge loop variable for a list that also holds an
    optional edge.
  • Command.invert/apply took document: object and then read four
    attributes off it. They now take SceneDocumentParts, which declares
    exactly those four.

Scope

[tool.mypy].files now lists backend/domain rather than individual
modules: all 18, up from 3 at the start of the day. 48 source files check
clean.

Test plan

  • Full suite: 3,245 passed, 19 skipped.
  • The one failure, test_cancel_during_backoff_aborts_promptly, asserts a
    cancel completes within a 2.0s wall-clock budget. It fails the same way on
    a clean checkout of main with no local changes, and passes on the same
    checkout when the machine is idle — it measures the machine, not the code,
    and is unrelated to this change.
  • 117 frame/container/group/bbox/measured-size tests pass. That is the real
    check on the _recompute_group_bounds restructure, which is the one piece
    of load-bearing geometry this touches.
  • ruff check . clean. mypy clean across 48 source files.

🤖 Generated with Claude Code

PR #411 left 124 errors in backend/domain/, all in graph.py and the four
cross-cutting mixins (groups 71, branches 26, commands 10, layout 8,
graph 9). Every one was the same union-attr shape the per-kind mixins had:
SceneNode.state is typed `NodeState | None` against a field-less marker.

require_node could not reach them. It looks a node up by id and narrows on
the kind, and this code mostly does neither - it iterates every node in the
scene and skips what it does not want, or narrows a node handed to it as a
parameter. Three additions close that:

  * is_node_of(node, kind, state_cls) - a TypeGuard for a node already in
    hand. TypeGuard, not TypeIs, so the repo's declared 3.10 floor is
    enough and nothing imports typing_extensions at runtime. It narrows
    only the positive branch, so a continue-on-wrong-kind loop reads as
    `if is_node_of(...)` instead of `if not ...: continue`. It also
    requires a non-None state, matching the `and node.state is not None`
    conjunct every hand-written check it replaces already carried.

  * require_node/optional_node now take a tuple of kinds as well as one.
    Group geometry is frame-or-container throughout. `" or ".join` of a
    one-element tuple is that element, so the single-kind error message is
    byte-identical.

  * GroupSizedState, a base holding group_width/group_height, which
    FrameState and ContainerState now inherit instead of each declaring.
    FrameState's docstring explains at length why those two kinds are not
    one class: is_locked and the group_manual_* quartet are meaningless
    for a container. That reasoning is about those five fields and is
    unchanged. group_width/group_height are common to both, are named for
    both by the wire contract, and are read for both by every piece of
    group geometry - which is exactly the code no single per-kind class
    could describe. Neither kind's field set changes; the two shared
    fields are declared one level up. Nothing serializes by field order
    (there is no asdict/fields() call anywhere in the domain), and both
    construction sites use keywords.

SceneDocumentParts gains last_chat_node_id, final_deliverable_node_id and
current_chat_id. Without them BranchOps inferred `str` from its own first
assignment and then rejected the `= None` two lines later - which is how
SceneDocument's own `final_deliverable_node_id: str | None = None`
declaration ended up reported as an error against itself.

Four non-narrowing fixes, none behavioural:

  * _bbox_of_members initialised four accumulators to None on one line and
    tested only the first before using all four. They are set together or
    not at all; the condition now says so.
  * layout.py rebound `parent` (a str elsewhere in the module) to a
    SceneNode, and passed `fid if fid is not None else cid` to a function
    taking str after a guard that proves it is not None. Renamed, and
    hoisted into a variable the guard can narrow.
  * branches.py reused the `edge` loop variable for a list that also holds
    an optional edge.
  * Command.invert/apply took `document: object` and then read four
    attributes off it. They take SceneDocumentParts, which declares
    exactly those four.

[tool.mypy].files now lists backend/domain rather than individual modules:
all 18, up from 3 at the start of the day. 48 source files check clean.

Test plan: full suite, 3,245 passed / 19 skipped. The one failure,
test_cancel_during_backoff_aborts_promptly, asserts a cancel completes
within a 2.0s wall-clock budget; it fails the same way on a clean checkout
of main under load, and is unrelated to this change. 117 frame/container/
group/bbox/measured-size tests pass, which is the real check on the
_recompute_group_bounds restructure. ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit 9480d48 into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the types/domain-cross-cutting-mixins branch September 4, 2026 17:19
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