1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add gated changeset application and graph controls

This commit is contained in:
Andraxion 2026-07-25 16:00:19 -04:00
parent 3c15e26283
commit 78335c8973
20 changed files with 1813 additions and 453 deletions

View file

@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from unittest import mock
from docforge.application import CanonicalApplicationService, GenericCanonicalApplier
from docforge.changesets import ChangesetStore
from docforge.errors import DocForgeError
from docforge.project import Project
@ -182,6 +183,91 @@ class DocForgeChangesetTests(unittest.TestCase):
self.assertEqual("delete", delete_store.diff("delete-node")["changes"][0]["operation"])
self.assertEqual(delete_before, self.canonical_digest(delete_root))
def test_hash_bound_application_writes_projection_and_refreshes_derived_state(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
hashes = {node.node_id: node.content_hash for node in project.load().nodes}
created = store.create("apply-all")
updated = store.propose_update(
changeset_id="apply-all",
expected_changeset_hash=created["changeset_hash"],
node_id="guide.workflow",
expected_content_hash=hashes["guide.workflow"],
metadata={"summary": "Applied through the canonical application service."},
content="The applied workflow is now canonical.",
relationship_changes=[],
rationale="Exercise a canonical update.",
)
added = store.propose_create(
changeset_id="apply-all",
expected_changeset_hash=updated["changeset_hash"],
node_id="guide.applied",
target_source="docs/content/applied.md",
metadata=self.new_metadata(),
content="This node was created by an approved changeset.",
relationship_changes=[],
rationale="Exercise a canonical creation.",
)
moved = store.propose_move(
changeset_id="apply-all",
expected_changeset_hash=added["changeset_hash"],
node_id="guide.foundation",
expected_content_hash=hashes["guide.foundation"],
target_source="docs/content/foundation-moved.md",
rationale="Exercise a canonical move.",
)
final = store.propose_delete(
changeset_id="apply-all",
expected_changeset_hash=moved["changeset_hash"],
node_id="proof.validation",
expected_content_hash=hashes["proof.validation"],
relationship_changes=[
{
"action": "remove",
"source_id": "proof.validation",
"relation": "proves",
"target_id": "guide.workflow",
}
],
rationale="Exercise a canonical deletion.",
)
service = CanonicalApplicationService(
project,
applier_id="alpha-editor",
applier=GenericCanonicalApplier(project),
)
result = service.apply("apply-all", str(final["changeset_hash"]))
snapshot = project.load()
node_ids = {node.node_id for node in snapshot.nodes}
workflow = next(node for node in snapshot.nodes if node.node_id == "guide.workflow")
self.assertTrue(result["applied"])
self.assertEqual(
[
"docs/content/applied.md",
"docs/content/foundation-moved.md",
"docs/content/foundation.md",
"docs/content/proof.toml",
"docs/content/workflow.md",
],
result["applied_sources"],
)
self.assertEqual({"guide.applied", "guide.foundation", "guide.workflow"}, node_ids)
self.assertEqual(
"Applied through the canonical application service.",
workflow.summary,
)
self.assertFalse((root / "docs/content/foundation.md").exists())
self.assertFalse((root / "docs/content/proof.toml").exists())
self.assertTrue((root / "docs/content/foundation-moved.md").is_file())
self.assertTrue((root / ".docforge/cache/index.sqlite3").is_file())
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
with self.assertRaisesRegex(DocForgeError, "Canonical project changed"):
service.apply("apply-all", str(final["changeset_hash"]))
def test_optimistic_and_cross_changeset_conflicts_preserve_both_proposals(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))

107
tests/test_cli.py Normal file
View file

