1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tests/test_changesets.py

559 lines
26 KiB
Python
Raw Normal View History

2026-07-22 02:58:51 -04:00
from __future__ import annotations
import hashlib
import json
import multiprocessing
import shutil
import tempfile
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
from docforge.changesets import ChangesetStore
from docforge.errors import DocForgeError
from docforge.project import Project
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
def _append_in_process(
root: str,
node_id: str,
expected_changeset_hash: str,
expected_content_hash: str,
start: Any,
result: Any,
) -> None:
"""Attempt one append in an independent process and report its stable outcome."""
start.wait()
try:
ChangesetStore(Project.open(root), "alpha-editor").propose_update(
changeset_id="simultaneous",
expected_changeset_hash=expected_changeset_hash,
node_id=node_id,
expected_content_hash=expected_content_hash,
metadata={"summary": f"Concurrent proposal for {node_id}."},
content=None,
relationship_changes=[],
rationale="Prove process-safe optimistic serialization.",
)
except DocForgeError as error:
result.send(error.code)
else:
result.send("ok")
finally:
result.close()
class DocForgeChangesetTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
root = destination / "alpha"
shutil.copytree(FIXTURES / "alpha", root)
return root
def canonical_digest(self, root: Path) -> str:
paths = [
root / ".docforge" / "project.toml",
root / "POLICY.md",
*(root / "docs" / "content").glob("*"),
]
digest = hashlib.sha256()
for path in sorted((path for path in paths if path.is_file()), key=lambda item: str(item)):
digest.update(path.relative_to(root).as_posix().encode())
digest.update(path.read_bytes())
return digest.hexdigest()
@staticmethod
def node_hash(project: Project, node_id: str) -> str:
return next(node.content_hash for node in project.load().nodes if node.node_id == node_id)
@staticmethod
def new_metadata(*, family: str = "guide") -> dict[str, object]:
return {
"title": "New proposal node",
"family": family,
"authority": "proposal",
"status": "active",
"tags": ["proposal", "test"],
"summary": "Exercises an isolated structured creation proposal.",
}
def test_all_four_operations_validate_and_never_change_canonical_sources(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
create_root = self.copy_fixture(parent / "create")
create_before = self.canonical_digest(create_root)
create_store = ChangesetStore(Project.open(create_root), "alpha-editor")
created = create_store.create("create-node")
proposed = create_store.propose_create(
changeset_id="create-node",
expected_changeset_hash=created["changeset_hash"],
node_id="guide.new-node",
target_source="docs/content/new-node.md",
metadata=self.new_metadata(),
content="This content exists only inside the proposal.",
relationship_changes=[
{
"action": "add",
"source_id": "guide.new-node",
"relation": "depends_on",
"target_id": "guide.foundation",
}
],
rationale="Add one independently reviewable guide node.",
)
self.assertEqual(1, proposed["operation_count"])
self.assertEqual(create_before, self.canonical_digest(create_root))
self.assertFalse((create_root / "docs/content/new-node.md").exists())
create_diff = create_store.diff("create-node")
self.assertEqual("create", create_diff["changes"][0]["operation"])
self.assertIn("proposal", create_diff["changes"][0]["content_diff"])
self.assertEqual(create_diff, create_store.diff("create-node"))
update_root = self.copy_fixture(parent / "update")
update_before = self.canonical_digest(update_root)
update_project = Project.open(update_root)
update_store = ChangesetStore(update_project, "alpha-editor")
update_changeset = update_store.create("update-node")
updated = update_store.propose_update(
changeset_id="update-node",
expected_changeset_hash=update_changeset["changeset_hash"],
node_id="guide.workflow",
expected_content_hash=self.node_hash(update_project, "guide.workflow"),
metadata={"summary": "A proposed replacement workflow summary."},
content="Proposed workflow content that is not canonical yet.",
relationship_changes=[],
rationale="Replace the workflow explanation without applying it.",
)
self.assertEqual(3, updated["projected_node_count"])
update_diff = update_store.diff("update-node")["changes"][0]
self.assertEqual("update", update_diff["operation"])
self.assertIn("summary", update_diff["metadata"])
self.assertEqual(update_before, self.canonical_digest(update_root))
move_root = self.copy_fixture(parent / "move")
move_before = self.canonical_digest(move_root)
move_project = Project.open(move_root)
move_store = ChangesetStore(move_project, "alpha-editor")
move_changeset = move_store.create("move-node")
move_store.propose_move(
changeset_id="move-node",
expected_changeset_hash=move_changeset["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=self.node_hash(move_project, "guide.foundation"),
target_source="docs/content/foundation-moved.md",
rationale="Move the node while preserving its stable identity.",
)
move_diff = move_store.diff("move-node")["changes"][0]
self.assertEqual(
{
"before": "docs/content/foundation.md",
"after": "docs/content/foundation-moved.md",
},
move_diff["source"],
)
self.assertEqual(move_before, self.canonical_digest(move_root))
delete_root = self.copy_fixture(parent / "delete")
delete_before = self.canonical_digest(delete_root)
delete_project = Project.open(delete_root)
delete_store = ChangesetStore(delete_project, "alpha-editor")
delete_changeset = delete_store.create("delete-node")
deleted = delete_store.propose_delete(
changeset_id="delete-node",
expected_changeset_hash=delete_changeset["changeset_hash"],
node_id="proof.validation",
expected_content_hash=self.node_hash(delete_project, "proof.validation"),
relationship_changes=[
{
"action": "remove",
"source_id": "proof.validation",
"relation": "proves",
"target_id": "guide.workflow",
}
],
rationale="Remove the proof node and its owned relationship in one proposal.",
)
self.assertEqual(2, deleted["projected_node_count"])
self.assertEqual("delete", delete_store.diff("delete-node")["changes"][0]["operation"])
self.assertEqual(delete_before, self.canonical_digest(delete_root))
def test_optimistic_and_cross_changeset_conflicts_preserve_both_proposals(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
first = store.create("first")
second = store.create("second")
foundation_hash = self.node_hash(project, "guide.foundation")
first_result = store.propose_update(
changeset_id="first",
expected_changeset_hash=first["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=foundation_hash,
metadata={"summary": "First proposal owns this node."},
content=None,
relationship_changes=[],
rationale="Claim the foundation node in the first proposal.",
)
with self.assertRaisesRegex(DocForgeError, "overlaps another proposal") as conflict:
store.propose_update(
changeset_id="second",
expected_changeset_hash=second["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=foundation_hash,
metadata={"summary": "Second proposal conflicts with the first."},
content=None,
relationship_changes=[],
rationale="Prove exact overlap detection.",
)
self.assertEqual("proposal_conflict", conflict.exception.code)
self.assertEqual("first", conflict.exception.details["conflicts"][0]["changeset_id"])
self.assertEqual(0, store.validate("second")["operation_count"])
with self.assertRaisesRegex(DocForgeError, "changed after") as stale_hash:
store.propose_create(
changeset_id="first",
expected_changeset_hash=first["changeset_hash"],
node_id="guide.another",
target_source="docs/content/another.md",
metadata=self.new_metadata(),
content="Another proposal operation.",
relationship_changes=[],
rationale="Use an obsolete changeset hash.",
)
self.assertEqual("changeset_conflict", stale_hash.exception.code)
self.assertEqual(
first_result["changeset_hash"], store.validate("first")["changeset_hash"]
)
def test_simultaneous_process_appends_serialize_without_losing_an_operation(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
initial = ChangesetStore(project, "alpha-editor").create("simultaneous")
hashes = {
node.node_id: node.content_hash
for node in project.load().nodes
if node.node_id in {"guide.foundation", "guide.workflow"}
}
context = multiprocessing.get_context("spawn")
start = context.Event()
receivers: list[Any] = []
processes: list[multiprocessing.Process] = []
for node_id in ("guide.foundation", "guide.workflow"):
receiver, sender = context.Pipe(duplex=False)
process = context.Process(
target=_append_in_process,
args=(
str(root),
node_id,
initial["changeset_hash"],
hashes[node_id],
start,
sender,
),
)
process.start()
sender.close()
receivers.append(receiver)
processes.append(process)
start.set()
results = sorted(receiver.recv() for receiver in receivers)
for receiver in receivers:
receiver.close()
for process in processes:
process.join(timeout=10)
self.assertEqual(0, process.exitcode)
self.assertEqual(["changeset_conflict", "ok"], results)
validated = ChangesetStore(project).validate("simultaneous")
self.assertEqual(1, validated["operation_count"])
def test_configured_changeset_count_operation_and_size_limits_fail_atomically(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
count_root = self.copy_fixture(parent / "count")
descriptor = count_root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8")
.replace("max_changesets = 20", "max_changesets = 1")
.replace("max_changeset_operations = 20", "max_changeset_operations = 1"),
encoding="utf-8",
)
count_project = Project.open(count_root)
count_store = ChangesetStore(count_project, "alpha-editor")
limited = count_store.create("limited")
with self.assertRaisesRegex(DocForgeError, "changeset limit") as count_error:
count_store.create("excess")
self.assertEqual("changeset_limit", count_error.exception.code)
self.assertFalse((count_root / ".docforge/changesets/excess.json").exists())
first = count_store.propose_update(
changeset_id="limited",
expected_changeset_hash=limited["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=self.node_hash(count_project, "guide.foundation"),
metadata={"summary": "The only permitted operation."},
content=None,
relationship_changes=[],
rationale="Reach the configured operation limit.",
)
limited_path = count_root / ".docforge/changesets/limited.json"
limited_bytes = limited_path.read_bytes()
with self.assertRaisesRegex(DocForgeError, "operation limit") as operation_error:
count_store.propose_update(
changeset_id="limited",
expected_changeset_hash=first["changeset_hash"],
node_id="guide.workflow",
expected_content_hash=self.node_hash(count_project, "guide.workflow"),
metadata={"summary": "This operation exceeds the limit."},
content=None,
relationship_changes=[],
rationale="Prove the configured operation cap.",
)
self.assertEqual("changeset_operation_limit", operation_error.exception.code)
self.assertEqual(limited_bytes, limited_path.read_bytes())
size_root = self.copy_fixture(parent / "size")
size_descriptor = size_root / ".docforge/project.toml"
size_descriptor.write_text(
size_descriptor.read_text(encoding="utf-8").replace(
"max_changeset_bytes = 100000", "max_changeset_bytes = 64"
),
encoding="utf-8",
)
size_store = ChangesetStore(Project.open(size_root), "alpha-editor")
with self.assertRaisesRegex(DocForgeError, "size limit") as size_error:
size_store.create("too-large")
self.assertEqual("changeset_too_large", size_error.exception.code)
self.assertFalse((size_root / ".docforge/changesets/too-large.json").exists())
def test_stale_base_bad_content_hash_and_failed_delete_are_atomic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
stale_root = self.copy_fixture(parent / "stale")
stale_project = Project.open(stale_root)
stale_store = ChangesetStore(stale_project, "alpha-editor")
stale = stale_store.create("stale")
stale_path = stale_root / ".docforge/changesets/stale.json"
stale_bytes = stale_path.read_bytes()
workflow = stale_root / "docs/content/workflow.md"
workflow.write_text(workflow.read_text() + "\nCanonical change.\n", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "Canonical project changed") as base:
stale_store.propose_move(
changeset_id="stale",
expected_changeset_hash=stale["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=self.node_hash(
Project.open(stale_root), "guide.foundation"
),
target_source="docs/content/foundation-moved.md",
rationale="This proposal has an obsolete base.",
)
self.assertEqual("base_conflict", base.exception.code)
self.assertEqual(stale_bytes, stale_path.read_bytes())
self.assertEqual("stale", stale_store.inspect("stale")["base_state"])
self.assertEqual("stale", stale_store.list_changesets()["changesets"][0]["base_state"])
delete_root = self.copy_fixture(parent / "delete")
delete_project = Project.open(delete_root)
delete_store = ChangesetStore(delete_project, "alpha-editor")
delete = delete_store.create("delete")
delete_path = delete_root / ".docforge/changesets/delete.json"
delete_bytes = delete_path.read_bytes()
with self.assertRaisesRegex(DocForgeError, "every incident relationship") as edges:
delete_store.propose_delete(
changeset_id="delete",
expected_changeset_hash=delete["changeset_hash"],
node_id="proof.validation",
expected_content_hash=self.node_hash(delete_project, "proof.validation"),
relationship_changes=[],
rationale="An incomplete delete must not be stored.",
)
self.assertEqual("unresolved_relationships", edges.exception.code)
self.assertEqual(delete_bytes, delete_path.read_bytes())
with self.assertRaisesRegex(DocForgeError, "changed after"):
delete_store.propose_update(
changeset_id="delete",
expected_changeset_hash=delete["changeset_hash"],
node_id="guide.foundation",
expected_content_hash="0" * 64,
metadata={"summary": "Wrong base hash."},
content=None,
relationship_changes=[],
rationale="Reject a mismatched node hash.",
)
self.assertEqual(delete_bytes, delete_path.read_bytes())
def test_writer_family_permissions_ownership_and_paths_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
descriptor = root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text().replace(
'families = ["guide", "proof"]', 'families = ["guide"]'
)
+ """
[[changesets.writers]]
id = "other-editor"
families = ["guide"]
operations = ["create", "update", "move", "delete"]
""",
encoding="utf-8",
)
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
changeset = store.create("permissions")
path = root / ".docforge/changesets/permissions.json"
before = path.read_bytes()
with self.assertRaisesRegex(DocForgeError, "families") as family:
store.propose_create(
changeset_id="permissions",
expected_changeset_hash=changeset["changeset_hash"],
node_id="proof.new",
target_source="docs/content/proof-new.md",
metadata=self.new_metadata(family="proof"),
content="A forbidden family proposal.",
relationship_changes=[],
rationale="Prove family permission enforcement.",
)
self.assertEqual("family_forbidden", family.exception.code)
self.assertEqual(before, path.read_bytes())
other = ChangesetStore(project, "other-editor")
with self.assertRaisesRegex(DocForgeError, "does not own") as owner:
other.propose_create(
changeset_id="permissions",
expected_changeset_hash=changeset["changeset_hash"],
node_id="guide.other",
target_source="docs/content/other.md",
metadata=self.new_metadata(),
content="Another writer cannot append here.",
relationship_changes=[],
rationale="Prove immutable changeset ownership.",
)
self.assertEqual("changeset_owner_conflict", owner.exception.code)
with self.assertRaisesRegex(DocForgeError, "canonical content root"):
store.propose_create(
changeset_id="permissions",
expected_changeset_hash=changeset["changeset_hash"],
node_id="guide.escape",
target_source="outside.md",
metadata=self.new_metadata(),
content="This path is outside the content root.",
relationship_changes=[],
rationale="Prove target confinement.",
)
outside = Path(directory) / "outside.json"
outside.write_text(json.dumps({"secret": True}), encoding="utf-8")
symlink = root / ".docforge/changesets/symlink.json"
symlink.symlink_to(outside)
with self.assertRaisesRegex(DocForgeError, "symbolic links"):
store.validate("symlink")
self.assertEqual({"secret": True}, json.loads(outside.read_text()))
def test_changeset_configuration_overlap_and_invalid_permissions_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
overlap_root = self.copy_fixture(parent / "overlap")
overlap = overlap_root / ".docforge/project.toml"
overlap.write_text(
overlap.read_text().replace(
'root = ".docforge/changesets"', 'root = "docs/content/changesets"'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "must not overlap"):
Project.open(overlap_root)
invalid_root = self.copy_fixture(parent / "invalid")
invalid = invalid_root / ".docforge/project.toml"
invalid.write_text(
invalid.read_text().replace(
'operations = ["create", "update", "move", "delete"]',
'operations = ["create", "execute"]',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "operations are empty or invalid"):
Project.open(invalid_root)
symlink_root = self.copy_fixture(parent / "symlink")
outside = parent / "outside-changesets"
outside.mkdir()
(symlink_root / ".docforge/changesets").symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(DocForgeError, "outside the project root"):
Project.open(symlink_root)
def test_toml_anchors_and_mid_write_source_set_changes_fail_atomically(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
toml_root = self.copy_fixture(parent / "toml")
toml_store = ChangesetStore(Project.open(toml_root), "alpha-editor")
changeset = toml_store.create("toml-create")
path = toml_root / ".docforge/changesets/toml-create.json"
before = path.read_bytes()
with self.assertRaisesRegex(DocForgeError, "explicit source_anchor"):
toml_store.propose_create(
changeset_id="toml-create",
expected_changeset_hash=changeset["changeset_hash"],
node_id="proof.second",
target_source="docs/content/proof.toml",
metadata=self.new_metadata(family="proof"),
content="A TOML proposal needs an explicit anchor.",
relationship_changes=[],
rationale="Reject ambiguous TOML placement.",
)
self.assertEqual(before, path.read_bytes())
duplicate_anchor = {
**self.new_metadata(family="proof"),
"source_anchor": "node-1",
}
with self.assertRaisesRegex(DocForgeError, "anchors must be unique"):
toml_store.propose_create(
changeset_id="toml-create",
expected_changeset_hash=changeset["changeset_hash"],
node_id="proof.second",
target_source="docs/content/proof.toml",
metadata=duplicate_anchor,
content="A duplicate anchor is still ambiguous.",
relationship_changes=[],
rationale="Reject duplicate TOML anchors.",
)
self.assertEqual(before, path.read_bytes())
race_root = self.copy_fixture(parent / "race")
race_project = Project.open(race_root)
race_store = ChangesetStore(race_project, "alpha-editor")
sources = race_project.canonical_source_paths()
invented = race_root / "docs/content/invented.md"
with (
mock.patch.object(
race_project,
"canonical_source_paths",
side_effect=[sources, sources, sources, (*sources, invented)],
),
self.assertRaisesRegex(DocForgeError, "changed during changeset storage"),
):
race_store.create("source-race")
self.assertFalse((race_root / ".docforge/changesets/source-race.json").exists())
if __name__ == "__main__":
unittest.main()