diff --git a/backend/domain/_composed.py b/backend/domain/_composed.py index 442a0f8..6f76d4a 100644 --- a/backend/domain/_composed.py +++ b/backend/domain/_composed.py @@ -50,6 +50,15 @@ class SceneDocumentParts: pins: Any _counter: itertools.count + # Scalars held on SceneDocument that the mixins both read and + # write. Without these BranchOps infers `str` from its own first + # assignment and then rejects the `= None` two lines later, which + # is how a field declared `str | None` on SceneDocument itself + # produced an error on SceneDocument's own declaration. + last_chat_node_id: str | None + final_deliverable_node_id: str | None + current_chat_id: int | None + # The undo/redo command layer (CommandOps' own, consumed by siblings). command_log: deque redo_stack: list diff --git a/backend/domain/branches.py b/backend/domain/branches.py index 22313fa..3b8fb6e 100644 --- a/backend/domain/branches.py +++ b/backend/domain/branches.py @@ -31,6 +31,9 @@ if TYPE_CHECKING: from graphlink_model_catalog import ModelRef +from backend.domain.node_access import require_node +from backend.domain.node_states import ChatState, NoteState + from backend.domain._composed import SceneDocumentParts @@ -43,11 +46,7 @@ def mark_branch_comparison_note(self, node_id: str, source_node_ids: list[str]) set_note_content, mirroring set_group_color's own "extra setter call right after creation" shape (see the WS intent wrapper in register_canvas).""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "note": - raise SceneError(f"node is not a note node: {node_id}") + node = require_node(self.nodes, node_id, "note", NoteState) node.state.is_branch_comparison = True node.item_ids = list(source_node_ids) @@ -66,11 +65,7 @@ def mark_branch_synthesis( of a note-kind one (see ChatState's own comment, backend/domain/ node_states.py, for why this is a distinct method/flag rather than reusing Compare Branches').""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) node.state.is_branch_synthesis = True node.item_ids = list(source_node_ids) node.state.synthesis_instructions = str(instructions) @@ -96,11 +91,7 @@ def set_branch_status(self, node_id: str, status: str) -> None: already established that 2+ branches can be simultaneously legitimate (its own item_ids records multiple sources at once) - forcing exclusivity here would fight that existing workflow.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) status = str(status) if status not in self.BRANCH_STATUS_VALUES: raise SceneError(f"invalid branch status: {status}") @@ -115,11 +106,9 @@ def set_final_deliverable(self, node_id: str, is_final: bool) -> None: branch_status on purpose - no validation ties them together (a "rejected" node CAN technically be marked Final Deliverable; this is not blocked, though not a realistic path either).""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + # Validation only - the node itself is not needed, just the two + # SceneErrors this raises for a missing or non-chat id. + require_node(self.nodes, node_id, "chat", ChatState) if is_final: self.final_deliverable_node_id = node_id elif self.final_deliverable_node_id == node_id: @@ -131,11 +120,7 @@ def set_model_override(self, node_id: str, provider: str, model_id: str) -> None resolve_model_for_node's own docstring) resolves to. Both fields write together, mirroring set_group_color's own "no partial value" posture - a pin is a real (provider, model_id) pair or nothing.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) provider = str(provider or "").strip() model_id = str(model_id or "").strip() if not provider or not model_id: @@ -144,11 +129,7 @@ def set_model_override(self, node_id: str, provider: str, model_id: str) -> None node.state.override_model_id = model_id def clear_model_override(self, node_id: str) -> None: - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) node.state.override_provider = "" node.state.override_model_id = "" @@ -168,11 +149,7 @@ def set_chat_index_into_knowledge(self, node_id: str, enabled: bool) -> None: intents own the side effects" separation (chat_library persistence is owned by intents_chat_library.py, never by graph.py/branches.py directly).""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) node.state.index_into_knowledge = bool(enabled) def resolve_model_for_node( @@ -349,9 +326,11 @@ def delete_chat_node(self, node_id: str) -> None: for target in reconnect_targets: self.connect(parent_id, target) - for edge in [parent_edge, *child_edges, *note_edges]: - if edge is not None: - self.edges.pop(edge.id, None) + # A distinct name from the `edge` above: this list also holds + # parent_edge, which may be None. + for stale_edge in [parent_edge, *child_edges, *note_edges]: + if stale_edge is not None: + self.edges.pop(stale_edge.id, None) if self.last_chat_node_id == node_id: # The active branch continues from wherever it now ends: the @@ -515,11 +494,7 @@ def regenerate_response(self, node_id: str) -> tuple[SceneNode, str]: SceneError; the WS-intent wrapper in register_canvas catches it and shows ONE friendly notification for all three cases - see that wrapper for why.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + node = require_node(self.nodes, node_id, "chat", ChatState) parent_edge = self._branch_parent_edge(node_id) if parent_edge is None: raise SceneError(f"node has no parent and cannot be regenerated: {node_id}") @@ -616,10 +591,6 @@ def collapse_branch(self, node_id: str, collapsed: bool) -> None: so a node that had previously been individually expanded/collapsed differently loses that distinction the first time this runs - an accepted, stated tradeoff, not solved here.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "chat": - raise SceneError(f"node is not a chat node: {node_id}") + require_node(self.nodes, node_id, "chat", ChatState) # validation only for nid in self._chat_subtree_ids(node_id): self.nodes[nid].is_collapsed = bool(collapsed) diff --git a/backend/domain/commands.py b/backend/domain/commands.py index 671567c..ecf2fb7 100644 --- a/backend/domain/commands.py +++ b/backend/domain/commands.py @@ -167,7 +167,7 @@ def is_noop(self) -> bool: self.node_before or self.node_after or self.edge_before or self.edge_after ) and (self.pin_before is None or self.pin_before == self.pin_after) - def invert(self, document: object) -> None: + def invert(self, document: SceneDocumentParts) -> None: """Restores document state to exactly how it was before this command's mutator ran. Goes through the *_before snapshots only - never re-invokes the original domain method, which is the whole @@ -185,7 +185,7 @@ def invert(self, document: object) -> None: # per-id scoping doesn't apply to pins the way it does nodes). document.pins.reset(list(self.pin_before)) - def apply(self, document: object) -> None: + def apply(self, document: SceneDocumentParts) -> None: """The mirror of invert() - restores to the *_after state. Not needed for a first-time forward mutation (that already happened for real before this Command was constructed); this exists for @@ -283,13 +283,18 @@ def _restore(live: dict, snapshot: dict) -> None: # only case where trusting the snapshot is correct. Mirrored # below for HarnessState.harness_activity, same rationale. current = live.get(key) - if isinstance(getattr(restored, "state", None), PlanState) and isinstance( - getattr(current, "state", None), PlanState, - ): + # `current is not None` is redundant at runtime - getattr's own + # default already makes the isinstance fail - and is spelled out + # so the reads below are checkable. getattr stays because this + # helper also restores edges and image assets, which have no + # .state at all. + if current is not None and isinstance( + getattr(restored, "state", None), PlanState, + ) and isinstance(getattr(current, "state", None), PlanState): restored.state.builder_activity = current.state.builder_activity - if isinstance(getattr(restored, "state", None), HarnessState) and isinstance( - getattr(current, "state", None), HarnessState, - ): + if current is not None and isinstance( + getattr(restored, "state", None), HarnessState, + ) and isinstance(getattr(current, "state", None), HarnessState): restored.state.harness_activity = current.state.harness_activity live[key] = restored diff --git a/backend/domain/graph.py b/backend/domain/graph.py index 3019da2..a067bd1 100644 --- a/backend/domain/graph.py +++ b/backend/domain/graph.py @@ -64,6 +64,7 @@ SceneError, SceneNode, ) +from backend.domain.node_access import is_node_of, require_node from backend.domain.node_states import ( ArtifactState, ChartState, @@ -631,7 +632,11 @@ def move_node(self, node_id: str, x: float, y: float) -> SceneNode: # the live bbox stay in agreement. See _recompute_group_bounds # for how this anchor is unioned with live content so it still # can never clip a member. - node.state.group_manual_x, node.state.group_manual_y = node.x, node.y + # Cannot raise: the node is present and its kind was just + # checked. Re-fetched under FrameState purely so the two + # frame-only writes below are checkable. + frame = require_node(self.nodes, node_id, "frame", FrameState) + frame.state.group_manual_x, frame.state.group_manual_y = node.x, node.y self._recompute_group_bounds(node_id) # R6.1: keep every frame/container this node is a member of enclosing # it - a node is a member of at most one frame AND at most one @@ -672,7 +677,7 @@ def move_nodes(self, positions: list[tuple[str, float, float]]) -> None: continue node.x, node.y = float(x), float(y) moved_ids.add(node_id) - if node.kind == "frame": + if is_node_of(node, "frame", FrameState): node.state.group_manual_x, node.state.group_manual_y = node.x, node.y affected_groups: set[str] = set() for moved_id in moved_ids: diff --git a/backend/domain/groups.py b/backend/domain/groups.py index 97a057f..ffca9f2 100644 --- a/backend/domain/groups.py +++ b/backend/domain/groups.py @@ -29,7 +29,13 @@ SceneError, SceneNode, ) -from backend.domain.node_states import ContainerState, FrameState +from backend.domain.node_access import is_node_of, optional_node, require_node +from backend.domain.node_states import ( + ChartState, + ContainerState, + FrameState, + GroupSizedState, +) from backend.domain._composed import SceneDocumentParts @@ -75,9 +81,9 @@ def _member_footprint(self, member: SceneNode) -> tuple[float, float]: # test_node_state_migration.py's ADR-002 gate reads this # statically and an intermediate local would read as a bare # field access on the node itself. - if member.kind == "chart" and member.state is not None: + if is_node_of(member, "chart", ChartState): width, height = member.state.chart_width, member.state.chart_height - elif member.kind in ("frame", "container") and member.state is not None: + elif is_node_of(member, ("frame", "container"), GroupSizedState): width, height = member.state.group_width, member.state.group_height if not (width and width > 0): width = GROUP_MEMBER_DEFAULT_WIDTH @@ -129,7 +135,7 @@ def set_measured_node_sizes(self, sizes: list[tuple[str, float, float]]) -> bool for _ in range(4): pass_changed = False for group in self.nodes.values(): - if group.kind not in ("frame", "container"): + if not is_node_of(group, ("frame", "container"), GroupSizedState): continue if not any(member_id in touched for member_id in group.item_ids): continue @@ -157,7 +163,10 @@ def _bbox_of_members(self, item_ids: list[str]) -> tuple[float, float, float, fl origin when item_ids is empty or every id is stale, so callers (including resize_frame's own minimum-size clamp) always get a well-defined rect back.""" - left = top = right = bottom = None + left: float | None = None + top: float | None = None + right: float | None = None + bottom: float | None = None for member_id in item_ids: member = self.nodes.get(member_id) if member is None: @@ -170,7 +179,10 @@ def _bbox_of_members(self, item_ids: list[str]) -> tuple[float, float, float, fl top = my1 if top is None else min(top, my1) right = mx2 if right is None else max(right, mx2) bottom = my2 if bottom is None else max(bottom, my2) - if left is None: + # All four are set together by the loop above or none of them are, + # so this is the same single condition it has always been - spelled + # out so a checker can see the arithmetic below is safe. + if left is None or top is None or right is None or bottom is None: left = top = 0.0 right, bottom = GROUP_MEMBER_DEFAULT_WIDTH, GROUP_MEMBER_DEFAULT_HEIGHT x = left - GROUP_PADDING @@ -225,37 +237,41 @@ def _recompute_group_bounds(self, node_id: str) -> None: nothing manual set): x/y/width/height come straight from the padded bbox-of-members. """ - node = self.nodes.get(node_id) - if node is None or node.kind not in ("frame", "container"): + node = optional_node(self.nodes, node_id, ("frame", "container"), GroupSizedState) + if node is None: return if node.is_collapsed: node.state.group_width = GROUP_COLLAPSED_WIDTH node.state.group_height = GROUP_COLLAPSED_HEIGHT return bx, by, bw, bh = self._bbox_of_members(node.item_ids) - has_manual = node.kind == "frame" and ( - node.state.group_manual_width is not None - or node.state.group_manual_height is not None - or node.state.group_manual_x is not None - or node.state.group_manual_y is not None - ) - if has_manual: + # The same `node.kind == "frame" and (...)` test this has always + # made, with the frame re-fetched under its own state type so the + # group_manual_* reads below are checkable. A container never + # reaches the branch, exactly as before - it has no such fields. + frame = optional_node(self.nodes, node_id, "frame", FrameState) + if frame is not None and ( + frame.state.group_manual_width is not None + or frame.state.group_manual_height is not None + or frame.state.group_manual_x is not None + or frame.state.group_manual_y is not None + ): width = ( - node.state.group_manual_width - if node.state.group_manual_width is not None - else (node.state.group_width or bw) + frame.state.group_manual_width + if frame.state.group_manual_width is not None + else (frame.state.group_width or bw) ) height = ( - node.state.group_manual_height - if node.state.group_manual_height is not None - else (node.state.group_height or bh) + frame.state.group_manual_height + if frame.state.group_manual_height is not None + else (frame.state.group_height or bh) ) - if node.state.group_manual_x is not None and node.state.group_manual_y is not None: - anchor_x, anchor_y = node.state.group_manual_x, node.state.group_manual_y + if frame.state.group_manual_x is not None and frame.state.group_manual_y is not None: + anchor_x, anchor_y = frame.state.group_manual_x, frame.state.group_manual_y else: anchor_x = bx + bw / 2.0 - width / 2.0 anchor_y = by + bh / 2.0 - height / 2.0 - node.x, node.y, node.state.group_width, node.state.group_height = self._union_rect( + frame.x, frame.y, frame.state.group_width, frame.state.group_height = self._union_rect( (anchor_x, anchor_y, width, height), (bx, by, bw, bh) ) return @@ -408,11 +424,7 @@ def toggle_frame_lock(self, node_id: str) -> None: drag-suppression concept at the domain-model layer, only at the frontend interaction layer), but keeping the call is cheap and future-proofs a later change to that math.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "frame": - raise SceneError(f"node is not a frame node: {node_id}") + node = require_node(self.nodes, node_id, "frame", FrameState) node.state.is_locked = not node.state.is_locked self._recompute_group_bounds(node_id) @@ -440,11 +452,7 @@ def resize_frame(self, node_id: str, width: float, height: float) -> None: immediately afterward so x/y re-centers on the current member bbox around the new size right away, same posture as toggle_frame_lock's own trailing recompute call.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "frame": - raise SceneError(f"node is not a frame node: {node_id}") + node = require_node(self.nodes, node_id, "frame", FrameState) _, _, min_width, min_height = self._bbox_of_members(node.item_ids) node.state.group_manual_width = max(float(width), min_width) node.state.group_manual_height = max(float(height), min_height) @@ -458,11 +466,7 @@ def fit_frame_to_content(self, node_id: str) -> None: an immediate bbox recompute. The size half is the exact inverse of resize_frame; the position half is what makes this button also undo an independent unlocked-frame drag, not just a resize.""" - node = self.nodes.get(node_id) - if node is None: - raise SceneError(f"unknown node: {node_id}") - if node.kind != "frame": - raise SceneError(f"node is not a frame node: {node_id}") + node = require_node(self.nodes, node_id, "frame", FrameState) node.state.group_manual_width = None node.state.group_manual_height = None node.state.group_manual_x = None diff --git a/backend/domain/layout.py b/backend/domain/layout.py index 00821ac..2c5516f 100644 --- a/backend/domain/layout.py +++ b/backend/domain/layout.py @@ -34,6 +34,9 @@ SceneNode, ) +from backend.domain.node_access import is_node_of +from backend.domain.node_states import ChartState, GroupSizedState + from backend.domain._composed import SceneDocumentParts # Clearance between neighbouring nodes. Horizontal is a little wider than @@ -113,9 +116,9 @@ def node_footprint(self, node: SceneNode) -> tuple[float, float]: measured = self.measured_sizes.get(node.id) width, height = measured if measured is not None else (None, None) if not (width and width > 0 and height and height > 0): - if node.kind == "chart" and node.state is not None: + if is_node_of(node, "chart", ChartState): width, height = node.state.chart_width, node.state.chart_height - elif node.kind in ("frame", "container") and node.state is not None: + elif is_node_of(node, ("frame", "container"), GroupSizedState): width, height = node.state.group_width, node.state.group_height if not (width and width > 0) or not (height and height > 0): fw, fh = KIND_FALLBACK_FOOTPRINTS.get(node.kind, DEFAULT_FALLBACK_FOOTPRINT) @@ -382,9 +385,10 @@ def find_cluster(group_id: str) -> str: def root_owner_key(nid: str) -> tuple[str, int]: fid, cid = frame_of.get(nid), container_of.get(nid) - if fid is None and cid is None: + owner = fid if fid is not None else cid + if owner is None: return "", 0 - cluster = find_cluster(fid if fid is not None else cid) + cluster = find_cluster(owner) # Within one cluster: container-only members first, the hinge # (dual membership) in the middle, frame-only members last - # for the common two-group cluster this puts each group's own @@ -445,8 +449,9 @@ def root_owner_key(nid: str) -> tuple[str, int]: (e for e in self.edges.values() if e.target == node.id), None, ) if parent_edge is not None and parent_edge.source in self.nodes: - parent = self.nodes[parent_edge.source] - node.x, node.y = parent.x, parent.y + # Not `parent`: that name is a str elsewhere in this module. + parent_node = self.nodes[parent_edge.source] + node.x, node.y = parent_node.x, parent_node.y self._organize_groups() diff --git a/backend/domain/node_access.py b/backend/domain/node_access.py index e06e0e8..5bbb92c 100644 --- a/backend/domain/node_access.py +++ b/backend/domain/node_access.py @@ -26,7 +26,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeGuard, TypeVar from backend.domain.model import SceneError, SceneNode from backend.domain.node_states import NodeState @@ -43,8 +43,15 @@ class _NodeWith(SceneNode, Generic[_S]): state: _S # type: ignore[assignment] +def _kind_names(kind: str | tuple[str, ...]) -> tuple[str, ...]: + """One kind or several, as a tuple. `" or ".join` of a single-element + tuple is that element, so the one-kind error message is unchanged.""" + return (kind,) if isinstance(kind, str) else kind + + def require_node( - nodes: dict[str, SceneNode], node_id: str, kind: str, state_cls: type[_S], + nodes: dict[str, SceneNode], node_id: str, kind: str | tuple[str, ...], + state_cls: type[_S], ) -> _NodeWith[_S]: """The node with `node_id`, guaranteed to exist and to be `kind`. @@ -60,13 +67,15 @@ def require_node( node = nodes.get(node_id) if node is None: raise SceneError(f"unknown node: {node_id}") - if node.kind != kind: - raise SceneError(f"node is not a {kind} node: {node_id}") + kinds = _kind_names(kind) + if node.kind not in kinds: + raise SceneError(f"node is not a {' or '.join(kinds)} node: {node_id}") return node # type: ignore[return-value] def optional_node( - nodes: dict[str, SceneNode], node_id: str, kind: str, state_cls: type[_S], + nodes: dict[str, SceneNode], node_id: str, kind: str | tuple[str, ...], + state_cls: type[_S], ) -> "_NodeWith[_S] | None": """require_node's silent sibling: None instead of a raise. @@ -76,6 +85,35 @@ def optional_node( for the fields they set on the way through. """ node = nodes.get(node_id) - if node is None or node.kind != kind: + if node is None or node.kind not in _kind_names(kind): return None return node # type: ignore[return-value] + + +def is_node_of( + node: SceneNode | None, kind: str | tuple[str, ...], state_cls: type[_S], +) -> TypeGuard["_NodeWith[_S]"]: + """require_node's form for a node you already hold. + + The group-geometry code iterates `self.nodes.values()` and skips + everything that is not a frame or a container; there is no id to look + up a second time, and doing so to get the narrowing would be a dict + read bought purely to satisfy a checker. This narrows the node in hand + instead. + + TypeGuard, not TypeIs, so that the 3.10 floor this repo declares 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(...)` rather than `if not ...: continue`. + + Unlike require_node this also rejects a node with no state at all. Its + callers hold a node that reached them from somewhere - a dict of every + node in the scene, a caller's own parameter - rather than one their + kind's constructor just made, and every hand-written check it replaces + carried the same `and node.state is not None` conjunct. + """ + return ( + node is not None + and node.kind in _kind_names(kind) + and node.state is not None + ) diff --git a/backend/domain/node_states.py b/backend/domain/node_states.py index b654df0..3e1c84b 100644 --- a/backend/domain/node_states.py +++ b/backend/domain/node_states.py @@ -279,7 +279,31 @@ class ChartState(NodeState): @dataclass -class FrameState(NodeState): +class GroupSizedState(NodeState): + """The two fields frame and container genuinely share. + + FrameState's docstring explains 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. It was never + about group_width/group_height, which the wire contract names for both + kinds and which every piece of group geometry reads for both. + + Naming that overlap in one place is what lets the group-geometry code + be type-checked: `_recompute_group_bounds` and its callers look a node + up, confirm it is a frame or a container, and then read + `.state.group_width` - which no single per-kind class could describe. + Nothing about the wire payload, the persisted shape or either kind's + field set changes; the fields are declared one level up. + """ + + group_width: float | None = None + group_height: float | None = None + + +@dataclass +class FrameState(GroupSizedState): """Relocated verbatim from SceneNode's frame-only fields (former backend/domain/model.py fields, R6.1). @@ -325,32 +349,29 @@ class FrameState(NodeState): round-trip without the collapsed-pill overwrite (this pair) destroying it. - NOT shared with ContainerState despite group_width/group_height being - common to both: is_locked/group_manual_* are explicitly meaningless - for containers (no lock concept, no manual-resize capability - no - resize_container method exists), so forcing them onto a shared base - would resurrect the exact "every kind carries fields it never uses" - problem this migration exists to remove.""" + NOT the same class as ContainerState: is_locked/group_manual_* are + explicitly meaningless for containers (no lock concept, no + manual-resize capability - no resize_container method exists), so + forcing them onto a shared base would resurrect the exact "every kind + carries fields it never uses" problem this migration exists to remove. + group_width/group_height ARE common to both, and are declared on the + GroupSizedState base both inherit - see its own docstring.""" is_locked: bool = True group_manual_width: float | None = None group_manual_height: float | None = None group_manual_x: float | None = None group_manual_y: float | None = None - group_width: float | None = None - group_height: float | None = None @dataclass -class ContainerState(NodeState): +class ContainerState(GroupSizedState): """Relocated verbatim from SceneNode's group_width/group_height fields (former backend/domain/model.py fields, R6.1), as they apply to container kind specifically - see FrameState's own docstring for why - this is a separate class rather than a shared base with FrameState, - despite the field-name overlap.""" - - group_width: float | None = None - group_height: float | None = None + this is a separate class from FrameState rather than the same one, + despite the field-name overlap. The two fields themselves are declared + on GroupSizedState, which both inherit.""" @dataclass diff --git a/pyproject.toml b/pyproject.toml index 7488174..4ed7ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -356,40 +356,22 @@ files = [ "backend/events.py", "backend/token_counter.py", "graphlink_process_env.py", - # 2026-09-04, second widening: the first three backend/domain/ entries. + # 2026-09-04: backend/domain/, the whole package. # # The blocker was never annotation effort - it was that SceneNode.state is # typed `NodeState | None` against a field-less marker, so every per-kind # field access was unverifiable. backend/domain/node_access.py's - # require_node narrows the NODE, which + # require_node/optional_node/is_node_of narrow the NODE, which # tests/test_node_state_migration.py permits (it constrains the ACCESS # SHAPE, `.state.`, not how the node was obtained) where - # aliasing the state does not. These three modules check clean because of - # it; the rest of backend/domain/ follows as its per-kind groups are - # extracted the same way. - "backend/domain/node_access.py", - "backend/domain/nodes_code_review.py", - "backend/domain/nodes_gitlink.py", - # 2026-09-04, third widening: the rest of backend/domain/'s per-kind - # groups, once the last thirteen kinds were extracted out of - # SceneDocument the same way, plus the four leaf modules they sit on. + # aliasing the state does not. # - # 13 of the 18 modules in backend/domain/ are now checked. The five that - # are not are graph.py itself and the four cross-cutting mixins - # (groups/branches/commands/layout), which hold 124 errors between them - # of the same union-attr shape - they read per-kind state off nodes they - # look up generically, so require_node cannot narrow them without a kind - # to narrow to. That is the next piece of work, not a gap being waved - # through. - "backend/domain/_composed.py", - "backend/domain/content_codec.py", - "backend/domain/model.py", - "backend/domain/node_states.py", - "backend/domain/nodes_agent_runs.py", - "backend/domain/nodes_content.py", - "backend/domain/nodes_conversational.py", - "backend/domain/nodes_planning.py", - "backend/domain/nodes_visual.py", + # Widened in three steps as the per-kind method groups came out of + # SceneDocument: three modules, then thirteen, then all eighteen once the + # four cross-cutting mixins were narrowed too. That last group could not + # use require_node directly - they read per-kind state off nodes they hold + # rather than look up by id, which is what is_node_of is for. + "backend/domain", # 2026-09-04: the first widening since this list was written, and the # ratchet moving the direction its own comment asks for. #