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

feat: add deterministic preview rendering

This commit is contained in:
Andraxion 2026-07-22 03:32:05 -04:00
parent 8c75f4f44d
commit 411f417670
23 changed files with 1413 additions and 142 deletions

View file

@ -19,6 +19,18 @@ id = "alpha-editor"
families = ["guide", "proof"]
operations = ["create", "update", "move", "delete"]
[render]
template_root = "docs/templates"
preview_root = ".docforge/previews"
[[render.views]]
id = "manual"
renderer = "generic_html"
template = "manual.html"
output = ".docforge/rendered/manual.html"
title = "Alpha Manual"
families = ["guide", "proof"]
[graph]
allowed_relations = ["depends_on", "proves", "supersedes", "relates_to", "returns_to"]

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="docforge-render" content="{{ docforge_render_identity }}">
<title>{{ docforge_title }}</title>
</head>
<body data-project="{{ docforge_project_id }}" data-view="{{ docforge_view_id }}">
<header><h1>{{ docforge_title }}</h1></header>
<main>{{ docforge_content }}</main>
</body>
</html>

View file

@ -35,7 +35,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
names = tuple(tool.name for tool in response.tools)
self.assertEqual(ALL_TOOLS, names)
self.assertEqual(9, len(PROPOSAL_TOOLS))
self.assertEqual(10, len(PROPOSAL_TOOLS))
self.assertFalse(
any(
token in name
@ -79,8 +79,11 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(contract["canonical_writes_allowed"])
self.assertFalse(contract["project_switching_allowed"])
self.assertIn("canonical_writes", contract["excluded_operations"])
self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"])
self.assertFalse(contract["isolated_changeset_writes_allowed"])
self.assertFalse(contract["proposal_access"]["enabled"])
self.assertTrue(results[10].structuredContent["configured"])
self.assertEqual("stale", results[10].structuredContent["state"])
context = results[8].structuredContent
self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"])
@ -224,6 +227,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
diff = await session.call_tool(
"docforge_get_changeset_diff", {"changeset_id": "mcp-update"}
)
preview = await session.call_tool(
"docforge_preview_changeset",
{"changeset_id": "mcp-update", "view_id": "manual"},
)
undeclared = await session.call_tool(
"docforge_preview_changeset",
{"changeset_id": "mcp-update", "view_id": "not-declared"},
)
self.assertTrue(contract.structuredContent["proposal_access"]["enabled"])
self.assertEqual(
@ -245,6 +256,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
["update", "create", "move", "delete"],
[change["operation"] for change in diff.structuredContent["changes"]],
)
self.assertEqual("current", preview.structuredContent["state"])
self.assertEqual(
".docforge/previews/mcp-update/manual.html",
preview.structuredContent["preview"]["path"],
)
self.assertTrue(preview.structuredContent["preview_identity"])
self.assertEqual("error", undeclared.structuredContent["status"])
self.assertEqual("unknown_render_view", undeclared.structuredContent["error"]["code"])
canonical_after = {
path.relative_to(root).as_posix(): path.read_bytes()
for path in (root / "docs/content").glob("*")
@ -253,6 +272,8 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(canonical_before, canonical_after)
self.assertFalse((root / "docs/content/mcp-node.md").exists())
self.assertTrue((root / ".docforge/changesets/mcp-update.json").is_file())
self.assertTrue((root / ".docforge/previews/mcp-update/manual.html").is_file())
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
async def test_server_without_writer_rejects_proposal_mutation_structurally(self) -> None:
with tempfile.TemporaryDirectory() as directory:

338
tests/test_rendering.py Normal file
View file

@ -0,0 +1,338 @@
from __future__ import annotations
import contextlib
import hashlib
import io
import json
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from docforge.changesets import ChangesetStore
from docforge.cli import main
from docforge.errors import DocForgeError
from docforge.project import Project
from docforge.rendering import RenderService
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class DocForgeRenderingTests(unittest.TestCase):
def copy_fixture(self, name: str, destination: Path) -> Path:
root = destination / name
shutil.copytree(FIXTURES / name, root)
return root
@staticmethod
def project_content_hash(root: Path) -> str:
digest = hashlib.sha256()
paths = [
root / ".docforge/project.toml",
root / "POLICY.md",
*(root / "docs/content").glob("*"),
*(root / "docs/templates").glob("*"),
]
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("utf-8"))
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)
def test_declared_render_is_repeatable_and_status_detects_stale_output(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
service = RenderService(project)
missing = service.status()
self.assertTrue(missing["configured"])
self.assertEqual("stale", missing["state"])
self.assertEqual("missing", missing["outputs"][0]["state"])
first = service.render("manual")
output = root / ".docforge/rendered/manual.html"
first_bytes = output.read_bytes()
second = service.render("manual")
self.assertEqual(
first["output"]["render_identity"], second["output"]["render_identity"]
)
self.assertEqual(
first["output"]["actual_output_hash"], second["output"]["actual_output_hash"]
)
self.assertEqual(first_bytes, output.read_bytes())
self.assertEqual("current", service.status("manual")["state"])
workflow = root / "docs/content/workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nA new canonical sentence.\n",
encoding="utf-8",
)
stale = service.status("manual")
self.assertEqual("stale", stale["state"])
self.assertEqual("stale", stale["outputs"][0]["state"])
self.assertEqual(first_bytes, output.read_bytes())
def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
changesets = ChangesetStore(project, "alpha-editor")
service = RenderService(project, changesets)
canonical_before = self.project_content_hash(root)
canonical_render = service.render("manual")
committed_output = root / ".docforge/rendered/manual.html"
committed_before = committed_output.read_bytes()
created = changesets.create("user-preview")
proposed = changesets.propose_update(
changeset_id="user-preview",
expected_changeset_hash=created["changeset_hash"],
node_id="guide.workflow",
expected_content_hash=self.node_hash(project, "guide.workflow"),
metadata={"summary": "A summary visible only in the preview."},
content="<script>alert('unsafe')</script>\n\n**Rendered safely.**",
relationship_changes=[],
rationale="Show the proposed content through the declared view.",
)
first = service.preview("user-preview", "manual")
preview_path = root / ".docforge/previews/user-preview/manual.html"
preview_bytes = preview_path.read_bytes()
second = service.preview("user-preview", "manual")
self.assertEqual(proposed["changeset_hash"], first["changeset_hash"])
self.assertEqual(first["preview_identity"], second["preview_identity"])
self.assertEqual(preview_bytes, preview_path.read_bytes())
self.assertNotEqual(
canonical_render["output"]["render_identity"], first["preview_identity"]
)
html = preview_bytes.decode("utf-8")
self.assertIn("&lt;script&gt;", html)
self.assertNotIn("<script>", html)
self.assertIn("<strong>Rendered safely.</strong>", html)
self.assertIn("A summary visible only in the preview.", html)
self.assertEqual(
".docforge/previews/user-preview/manual.html", first["preview"]["path"]
)
self.assertEqual(canonical_before, self.project_content_hash(root))
self.assertEqual(committed_before, committed_output.read_bytes())
self.assertEqual("current", service.status("manual")["state"])
def test_failed_and_mid_input_renders_preserve_previous_outputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
service = RenderService(project)
service.render("manual")
output = root / ".docforge/rendered/manual.html"
before = output.read_bytes()
workflow = root / "docs/content/workflow.md"
original_verify = service._verify_canonical
def mutate_before_replace(snapshot, view, template_bytes) -> None:
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nChanged during render.\n",
encoding="utf-8",
)
original_verify(snapshot, view, template_bytes)
with (
mock.patch.object(service, "_verify_canonical", side_effect=mutate_before_replace),
self.assertRaisesRegex(DocForgeError, "changed during rendering") as changed,
):
service.render("manual")
self.assertEqual("render_input_changed", changed.exception.code)
self.assertEqual(before, output.read_bytes())
self.assertFalse(tuple(output.parent.glob(".docforge-render-*")))
fresh_root = self.copy_fixture("alpha", Path(directory) / "invalid")
fresh_project = Project.open(fresh_root)
fresh_service = RenderService(fresh_project)
fresh_service.render("manual")
fresh_output = fresh_root / ".docforge/rendered/manual.html"
fresh_before = fresh_output.read_bytes()
template = fresh_root / "docs/templates/manual.html"
template.write_text("<html>{{ unsupported }}</html>", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "unsupported tokens"):
fresh_service.render("manual")
self.assertEqual(fresh_before, fresh_output.read_bytes())
def test_render_configuration_paths_commands_views_and_limits_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
command_root = self.copy_fixture("alpha", parent / "command")
command_descriptor = command_root / ".docforge/project.toml"
marker = parent / "command-ran"
command_descriptor.write_text(
command_descriptor.read_text(encoding="utf-8").replace(
'title = "Alpha Manual"\nfamilies = ["guide", "proof"]',
'title = "Alpha Manual"\n'
'families = ["guide", "proof"]\n'
f'command = "touch {marker.as_posix()}"',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
Project.open(command_root)
self.assertFalse(marker.exists())
renderer_root = self.copy_fixture("alpha", parent / "renderer")
renderer_descriptor = renderer_root / ".docforge/project.toml"
renderer_descriptor.write_text(
renderer_descriptor.read_text(encoding="utf-8").replace(
'renderer = "generic_html"', 'renderer = "shell"'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "unsupported built-in renderer"):
Project.open(renderer_root)
output_root = self.copy_fixture("alpha", parent / "output")
output_descriptor = output_root / ".docforge/project.toml"
output_descriptor.write_text(
output_descriptor.read_text(encoding="utf-8").replace(
'output = ".docforge/rendered/manual.html"',
'output = "docs/content/manual.html"',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "protected project root"):
Project.open(output_root)
template_root = self.copy_fixture("alpha", parent / "template")
template_descriptor = template_root / ".docforge/project.toml"
template_descriptor.write_text(
template_descriptor.read_text(encoding="utf-8")
.replace('template_root = "docs/templates"', 'template_root = "docs/content"')
.replace('template = "manual.html"', 'template = "foundation.md"'),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "Template input must not overlap"):
Project.open(template_root)
limit_root = self.copy_fixture("alpha", parent / "limit")
limit_descriptor = limit_root / ".docforge/project.toml"
limit_descriptor.write_text(
limit_descriptor.read_text(encoding="utf-8") + "\nmax_render_bytes = 100\n",
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
Project.open(limit_root)
limit_descriptor.write_text(
limit_descriptor.read_text(encoding="utf-8")
.replace("\nmax_render_bytes = 100\n", "")
.replace(
"max_changeset_bytes = 100000",
"max_changeset_bytes = 100000\nmax_render_bytes = 100",
),
encoding="utf-8",
)
limit_service = RenderService(Project.open(limit_root))
with self.assertRaisesRegex(DocForgeError, "configured limit") as limit_error:
limit_service.render("manual")
self.assertEqual("render_too_large", limit_error.exception.code)
self.assertFalse((limit_root / ".docforge/rendered/manual.html").exists())
template_limit_root = self.copy_fixture("alpha", parent / "template-limit")
template_limit_descriptor = template_limit_root / ".docforge/project.toml"
template_limit_descriptor.write_text(
template_limit_descriptor.read_text(encoding="utf-8").replace(
"max_changeset_bytes = 100000",
"max_changeset_bytes = 100000\nmax_template_bytes = 10",
),
encoding="utf-8",
)
template_limit_service = RenderService(Project.open(template_limit_root))
with self.assertRaisesRegex(DocForgeError, "template exceeds") as template_limit:
template_limit_service.render("manual")
self.assertEqual("template_too_large", template_limit.exception.code)
self.assertFalse((template_limit_root / ".docforge/rendered/manual.html").exists())
safe_root = self.copy_fixture("alpha", parent / "safe")
safe_service = RenderService(Project.open(safe_root))
with self.assertRaisesRegex(DocForgeError, "not declared") as unknown:
safe_service.render("not-a-view")
self.assertEqual("unknown_render_view", unknown.exception.code)
def test_symlink_inputs_and_outputs_are_rejected_and_unconfigured_status_is_explicit(
self,
) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
root = self.copy_fixture("alpha", parent)
project = Project.open(root)
service = RenderService(project)
outside_template = parent / "outside-template.html"
outside_template.write_text("{{ docforge_content }}", encoding="utf-8")
template = root / "docs/templates/manual.html"
template.unlink()
template.symlink_to(outside_template)
with self.assertRaisesRegex(DocForgeError, "missing or unsafe"):
service.render("manual")
template.unlink()
shutil.copy2(FIXTURES / "alpha/docs/templates/manual.html", template)
outside_output = parent / "outside-output.html"
outside_output.write_text("do not replace", encoding="utf-8")
output = root / ".docforge/rendered/manual.html"
output.parent.mkdir(parents=True)
output.symlink_to(outside_output)
with self.assertRaisesRegex(DocForgeError, "output path is unsafe"):
service.render("manual")
self.assertEqual("do not replace", outside_output.read_text(encoding="utf-8"))
beta = RenderService(Project.open(FIXTURES / "beta")).status()
self.assertFalse(beta["configured"])
self.assertEqual("not_configured", beta["state"])
self.assertEqual([], beta["outputs"])
def test_cli_exposes_declared_render_status_and_isolated_preview_only(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
changesets = ChangesetStore(project, "alpha-editor")
created = changesets.create("cli-preview")
changesets.propose_update(
changeset_id="cli-preview",
expected_changeset_hash=created["changeset_hash"],
node_id="guide.workflow",
expected_content_hash=self.node_hash(project, "guide.workflow"),
metadata={"summary": "CLI preview summary."},
content=None,
relationship_changes=[],
rationale="Exercise the explicit CLI preview path.",
)
commands = (
("render", "manual"),
("render-status", "manual"),
("preview", "cli-preview", "manual"),
)
results: list[dict] = []
for command in commands:
stream = io.StringIO()
with contextlib.redirect_stdout(stream):
self.assertEqual(0, main(["--project-root", str(root), *command]))
results.append(json.loads(stream.getvalue()))
self.assertEqual("current", results[0]["state"])
self.assertEqual("current", results[1]["state"])
self.assertEqual("current", results[2]["state"])
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())
stream = io.StringIO()
with contextlib.redirect_stdout(stream):
self.assertEqual(
2,
main(["--project-root", str(root), "render", "undeclared"]),
)
self.assertEqual("unknown_render_view", json.loads(stream.getvalue())["error"]["code"])
if __name__ == "__main__":
unittest.main()