449 lines
18 KiB
Python
449 lines
18 KiB
Python
|
|
"""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,
|
||
|
|
)
|