from __future__ import annotations import contextlib import io import json import shutil import sqlite3 import tempfile import unittest from collections.abc import Callable from dataclasses import replace from pathlib import Path from unittest import mock from jsonschema import Draft202012Validator from mcp.shared.memory import create_connected_server_and_client_session import docforge.generation_diff as generation_diff_module from docforge.cli import main from docforge.errors import DocForgeError from docforge.generation_diff import ( GenerationDiffDraft, finalize_generation_diff, generation_diff_path, publish_generation_diff, validate_generation_diff_receipt, ) from docforge.index import ProjectIndex from docforge.mcp_server import DocForgeService, create_server from docforge.models import ProjectDescriptor, ProjectSnapshot from docforge.pagination import canonical_hash from docforge.project import Project, project_root_fingerprint from docforge.telemetry import request ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" GENERATION_DIFF_SCHEMA = json.loads( (ROOT / "schemas" / "generation-diff.schema.json").read_text(encoding="utf-8") ) GENERATION_DIFF_PAGE_SCHEMA = json.loads( (ROOT / "schemas" / "generation-diff-page.schema.json").read_text(encoding="utf-8") ) RESULT_SCHEMA = json.loads((ROOT / "schemas" / "result.schema.json").read_text(encoding="utf-8")) class StaticProject: """Small legacy one-method project used to prove load-free status behavior.""" def __init__(self, snapshot: ProjectSnapshot) -> None: self.descriptor: ProjectDescriptor = snapshot.descriptor self.snapshot = snapshot self.load_calls = 0 def load(self) -> ProjectSnapshot: self.load_calls += 1 return self.snapshot def canonical_source_paths(self) -> tuple[Path, ...]: return () def validate_proposal( self, base: ProjectSnapshot, projected: ProjectSnapshot, operations: tuple[object, ...], ) -> None: del base, projected, operations class GenerationDiffTests(unittest.TestCase): def copy_fixture(self, destination: Path) -> Path: root = destination / "alpha" shutil.copytree(FIXTURES / "alpha", root) shutil.rmtree(root / ".docforge" / "cache", ignore_errors=True) return root @staticmethod def change_graph(root: Path, suffix: str = "changed") -> None: foundation = root / "docs" / "content" / "foundation.md" foundation.write_text( foundation.read_text(encoding="utf-8").replace( "Defines which Alpha files own documentation facts.", f"Defines which Alpha files own documentation facts. {suffix}", ), encoding="utf-8", ) proof = root / "docs" / "content" / "proof.toml" proof.write_text( proof.read_text(encoding="utf-8").replace( 'proves = ["guide.workflow"]', 'proves = ["guide.foundation"]', ), encoding="utf-8", ) @staticmethod def rehash_receipt(receipt: dict[str, object]) -> dict[str, object]: items = receipt["items"] if not isinstance(items, list): raise AssertionError("receipt items are not a list") for item in items: if not isinstance(item, dict): raise AssertionError("receipt item is not an object") item["item_hash"] = canonical_hash( {key: value for key, value in item.items() if key != "item_hash"} ) retained_hash = canonical_hash([item["item_hash"] for item in items]) receipt["retained_collection_hash"] = retained_hash if not receipt["details_truncated"]: receipt["full_collection_hash"] = retained_hash receipt["receipt_hash"] = canonical_hash( {key: value for key, value in receipt.items() if key != "receipt_hash"} ) return receipt def transition_receipt(self, root: Path) -> tuple[Project, ProjectIndex, dict[str, object]]: project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) index.build() receipt = json.loads(generation_diff_path(project.descriptor).read_text(encoding="utf-8")) return project, index, receipt def test_first_build_and_exact_transition_are_schema_valid(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) first = index.build() first_receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) Draft202012Validator(GENERATION_DIFF_SCHEMA).validate(first_receipt) self.assertTrue( validate_generation_diff_receipt( first_receipt, descriptor=project.descriptor, ) ) self.assertEqual("baseline", first_receipt["kind"]) self.assertEqual("no_predecessor", first_receipt["reason"]) self.assertEqual(0, first_receipt["full_item_count"]) self.assertEqual("ok", first["status"]) self.change_graph(root) second = index.build() receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) Draft202012Validator(GENERATION_DIFF_SCHEMA).validate(receipt) self.assertEqual("transition", receipt["kind"]) self.assertEqual( { "nodes_added": 0, "nodes_removed": 0, "nodes_changed": 2, "edges_added": 1, "edges_removed": 1, "total_changes": 4, }, receipt["summary"], ) self.assertEqual( [ ("edge", "added"), ("edge", "removed"), ("node", "changed"), ("node", "changed"), ], [(item["entity"], item["change"]) for item in receipt["items"]], ) self.assertNotIn("logic_hash", json.dumps(receipt, sort_keys=True).casefold()) self.assertEqual("ok", second["status"]) status = ProjectIndex(project).generation_diff() self.assertEqual("current", status["receipt_state"]) self.assertEqual(receipt["receipt_hash"], status["generation_diff"]["receipt_hash"]) def test_same_generation_reindex_preserves_latest_meaningful_transition(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) index.build() before = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) index.build() after = json.loads(generation_diff_path(project.descriptor).read_text(encoding="utf-8")) self.assertEqual("transition", after["kind"]) self.assertEqual(before["from_generation"], after["from_generation"]) self.assertEqual(before["to_generation"], after["to_generation"]) self.assertEqual(before["summary"], after["summary"]) self.assertEqual(before["full_collection_hash"], after["full_collection_hash"]) self.assertNotEqual(before["index_signature"], after["index_signature"]) self.assertEqual( ["generation-diff.json"], [path.name for path in project.descriptor.cache_root.glob("*generation-diff*")], ) def test_receipt_item_and_byte_limits_preserve_exact_summary_hashes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) descriptor = Project.open(root).descriptor generation = { "revision": "unversioned", "source_hash": "1" * 64, "node_count": 0, "node_hash": "2" * 64, "edge_count": 0, "edge_hash": "3" * 64, "index_schema_version": 3, } items: list[dict[str, object]] = [] for index in range(1_002): item: dict[str, object] = { "entity": "edge", "change": "added", "source_id": f"source-{index:04d}", "relation": "relates_to", "target_id": f"target-{index:04d}", } item["item_hash"] = canonical_hash(item) items.append(item) fields: dict[str, object] = { "schema_version": 1, "diff_semantics_version": 1, "project_id": descriptor.project_id, "project_root_fingerprint": project_root_fingerprint(descriptor.root), "adapter": descriptor.adapter, "kind": "transition", "reason": None, "from_generation": generation, "to_generation": {**generation, "source_hash": "4" * 64}, "summary": { "nodes_added": 0, "nodes_removed": 0, "nodes_changed": 0, "edges_added": len(items), "edges_removed": 0, "total_changes": len(items), }, "full_item_count": len(items), "full_collection_hash": canonical_hash([item["item_hash"] for item in items]), } receipt = finalize_generation_diff( GenerationDiffDraft(fields=fields, items=tuple(items)), signature=(1, 2, 3, 4, 5), ) Draft202012Validator(GENERATION_DIFF_SCHEMA).validate(receipt) self.assertEqual(1_002, receipt["full_item_count"]) self.assertEqual(1_000, receipt["retained_item_count"]) self.assertEqual("receipt_item_limit", receipt["truncation_reason"]) self.assertNotEqual( receipt["full_collection_hash"], receipt["retained_collection_hash"], ) huge = dict(items[0]) huge["source_id"] = "source-" + ("x" * 1_100_000) huge["item_hash"] = canonical_hash( {key: value for key, value in huge.items() if key != "item_hash"} ) huge_fields = { **fields, "summary": { **fields["summary"], "edges_added": 1, "total_changes": 1, }, "full_item_count": 1, "full_collection_hash": canonical_hash([huge["item_hash"]]), } byte_limited = finalize_generation_diff( GenerationDiffDraft(fields=huge_fields, items=(huge,)), signature=(1, 2, 3, 4, 5), ) self.assertEqual(0, byte_limited["retained_item_count"]) self.assertEqual("receipt_byte_limit", byte_limited["truncation_reason"]) def test_receipt_runtime_and_schema_reject_malformed_semantics(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project, _, receipt = self.transition_receipt(root) validator = Draft202012Validator(GENERATION_DIFF_SCHEMA) malformed_fields = json.loads(json.dumps(receipt)) changed = next( item for item in malformed_fields["items"] if item["entity"] == "node" and item["change"] == "changed" ) changed["changed_fields"] = 1 self.rehash_receipt(malformed_fields) self.assertFalse( validate_generation_diff_receipt( malformed_fields, descriptor=project.descriptor, ) ) self.assertFalse(validator.is_valid(malformed_fields)) duplicate_fields = json.loads(json.dumps(receipt)) changed = next( item for item in duplicate_fields["items"] if item["entity"] == "node" and item["change"] == "changed" ) changed["changed_fields"] = ["content", "content"] self.rehash_receipt(duplicate_fields) self.assertFalse(validate_generation_diff_receipt(duplicate_fields)) self.assertFalse(validator.is_valid(duplicate_fields)) impossible_added = json.loads(json.dumps(receipt)) changed = next( item for item in impossible_added["items"] if item["entity"] == "node" and item["change"] == "changed" ) changed["change"] = "added" changed["changed_fields"] = [] self.rehash_receipt(impossible_added) self.assertFalse(validate_generation_diff_receipt(impossible_added)) self.assertFalse(validator.is_valid(impossible_added)) reordered = json.loads(json.dumps(receipt)) reordered["items"].reverse() self.rehash_receipt(reordered) self.assertFalse(validate_generation_diff_receipt(reordered)) same_generation = json.loads(json.dumps(receipt)) same_generation["from_generation"] = same_generation["to_generation"] self.rehash_receipt(same_generation) self.assertFalse(validate_generation_diff_receipt(same_generation)) impossible_truncation = json.loads(json.dumps(receipt)) impossible_truncation["items"].pop() impossible_truncation["retained_item_count"] = len(impossible_truncation["items"]) impossible_truncation["details_truncated"] = True impossible_truncation["truncation_reason"] = "receipt_item_limit" self.rehash_receipt(impossible_truncation) self.assertFalse(validate_generation_diff_receipt(impossible_truncation)) self.assertFalse(validator.is_valid(impossible_truncation)) baseline_with_changes = json.loads(json.dumps(receipt)) baseline_with_changes["kind"] = "baseline" baseline_with_changes["reason"] = "no_meaningful_transition" baseline_with_changes["from_generation"] = None self.rehash_receipt(baseline_with_changes) self.assertFalse(validate_generation_diff_receipt(baseline_with_changes)) self.assertFalse(validator.is_valid(baseline_with_changes)) def test_generation_collision_and_nondeterministic_build_fail_precommit(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) base = Project.open(root).load() static = StaticProject(base) index = ProjectIndex(static) index.build() original_index = index.path.read_bytes() original_receipt = generation_diff_path(static.descriptor).read_bytes() changed_node = replace( base.nodes[0], title="Different graph under the same generation", ) static.snapshot = replace( base, nodes=(changed_node, *base.nodes[1:]), ) with self.assertRaises(DocForgeError) as collision: index.build() self.assertEqual("generation_collision", collision.exception.code) self.assertEqual(original_index, index.path.read_bytes()) self.assertEqual( original_receipt, generation_diff_path(static.descriptor).read_bytes(), ) calls = 0 def unstable() -> ProjectSnapshot: nonlocal calls calls += 1 return ( base if calls == 1 else replace( base, nodes=(changed_node, *base.nodes[1:]), ) ) static.snapshot = base with ( mock.patch.object(static, "load", side_effect=unstable), self.assertRaises(DocForgeError) as changed, ): ProjectIndex(static).build() self.assertEqual("source_changed", changed.exception.code) def test_post_commit_receipt_failures_report_degraded_success(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) with ( mock.patch.object( ProjectIndex, "_write_attestation", side_effect=OSError("attestation failed"), ), mock.patch.object( Project, "record_generation", side_effect=OSError("generation failed"), ), mock.patch( "docforge.index.publish_generation_diff", side_effect=OSError("diff failed"), ), ): result = index.build() self.assertEqual("ok", result["status"]) self.assertEqual("degraded", result["publication"]["state"]) self.assertEqual("published", result["publication"]["index"]) self.assertEqual( {"attestation", "source_generation", "generation_diff"}, {error["stage"] for error in result["publication"]["errors"]}, ) with contextlib.closing(__import__("sqlite3").connect(index.path)) as connection: metadata = dict(connection.execute("SELECT key, value FROM metadata")) self.assertEqual(project.load().source_hash, metadata["source_hash"]) def test_predecessor_requires_attestation_and_valid_generation_identity(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() index.attestation_path.unlink() self.change_graph(root) result = index.build() receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) self.assertEqual("ok", result["status"]) self.assertEqual("baseline", receipt["kind"]) self.assertEqual("predecessor_unattested", receipt["reason"]) self.assertTrue(validate_generation_diff_receipt(receipt)) with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() with contextlib.closing(sqlite3.connect(index.path)) as connection: connection.execute("UPDATE metadata SET value = 'bad' WHERE key = 'source_hash'") connection.commit() index._write_attestation() self.change_graph(root) index.build() receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) self.assertEqual("baseline", receipt["kind"]) self.assertEqual("predecessor_corrupt", receipt["reason"]) self.assertTrue(validate_generation_diff_receipt(receipt)) def test_sqlite_sidecars_refuse_precommit_publication(self) -> None: for suffix in ("-wal", "-journal", "-shm"): with self.subTest(suffix=suffix), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() original_index = index.path.read_bytes() original_receipt = generation_diff_path(project.descriptor).read_bytes() Path(f"{index.path}{suffix}").write_bytes(b"unproven-sidecar") self.change_graph(root) with self.assertRaises(DocForgeError) as blocked: index.build() self.assertEqual("index_busy", blocked.exception.code) self.assertEqual(original_index, index.path.read_bytes()) self.assertEqual( original_receipt, generation_diff_path(project.descriptor).read_bytes(), ) def test_live_wal_state_cannot_bypass_main_index_identity(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() original_receipt = generation_diff_path(project.descriptor).read_bytes() connection = sqlite3.connect(index.path) try: self.assertEqual( "wal", connection.execute("PRAGMA journal_mode=WAL").fetchone()[0], ) connection.execute( "UPDATE metadata SET value = ? WHERE key = 'source_hash'", ("f" * 64,), ) connection.commit() self.assertTrue(Path(f"{index.path}-wal").exists()) main_file_after_wal = index.path.read_bytes() with self.assertRaises(DocForgeError) as blocked: index.build() self.assertEqual("index_busy", blocked.exception.code) self.assertEqual(main_file_after_wal, index.path.read_bytes()) self.assertEqual( original_receipt, generation_diff_path(project.descriptor).read_bytes(), ) finally: connection.close() def test_sidecar_or_source_change_during_diff_preparation_aborts_precommit(self) -> None: for mutation in ("source", "sidecar"): with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() original_index = index.path.read_bytes() original_receipt = generation_diff_path(project.descriptor).read_bytes() self.change_graph(root) from docforge import index as index_module real_prepare = index_module.prepare_generation_diff def mutate_after_prepare( *args: object, _prepare: Callable[..., object] = real_prepare, _mutation: str = mutation, _root: Path = root, _index: ProjectIndex = index, **kwargs: object, ) -> object: draft = _prepare(*args, **kwargs) if _mutation == "source": source = _root / "docs" / "content" / "workflow.md" source.write_text( source.read_text(encoding="utf-8") + "\nConcurrent change.\n", encoding="utf-8", ) else: Path(f"{_index.path}-wal").write_bytes(b"appeared") return draft with ( mock.patch( "docforge.index.prepare_generation_diff", side_effect=mutate_after_prepare, ), self.assertRaises(DocForgeError) as blocked, ): index.build() self.assertEqual( "source_changed" if mutation == "source" else "index_busy", blocked.exception.code, ) self.assertEqual(original_index, index.path.read_bytes()) self.assertEqual( original_receipt, generation_diff_path(project.descriptor).read_bytes(), ) def test_cache_root_symlink_cannot_redirect_receipt_publication(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) cache_root = project.descriptor.cache_root preserved = cache_root.with_name("preserved-cache") outside = root / "outside-cache" outside.mkdir() cache_root.rename(preserved) cache_root.symlink_to(outside, target_is_directory=True) try: with self.assertRaises(DocForgeError) as blocked: publish_generation_diff(project.descriptor, receipt) self.assertEqual("path_escape", blocked.exception.code) self.assertFalse((outside / "generation-diff.json").exists()) finally: cache_root.unlink() preserved.rename(cache_root) def test_post_commit_identity_and_durability_failures_are_degraded(self) -> None: cases = ( ("_fsync_cache_directory", OSError("fsync failed"), "index_directory_sync"), ( "_published_index_signature", OSError("signature failed"), "index_identity", ), ) for method, failure, stage in cases: with self.subTest(method=method), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) with mock.patch.object(ProjectIndex, method, side_effect=failure): result = index.build() self.assertEqual("ok", result["status"]) self.assertEqual("degraded", result["publication"]["state"]) self.assertIn( stage, {error["stage"] for error in result["publication"]["errors"]}, ) with contextlib.closing(sqlite3.connect(index.path)) as connection: metadata = dict(connection.execute("SELECT key, value FROM metadata")) self.assertEqual(project.load().source_hash, metadata["source_hash"]) def test_post_commit_receipts_fail_independently(self) -> None: cases = ( ("attestation", "docforge.index.ProjectIndex._write_attestation"), ("source_generation", "docforge.project.Project.record_generation"), ("generation_diff", "docforge.index.publish_generation_diff"), ) for failed_receipt, target in cases: with self.subTest(receipt=failed_receipt), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) with mock.patch(target, side_effect=OSError("failed independently")): result = index.build() receipts = result["publication"]["receipts"] self.assertEqual("unavailable", receipts[failed_receipt]["state"]) for name in {"attestation", "source_generation", "generation_diff"} - { failed_receipt }: self.assertEqual("published", receipts[name]["state"]) def test_read_is_bounded_read_only_and_legacy_projects_do_not_load(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() receipt_path = generation_diff_path(project.descriptor) original_receipt = json.loads(receipt_path.read_text(encoding="utf-8")) before = { path: path.stat().st_mtime_ns for path in project.descriptor.cache_root.iterdir() if path.is_file() } with ( request("test", enabled=True) as collector, mock.patch.object(project, "load", side_effect=AssertionError("loaded")), mock.patch.object(index, "check", side_effect=AssertionError("checked")), mock.patch.object(index, "build", side_effect=AssertionError("built")), mock.patch.object(index, "synchronize", side_effect=AssertionError("synced")), ): result = index.generation_diff() self.assertEqual("current", result["receipt_state"]) self.assertIsNotNone(collector) diagnostics = collector.as_dict(outcome="ok") for counter in ( "project_loads", "source_files_parsed", "adapter_projection_loads", "adapter_source_extractions", "index_checks", "index_synchronizations", "index_builds", ): self.assertEqual(0, diagnostics["counters"][counter]) self.assertGreaterEqual(diagnostics["counters"]["source_generation_checks"], 2) self.assertEqual( before, { path: path.stat().st_mtime_ns for path in project.descriptor.cache_root.iterdir() if path.is_file() }, ) with mock.patch.object( generation_diff_module, "validate_generation_diff_receipt", wraps=generation_diff_module.validate_generation_diff_receipt, ) as validated: self.assertEqual("current", index.generation_diff()["receipt_state"]) self.assertEqual(1, validated.call_count) receipt_path.write_text("{broken", encoding="utf-8") corrupt_before = receipt_path.read_bytes() corrupt = index.generation_diff() self.assertEqual("unverified", corrupt["receipt_state"]) self.assertEqual("corrupt_receipt", corrupt["receipt_reason"]) self.assertEqual(corrupt_before, receipt_path.read_bytes()) foreign = {**original_receipt, "project_id": "foreign-project"} foreign["receipt_hash"] = canonical_hash( {key: value for key, value in foreign.items() if key != "receipt_hash"} ) receipt_path.write_text( json.dumps(foreign, sort_keys=True, indent=2) + "\n", encoding="utf-8", ) foreign_result = index.generation_diff() self.assertEqual("unverified", foreign_result["receipt_state"]) self.assertEqual("foreign_receipt", foreign_result["receipt_reason"]) outside = root / "foreign-generation-diff.json" outside.write_text( json.dumps(original_receipt, sort_keys=True, indent=2) + "\n", encoding="utf-8", ) receipt_path.unlink() receipt_path.symlink_to(outside) unsafe = index.generation_diff() self.assertEqual("unsafe", unsafe["receipt_state"]) self.assertEqual("unsafe_receipt", unsafe["receipt_reason"]) snapshot = project.load() legacy = StaticProject(snapshot) calls = legacy.load_calls unknown = ProjectIndex(legacy).generation_diff() self.assertEqual("unknown", unknown["receipt_state"]) self.assertEqual(calls, legacy.load_calls) def test_cli_generation_diff_is_additive_and_paged(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() self.change_graph(root) index.build() output = io.StringIO() with contextlib.redirect_stdout(output): code = main( [ "--project-root", str(root), "generation-diff", "--limit", "1", ] ) result = json.loads(output.getvalue()) self.assertEqual(0, code) Draft202012Validator(RESULT_SCHEMA).validate(result) Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).validate( { "generation_diff": result["generation_diff"], "pagination": result["pagination"], } ) self.assertEqual("generation-diff.items", result["pagination"]["kind"]) self.assertEqual(1, result["pagination"]["returned_count"]) self.assertTrue(result["pagination"]["has_more"]) self.assertNotIn("next_cursor", result) self.assertNotIn("pagination", result["generation_diff"]) self.assertEqual( { "page_schema_version", "receipt_header", "items", "omissions", "page_hash", }, set(result["generation_diff"]), ) page = result["generation_diff"] self.assertIn("stored_receipt_hash", page["receipt_header"]) self.assertNotIn("receipt_hash", page["receipt_header"]) self.assertEqual( page["page_hash"], canonical_hash( { "page_schema_version": 1, "receipt_state": result["receipt_state"], "receipt_header": page["receipt_header"], "pagination": result["pagination"], "items": page["items"], "omissions": page["omissions"], } ), ) self.assertEqual( result["pagination"]["returned_count"], len(page["items"]) + len(page["omissions"]), ) self.assertEqual( result["pagination"]["total_count"], page["receipt_header"]["retained_item_count"], ) malformed_page = json.loads(json.dumps(result)) stored_receipt = json.loads( generation_diff_path(project.descriptor).read_text(encoding="utf-8") ) node = next(item for item in stored_receipt["items"] if item["entity"] == "node") node["change"] = "added" malformed_page["generation_diff"]["items"] = [node] self.assertFalse( Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).is_valid( { "generation_diff": malformed_page["generation_diff"], "pagination": malformed_page["pagination"], } ) ) malformed_header = json.loads(json.dumps(result)) header = malformed_header["generation_diff"]["receipt_header"] header["kind"] = "baseline" header["reason"] = "no_meaningful_transition" header["from_generation"] = None self.assertFalse( Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).is_valid( { "generation_diff": malformed_header["generation_diff"], "pagination": malformed_header["pagination"], } ) ) contradictory_pagination = json.loads(json.dumps(result)) contradictory_pagination["pagination"]["has_more"] = False self.assertFalse( Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).is_valid( { "generation_diff": contradictory_pagination["generation_diff"], "pagination": contradictory_pagination["pagination"], } ) ) missing_cursor = json.loads(json.dumps(result)) missing_cursor["pagination"]["next_cursor"] = None self.assertFalse( Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).is_valid( { "generation_diff": missing_cursor["generation_diff"], "pagination": missing_cursor["pagination"], } ) ) class GenerationDiffMcpTests(unittest.IsolatedAsyncioTestCase): def copy_fixture(self, destination: Path) -> Path: root = destination / "alpha" shutil.copytree(FIXTURES / "alpha", root) shutil.rmtree(root / ".docforge" / "cache", ignore_errors=True) return root async def test_mcp_surface_paginates_and_cursors_bind_the_receipt(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) index = ProjectIndex(project) index.build() GenerationDiffTests.change_graph(root) index.build() async with create_connected_server_and_client_session( create_server(root), raise_exceptions=True, ) as session: tools = {tool.name: tool for tool in (await session.list_tools()).tools} schema = tools["docforge_get_generation_diff"].inputSchema self.assertEqual({"limit", "cursor"}, set(schema["properties"])) self.assertEqual([], schema.get("required", [])) first = await session.call_tool( "docforge_get_generation_diff", {"limit": 1}, ) first_result = first.structuredContent Draft202012Validator(RESULT_SCHEMA).validate(first_result) Draft202012Validator(GENERATION_DIFF_PAGE_SCHEMA).validate( { "generation_diff": first_result["generation_diff"], "pagination": first_result["pagination"], } ) self.assertEqual("current", first_result["receipt_state"]) self.assertEqual(1, first_result["pagination"]["returned_count"]) cursor = first_result["pagination"]["next_cursor"] self.assertIsInstance(cursor, str) self.assertLess(len(cursor), 1_000) self.assertNotIn("next_cursor", first_result) second = await session.call_tool( "docforge_get_generation_diff", {"limit": 2, "cursor": cursor}, ) self.assertEqual(2, second.structuredContent["pagination"]["returned_count"]) foundation = root / "docs" / "content" / "foundation.md" foundation.write_text( foundation.read_text(encoding="utf-8") + "\nAnother transition.\n", encoding="utf-8", ) ProjectIndex(Project.open(root)).build() stale = await session.call_tool( "docforge_get_generation_diff", {"limit": 1, "cursor": cursor}, ) self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"]) Draft202012Validator(RESULT_SCHEMA).validate(stale.structuredContent) def test_service_diagnostics_drop_before_primary_page(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) descriptor_path = root / ".docforge" / "project.toml" descriptor_path.write_text( descriptor_path.read_text(encoding="utf-8").replace( "max_results = 20", "max_results = 20\nmax_tool_output_chars = 2100", ), encoding="utf-8", ) project = Project.open(root) ProjectIndex(project).build() service = DocForgeService( project, diagnostics=True, capability_mode_name="read", ) result = service.generation_diff() self.assertEqual("ok", result["status"]) self.assertNotIn("diagnostics", result) self.assertLessEqual(service._encoded_length(result), 2_100) def test_page_sizing_uses_logarithmic_response_encodes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) descriptor_path = root / ".docforge" / "project.toml" descriptor_path.write_text( descriptor_path.read_text(encoding="utf-8").replace( "max_results = 20", "max_results = 1000", ), encoding="utf-8", ) project = Project.open(root) index = ProjectIndex(project) index.build() stored = index.generation_diff() receipt = dict(stored["generation_diff"]) receipt["items"] = [ { "item_hash": canonical_hash({"ordinal": ordinal}), "payload": "x" * 500, } for ordinal in range(1_000) ] service = DocForgeService( project, capability_mode_name="read", ) with ( mock.patch.object( service.index, "generation_diff", return_value={**stored, "generation_diff": receipt}, ), mock.patch.object( service, "_encoded_length", wraps=service._encoded_length, ) as encoded, ): result = service.generation_diff(limit=1_000) self.assertEqual("ok", result["status"]) self.assertGreater(result["pagination"]["returned_count"], 0) self.assertLess(result["pagination"]["returned_count"], 1_000) self.assertLessEqual(encoded.call_count, 15)