@ -0,0 +1,107 @@
from __future__ import annotations
import os
import shutil
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from docforge.changesets import ChangesetStore
from docforge.cli import _parser, _run
from docforge.project import Project
from docforge.viewer_manager import ViewerManager
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class DocForgeCliTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
root = destination / "alpha"
shutil.copytree(FIXTURES / "alpha", root)
return root
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
root = self.copy_fixture(parent)
parser = _parser()
reindexed = _run(parser.parse_args(["--project-root", str(root), "reindex"]))
self.assertTrue(reindexed["reindexed"])
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
node = next(node for node in project.load().nodes if node.node_id == "guide.workflow")
created = store.create("cli-apply")
proposed = store.propose_update(
changeset_id="cli-apply",
expected_changeset_hash=str(created["changeset_hash"]),
node_id=node.node_id,
expected_content_hash=node.content_hash,
metadata={"summary": "Applied through the CLI."},
content=None,
relationship_changes=[],
rationale="Verify the direct application command.",
)
applied = _run(
parser.parse_args(
[
"--project-root",
str(root),
"apply",
"cli-apply",
"--changeset-hash",
str(proposed["changeset_hash"]),
"--applier",
"alpha-editor",
]
)
)
self.assertTrue(applied["applied"])
state_path = parent / "viewer-manager.json"
manager = ViewerManager(state_path, check_interval_seconds=0.02)
thread = threading.Thread(target=manager.serve_forever, daemon=True)
previous = os.environ.get("DOCFORGE_VIEWER_MANAGER_STATE")
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = str(state_path)
thread.start()
deadline = time.monotonic() + 2
while not state_path.exists() and time.monotonic() < deadline:
time.sleep(0.01)
try:
with mock.patch("docforge.cli.webbrowser.open", return_value=True) as opened:
visualized = _run(
parser.parse_args(
[
"--project-root",
str(root),
"visualize",
"--node",
"guide.workflow",
]
)
)
self.assertTrue(visualized["opened_browser"])
opened.assert_called_once()
status = _run(
parser.parse_args(["--project-root", str(root), "visualization-status"])
)
self.assertEqual("running", status["state"])
stopped = _run(
parser.parse_args(["--project-root", str(root), "visualization-stop"])
)
self.assertEqual("stopped", stopped["state"])
finally:
manager.shutdown()
thread.join(timeout=2)
if previous is None:
os.environ.pop("DOCFORGE_VIEWER_MANAGER_STATE", None)
else:
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
if __name__ == "__main__":
unittest.main()

View file

@ -17,6 +17,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from docforge.index import ProjectIndex
from docforge.mcp_server import (
ALL_TOOLS,
APPLICATION_TOOLS,
CONTENT_WARNING,
PROPOSAL_TOOLS,
DocForgeService,
@ -130,7 +131,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"])
self.assertEqual("graph-browser@11", visualization["template"])
self.assertEqual("graph-browser@12", visualization["template"])
self.assertEqual("managed_idle", visualization["lifetime"]["policy"])
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
@ -343,6 +344,73 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual("proposal_access_disabled", result.structuredContent["error"]["code"])
self.assertFalse((root / ".docforge/changesets/disabled.json").exists())
async def test_canonical_apply_tool_is_opt_in_hash_bound_and_refreshes_outputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
node_hash = next(
node.content_hash
for node in project.load().nodes
if node.node_id == "guide.workflow"
)
async with create_connected_server_and_client_session(
create_server(
root,
"alpha-editor",
canonical_applier_id="alpha-editor",
),
raise_exceptions=True,
) as session:
names = tuple(tool.name for tool in (await session.list_tools()).tools)
contract = await session.call_tool("docforge_get_contract", {})
created = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": "mcp-apply"},
)
updated = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": created.structuredContent["changeset_hash"],
"node_id": "guide.workflow",
"expected_content_hash": node_hash,
"metadata": {"summary": "Applied through the gated MCP tool."},
"content": None,
"relationship_changes": [],
"rationale": "Verify canonical MCP application.",
},
)
wrong = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": "0" * 64,
},
)
applied = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": updated.structuredContent["changeset_hash"],
},
)
self.assertEqual((*ALL_TOOLS, *APPLICATION_TOOLS), names)
self.assertTrue(contract.structuredContent["canonical_writes_allowed"])
self.assertTrue(contract.structuredContent["canonical_application_access"]["enabled"])
self.assertNotIn(
"canonical_changeset_application",
contract.structuredContent["excluded_operations"],
)
self.assertEqual("changeset_conflict", wrong.structuredContent["error"]["code"])
self.assertTrue(applied.structuredContent["applied"])
workflow = next(
node for node in project.load().nodes if node.node_id == "guide.workflow"
)
self.assertEqual("Applied through the gated MCP tool.", workflow.summary)
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
async def test_stdio_transport_serves_the_same_project_bound_contract(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("beta", Path(directory))

View file

@ -70,6 +70,7 @@ class VisualizationTests(unittest.TestCase):
first = snapshot.node("guide.workflow", depth=2, limit=2)
second = snapshot.node("guide.workflow", depth=2, limit=2)
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
source = snapshot.source("guide.workflow")
self.assertEqual(3, overview["node_count"])
self.assertEqual(2, overview["edge_count"])
@ -87,6 +88,8 @@ class VisualizationTests(unittest.TestCase):
self.assertFalse(filtered["truncated"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
self.assertLessEqual(len(first["edges"]), 2)
self.assertEqual("docs/content/workflow.md", source["source_path"])
self.assertIn("Editors change canonical nodes", source["content"])
self.assertIn(
"guide.workflow",
{node["node_id"] for node in first["nodes"]},
@ -112,6 +115,14 @@ class VisualizationTests(unittest.TestCase):
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None:
self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML)
self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML)
self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML)
self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML)
self.assertIn("state.hiddenNodes.add(nodeId)", _GRAPH_BROWSER_HTML)
self.assertIn("grid-template-rows: auto minmax(0, 1fr) auto", _GRAPH_BROWSER_HTML)
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
script = _GRAPH_BROWSER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]