2026-07-22 04:17:05 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
import hashlib
|
2026-07-28 19:44:25 -04:00
|
|
|
import importlib
|
2026-07-26 09:32:25 -04:00
|
|
|
import sqlite3
|
2026-07-28 19:44:25 -04:00
|
|
|
import sys
|
2026-07-22 04:17:05 -04:00
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
2026-07-22 11:50:49 -04:00
|
|
|
from collections.abc import Mapping
|
2026-07-26 09:32:25 -04:00
|
|
|
from contextlib import closing
|
2026-07-22 04:17:05 -04:00
|
|
|
from dataclasses import replace
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-07-22 05:59:20 -04:00
|
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
from docforge.adapter_contract import (
|
2026-07-27 16:01:40 -04:00
|
|
|
AdapterAssembly,
|
2026-07-22 04:17:05 -04:00
|
|
|
AdapterEdge,
|
2026-07-28 19:44:25 -04:00
|
|
|
AdapterImplementation,
|
2026-07-25 19:08:39 -04:00
|
|
|
AdapterManifest,
|
2026-07-22 04:17:05 -04:00
|
|
|
AdapterNode,
|
|
|
|
|
AdapterProject,
|
|
|
|
|
AdapterProjection,
|
2026-07-22 11:50:49 -04:00
|
|
|
AdapterProjectSettings,
|
2026-07-25 19:08:39 -04:00
|
|
|
AdapterSource,
|
|
|
|
|
AdapterSourceProjection,
|
2026-07-22 04:17:05 -04:00
|
|
|
ShadowArtifact,
|
|
|
|
|
compare_artifacts,
|
|
|
|
|
validate_projection,
|
|
|
|
|
)
|
|
|
|
|
from docforge.errors import DocForgeError
|
|
|
|
|
from docforge.index import ProjectIndex
|
2026-07-22 11:50:49 -04:00
|
|
|
from docforge.mcp_server import (
|
|
|
|
|
ALL_TOOLS,
|
|
|
|
|
READ_TOOLS,
|
|
|
|
|
create_project_server,
|
|
|
|
|
create_read_only_server,
|
|
|
|
|
)
|
|
|
|
|
from docforge.models import (
|
|
|
|
|
Edge,
|
2026-07-25 19:08:39 -04:00
|
|
|
LogicEdge,
|
|
|
|
|
LogicNode,
|
|
|
|
|
LogicProjection,
|
2026-07-22 11:50:49 -04:00
|
|
|
Node,
|
|
|
|
|
ProjectSnapshot,
|
|
|
|
|
ProposalWriter,
|
|
|
|
|
RenderConfig,
|
|
|
|
|
RenderView,
|
|
|
|
|
)
|
2026-07-29 05:07:16 -04:00
|
|
|
from docforge.telemetry import request
|
2026-07-29 03:12:30 -04:00
|
|
|
from docforge.viewer_manager import ViewerManagerClient
|
2026-07-25 21:08:43 -04:00
|
|
|
from docforge.visualization import VisualizationIndexSnapshot
|
2026-07-22 04:17:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Loader:
|
|
|
|
|
def __init__(self, projection: AdapterProjection) -> None:
|
|
|
|
|
self.projection = projection
|
|
|
|
|
|
|
|
|
|
def load_projection(self) -> AdapterProjection:
|
|
|
|
|
return self.projection
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
class IncrementalLoader:
|
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
|
|
|
self.root = root
|
|
|
|
|
self.sources = {
|
|
|
|
|
"guide.foundation": "Foundation content.",
|
|
|
|
|
"guide.workflow": "Workflow content.",
|
|
|
|
|
}
|
|
|
|
|
self.source_paths = {
|
|
|
|
|
"guide.foundation": "docs/foundation.md",
|
|
|
|
|
"guide.workflow": "docs/workflow.md",
|
|
|
|
|
}
|
|
|
|
|
self.workflow_dependencies = ("guide.foundation",)
|
|
|
|
|
self.extract_calls: list[str] = []
|
|
|
|
|
self.fail_source: str | None = None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _hash(value: str) -> str:
|
|
|
|
|
return hashlib.sha256(value.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
def load_manifest(self) -> AdapterManifest:
|
|
|
|
|
source_items = tuple(
|
|
|
|
|
AdapterSource(
|
|
|
|
|
source_id=source_id,
|
|
|
|
|
source_path=self.source_paths[source_id],
|
|
|
|
|
fingerprint=self._hash(content),
|
|
|
|
|
extractor_version="python-ast@1",
|
|
|
|
|
dependencies=(self.workflow_dependencies if source_id == "guide.workflow" else ()),
|
|
|
|
|
)
|
|
|
|
|
for source_id, content in sorted(self.sources.items())
|
|
|
|
|
)
|
|
|
|
|
digest = hashlib.sha256()
|
|
|
|
|
for source in source_items:
|
|
|
|
|
digest.update(source.source_id.encode())
|
|
|
|
|
digest.update(source.source_path.encode())
|
|
|
|
|
digest.update(source.fingerprint.encode())
|
|
|
|
|
digest.update(source.extractor_version.encode())
|
|
|
|
|
for dependency in source.dependencies:
|
|
|
|
|
digest.update(dependency.encode())
|
|
|
|
|
return AdapterManifest(
|
|
|
|
|
project_id="incremental-fixture",
|
|
|
|
|
title="Incremental fixture",
|
|
|
|
|
adapter_id="fixture-incremental",
|
|
|
|
|
adapter_version="1",
|
|
|
|
|
root=self.root,
|
|
|
|
|
revision=digest.hexdigest()[:12],
|
|
|
|
|
source_hash=digest.hexdigest(),
|
|
|
|
|
families=("guide",),
|
|
|
|
|
allowed_relations=("depends_on",),
|
|
|
|
|
sources=source_items,
|
|
|
|
|
estimated_nodes=10,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def extract_source(self, source: AdapterSource) -> AdapterSourceProjection:
|
|
|
|
|
self.extract_calls.append(source.source_id)
|
|
|
|
|
if self.fail_source == source.source_id:
|
|
|
|
|
raise RuntimeError("intentional extraction failure")
|
|
|
|
|
content = self.sources[source.source_id]
|
|
|
|
|
node = Node(
|
|
|
|
|
node_id=source.source_id,
|
|
|
|
|
title=source.source_id.rsplit(".", 1)[-1].title(),
|
|
|
|
|
family="guide",
|
|
|
|
|
authority="authoritative",
|
|
|
|
|
status="active",
|
|
|
|
|
tags=("guide",),
|
|
|
|
|
summary=f"{source.source_id} summary.",
|
|
|
|
|
content=content,
|
|
|
|
|
source_path=source.source_path,
|
|
|
|
|
source_anchor=None,
|
|
|
|
|
content_hash=self._hash(content),
|
|
|
|
|
)
|
|
|
|
|
edges = (
|
|
|
|
|
(AdapterEdge(Edge("guide.workflow", "depends_on", "guide.foundation")),)
|
|
|
|
|
if source.source_id == "guide.workflow"
|
|
|
|
|
else ()
|
|
|
|
|
)
|
|
|
|
|
logic = (
|
|
|
|
|
(
|
|
|
|
|
LogicProjection(
|
|
|
|
|
owner_node_id="guide.workflow",
|
|
|
|
|
source_id="guide.workflow",
|
|
|
|
|
nodes=(
|
|
|
|
|
LogicNode("entry", "entry", "Entry", None),
|
|
|
|
|
LogicNode("return", "return", "Return", None),
|
|
|
|
|
),
|
|
|
|
|
edges=(LogicEdge("entry", "return", "return", "RETURN", 0),),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
if source.source_id == "guide.workflow"
|
|
|
|
|
else ()
|
|
|
|
|
)
|
|
|
|
|
return AdapterSourceProjection(
|
|
|
|
|
source_id=source.source_id,
|
|
|
|
|
fingerprint=source.fingerprint,
|
|
|
|
|
nodes=(AdapterNode(node),),
|
|
|
|
|
edges=edges,
|
|
|
|
|
logic=logic,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def load_projection(self) -> AdapterProjection:
|
|
|
|
|
manifest = self.load_manifest()
|
|
|
|
|
contributions = tuple(self.extract_source(source) for source in manifest.sources)
|
|
|
|
|
return AdapterProjection(
|
|
|
|
|
project_id=manifest.project_id,
|
|
|
|
|
title=manifest.title,
|
|
|
|
|
adapter_id=manifest.adapter_id,
|
|
|
|
|
adapter_version=manifest.adapter_version,
|
|
|
|
|
root=manifest.root,
|
|
|
|
|
revision=manifest.revision,
|
|
|
|
|
source_hash=manifest.source_hash,
|
|
|
|
|
nodes=tuple(
|
|
|
|
|
sorted(
|
|
|
|
|
(node for contribution in contributions for node in contribution.nodes),
|
|
|
|
|
key=lambda item: item.node.node_id,
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
edges=tuple(
|
|
|
|
|
sorted(
|
|
|
|
|
(edge for contribution in contributions for edge in contribution.edges),
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
item.edge.source_id,
|
|
|
|
|
item.edge.relation,
|
|
|
|
|
item.edge.target_id,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 16:01:40 -04:00
|
|
|
class OverlappingIncrementalLoader(IncrementalLoader):
|
|
|
|
|
def extract_source(self, source: AdapterSource) -> AdapterSourceProjection:
|
|
|
|
|
self.extract_calls.append(source.source_id)
|
|
|
|
|
content = self.sources[source.source_id]
|
|
|
|
|
shared = Node(
|
|
|
|
|
node_id="guide.shared",
|
|
|
|
|
title="Shared",
|
|
|
|
|
family="guide",
|
|
|
|
|
authority="derived",
|
|
|
|
|
status="active",
|
|
|
|
|
tags=("guide",),
|
|
|
|
|
summary=f"Evidence selected from {source.source_id}.",
|
|
|
|
|
content=content,
|
|
|
|
|
source_path=source.source_path,
|
|
|
|
|
source_anchor=None,
|
|
|
|
|
content_hash=self._hash(content),
|
|
|
|
|
)
|
|
|
|
|
return AdapterSourceProjection(
|
|
|
|
|
source_id=source.source_id,
|
|
|
|
|
fingerprint=source.fingerprint,
|
|
|
|
|
nodes=(AdapterNode(shared),),
|
|
|
|
|
edges=(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def assemble_projection(
|
|
|
|
|
self,
|
|
|
|
|
manifest: AdapterManifest,
|
|
|
|
|
contributions: tuple[AdapterSourceProjection, ...],
|
|
|
|
|
) -> AdapterAssembly:
|
|
|
|
|
selected = min(contributions, key=lambda item: item.source_id)
|
|
|
|
|
return AdapterAssembly(
|
|
|
|
|
AdapterProjection(
|
|
|
|
|
project_id=manifest.project_id,
|
|
|
|
|
title=manifest.title,
|
|
|
|
|
adapter_id=manifest.adapter_id,
|
|
|
|
|
adapter_version=manifest.adapter_version,
|
|
|
|
|
root=manifest.root,
|
|
|
|
|
revision=manifest.revision,
|
|
|
|
|
source_hash=manifest.source_hash,
|
|
|
|
|
nodes=selected.nodes,
|
|
|
|
|
edges=(),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def load_projection(self) -> AdapterProjection:
|
|
|
|
|
manifest = self.load_manifest()
|
|
|
|
|
contributions = tuple(self.extract_source(source) for source in manifest.sources)
|
|
|
|
|
return self.assemble_projection(manifest, contributions).projection
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 03:12:30 -04:00
|
|
|
class NonLogicIncrementalLoader(IncrementalLoader):
|
|
|
|
|
"""Exercise non-AST incremental caching without publishing function Logic."""
|
|
|
|
|
|
|
|
|
|
def extract_source(self, source: AdapterSource) -> AdapterSourceProjection:
|
|
|
|
|
return replace(super().extract_source(source), logic=())
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
class AdapterContractTests(unittest.TestCase):
|
|
|
|
|
def projection(self, root: Path) -> AdapterProjection:
|
|
|
|
|
foundation = Node(
|
|
|
|
|
node_id="guide.foundation",
|
|
|
|
|
title="Foundation",
|
|
|
|
|
family="guide",
|
|
|
|
|
authority="authoritative",
|
|
|
|
|
status="active",
|
|
|
|
|
tags=("guide",),
|
|
|
|
|
summary="The base contract.",
|
|
|
|
|
content="Foundation content.",
|
|
|
|
|
source_path="docs/foundation.md",
|
|
|
|
|
source_anchor=None,
|
|
|
|
|
content_hash="1" * 64,
|
|
|
|
|
)
|
|
|
|
|
workflow = Node(
|
|
|
|
|
node_id="guide.workflow",
|
|
|
|
|
title="Workflow",
|
|
|
|
|
family="guide",
|
|
|
|
|
authority="approved_plan",
|
|
|
|
|
status="planned",
|
|
|
|
|
tags=("guide", "workflow"),
|
|
|
|
|
summary="The editing workflow.",
|
|
|
|
|
content="Workflow content.",
|
|
|
|
|
source_path="docs/workflow.md",
|
|
|
|
|
source_anchor=None,
|
|
|
|
|
content_hash="2" * 64,
|
|
|
|
|
)
|
|
|
|
|
return AdapterProjection(
|
|
|
|
|
project_id="adapter-fixture",
|
|
|
|
|
title="Adapter fixture",
|
|
|
|
|
adapter_id="fixture-shadow",
|
|
|
|
|
adapter_version="1",
|
|
|
|
|
root=root,
|
|
|
|
|
revision="fixture-revision",
|
|
|
|
|
source_hash="3" * 64,
|
|
|
|
|
nodes=(
|
|
|
|
|
AdapterNode(foundation, (("acceptance", "proven"),)),
|
|
|
|
|
AdapterNode(workflow, (("acceptance", "pending"),)),
|
|
|
|
|
),
|
|
|
|
|
edges=(
|
|
|
|
|
AdapterEdge(
|
|
|
|
|
Edge("guide.workflow", "depends_on", "guide.foundation"),
|
|
|
|
|
(("source", "docs/workflow.md"),),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_adapter_projection_builds_and_checks_through_standard_index(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
projection = self.projection(root)
|
|
|
|
|
project = AdapterProject(Loader(projection), cache_root=root / ".cache" / "shadow")
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
|
|
|
|
|
built = index.build()
|
|
|
|
|
checked = index.check()
|
|
|
|
|
|
|
|
|
|
self.assertEqual("fixture-shadow@1", built["adapter"])
|
|
|
|
|
self.assertEqual(2, checked["node_count"])
|
|
|
|
|
self.assertEqual("Workflow", index.get_node("guide.workflow")["node"]["title"])
|
|
|
|
|
self.assertEqual(projection.identity(), projection.identity())
|
|
|
|
|
|
|
|
|
|
def test_projection_rejects_unsorted_metadata_graph_and_identity_changes(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
projection = self.projection(root)
|
|
|
|
|
invalid_metadata = replace(
|
|
|
|
|
projection,
|
|
|
|
|
nodes=(
|
|
|
|
|
replace(
|
|
|
|
|
projection.nodes[0],
|
|
|
|
|
metadata=(("z", "last"), ("a", "first")),
|
|
|
|
|
),
|
|
|
|
|
projection.nodes[1],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "metadata keys"):
|
|
|
|
|
validate_projection(invalid_metadata)
|
|
|
|
|
|
|
|
|
|
invalid_source = replace(
|
|
|
|
|
projection,
|
|
|
|
|
nodes=(
|
|
|
|
|
replace(
|
|
|
|
|
projection.nodes[0],
|
|
|
|
|
node=replace(projection.nodes[0].node, source_path="../outside.md"),
|
|
|
|
|
),
|
|
|
|
|
projection.nodes[1],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "source path"):
|
|
|
|
|
validate_projection(invalid_source)
|
|
|
|
|
|
|
|
|
|
broken = replace(
|
|
|
|
|
projection,
|
|
|
|
|
edges=(AdapterEdge(Edge("guide.workflow", "depends_on", "missing.node")),),
|
|
|
|
|
)
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "missing nodes"):
|
|
|
|
|
validate_projection(broken)
|
|
|
|
|
|
|
|
|
|
loader = Loader(projection)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "shadow")
|
|
|
|
|
loader.projection = replace(projection, adapter_version="2")
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "identity changed"):
|
|
|
|
|
project.load()
|
|
|
|
|
|
|
|
|
|
def test_adapter_cache_must_remain_inside_project(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
outside = root.parent / "outside-adapter-cache"
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "confined"):
|
|
|
|
|
AdapterProject(Loader(self.projection(root)), cache_root=outside)
|
|
|
|
|
|
2026-07-28 19:44:25 -04:00
|
|
|
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"],
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 05:07:16 -04:00
|
|
|
def test_warm_incremental_adapter_load_remains_visible_to_diagnostics(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = IncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
|
|
|
|
|
with request("test", enabled=True) as cold_collector:
|
|
|
|
|
project.load()
|
|
|
|
|
assert cold_collector is not None
|
|
|
|
|
cold_counters = cold_collector.as_dict(outcome="ok")["counters"]
|
|
|
|
|
self.assertEqual(1, cold_counters["project_loads"])
|
|
|
|
|
self.assertEqual(0, cold_counters["adapter_projection_loads"])
|
|
|
|
|
self.assertEqual(2, cold_counters["adapter_source_extractions"])
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
|
|
|
|
|
with request("test", enabled=True) as warm_collector:
|
|
|
|
|
project.load()
|
|
|
|
|
|
|
|
|
|
assert warm_collector is not None
|
|
|
|
|
counters = warm_collector.as_dict(outcome="ok")["counters"]
|
|
|
|
|
self.assertEqual(1, counters["project_loads"])
|
|
|
|
|
self.assertEqual(0, counters["adapter_projection_loads"])
|
|
|
|
|
self.assertEqual(0, counters["adapter_source_extractions"])
|
|
|
|
|
self.assertEqual([], loader.extract_calls)
|
|
|
|
|
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
index.build()
|
|
|
|
|
with request("test", enabled=True) as read_collector:
|
|
|
|
|
index.get_node("guide.workflow")
|
|
|
|
|
assert read_collector is not None
|
|
|
|
|
read_counters = read_collector.as_dict(outcome="ok")["counters"]
|
|
|
|
|
self.assertEqual(0, read_counters["project_loads"])
|
|
|
|
|
self.assertEqual(0, read_counters["adapter_projection_loads"])
|
|
|
|
|
self.assertEqual(0, read_counters["adapter_source_extractions"])
|
|
|
|
|
self.assertEqual(0, read_counters["index_builds"])
|
|
|
|
|
|
|
|
|
|
legacy = AdapterProject(
|
|
|
|
|
Loader(self.projection(root)),
|
|
|
|
|
cache_root=root / ".cache" / "legacy",
|
|
|
|
|
)
|
|
|
|
|
with request("test", enabled=True) as legacy_collector:
|
|
|
|
|
legacy.load()
|
|
|
|
|
assert legacy_collector is not None
|
|
|
|
|
legacy_counters = legacy_collector.as_dict(outcome="ok")["counters"]
|
|
|
|
|
self.assertEqual(1, legacy_counters["project_loads"])
|
|
|
|
|
self.assertEqual(1, legacy_counters["adapter_projection_loads"])
|
|
|
|
|
self.assertEqual(0, legacy_counters["adapter_source_extractions"])
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = IncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
|
|
|
|
|
first = index.build()
|
|
|
|
|
self.assertEqual(2, first["build"]["reparsed_sources"])
|
|
|
|
|
self.assertEqual(0, first["build"]["cache_hits"])
|
2026-07-25 21:08:43 -04:00
|
|
|
self.assertEqual(1, first["logic_projection_count"])
|
|
|
|
|
logic = index.get_logic("guide.workflow")
|
|
|
|
|
self.assertTrue(logic["available"])
|
|
|
|
|
self.assertEqual("guide.workflow", logic["projection"]["owner_node_id"])
|
|
|
|
|
self.assertEqual(2, len(logic["projection"]["nodes"]))
|
|
|
|
|
self.assertFalse(index.get_logic("guide.foundation")["available"])
|
|
|
|
|
visual_logic = VisualizationIndexSnapshot(index, index.check()).logic("guide.workflow")
|
|
|
|
|
self.assertTrue(visual_logic["available"])
|
|
|
|
|
self.assertEqual("entry", visual_logic["root"])
|
|
|
|
|
self.assertEqual("return", visual_logic["edges"][0]["relation"])
|
2026-07-29 02:59:15 -04:00
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
loader.extract_calls.clear()
|
2026-07-25 20:00:21 -04:00
|
|
|
cache_path = root / ".cache" / "incremental" / "extractions.json"
|
|
|
|
|
cache_modified = cache_path.stat().st_mtime_ns
|
2026-07-25 19:08:39 -04:00
|
|
|
|
|
|
|
|
second = index.build()
|
|
|
|
|
self.assertEqual([], loader.extract_calls)
|
|
|
|
|
self.assertEqual(0, second["build"]["reparsed_sources"])
|
|
|
|
|
self.assertEqual(2, second["build"]["cache_hits"])
|
2026-07-25 20:00:21 -04:00
|
|
|
self.assertEqual(cache_modified, cache_path.stat().st_mtime_ns)
|
2026-07-25 19:08:39 -04:00
|
|
|
|
|
|
|
|
loader.sources["guide.foundation"] = "Changed foundation."
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "does not match"):
|
|
|
|
|
index.check()
|
|
|
|
|
self.assertEqual([], loader.extract_calls)
|
|
|
|
|
changed = index.build()
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
["guide.foundation", "guide.workflow"],
|
|
|
|
|
loader.extract_calls,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(2, changed["build"]["invalidated_sources"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"Changed foundation.",
|
|
|
|
|
index.get_node("guide.foundation")["node"]["content"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
loader.source_paths["guide.workflow"] = "docs/workflow-renamed.md"
|
|
|
|
|
loader.workflow_dependencies = ()
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
renamed = index.build()
|
|
|
|
|
self.assertEqual(["guide.workflow"], loader.extract_calls)
|
|
|
|
|
self.assertEqual(1, renamed["build"]["invalidated_sources"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"docs/workflow-renamed.md",
|
|
|
|
|
index.get_node("guide.workflow")["node"]["source_path"],
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 02:59:15 -04:00
|
|
|
def test_no_ast_index_policy_rejects_logic_publication(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
project = AdapterProject(
|
|
|
|
|
IncrementalLoader(root),
|
|
|
|
|
cache_root=root / ".cache" / "no-ast",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(DocForgeError) as captured:
|
|
|
|
|
ProjectIndex(project, allow_logic=False).build()
|
|
|
|
|
|
|
|
|
|
self.assertEqual("adapter_policy_forbids_logic", captured.exception.code)
|
|
|
|
|
self.assertFalse(project.descriptor.index_path.exists())
|
|
|
|
|
|
2026-07-29 03:12:30 -04:00
|
|
|
def test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
legacy = AdapterProject(
|
|
|
|
|
Loader(self.projection(root)),
|
|
|
|
|
cache_root=root / ".cache" / "legacy-no-ast",
|
|
|
|
|
)
|
|
|
|
|
legacy_index = ProjectIndex(legacy, allow_logic=False)
|
|
|
|
|
self.assertEqual(2, legacy_index.build()["node_count"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"guide.workflow",
|
|
|
|
|
legacy_index.get_node("guide.workflow")["node"]["node_id"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = NonLogicIncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(
|
|
|
|
|
loader,
|
|
|
|
|
cache_root=root / ".cache" / "incremental-no-ast",
|
|
|
|
|
)
|
|
|
|
|
index = ProjectIndex(project, allow_logic=False)
|
|
|
|
|
first = index.build()
|
|
|
|
|
self.assertEqual(2, first["build"]["reparsed_sources"])
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
|
|
|
|
|
second = index.build()
|
|
|
|
|
|
|
|
|
|
self.assertEqual([], loader.extract_calls)
|
|
|
|
|
self.assertEqual(2, second["build"]["cache_hits"])
|
|
|
|
|
self.assertEqual(0, second["build"]["reparsed_sources"])
|
|
|
|
|
self.assertEqual(0, second["logic_projection_count"])
|
|
|
|
|
|
|
|
|
|
def test_no_ast_rejects_preexisting_logic_index_and_viewer_snapshot(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
project = AdapterProject(
|
|
|
|
|
IncrementalLoader(root),
|
|
|
|
|
cache_root=root / ".cache" / "preexisting-logic",
|
|
|
|
|
)
|
|
|
|
|
ProjectIndex(project).build()
|
|
|
|
|
preserved = ProjectIndex(project, allow_logic=False)
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(DocForgeError) as checked:
|
|
|
|
|
preserved.check(verify_rows=False)
|
|
|
|
|
self.assertEqual("adapter_policy_forbids_logic", checked.exception.code)
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(DocForgeError) as viewed:
|
|
|
|
|
ViewerManagerClient(
|
|
|
|
|
preserved,
|
|
|
|
|
state_path=root / ".cache" / "viewer-state.json",
|
|
|
|
|
).start()
|
|
|
|
|
self.assertEqual("adapter_policy_forbids_logic", viewed.exception.code)
|
|
|
|
|
|
2026-07-26 09:32:25 -04:00
|
|
|
def test_fast_incremental_reads_reverify_a_changed_index_file(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = IncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
index.build()
|
|
|
|
|
index.synchronize()
|
|
|
|
|
self.assertTrue(index.attestation_path.is_file())
|
|
|
|
|
fresh = ProjectIndex(project)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"current",
|
|
|
|
|
fresh.synchronize()["synchronization"]["action"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with closing(sqlite3.connect(index.path)) as connection:
|
|
|
|
|
connection.execute(
|
|
|
|
|
"UPDATE nodes SET content = ? WHERE node_id = ?",
|
|
|
|
|
("tampered", "guide.foundation"),
|
|
|
|
|
)
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
|
|
|
|
with self.assertRaisesRegex(DocForgeError, "rows do not match metadata"):
|
|
|
|
|
index.get_node("guide.foundation")
|
|
|
|
|
|
|
|
|
|
repaired = index.synchronize()
|
|
|
|
|
self.assertEqual("rebuilt", repaired["synchronization"]["action"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"Foundation content.",
|
|
|
|
|
index.get_node("guide.foundation")["node"]["content"],
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
def test_incremental_delete_failure_and_equivalence_are_safe(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = IncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
index.build()
|
|
|
|
|
(root / ".cache" / "incremental" / "extractions.json").write_text(
|
|
|
|
|
"{broken", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
cache_miss = index.build()
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
["guide.foundation", "guide.workflow"],
|
|
|
|
|
loader.extract_calls,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(2, cache_miss["build"]["reparsed_sources"])
|
|
|
|
|
original_index = index.path.read_bytes()
|
|
|
|
|
cache_path = root / ".cache" / "incremental" / "extractions.json"
|
|
|
|
|
original_cache = cache_path.read_bytes()
|
|
|
|
|
|
|
|
|
|
loader.sources["guide.foundation"] = "Broken extraction."
|
|
|
|
|
loader.fail_source = "guide.foundation"
|
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "intentional"):
|
|
|
|
|
index.build()
|
|
|
|
|
self.assertEqual(original_index, index.path.read_bytes())
|
|
|
|
|
self.assertEqual(original_cache, cache_path.read_bytes())
|
|
|
|
|
|
|
|
|
|
loader.fail_source = None
|
|
|
|
|
index.build()
|
|
|
|
|
loader.sources.pop("guide.workflow")
|
|
|
|
|
deleted = index.build()
|
|
|
|
|
self.assertEqual(1, deleted["build"]["deleted_sources"])
|
|
|
|
|
self.assertEqual(1, deleted["node_count"])
|
|
|
|
|
self.assertEqual(0, deleted["edge_count"])
|
|
|
|
|
self.assertIsNone(project.logic_projection("guide.workflow"))
|
|
|
|
|
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
equivalent = project.verify_incremental_equivalence()
|
|
|
|
|
self.assertEqual("ok", equivalent["status"])
|
|
|
|
|
self.assertEqual(1, equivalent["node_count"])
|
|
|
|
|
|
2026-07-27 16:01:40 -04:00
|
|
|
def test_incremental_adapter_can_assemble_overlapping_source_evidence(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
loader = OverlappingIncrementalLoader(root)
|
|
|
|
|
project = AdapterProject(loader, cache_root=root / ".cache" / "overlap")
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
|
|
|
|
|
first = index.build()
|
|
|
|
|
self.assertEqual(2, first["build"]["reparsed_sources"])
|
|
|
|
|
self.assertEqual(1, first["node_count"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"Foundation content.",
|
|
|
|
|
index.get_node("guide.shared")["node"]["content"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
warm = index.build()
|
|
|
|
|
self.assertEqual(2, warm["build"]["cache_hits"])
|
|
|
|
|
self.assertEqual([], loader.extract_calls)
|
|
|
|
|
|
|
|
|
|
loader.sources["guide.workflow"] = "Changed overlapping evidence."
|
|
|
|
|
loader.extract_calls.clear()
|
|
|
|
|
changed = index.build()
|
|
|
|
|
self.assertEqual(["guide.workflow"], loader.extract_calls)
|
|
|
|
|
self.assertEqual(1, changed["build"]["reparsed_sources"])
|
|
|
|
|
self.assertEqual(1, changed["node_count"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"Foundation content.",
|
|
|
|
|
index.get_node("guide.shared")["node"]["content"],
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual("ok", project.verify_incremental_equivalence()["status"])
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
def test_artifact_comparison_is_complete_and_byte_exact(self) -> None:
|
|
|
|
|
reference = (
|
|
|
|
|
ShadowArtifact("manual", b"same"),
|
|
|
|
|
ShadowArtifact("timeline", b"old"),
|
|
|
|
|
)
|
|
|
|
|
exact = compare_artifacts(reference, reference)
|
|
|
|
|
self.assertEqual("ok", exact["status"])
|
|
|
|
|
self.assertEqual(2, exact["count"])
|
|
|
|
|
|
|
|
|
|
mismatch = compare_artifacts(
|
|
|
|
|
reference,
|
|
|
|
|
(
|
|
|
|
|
ShadowArtifact("timeline", b"new"),
|
|
|
|
|
ShadowArtifact("extra", b"extra"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual("mismatch", mismatch["status"])
|
|
|
|
|
self.assertEqual(["manual"], mismatch["missing"])
|
|
|
|
|
self.assertEqual(["extra"], mismatch["unexpected"])
|
|
|
|
|
self.assertEqual(["timeline"], mismatch["changed"])
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 05:59:20 -04:00
|
|
|
class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
2026-07-28 19:44:25 -04:00
|
|
|
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)
|
|
|
|
|
|
2026-07-22 05:59:20 -04:00
|
|
|
async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
fixture = AdapterContractTests()
|
|
|
|
|
project = AdapterProject(
|
|
|
|
|
Loader(fixture.projection(root)),
|
|
|
|
|
cache_root=root / ".cache" / "adapter-read-only",
|
|
|
|
|
)
|
|
|
|
|
index = ProjectIndex(project)
|
|
|
|
|
index.build()
|
|
|
|
|
calls: list[tuple[str, int | None]] = []
|
|
|
|
|
|
|
|
|
|
def context_provider(
|
|
|
|
|
current: ProjectIndex, profile: str, budget: int | None
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
checked = current.check()
|
|
|
|
|
calls.append((profile, budget))
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"project_id": checked["project_id"],
|
|
|
|
|
"project_root_fingerprint": checked["project_root_fingerprint"],
|
|
|
|
|
"revision": checked["revision"],
|
|
|
|
|
"source_hash": checked["source_hash"],
|
|
|
|
|
"adapter": checked["adapter"],
|
|
|
|
|
"profile": profile,
|
|
|
|
|
"budget": budget,
|
|
|
|
|
"estimated_tokens": 1,
|
|
|
|
|
"entries": [],
|
|
|
|
|
"omissions": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server = create_read_only_server(project, context_provider=context_provider)
|
|
|
|
|
async with create_connected_server_and_client_session(
|
|
|
|
|
server, raise_exceptions=True
|
|
|
|
|
) as session:
|
|
|
|
|
tools = await session.list_tools()
|
|
|
|
|
info = await session.call_tool("docforge_project_info", {})
|
|
|
|
|
contract = await session.call_tool("docforge_get_contract", {})
|
|
|
|
|
context = await session.call_tool(
|
|
|
|
|
"docforge_get_context", {"profile": "fixture", "budget": 321}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(READ_TOOLS, tuple(tool.name for tool in tools.tools))
|
|
|
|
|
self.assertEqual("adapter-fixture", info.structuredContent["project_id"])
|
|
|
|
|
self.assertEqual(list(READ_TOOLS), contract.structuredContent["allowed_tools"])
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"isolated_changeset_writes",
|
|
|
|
|
contract.structuredContent["excluded_operations"],
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(contract.structuredContent["proposal_access"]["enabled"])
|
|
|
|
|
self.assertFalse(contract.structuredContent["isolated_changeset_writes_allowed"])
|
|
|
|
|
self.assertEqual("fixture", context.structuredContent["profile"])
|
|
|
|
|
self.assertEqual([("fixture", 321)], calls)
|
|
|
|
|
self.assertFalse(project.descriptor.changeset_root.exists())
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
async def test_adapter_project_proposals_require_explicit_policy_and_stay_isolated(
|
|
|
|
|
self,
|
|
|
|
|
) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory).resolve()
|
|
|
|
|
(root / ".docforge").mkdir()
|
|
|
|
|
descriptor = root / ".docforge" / "project.toml"
|
|
|
|
|
descriptor.write_text("adapter fixture\n", encoding="utf-8")
|
|
|
|
|
content_root = root / "docs"
|
|
|
|
|
content_root.mkdir()
|
|
|
|
|
sources = (content_root / "foundation.md", content_root / "workflow.md")
|
|
|
|
|
for source in sources:
|
|
|
|
|
source.write_text(f"canonical {source.stem}\n", encoding="utf-8")
|
|
|
|
|
template_root = root / "templates"
|
|
|
|
|
template_root.mkdir()
|
|
|
|
|
template = template_root / "review.html"
|
|
|
|
|
template.write_text(
|
|
|
|
|
"<html><head><title>{{ docforge_title }}</title></head>"
|
|
|
|
|
"<body>{{ docforge_content }}</body></html>\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
validation_calls: list[tuple[Mapping[str, object], ...]] = []
|
|
|
|
|
|
|
|
|
|
def validate_proposal(
|
|
|
|
|
base: ProjectSnapshot,
|
|
|
|
|
projected: ProjectSnapshot,
|
|
|
|
|
operations: tuple[Mapping[str, object], ...],
|
|
|
|
|
) -> None:
|
|
|
|
|
self.assertEqual(base.edges, projected.edges)
|
|
|
|
|
validation_calls.append(operations)
|
|
|
|
|
|
|
|
|
|
settings = AdapterProjectSettings(
|
|
|
|
|
descriptor_path=descriptor,
|
|
|
|
|
content_roots=(content_root,),
|
|
|
|
|
canonical_sources=sources,
|
|
|
|
|
changeset_root=root / ".cache" / "changesets",
|
|
|
|
|
proposal_writers=(ProposalWriter("fixture-writer", ("guide",), ("update",)),),
|
|
|
|
|
render=RenderConfig(
|
|
|
|
|
template_root=template_root,
|
|
|
|
|
preview_root=root / ".cache" / "previews",
|
|
|
|
|
views=(
|
|
|
|
|
RenderView(
|
|
|
|
|
"review",
|
|
|
|
|
"generic_html",
|
|
|
|
|
template,
|
|
|
|
|
root / ".cache" / "rendered" / "review.html",
|
|
|
|
|
"Adapter review",
|
|
|
|
|
("guide",),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
proposal_validator=validate_proposal,
|
|
|
|
|
)
|
|
|
|
|
project = AdapterProject(
|
|
|
|
|
Loader(AdapterContractTests().projection(root)),
|
|
|
|
|
cache_root=root / ".cache" / "adapter",
|
|
|
|
|
settings=settings,
|
|
|
|
|
)
|
|
|
|
|
ProjectIndex(project).build()
|
|
|
|
|
before = {source: source.read_bytes() for source in sources}
|
|
|
|
|
server = create_project_server(project, proposal_writer="fixture-writer")
|
|
|
|
|
async with create_connected_server_and_client_session(
|
|
|
|
|
server, raise_exceptions=True
|
|
|
|
|
) as session:
|
|
|
|
|
tools = await session.list_tools()
|
|
|
|
|
created = await session.call_tool(
|
|
|
|
|
"docforge_create_changeset", {"changeset_id": "adapter-update"}
|
|
|
|
|
)
|
|
|
|
|
node = await session.call_tool("docforge_get_node", {"node_id": "guide.workflow"})
|
|
|
|
|
proposed = await session.call_tool(
|
|
|
|
|
"docforge_propose_node_update",
|
|
|
|
|
{
|
|
|
|
|
"changeset_id": "adapter-update",
|
|
|
|
|
"expected_changeset_hash": created.structuredContent["changeset_hash"],
|
|
|
|
|
"node_id": "guide.workflow",
|
|
|
|
|
"expected_content_hash": node.structuredContent["node"]["content_hash"],
|
|
|
|
|
"metadata": None,
|
|
|
|
|
"content": "Updated workflow content.",
|
|
|
|
|
"relationship_changes": [],
|
|
|
|
|
"rationale": "Prove adapter proposal policy.",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
validated = await session.call_tool(
|
|
|
|
|
"docforge_validate_changeset",
|
|
|
|
|
{"changeset_id": "adapter-update"},
|
|
|
|
|
)
|
|
|
|
|
diff = await session.call_tool(
|
|
|
|
|
"docforge_get_changeset_diff",
|
|
|
|
|
{"changeset_id": "adapter-update"},
|
|
|
|
|
)
|
|
|
|
|
preview = await session.call_tool(
|
|
|
|
|
"docforge_preview_changeset",
|
|
|
|
|
{"changeset_id": "adapter-update", "view_id": "review"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(ALL_TOOLS, tuple(tool.name for tool in tools.tools))
|
|
|
|
|
self.assertEqual("ok", proposed.structuredContent["status"])
|
|
|
|
|
self.assertTrue(validated.structuredContent["valid"])
|
|
|
|
|
self.assertEqual("update", diff.structuredContent["changes"][0]["operation"])
|
|
|
|
|
preview_path = root / preview.structuredContent["preview"]["path"]
|
|
|
|
|
self.assertTrue(preview_path.is_file())
|
|
|
|
|
self.assertTrue(validation_calls)
|
|
|
|
|
self.assertEqual(before, {source: source.read_bytes() for source in sources})
|
|
|
|
|
|
2026-07-22 05:59:20 -04:00
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|