diff --git a/ACTIVE_SLICE.md b/ACTIVE_SLICE.md index 6a4db97..1c4ec61 100644 --- a/ACTIVE_SLICE.md +++ b/ACTIVE_SLICE.md @@ -1,13 +1,13 @@ # Active slice ```text -Slice: DFG-22 deterministic incremental adapter assembly -Goal: Let language frontends cache repeated raw source evidence while publishing one deterministic graph without maintaining a second project-owned extraction cache. -In scope: Optional generic assembly contract; cached contribution assembly; manifest-bound identity checks; final Logic ownership checks; overlapping-evidence tests; incremental documentation and onboarding guidance. -Out of scope: Language-specific merge rules; bundled C++, Rust, Java, or other frontends; changes to the default unique-ownership path; hidden source reads during assembly; project-owned cache formats; source mutation; deployment or publication. -Done when: An adapter can cache overlapping source contributions, deterministically assemble one valid node, reuse the warm cache, reparse only one changed source, preserve the selected published fact, pass full/incremental equivalence, and the complete DocForge quality gate passes. -Owners: DocForge owns cached contribution delivery, the optional assembly boundary, identity validation, and final graph validation. The adapter owns deterministic evidence selection and merge semantics. Canonical source files remain authoritative. -Proof: The focused adapter and onboarding suite passed 14 tests. Strict Pyright passed with no errors or warnings. Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality gate passed. The complete warning-strict suite passed 82 tests and 2 subtests. The overlap fixture cached two source contributions, published one deterministic node, reused both warm entries, reparsed only one changed source, preserved the selected node, and passed full/incremental equivalence. +Slice: DFG-23 process-stable adapter implementation boundary +Goal: Prevent a long-running project server from synchronizing with adapter code or configuration that changed after the adapter object was imported. +In scope: Language-neutral implementation roots/files/suffixes; project-local Python package inference; descriptor fingerprinting; bounded change evidence; restart-required MCP remediation; staged/unstaged deletion contract guidance. +Out of scope: In-process Python module reloading; MCP self-restart; project-specific Git enumeration; canonical source mutation; deployment or publication. +Done when: Every MCP operation rejects added, changed, deleted, missing, or unsafe adapter implementation files before synchronization; derived files outside the declared boundary remain ignored; descriptor changes require restart; deletion semantics remain manifest-owned and staging-independent; the complete DocForge quality gate passes. +Owners: DocForge owns implementation-boundary confinement, fingerprinting, bounded diagnostics, MCP preflight, and restart remediation. Adapters own the declared boundary and current source-manifest enumeration. Canonical sources and Git staging remain outside this lifecycle guard. +Proof: The focused adapter suite passed 15 tests, including inferred and explicit implementation boundaries, descriptor changes, additions, edits, deletions, ignored derived files, and exact MCP remediation. Strict Pyright passed with no errors or warnings. Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality gate passed. The complete warning-strict suite passed 87 tests and 2 subtests. ``` **Next gate:** Prove Worldforge's C++ integration against the generic incremental and assembly diff --git a/README.md b/README.md index b3df9c6..64d9fc5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ declared manuals, visualizes project structure, and manages reviewable documenta - Applies one explicitly approved changeset hash through CLI or gated MCP. - Supports opt-in incremental adapters with reverse-dependency invalidation and full-build equivalence checks. +- Detects project-local adapter implementation and configuration changes and requires a fresh + project-bound process before any further MCP work. - Keeps function-scoped control-flow projections separate from the primary architecture graph. - Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web, function-scoped Logic, source inspection, and branch-aware node hiding. diff --git a/SLICE_HISTORY.md b/SLICE_HISTORY.md index 7f0e421..aae15cd 100644 --- a/SLICE_HISTORY.md +++ b/SLICE_HISTORY.md @@ -1,5 +1,29 @@ # Completed slices +## DFG-23 process-stable adapter implementation boundary + +### Changed + +- Added a confined implementation boundary with explicit roots, files, and suffixes. +- Inferred the project-local Python package containing a loader and included its declared + descriptor without requiring every existing adapter to opt in manually. +- Fingerprinted relative implementation paths and bytes at process start under fixed file and byte + limits. +- Rejected edits, additions, deletions, missing files, and unsafe replacements with + `adapter_restart_required` before MCP synchronization or proposal validation. +- Returned bounded change evidence and explicit non-retryable `restart_project_server` + remediation. +- Clarified that current source manifests must treat staged and unstaged deletions identically; + Git staging is not a synchronization operation. + +### Verification + +- Focused tests cover inferred and explicit implementation boundaries, descriptor changes, added, + edited, and deleted files, ignored derived files, and exact MCP remediation. +- Strict Pyright, Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality + gate passed. +- The complete warning-strict suite passed 87 tests and 2 subtests. + ## DFG-22 deterministic incremental adapter assembly ### Changed diff --git a/docs/ADAPTER_AUTHORING_GUIDE.md b/docs/ADAPTER_AUTHORING_GUIDE.md index cb6dff1..f77c3fe 100644 --- a/docs/ADAPTER_AUTHORING_GUIDE.md +++ b/docs/ADAPTER_AUTHORING_GUIDE.md @@ -296,6 +296,34 @@ The incremental path is: Missing, incompatible, or corrupt cache data is a cache miss. It must never become a partial graph or replace the last valid index. +### Declare the process-stable adapter implementation boundary + +An adapter object is loaded once when its project-bound process starts. Source synchronization can +refresh the graph, but it cannot safely replace already imported adapter code in place. + +`AdapterProject` automatically fingerprints a project-local Python package containing the loader +class and a declared descriptor file. Declare a broader or non-Python boundary explicitly when the +adapter uses helpers, configuration, schemas, or templates outside that inferred package: + +```python +settings = AdapterProjectSettings( + implementation=AdapterImplementation( + roots=(project_root / "docforge_adapter",), + files=(project_root / ".docforge" / "project.toml",), + suffixes=(".py", ".toml"), + ), +) +``` + +The boundary is confined to the project root and limited to 4,096 files and 64,000,000 bytes. +DocForge fingerprints relative paths and bytes. An added, changed, deleted, missing, or symlinked +implementation file produces `adapter_restart_required` before another MCP operation. Restart the +project-bound server; do not use Python module reloading to mutate a live adapter graph. + +The manifest remains a current source snapshot, not a Git-index snapshot. A Git-backed adapter must +omit a deleted source whether its deletion is unstaged or staged. Staging is never a required +DocForge synchronization step. + ## Step 9: keep the complete path independent The full rebuild must not read the incremental extraction cache. Otherwise equivalence compares @@ -344,7 +372,9 @@ An adapter is not complete until these cases pass: | Shared header/module change | Every reverse dependent reparses | | Added source | New contribution appears without stale duplicates | | Renamed source | Old contribution disappears and new identity follows policy | -| Deleted source | Owned nodes, relationships, and Logic disappear | +| Unstaged and staged deleted source | Owned nodes, relationships, and Logic disappear identically | +| Adapter implementation edit/add/delete | `adapter_restart_required` before synchronization | +| Adapter descriptor/configuration edit | `adapter_restart_required` before synchronization | | Build flags/features change | Affected units invalidate | | Extractor version change | Old contributions invalidate | | Corrupt cache | Clean recovery without partial publication | @@ -445,4 +475,3 @@ For every such change: - [ ] Session composition and family isolation are proven. - [ ] Viewer, query, context, and Logic retrieval are proven. - [ ] Performance, graph shape, unsupported facts, and version rules are recorded. - diff --git a/docs/INCREMENTAL_INDEXING.md b/docs/INCREMENTAL_INDEXING.md index 2e05ec9..4abb941 100644 --- a/docs/INCREMENTAL_INDEXING.md +++ b/docs/INCREMENTAL_INDEXING.md @@ -96,10 +96,27 @@ DocForge invalidates a source when: Deleted sources are omitted from the candidate projection. Their cached dependency declarations remain available long enough to invalidate surviving dependents. +The manifest describes the current supported filesystem snapshot. It must not retain a missing file +only because Git still tracks it, and it must not require staging before a deletion disappears. +Git-backed adapters must prove that staged and unstaged deletions produce the same current source +set. DocForge then removes the omitted contribution and invalidates its surviving reverse +dependents. + If an adapter cannot precisely describe the affected sources, it should declare broader dependencies or change its adapter/extractor version. Incorrectly retaining a stale relationship is never an acceptable optimization. +## Adapter implementation lifecycle + +Graph source changes are synchronizable. Changes to the code or configuration implementing the +adapter are not. + +`AdapterProject` fingerprints the inferred or explicitly declared implementation boundary when the +project process starts. Every MCP operation validates that boundary before work begins. Changes to +implementation paths or bytes return `adapter_restart_required` with bounded added, changed, and +deleted path evidence. The error is intentionally not auto-repaired through `docforge_sync`; a +fresh process must import and validate the current adapter. + ## Build reporting `build` and `reindex` include an extraction report: diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index a9a10f3..7b2ffa6 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -38,6 +38,11 @@ returns the complete fixed binding, active index path, proposal and application recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly. Neither operation changes canonical sources. +Adapter-backed servers also validate their process-start implementation fingerprint before every +tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is +`restart_project_server`; the current process does not reload project code, update Git staging, or +continue with a newly changed validator. + The normal command binds the generic project loader. An explicit project integration may instead construct the same read-only surface from a validated `ProjectService` and project-owned context provider. This form cannot register proposal tools. Project discovery, session selection, family diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 9cecd79..62316fc 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -677,6 +677,16 @@ without a source-analysis rewrite. ## Troubleshooting +### `adapter_restart_required` + +The project-local adapter code, its declared descriptor, or another implementation file changed +after the project-bound MCP process started. DocForge rejects every further operation before +synchronization because the live Python objects still represent the prior implementation. + +Restart the MCP server or start a fresh client session. Do not stage files merely to change the +adapter's source manifest, and do not attempt in-process module reloading. The error includes +bounded added, changed, and deleted path evidence to identify the changed implementation boundary. + ### `stale_index` or `visualization_stale` Normal MCP operations automatically repair a missing, stale, or invalid disposable index under a diff --git a/src/docforge/adapter_contract.py b/src/docforge/adapter_contract.py index f2d6f4d..afabf43 100644 --- a/src/docforge/adapter_contract.py +++ b/src/docforge/adapter_contract.py @@ -3,9 +3,10 @@ from __future__ import annotations import hashlib +import inspect import json from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Protocol, runtime_checkable @@ -179,6 +180,18 @@ ProposalValidator = Callable[ ], None, ] +MAX_IMPLEMENTATION_DIFF_PATHS = 50 +MAX_IMPLEMENTATION_FILES = 4_096 +MAX_IMPLEMENTATION_BYTES = 64_000_000 + + +@dataclass(frozen=True) +class AdapterImplementation: + """One confined implementation boundary that must remain stable for a process.""" + + roots: tuple[Path, ...] = () + files: tuple[Path, ...] = () + suffixes: tuple[str, ...] = () @dataclass(frozen=True) @@ -194,6 +207,13 @@ class AdapterProjectSettings: render: RenderConfig | None = None limits: Limits | None = None proposal_validator: ProposalValidator | None = None + implementation: AdapterImplementation | None = None + + +@dataclass(frozen=True) +class _ImplementationSnapshot: + fingerprint: str + files: tuple[tuple[str, str], ...] class AdapterProject: @@ -262,6 +282,12 @@ class AdapterProject: adapter_version, root, ) + self._implementation = self._validate_implementation( + root, + loader, + self.settings.implementation, + descriptor_path=self.settings.descriptor_path, + ) self._cache_path = resolved_cache / "extractions.json" self._last_build_report: dict[str, object] = { "mode": "full", @@ -341,8 +367,10 @@ class AdapterProject: limits=limits, ) self._canonical_sources = canonical_sources + self._implementation_snapshot = self._capture_implementation(initial=True) def load(self) -> ProjectSnapshot: + self.validate_runtime() canonical_sources = self.canonical_source_paths() captured = {path: path.read_bytes() for path in canonical_sources} projection = ( @@ -359,6 +387,7 @@ class AdapterProject: ) if identity != self._identity: raise DocForgeError("adapter_changed", "Adapter identity changed during the operation") + self.validate_runtime() if self.canonical_source_paths() != canonical_sources or any( not path.is_file() or path.read_bytes() != raw for path, raw in captured.items() ): @@ -382,6 +411,7 @@ class AdapterProject: def incremental_state(self) -> ProjectState | None: """Return current source identity without reconstructing the complete projection.""" + self.validate_runtime() loader = self._incremental_loader if loader is None: return None @@ -391,6 +421,7 @@ class AdapterProject: validate_manifest(manifest) if manifest.identity() != self._identity: raise DocForgeError("adapter_changed", "Adapter identity changed during the operation") + self.validate_runtime() if self.canonical_source_paths() != canonical_sources or any( not path.is_file() or path.read_bytes() != raw for path, raw in captured.items() ): @@ -412,6 +443,45 @@ class AdapterProject: return dict(self._last_build_report) + def validate_runtime(self) -> None: + """Reject use after declared adapter implementation files change.""" + + expected = self._implementation_snapshot + if expected is None: + return + current = self._capture_implementation() + if current is None: + raise DocForgeError( + "adapter_restart_required", + "Adapter implementation policy changed after the project server started", + ) + if current == expected: + return + expected_files = dict(expected.files) + current_files = dict(current.files) + added = sorted(set(current_files) - set(expected_files)) + deleted = sorted(set(expected_files) - set(current_files)) + changed = sorted( + path + for path in set(expected_files) & set(current_files) + if expected_files[path] != current_files[path] + ) + raise DocForgeError( + "adapter_restart_required", + "Adapter implementation changed after the project server started", + started_fingerprint=expected.fingerprint, + current_fingerprint=current.fingerprint, + added_count=len(added), + deleted_count=len(deleted), + changed_count=len(changed), + added=added[:MAX_IMPLEMENTATION_DIFF_PATHS], + deleted=deleted[:MAX_IMPLEMENTATION_DIFF_PATHS], + changed=changed[:MAX_IMPLEMENTATION_DIFF_PATHS], + paths_truncated=any( + len(paths) > MAX_IMPLEMENTATION_DIFF_PATHS for paths in (added, deleted, changed) + ), + ) + def logic_projection(self, owner_node_id: str) -> LogicProjection | None: """Load one lazily stored function-scoped logic projection.""" @@ -433,6 +503,7 @@ class AdapterProject: def verify_incremental_equivalence(self) -> dict[str, object]: """Prove the incremental and full loader contracts produce the same graph.""" + self.validate_runtime() if self._incremental_loader is None: raise DocForgeError( "incremental_disabled", "Adapter does not implement incremental extraction" @@ -456,6 +527,7 @@ class AdapterProject: "Incremental extraction does not match a full adapter projection", fields=mismatches, ) + self.validate_runtime() return { "status": "ok", "project_id": incremental.project_id, @@ -656,6 +728,7 @@ class AdapterProject: projected: ProjectSnapshot, operations: tuple[Mapping[str, object], ...], ) -> None: + self.validate_runtime() validator = self.settings.proposal_validator if validator is None: if operations: @@ -665,6 +738,179 @@ class AdapterProject: ) return validator(base, projected, operations) + self.validate_runtime() + + @classmethod + def _validate_implementation( + cls, + root: Path, + loader: AdapterLoader, + implementation: AdapterImplementation | None, + *, + descriptor_path: Path | None, + ) -> AdapterImplementation | None: + if implementation is None: + implementation = cls._infer_implementation(root, loader) + if descriptor_path is not None and ( + implementation is None + or descriptor_path.resolve(strict=False) + not in {path.resolve(strict=False) for path in implementation.files} + ): + implementation = ( + AdapterImplementation(files=(descriptor_path,)) + if implementation is None + else replace( + implementation, + files=(*implementation.files, descriptor_path), + ) + ) + if implementation is None: + return None + roots = cls._resolved_directories( + root, + implementation.roots, + label="implementation root", + ) + files = cls._resolved_files( + root, + implementation.files, + label="implementation file", + ) + suffixes = tuple(sorted(implementation.suffixes)) + if ( + not roots + and not files + or len(suffixes) != len(set(suffixes)) + or any( + not suffix or not suffix.startswith(".") or "/" in suffix or "\\" in suffix + for suffix in suffixes + ) + ): + raise DocForgeError( + "invalid_adapter", + "Adapter implementation policy is invalid", + ) + return AdapterImplementation(roots=roots, files=files, suffixes=suffixes) + + @staticmethod + def _infer_implementation( + root: Path, + loader: AdapterLoader, + ) -> AdapterImplementation | None: + source = inspect.getsourcefile(type(loader)) + if source is None: + return None + try: + source_path = Path(source).resolve(strict=True) + except OSError: + return None + if not source_path.is_file() or not source_path.is_relative_to(root): + return None + module = inspect.getmodule(type(loader)) + package = module.__package__.strip() if module and module.__package__ else "" + if package: + package_root = source_path.parent + for _ in package.split(".")[1:]: + package_root = package_root.parent + if package_root != root and (package_root / "__init__.py").is_file(): + return AdapterImplementation(roots=(package_root,), suffixes=(".py",)) + return AdapterImplementation(files=(source_path,)) + + def _capture_implementation( + self, + *, + initial: bool = False, + ) -> _ImplementationSnapshot | None: + implementation = self._implementation + if implementation is None: + return None + root = self.descriptor.root + candidates = set(implementation.files) + if len(candidates) > MAX_IMPLEMENTATION_FILES: + self._raise_implementation_boundary_error( + initial, + "Adapter implementation boundary exceeds its file limit", + max_files=MAX_IMPLEMENTATION_FILES, + ) + for implementation_root in implementation.roots: + if ( + implementation_root.is_symlink() + or not implementation_root.is_dir() + or not implementation_root.resolve(strict=False).is_relative_to(root) + ): + candidates.add(implementation_root) + continue + for path in implementation_root.rglob("*"): + if path.is_symlink() or ( + (not implementation.suffixes or path.suffix in implementation.suffixes) + and path.is_file() + ): + candidates.add(path) + if len(candidates) > MAX_IMPLEMENTATION_FILES: + self._raise_implementation_boundary_error( + initial, + "Adapter implementation boundary exceeds its file limit", + max_files=MAX_IMPLEMENTATION_FILES, + ) + captured: list[tuple[str, str]] = [] + unsafe: list[str] = [] + total_bytes = 0 + for path in sorted(candidates): + try: + relative = path.relative_to(root).as_posix() + if ( + path.is_symlink() + or not path.is_file() + or not path.resolve(strict=True).is_relative_to(root) + ): + unsafe.append(relative) + continue + size = path.stat().st_size + if size > MAX_IMPLEMENTATION_BYTES - total_bytes: + self._raise_implementation_boundary_error( + initial, + "Adapter implementation boundary exceeds its byte limit", + max_bytes=MAX_IMPLEMENTATION_BYTES, + ) + raw = path.read_bytes() + total_bytes += len(raw) + if total_bytes > MAX_IMPLEMENTATION_BYTES: + self._raise_implementation_boundary_error( + initial, + "Adapter implementation boundary exceeds its byte limit", + max_bytes=MAX_IMPLEMENTATION_BYTES, + ) + captured.append((relative, hashlib.sha256(raw).hexdigest())) + except (OSError, ValueError): + try: + unsafe.append(path.relative_to(root).as_posix()) + except ValueError: + unsafe.append(str(path)) + if unsafe: + self._raise_implementation_boundary_error( + initial, + "Adapter implementation boundary became missing or unsafe", + unsafe=sorted(unsafe), + ) + digest = hashlib.sha256() + for relative, content_hash in captured: + encoded = relative.encode() + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + digest.update(bytes.fromhex(content_hash)) + return _ImplementationSnapshot(digest.hexdigest(), tuple(captured)) + + @staticmethod + def _raise_implementation_boundary_error( + initial: bool, + message: str, + **details: object, + ) -> None: + raise DocForgeError( + "invalid_adapter" if initial else "adapter_restart_required", + message, + **details, + ) @staticmethod def _resolved_directories( diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index ba249f2..af1919d 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -15,7 +15,7 @@ from .changesets import ChangesetStore from .context import compile_context from .errors import DocForgeError from .index import ProjectIndex -from .models import ProjectService +from .models import ProjectService, RuntimeValidatedProject from .project import Project, project_root_fingerprint from .rendering import RenderService from .viewer_manager import ViewerManagerClient @@ -80,6 +80,7 @@ EXCLUDED_OPERATIONS = ( STALE_ERROR_CODES = frozenset( { "base_conflict", + "adapter_restart_required", "content_conflict", "source_changed", "stale_adapter_source", @@ -166,6 +167,8 @@ class DocForgeService: synchronization: dict[str, object] | None = None try: try: + if isinstance(self.project, RuntimeValidatedProject): + self.project.validate_runtime() result: dict[str, Any] = operation() except DocForgeError as error: if not synchronize or error.code not in RECOVERABLE_INDEX_ERROR_CODES: @@ -229,6 +232,11 @@ class DocForgeService: @staticmethod def _remediation(error: DocForgeError) -> dict[str, object] | None: + if error.code == "adapter_restart_required": + return { + "retryable": False, + "action": "restart_project_server", + } if error.code in {"missing_index", "stale_index", "invalid_index"}: return { "retryable": True, diff --git a/src/docforge/models.py b/src/docforge/models.py index 5d1f328..10a6b67 100644 --- a/src/docforge/models.py +++ b/src/docforge/models.py @@ -204,6 +204,13 @@ class IncrementalStateProject(ProjectService, Protocol): def incremental_state(self) -> ProjectState | None: ... +@runtime_checkable +class RuntimeValidatedProject(ProjectService, Protocol): + """Optional project boundary that proves its loaded implementation is current.""" + + def validate_runtime(self) -> None: ... + + @runtime_checkable class LogicProject(ProjectService, Protocol): """Optional project boundary exposing logic from its most recent validated load.""" diff --git a/tests/test_adapter_contract.py b/tests/test_adapter_contract.py index f4319d8..b125dae 100644 --- a/tests/test_adapter_contract.py +++ b/tests/test_adapter_contract.py @@ -1,7 +1,9 @@ from __future__ import annotations import hashlib +import importlib import sqlite3 +import sys import tempfile import unittest from collections.abc import Mapping @@ -14,6 +16,7 @@ from mcp.shared.memory import create_connected_server_and_client_session from docforge.adapter_contract import ( AdapterAssembly, AdapterEdge, + AdapterImplementation, AdapterManifest, AdapterNode, AdapterProject, @@ -346,6 +349,146 @@ class AdapterContractTests(unittest.TestCase): with self.assertRaisesRegex(DocForgeError, "confined"): AdapterProject(Loader(self.projection(root)), cache_root=outside) + def test_adapter_implementation_changes_require_a_process_restart(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + implementation_root = root / "adapter" + implementation_root.mkdir() + implementation = implementation_root / "loader.py" + implementation.write_text("VERSION = 1\n", encoding="utf-8") + ignored = implementation_root / "loader.pyc" + ignored.write_bytes(b"derived") + project = AdapterProject( + Loader(self.projection(root)), + cache_root=root / ".cache" / "shadow", + settings=AdapterProjectSettings( + implementation=AdapterImplementation( + roots=(implementation_root,), + suffixes=(".py",), + ) + ), + ) + ProjectIndex(project).build() + + ignored.write_bytes(b"changed derived state") + project.validate_runtime() + implementation.write_text("VERSION = 2\n", encoding="utf-8") + + with self.assertRaises(DocForgeError) as captured: + project.validate_runtime() + self.assertEqual("adapter_restart_required", captured.exception.code) + self.assertEqual(["adapter/loader.py"], captured.exception.details["changed"]) + self.assertEqual([], captured.exception.details["added"]) + self.assertEqual([], captured.exception.details["deleted"]) + self.assertEqual(1, captured.exception.details["changed_count"]) + self.assertFalse(captured.exception.details["paths_truncated"]) + + def test_adapter_implementation_additions_and_deletions_require_a_restart(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + implementation_root = root / "adapter" + implementation_root.mkdir() + original = implementation_root / "loader.py" + original.write_text("VERSION = 1\n", encoding="utf-8") + project = AdapterProject( + Loader(self.projection(root)), + cache_root=root / ".cache" / "shadow", + settings=AdapterProjectSettings( + implementation=AdapterImplementation( + roots=(implementation_root,), + suffixes=(".py",), + ) + ), + ) + + added = implementation_root / "helpers.py" + added.write_text("VALUE = 1\n", encoding="utf-8") + with self.assertRaises(DocForgeError) as addition: + project.load() + self.assertEqual("adapter_restart_required", addition.exception.code) + self.assertEqual(["adapter/helpers.py"], addition.exception.details["added"]) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + implementation_root = root / "adapter" + implementation_root.mkdir() + original = implementation_root / "loader.py" + original.write_text("VERSION = 1\n", encoding="utf-8") + project = AdapterProject( + Loader(self.projection(root)), + cache_root=root / ".cache" / "shadow", + settings=AdapterProjectSettings( + implementation=AdapterImplementation( + roots=(implementation_root,), + suffixes=(".py",), + ) + ), + ) + + original.unlink() + with self.assertRaises(DocForgeError) as deletion: + project.incremental_state() + self.assertEqual("adapter_restart_required", deletion.exception.code) + self.assertEqual(["adapter/loader.py"], deletion.exception.details["deleted"]) + + def test_adapter_implementation_is_inferred_from_a_project_local_package(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + package = root / "adapter_fixture_dynamic" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "loader.py").write_text( + "class Loader:\n" + " def __init__(self, projection):\n" + " self.projection = projection\n" + " def load_projection(self):\n" + " return self.projection\n", + encoding="utf-8", + ) + sys.path.insert(0, str(root)) + try: + module = importlib.import_module("adapter_fixture_dynamic.loader") + project = AdapterProject( + module.Loader(self.projection(root)), + cache_root=root / ".cache" / "shadow", + ) + (package / "helper.py").write_text("VALUE = 1\n", encoding="utf-8") + with self.assertRaises(DocForgeError) as captured: + project.validate_runtime() + finally: + sys.path.remove(str(root)) + sys.modules.pop("adapter_fixture_dynamic.loader", None) + sys.modules.pop("adapter_fixture_dynamic", None) + + self.assertEqual("adapter_restart_required", captured.exception.code) + self.assertEqual( + ["adapter_fixture_dynamic/helper.py"], + captured.exception.details["added"], + ) + + def test_adapter_descriptor_changes_require_a_restart(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + descriptor_root = root / ".docforge" + descriptor_root.mkdir() + descriptor = descriptor_root / "project.toml" + descriptor.write_text("adapter_version = 1\n", encoding="utf-8") + project = AdapterProject( + Loader(self.projection(root)), + cache_root=root / ".cache" / "shadow", + settings=AdapterProjectSettings(descriptor_path=descriptor), + ) + + descriptor.write_text("adapter_version = 2\n", encoding="utf-8") + with self.assertRaises(DocForgeError) as captured: + project.validate_runtime() + + self.assertEqual("adapter_restart_required", captured.exception.code) + self.assertEqual( + [".docforge/project.toml"], + captured.exception.details["changed"], + ) + def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() @@ -547,6 +690,45 @@ class AdapterContractTests(unittest.TestCase): class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase): + async def test_mcp_reports_adapter_restart_remediation_without_synchronizing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + implementation_root = root / "adapter" + implementation_root.mkdir() + implementation = implementation_root / "loader.py" + implementation.write_text("VERSION = 1\n", encoding="utf-8") + fixture = AdapterContractTests() + project = AdapterProject( + Loader(fixture.projection(root)), + cache_root=root / ".cache" / "adapter-read-only", + settings=AdapterProjectSettings( + implementation=AdapterImplementation( + roots=(implementation_root,), + suffixes=(".py",), + ) + ), + ) + ProjectIndex(project).build() + server = create_read_only_server(project) + implementation.write_text("VERSION = 2\n", encoding="utf-8") + + async with create_connected_server_and_client_session( + server, raise_exceptions=True + ) as session: + result = await session.call_tool("docforge_project_info", {}) + + self.assertEqual("error", result.structuredContent["status"]) + self.assertEqual( + "adapter_restart_required", + result.structuredContent["error"]["code"], + ) + self.assertEqual("stale", result.structuredContent["staleness"]) + self.assertEqual( + {"retryable": False, "action": "restart_project_server"}, + result.structuredContent["error"]["remediation"], + ) + self.assertNotIn("synchronization", result.structuredContent) + async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve()