from __future__ import annotations import argparse import hashlib import importlib import json import shutil import subprocess import sys import tempfile import tomllib import unittest from pathlib import Path from jsonschema import Draft202012Validator import docforge from docforge.changeset_contract import canonical_bytes, document_hash from docforge.changesets import ChangesetStore from docforge.cli import _parser from docforge.index import ProjectIndex from docforge.mcp_server import ( ALL_TOOLS, APPLICATION_TOOLS, PROPOSAL_TOOLS, READ_TOOLS, SERVER_VERSION, DocForgeService, ) from docforge.project import Project ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" SCHEMAS = ROOT / "schemas" PUBLIC_IMPORTS = { "docforge": ( "CanonicalApplier", "CanonicalApplicationService", "DocForgeError", "GenericCanonicalApplier", "Project", ), "docforge.adapter_contract": ( "AdapterAssembly", "AdapterConformanceReport", "AdapterEdge", "AdapterImplementation", "AdapterLoader", "AdapterManifest", "AdapterNode", "AdapterProject", "AdapterProjection", "AdapterProjectSettings", "AdapterSource", "AdapterSourceProjection", "CompleteAdapterAssemblyLoader", "IncrementalAdapterAssembler", "IncrementalAdapterLoader", "verify_adapter_conformance", ), "docforge.adapter_sdk": ( "AdapterAssembly", "AdapterConformanceReport", "AdapterLoader", "AdapterManifest", "AdapterProject", "AdapterProjection", "AdapterSourceProjection", "CompleteAdapterAssemblyLoader", "IncrementalAdapterLoader", "LogicProjection", "Node", "verify_adapter_conformance", ), "docforge.application": ( "CanonicalApplier", "CanonicalApplicationService", "GenericCanonicalApplier", ), "docforge.adapter_launcher": ( "ADAPTER_LAUNCHER_SCHEMA_VERSION", "AdapterLauncherV1", "AdapterSourceAvailabilityV1", "adapter_source_availability", "validate_adapter_launcher", ), "docforge.client_config": ( "generate_adapter_client_configuration", "generate_client_configuration", ), "docforge.doctor": ("run_doctor",), "docforge.index": ("ProjectIndex",), "docforge.graph_projection": ( "GraphViewRequestV1", "build_graph_projection_package", "build_graph_view_plan", ), "docforge.graph_rendering": ("GraphRenderService",), "docforge.manual_projection": ( "build_manual_projection_package", "build_manual_render_plan", ), "docforge.mcp_server": ( "create_project_server", "create_read_only_server", "create_server", ), "docforge.models": ( "Edge", "LogicEdge", "LogicNode", "LogicProjection", "Node", "ProjectDescriptor", "ProjectService", "ProjectSnapshot", ), "docforge.policy": ( "EffectivePolicyV1", "capability_mode", "compose_effective_policy", ), "docforge.projection_policy": ( "ProjectionPolicyV2", "compose_projection_policy", "validate_live_viewer_projection_mode", "validate_manual_projection_mode", "validate_portable_graph_projection_mode", ), "docforge.projection_fragments": ( "FragmentKey", "FragmentRecord", "ProjectionFragmentCache", "fragment_semantic_hash", ), "docforge.projection_contract": ( "GraphViewPlanV1", "ManualRenderPlanV1", "ProjectionArtifact", "ProjectionPackageV1", "ProjectionReceiptV1", "ProjectionRenderResult", "canonical_projection_bytes", "projection_hash", ), "docforge.projection_worker": ("render_projection_in_worker",), "docforge.reference_config": ( "REFERENCE_ADAPTER_CONFIG", "REFERENCE_ADAPTER_SCHEMA_VERSION", "REFERENCE_LANGUAGES", "ReferenceAdapterConfigV1", "load_reference_adapter_config", ), "docforge.reference_mcp": ( "REFERENCE_MCP_MODULE", "create_reference_project", "create_reference_server", ), "docforge.retrieval": ( "ContextCapsuleV1", "RetrievalPlanV1", "build_retrieval_plan", "relation_category", "validate_retrieval_plan", ), "docforge.render_contract": ( "GenericHtmlRenderer", "PreparedRender", "Renderer", "renderer_for", ), "docforge_renderers.manual": ("ManualHtmlRenderer",), "docforge_renderers.graph": ("PortableGraphHtmlRenderer",), } EXPECTED_ENTRY_POINTS = { "docforge": "docforge.cli:main", "docforge-mcp": "docforge.mcp_server:main", "docforge-viewer-manager": "docforge.viewer_manager:main", } EXPECTED_CLI_COMMANDS = { "apply", "backlinks", "build", "check", "configure", "context", "dependencies", "doctor", "filter", "generation-diff", "graph-plan", "graph-render", "graph-render-status", "impact", "info", "onboard", "preview", "reindex", "render", "render-status", "search", "show", "sync", "validate", "validate-index", "visualization-status", "visualization-stop", "visualize", } EXPECTED_MCP_TOOLS = { "docforge_abandon_changeset", "docforge_apply_changeset", "docforge_backlinks", "docforge_bootstrap", "docforge_create_changeset", "docforge_dependencies", "docforge_filter_nodes", "docforge_get_changeset", "docforge_get_changeset_diff", "docforge_get_context", "docforge_get_generation_diff", "docforge_get_task_context", "docforge_get_contract", "docforge_get_logic", "docforge_get_node", "docforge_impact", "docforge_list_changesets", "docforge_preview_changeset", "docforge_project_info", "docforge_propose_node_create", "docforge_propose_node_delete", "docforge_propose_node_move", "docforge_propose_node_update", "docforge_propose_relationship_update", "docforge_rebase_changeset", "docforge_register_changes", "docforge_render_status", "docforge_graph_plan", "docforge_graph_render_status", "docforge_search", "docforge_stop_visualization", "docforge_sync", "docforge_validate_changeset", "docforge_validate_project", "docforge_visualization_status", "docforge_visualize", } class PublicContractTests(unittest.TestCase): def copy_fixture(self, destination: Path) -> Path: root = destination / "alpha" shutil.copytree(FIXTURES / "alpha", root) return root @staticmethod def schema(name: str) -> dict[str, object]: return json.loads((SCHEMAS / name).read_text(encoding="utf-8")) def test_distribution_version_entry_points_and_imports_are_stable(self) -> None: configuration = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) project = configuration["project"] self.assertEqual("docforge", project["name"]) self.assertEqual(["version"], project["dynamic"]) self.assertEqual( "src/docforge/_version.py", configuration["tool"]["hatch"]["version"]["path"], ) self.assertEqual(docforge.__version__, SERVER_VERSION) scripts = project["scripts"] for name, target in EXPECTED_ENTRY_POINTS.items(): self.assertEqual(target, scripts[name]) for module_name, names in PUBLIC_IMPORTS.items(): module = importlib.import_module(module_name) for name in names: with self.subTest(module=module_name, name=name): self.assertTrue(hasattr(module, name)) version_surfaces = { "docforge.cli": "docforge 2.0.0\n", "docforge.mcp_server": "docforge-mcp 2.0.0\n", "docforge.reference_mcp": "python -m docforge.reference_mcp 2.0.0\n", "docforge.viewer_manager": "docforge-viewer-manager 2.0.0\n", } for module_name, expected in version_surfaces.items(): with self.subTest(module=module_name): completed = subprocess.run( [sys.executable, "-m", module_name, "--version"], cwd=ROOT, check=True, capture_output=True, text=True, ) self.assertEqual(expected, completed.stdout) def test_cli_and_mcp_names_remain_additively_compatible(self) -> None: parser = _parser() commands = next( action for action in parser._actions if isinstance(action, argparse._SubParsersAction) ) self.assertLessEqual(EXPECTED_CLI_COMMANDS, set(commands.choices)) self.assertLessEqual( EXPECTED_MCP_TOOLS, set((*ALL_TOOLS, *APPLICATION_TOOLS)), ) self.assertEqual((*READ_TOOLS, *PROPOSAL_TOOLS), ALL_TOOLS) completed = subprocess.run( [sys.executable, "-m", "docforge.mcp_server", "--help"], cwd=ROOT, check=True, capture_output=True, text=True, ) self.assertIn("--project-root", completed.stdout) self.assertIn("--no-ast", completed.stdout) self.assertIn("--manual-render-policy", completed.stdout) self.assertIn("--portable-graph-policy", completed.stdout) self.assertIn("--live-viewer-policy", completed.stdout) def test_published_schemas_validate_their_current_contract_examples(self) -> None: for path in sorted(SCHEMAS.glob("*.json")): with self.subTest(schema=path.name): Draft202012Validator.check_schema(json.loads(path.read_text(encoding="utf-8"))) descriptor = tomllib.loads( (FIXTURES / "alpha" / ".docforge" / "project.toml").read_text(encoding="utf-8") ) Draft202012Validator(self.schema("project.schema.json")).validate(descriptor) with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory)) project = Project.open(root) snapshot = project.load() node = snapshot.nodes[0] node_metadata = { "schema_version": 1, "id": node.node_id, "title": node.title, "family": node.family, "authority": node.authority, "status": node.status, "tags": list(node.tags), "summary": node.summary, } Draft202012Validator(self.schema("node.schema.json")).validate(node_metadata) edge = snapshot.edges[0] Draft202012Validator(self.schema("edge.schema.json")).validate( { "source_id": edge.source_id, "relation": edge.relation, "target_id": edge.target_id, } ) store = ChangesetStore(project, "alpha-editor") store.create("public-contract") changeset = json.loads( (root / ".docforge" / "changesets" / "public-contract.json").read_text( encoding="utf-8" ) ) Draft202012Validator(self.schema("changeset.schema.json")).validate(changeset) service = DocForgeService(project) ProjectIndex(project).build() success = service.project_info() error = service.invoke(lambda: service.index.get_node("missing.public-contract-node")) result_validator = Draft202012Validator(self.schema("result.schema.json")) result_validator.validate(success) result_validator.validate(error) generation_diff = service.generation_diff(limit=1) result_validator.validate(generation_diff) Draft202012Validator(self.schema("generation-diff-page.schema.json")).validate( { "generation_diff": generation_diff["generation_diff"], "pagination": generation_diff["pagination"], } ) def test_changeset_hash_is_exact_canonical_json_sha256(self) -> None: document = { "schema_version": 1, "changeset_id": "hash-contract", "project_id": "alpha-docs", "root_fingerprint": "0" * 16, "base_revision": "unversioned", "base_source_hash": "1" * 64, "creator": "alpha-editor", "operations": [], } expected = hashlib.sha256(canonical_bytes(document)).hexdigest() self.assertEqual(expected, document_hash(document)) if __name__ == "__main__": unittest.main()