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

Merge adapter lifecycle safeguards

This commit is contained in:
Andraxion 2026-07-29 02:59:24 -04:00
commit 15a913003c
11 changed files with 541 additions and 11 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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."""

View file

@ -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()