diff --git a/tests/test_milestone5_migration.py b/tests/test_milestone5_migration.py new file mode 100644 index 0000000..04c41d0 --- /dev/null +++ b/tests/test_milestone5_migration.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import unittest + +from tools.milestone5_migration import build_migration_evidence + + +class Milestone5MigrationTests(unittest.TestCase): + def test_actual_v1_project_index_and_proposal_migrate_exactly(self) -> None: + evidence = build_migration_evidence() + + self.assertEqual("v1.0.0", evidence["tag"]) + self.assertEqual("1.4.0", evidence["current"]["version"]) + self.assertEqual(1, evidence["current"]["index_schema_before"]) + self.assertEqual(3, evidence["current"]["index_schema_after"]) + self.assertEqual( + { + "canonical_bytes_preserved": True, + "graph_preserved": True, + "proposal_preserved": True, + "legacy_index_rebuilt": True, + "cli_superset": True, + "mcp_superset": True, + "v1_version_mismatch_recorded": True, + }, + evidence["proofs"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/milestone5_migration.py b/tools/milestone5_migration.py new file mode 100644 index 0000000..8553d00 --- /dev/null +++ b/tools/milestone5_migration.py @@ -0,0 +1,368 @@ +"""Rehearse an actual v1.0.0 project, index, and proposal under DocForge 1.4.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import sqlite3 +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path +from typing import cast + +from docforge import __version__ +from docforge.changesets import ChangesetStore +from docforge.command_reference import cli_command_references +from docforge.index import INDEX_SCHEMA_VERSION, ProjectIndex +from docforge.mcp_server import ALL_TOOLS, APPLICATION_TOOLS, SERVER_VERSION +from docforge.models import ProjectSnapshot +from docforge.project import Project + +ROOT = Path(__file__).resolve().parents[1] +V1_TAG = "v1.0.0" +V1_RUNTIME_SCRIPT = r""" +import argparse +import hashlib +import json +import sqlite3 +import sys +from pathlib import Path + +import docforge +from docforge.changesets import ChangesetStore +from docforge.cli import _parser +from docforge.index import ProjectIndex +from docforge.mcp_server import ALL_TOOLS, APPLICATION_TOOLS, SERVER_VERSION +from docforge.project import Project + + +def digest_snapshot(snapshot): + document = { + "source_hash": snapshot.source_hash, + "revision": snapshot.revision, + "nodes": [ + { + "id": node.node_id, + "title": node.title, + "family": node.family, + "authority": node.authority, + "status": node.status, + "tags": list(node.tags), + "summary": node.summary, + "content": node.content, + "source_path": node.source_path, + "source_anchor": node.source_anchor, + "content_hash": node.content_hash, + } + for node in snapshot.nodes + ], + "edges": [ + { + "source_id": edge.source_id, + "relation": edge.relation, + "target_id": edge.target_id, + } + for edge in snapshot.edges + ], + } + encoded = json.dumps(document, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def digest_canonical(project): + digest = hashlib.sha256() + paths = ( + project.descriptor.descriptor_path, + *project.descriptor.authority_files, + *project.canonical_source_paths(), + ) + for path in sorted( + set(paths), + key=lambda value: value.relative_to(project.descriptor.root).as_posix(), + ): + relative = path.relative_to(project.descriptor.root).as_posix() + digest.update(relative.encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def command_names(): + parser = _parser() + for action in parser._actions: + if getattr(action, "dest", None) == "command": + return sorted(action.choices) + raise RuntimeError("v1 CLI command parser is missing") + + +root = Path(sys.argv[1]) +project = Project.open(root) +snapshot = project.load() +canonical_before = digest_canonical(project) +index = ProjectIndex(project) +index_result = index.build() +with sqlite3.connect(index.path) as connection: + index_schema = connection.execute("PRAGMA user_version").fetchone()[0] +store = ChangesetStore(project, "alpha-editor") +created = store.create("m5-migration") +node = next(item for item in snapshot.nodes if item.node_id == "guide.workflow") +proposed = store.propose_update( + changeset_id="m5-migration", + expected_changeset_hash=created["changeset_hash"], + node_id=node.node_id, + expected_content_hash=node.content_hash, + metadata={"summary": "A migration-preserved v1 proposal."}, + content=None, + relationship_changes=[], + rationale="Prove active proposal compatibility across the successor release.", +) +changeset_path = root / ".docforge/changesets/m5-migration.json" +print(json.dumps({ + "package_metadata_version": "1.0.0", + "module_version": docforge.__version__, + "server_version": SERVER_VERSION, + "snapshot_hash": digest_snapshot(snapshot), + "source_hash": snapshot.source_hash, + "revision": snapshot.revision, + "canonical_hash": canonical_before, + "canonical_hash_after_proposal": digest_canonical(project), + "changeset_hash": proposed["changeset_hash"], + "changeset_file_hash": hashlib.sha256(changeset_path.read_bytes()).hexdigest(), + "index_schema": index_schema, + "index_action": index_result.get("action", "built"), + "cli_commands": command_names(), + "mcp_tools": sorted((*ALL_TOOLS, *APPLICATION_TOOLS)), +}, sort_keys=True, separators=(",", ":"))) +""" + + +class MigrationProofError(RuntimeError): + """The actual v1 migration rehearsal changed authoritative evidence.""" + + +def _snapshot_hash(snapshot: ProjectSnapshot) -> str: + document = { + "source_hash": snapshot.source_hash, + "revision": snapshot.revision, + "nodes": [ + { + "id": node.node_id, + "title": node.title, + "family": node.family, + "authority": node.authority, + "status": node.status, + "tags": list(node.tags), + "summary": node.summary, + "content": node.content, + "source_path": node.source_path, + "source_anchor": node.source_anchor, + "content_hash": node.content_hash, + } + for node in snapshot.nodes + ], + "edges": [ + { + "source_id": edge.source_id, + "relation": edge.relation, + "target_id": edge.target_id, + } + for edge in snapshot.edges + ], + } + encoded = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _canonical_hash(project: Project) -> str: + digest = hashlib.sha256() + paths = ( + project.descriptor.descriptor_path, + *project.descriptor.authority_files, + *project.canonical_source_paths(), + ) + for path in sorted( + set(paths), + key=lambda value: value.relative_to(project.descriptor.root).as_posix(), + ): + relative = path.relative_to(project.descriptor.root).as_posix() + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _command_names() -> set[str]: + return {reference.name for reference in cli_command_references()} + + +def _extract_v1(destination: Path) -> Path: + completed = subprocess.run( + ["git", "archive", "--format=tar", V1_TAG], + cwd=ROOT, + check=False, + capture_output=True, + ) + if completed.returncode != 0: + raise MigrationProofError(completed.stderr.decode("utf-8", errors="replace").strip()) + with tarfile.open(fileobj=io.BytesIO(completed.stdout), mode="r:") as archive: + archive.extractall(destination, filter="data") + return destination + + +def _run_v1(root: Path) -> dict[str, object]: + environment = dict(os.environ) + environment["PYTHONPATH"] = str(root / "src") + completed = subprocess.run( + [sys.executable, "-c", V1_RUNTIME_SCRIPT, str(root / "tests/fixtures/alpha")], + cwd=root, + env=environment, + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise MigrationProofError(f"v1 runtime rehearsal failed: {completed.stderr.strip()}") + try: + value: object = json.loads(completed.stdout) + except json.JSONDecodeError as error: + raise MigrationProofError("v1 runtime returned invalid evidence") from error + if not isinstance(value, dict): + raise MigrationProofError("v1 runtime returned an invalid evidence shape") + return cast(dict[str, object], value) + + +def _index_schema(index: ProjectIndex) -> int: + connection = sqlite3.connect(index.path) + try: + row = connection.execute("PRAGMA user_version").fetchone() + finally: + connection.close() + if row is None or type(row[0]) is not int: + raise MigrationProofError("Derived index schema is unavailable") + return cast(int, row[0]) + + +def build_migration_evidence() -> dict[str, object]: + """Run the tagged v1 implementation, then load its state through the current release.""" + + with tempfile.TemporaryDirectory(prefix="docforge-m5-migration-") as directory_name: + v1_root = _extract_v1(Path(directory_name) / "v1") + v1 = _run_v1(v1_root) + project_root = v1_root / "tests/fixtures/alpha" + project = Project.open(project_root) + snapshot = project.load() + index = ProjectIndex(project) + schema_before = _index_schema(index) + changeset_path = project_root / ".docforge/changesets/m5-migration.json" + changeset_file_before = changeset_path.read_bytes() + canonical_before = _canonical_hash(project) + current_snapshot_hash = _snapshot_hash(snapshot) + if current_snapshot_hash != v1.get("snapshot_hash"): + raise MigrationProofError("Current loading changed the tagged v1 graph") + if canonical_before != v1.get("canonical_hash"): + raise MigrationProofError("Current loading changed tagged v1 canonical sources") + if v1.get("canonical_hash") != v1.get("canonical_hash_after_proposal"): + raise MigrationProofError("Tagged v1 proposal mutated canonical sources") + store = ChangesetStore(project, "alpha-editor") + inspected = store.inspect("m5-migration") + if inspected.get("changeset_hash") != v1.get("changeset_hash"): + raise MigrationProofError("Current loading changed the tagged v1 proposal hash") + build = index.build() + schema_after = _index_schema(index) + if schema_after != INDEX_SCHEMA_VERSION: + raise MigrationProofError("Current release did not rebuild the legacy index schema") + if _snapshot_hash(project.load()) != current_snapshot_hash: + raise MigrationProofError("Index migration changed the canonical graph") + if _canonical_hash(project) != canonical_before: + raise MigrationProofError("Index migration changed canonical sources") + if changeset_path.read_bytes() != changeset_file_before: + raise MigrationProofError("Index migration changed the active v1 proposal") + v1_commands = set(cast(list[str], v1.get("cli_commands"))) + v1_tools = set(cast(list[str], v1.get("mcp_tools"))) + if not v1_commands <= _command_names(): + raise MigrationProofError("Current CLI is missing a tagged v1 command") + if not v1_tools <= set((*ALL_TOOLS, *APPLICATION_TOOLS)): + raise MigrationProofError("Current MCP surface is missing a tagged v1 tool") + if __version__ != SERVER_VERSION: + raise MigrationProofError("Current package and MCP versions disagree") + evidence: dict[str, object] = { + "schema_version": 1, + "tag": V1_TAG, + "v1": v1, + "current": { + "version": __version__, + "server_version": SERVER_VERSION, + "snapshot_hash": current_snapshot_hash, + "source_hash": snapshot.source_hash, + "revision": snapshot.revision, + "canonical_hash": canonical_before, + "changeset_hash": inspected["changeset_hash"], + "changeset_file_hash": hashlib.sha256(changeset_file_before).hexdigest(), + "index_schema_before": schema_before, + "index_schema_after": schema_after, + "index_action": build.get("action", build.get("status")), + "cli_command_count": len(_command_names()), + "mcp_tool_count": len(set((*ALL_TOOLS, *APPLICATION_TOOLS))), + }, + "proofs": { + "canonical_bytes_preserved": True, + "graph_preserved": True, + "proposal_preserved": True, + "legacy_index_rebuilt": True, + "cli_superset": True, + "mcp_superset": True, + "v1_version_mismatch_recorded": ( + v1.get("package_metadata_version") == "1.0.0" + and v1.get("module_version") == "0.15.0" + and v1.get("server_version") == "0.15.0" + ), + }, + } + if not cast(dict[str, object], evidence["proofs"])["v1_version_mismatch_recorded"]: + raise MigrationProofError("The inherited v1 version mismatch was not reproduced") + return evidence + + +def _write_output(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=".m5-migration-", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path) + arguments = parser.parse_args() + try: + evidence = build_migration_evidence() + except (MigrationProofError, OSError, subprocess.SubprocessError) as error: + print(str(error), file=sys.stderr) + return 2 + payload = json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + if arguments.output is not None: + _write_output(arguments.output, payload) + print(payload.decode("utf-8"), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())