"""Projected proposal semantics and deterministic review diffs.""" from __future__ import annotations import difflib from dataclasses import replace from pathlib import Path from typing import Any from .changeset_contract import ( METADATA_KEYS, REQUIRED_CREATE_METADATA, canonical_bytes, edge_dict, edge_key, node_metadata, validate_hash, ) from .errors import DocForgeError from .models import Edge, Node, ProjectSnapshot, ProposalWriter from .project import Project, validate_graph, validated_node_from_record class ProposalProjector: """Apply validated operations to an in-memory graph without canonical writes.""" def __init__(self, project: Project) -> None: self.project_service = project def project( self, snapshot: ProjectSnapshot, document: dict[str, Any] ) -> tuple[dict[str, Node], set[tuple[str, str, str]]]: writer = self.writer(document) nodes = {node.node_id: node for node in snapshot.nodes} edges = {(edge.source_id, edge.relation, edge.target_id) for edge in snapshot.edges} for operation in document["operations"]: self.apply_operation(snapshot, nodes, edges, operation, writer) ordered_nodes = tuple(sorted(nodes.values(), key=lambda node: node.node_id)) ordered_edges = tuple(Edge(*edge) for edge in sorted(edges)) validate_graph(ordered_nodes, ordered_edges) self._validate_source_layout(ordered_nodes) if len(ordered_nodes) > snapshot.descriptor.limits.max_nodes: raise DocForgeError("node_limit", "Projected project exceeds configured node limit") node_ids = set(nodes) for profile in snapshot.descriptor.profiles: missing = sorted(set(profile.required_nodes) - node_ids) if missing: raise DocForgeError( "required_node_delete", "Changeset removes nodes required by a context profile", profile=profile.profile_id, nodes=missing, ) return nodes, edges def apply_operation( self, snapshot: ProjectSnapshot, nodes: dict[str, Node], edges: set[tuple[str, str, str]], operation: dict[str, Any], writer: ProposalWriter, ) -> None: kind = operation["operation"] node_id = operation["node_id"] if kind not in writer.operations: raise DocForgeError( "operation_forbidden", "Writer cannot propose this operation", operation=kind ) current = nodes.get(node_id) if kind == "create": self._apply_create(snapshot, nodes, edges, operation, writer, current) return if current is None: raise DocForgeError("missing_node", "Operation target does not exist", node_id=node_id) expected_hash = operation["expected_content_hash"] validate_hash(expected_hash, "expected_content_hash") if expected_hash != current.content_hash: raise DocForgeError( "content_conflict", "Canonical node changed after the proposal was prepared", node_id=node_id, expected=expected_hash, actual=current.content_hash, ) if kind == "update": self._apply_update(snapshot, nodes, edges, operation, writer, current) return if kind == "move": self._apply_move(nodes, operation, writer, current) return if kind == "delete": self._apply_delete(nodes, edges, operation, writer, current) return raise DocForgeError("invalid_operation", "Unknown operation type", operation=kind) def _apply_create( self, snapshot: ProjectSnapshot, nodes: dict[str, Node], edges: set[tuple[str, str, str]], operation: dict[str, Any], writer: ProposalWriter, current: Node | None, ) -> None: node_id = operation["node_id"] if current is not None: raise DocForgeError("node_exists", "Create target already exists", node_id=node_id) if operation["expected_content_hash"] is not None: raise DocForgeError("invalid_operation", "Create expected_content_hash must be null") metadata = operation["metadata"] if not isinstance(metadata, dict) or not REQUIRED_CREATE_METADATA.issubset(metadata): raise DocForgeError( "invalid_operation", "Create requires complete node metadata", node_id=node_id ) if operation["content"] is None: raise DocForgeError("invalid_operation", "Create requires content", node_id=node_id) target = self._target_source(operation["target_source"]) self._check_source_available(nodes, target) if Path(target).suffix == ".toml" and not metadata.get("source_anchor"): raise DocForgeError( "source_anchor_required", "TOML node creation requires an explicit source_anchor", node_id=node_id, ) changes = operation["relationship_changes"] if any(change["action"] != "add" or change["source_id"] != node_id for change in changes): raise DocForgeError( "invalid_operation", "Create relationships must add edges from the new node" ) self._apply_relationship_changes(edges, changes) node = self._build_node( snapshot, node_id=node_id, metadata=metadata, content=operation["content"], source_path=target, edges=edges, ) self._require_families(writer, {node.family}) nodes[node_id] = node def _apply_update( self, snapshot: ProjectSnapshot, nodes: dict[str, Node], edges: set[tuple[str, str, str]], operation: dict[str, Any], writer: ProposalWriter, current: Node, ) -> None: if operation["target_source"] is not None: raise DocForgeError("invalid_operation", "Update cannot move a node") metadata = operation["metadata"] if metadata is not None and not isinstance(metadata, dict): raise DocForgeError("invalid_operation", "Update metadata must be an object or null") if ( metadata is None and operation["content"] is None and not operation["relationship_changes"] ): raise DocForgeError("empty_operation", "Update does not change anything") changes = operation["relationship_changes"] if any(change["source_id"] != current.node_id for change in changes): raise DocForgeError( "invalid_operation", "Update may change only relationships owned by its node" ) self._apply_relationship_changes(edges, changes) merged = {**node_metadata(current), **(metadata or {})} content = current.content if operation["content"] is None else operation["content"] node = self._build_node( snapshot, node_id=current.node_id, metadata=merged, content=content, source_path=current.source_path, edges=edges, ) self._require_families(writer, {current.family, node.family}) nodes[current.node_id] = node def _apply_move( self, nodes: dict[str, Node], operation: dict[str, Any], writer: ProposalWriter, current: Node, ) -> None: if operation["metadata"] is not None or operation["content"] is not None: raise DocForgeError("invalid_operation", "Move cannot change metadata or content") if operation["relationship_changes"]: raise DocForgeError("invalid_operation", "Move cannot change relationships") target = self._target_source(operation["target_source"]) if target == current.source_path: raise DocForgeError("empty_operation", "Move target matches the current source") if Path(target).suffix != Path(current.source_path).suffix: raise DocForgeError( "format_change_forbidden", "Move must preserve the canonical source format" ) self._check_source_available(nodes, target, excluding=current.node_id) self._require_families(writer, {current.family}) nodes[current.node_id] = replace(current, source_path=target) def _apply_delete( self, nodes: dict[str, Node], edges: set[tuple[str, str, str]], operation: dict[str, Any], writer: ProposalWriter, current: Node, ) -> None: if ( operation["target_source"] is not None or operation["metadata"] is not None or operation["content"] is not None ): raise DocForgeError("invalid_operation", "Delete accepts only edge removals") incident = {edge for edge in edges if current.node_id in (edge[0], edge[2])} supplied = { edge_key(change) for change in operation["relationship_changes"] if change["action"] == "remove" } if any(change["action"] != "remove" for change in operation["relationship_changes"]): raise DocForgeError("invalid_operation", "Delete relationships must be removals") if supplied != incident or len(supplied) != len(operation["relationship_changes"]): raise DocForgeError( "unresolved_relationships", "Delete must remove every incident relationship exactly once", required=[edge_dict(edge, action="remove") for edge in sorted(incident)], ) touched_families = {current.family} touched_families.update(nodes[edge[0]].family for edge in incident if edge[0] in nodes) self._require_families(writer, touched_families) self._apply_relationship_changes(edges, operation["relationship_changes"]) del nodes[current.node_id] def _build_node( self, snapshot: ProjectSnapshot, *, node_id: str, metadata: dict[str, Any], content: str, source_path: str, edges: set[tuple[str, str, str]], ) -> Node: unknown = sorted(set(metadata) - METADATA_KEYS) if unknown: raise DocForgeError( "invalid_operation", "Node metadata has unknown fields", fields=unknown ) record: dict[str, Any] = {"schema_version": 1, "id": node_id, **metadata} for relation in snapshot.descriptor.allowed_relations: targets = sorted( edge[2] for edge in edges if edge[0] == node_id and edge[1] == relation ) if targets: record[relation] = targets logical = canonical_bytes( {"record": record, "content": content, "source_path": source_path} ) node, _ = validated_node_from_record( record, content=content.strip(), source=snapshot.descriptor.root / source_path, relative_source=source_path, relations=snapshot.descriptor.allowed_relations, hash_bytes=logical, ) return node def _apply_relationship_changes( self, edges: set[tuple[str, str, str]], changes: list[dict[str, Any]] ) -> None: for change in changes: edge = edge_key(change) if change["relation"] not in self.project_service.descriptor.allowed_relations: raise DocForgeError( "invalid_relation", "Relationship type is not allowed", relation=change["relation"], ) if change["action"] == "add": if edge in edges: raise DocForgeError("duplicate_edge", "Relationship already exists", edge=edge) edges.add(edge) else: if edge not in edges: raise DocForgeError("missing_edge", "Relationship does not exist", edge=edge) edges.remove(edge) def touches( self, document: dict[str, Any], snapshot: ProjectSnapshot ) -> tuple[set[str], set[str]]: base_nodes = {node.node_id: node for node in snapshot.nodes} nodes: set[str] = set() sources: set[str] = set() for operation in document["operations"]: node_id = operation["node_id"] nodes.add(node_id) current = base_nodes.get(node_id) if current is not None: sources.add(current.source_path) if operation["target_source"] is not None: sources.add(operation["target_source"]) for change in operation["relationship_changes"]: nodes.update((change["source_id"], change["target_id"])) return nodes, sources def _target_source(self, raw: Any) -> str: if not isinstance(raw, str) or not raw: raise DocForgeError("invalid_operation", "target_source must be a relative path") relative = Path(raw) if ( relative.is_absolute() or ".." in relative.parts or relative.suffix not in {".md", ".toml"} ): raise DocForgeError( "invalid_target_source", "Target source must be confined Markdown or TOML", path=raw ) root = self.project_service.descriptor.root target = (root / relative).resolve(strict=False) if not target.is_relative_to(root) or not any( target.is_relative_to(content_root) for content_root in self.project_service.descriptor.content_roots ): raise DocForgeError( "invalid_target_source", "Target source must be inside a canonical content root" ) return target.relative_to(root).as_posix() @staticmethod def _check_source_available( nodes: dict[str, Node], target: str, *, excluding: str | None = None ) -> None: if Path(target).suffix != ".md": return occupants = sorted( node.node_id for node in nodes.values() if node.source_path == target and node.node_id != excluding ) if occupants: raise DocForgeError( "source_conflict", "Markdown sources may contain only one node", nodes=occupants ) @staticmethod def _validate_source_layout(nodes: tuple[Node, ...]) -> None: markdown_sources: dict[str, list[str]] = {} anchors: dict[tuple[str, str], list[str]] = {} for node in nodes: if Path(node.source_path).suffix == ".md": markdown_sources.setdefault(node.source_path, []).append(node.node_id) continue if not node.source_anchor: raise DocForgeError( "source_anchor_required", "TOML nodes require a stable source anchor", node_id=node.node_id, ) anchors.setdefault((node.source_path, node.source_anchor), []).append(node.node_id) markdown_conflicts = { source: sorted(node_ids) for source, node_ids in markdown_sources.items() if len(node_ids) > 1 } if markdown_conflicts: raise DocForgeError( "source_conflict", "Markdown sources may contain only one node", sources=markdown_conflicts, ) anchor_conflicts = [ {"source": source, "source_anchor": anchor, "nodes": sorted(node_ids)} for (source, anchor), node_ids in sorted(anchors.items()) if len(node_ids) > 1 ] if anchor_conflicts: raise DocForgeError( "source_anchor_conflict", "TOML source anchors must be unique within their source", conflicts=anchor_conflicts, ) @staticmethod def operation_diff( operation: dict[str, Any], before: Node | None, after: Node | None, added_edges: list[tuple[str, str, str]], removed_edges: list[tuple[str, str, str]], ) -> dict[str, object]: before_metadata = node_metadata(before) if before is not None else None after_metadata = node_metadata(after) if after is not None else None metadata_changes: dict[str, object] = {} for key in sorted(METADATA_KEYS): old = before_metadata.get(key) if before_metadata is not None else None new = after_metadata.get(key) if after_metadata is not None else None if old != new: metadata_changes[key] = {"before": old, "after": new} before_content = before.content if before is not None else "" after_content = after.content if after is not None else "" before_path = before.source_path if before is not None else "/dev/null" after_path = after.source_path if after is not None else "/dev/null" content_diff = "\n".join( difflib.unified_diff( before_content.splitlines(), after_content.splitlines(), fromfile=before_path, tofile=after_path, lineterm="", ) ) return { "sequence": operation["sequence"], "operation": operation["operation"], "node_id": operation["node_id"], "rationale": operation["rationale"], "source": {"before": before_path, "after": after_path}, "metadata": metadata_changes, "content_diff": content_diff, "relationships": { "added": [edge_dict(edge, action="add") for edge in added_edges], "removed": [edge_dict(edge, action="remove") for edge in removed_edges], }, "before_content_hash": before.content_hash if before is not None else None, "proposed_content_hash": after.content_hash if after is not None else None, } def writer(self, document: dict[str, Any]) -> ProposalWriter: writers = { writer.writer_id: writer for writer in self.project_service.descriptor.proposal_writers } return writers[document["creator"]] @staticmethod def _require_families(writer: ProposalWriter, families: set[str]) -> None: forbidden = sorted(families - set(writer.families)) if forbidden: raise DocForgeError( "family_forbidden", "Writer cannot propose changes to these node families", writer=writer.writer_id, families=forbidden, )