Add incremental adapter compiler boundary
This commit is contained in:
parent
82b3b90521
commit
696b62f9f8
20 changed files with 1592 additions and 122 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
|
|
@ -10,10 +11,13 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|||
|
||||
from docforge.adapter_contract import (
|
||||
AdapterEdge,
|
||||
AdapterManifest,
|
||||
AdapterNode,
|
||||
AdapterProject,
|
||||
AdapterProjection,
|
||||
AdapterProjectSettings,
|
||||
AdapterSource,
|
||||
AdapterSourceProjection,
|
||||
ShadowArtifact,
|
||||
compare_artifacts,
|
||||
validate_projection,
|
||||
|
|
@ -28,6 +32,9 @@ from docforge.mcp_server import (
|
|||
)
|
||||
from docforge.models import (
|
||||
Edge,
|
||||
LogicEdge,
|
||||
LogicNode,
|
||||
LogicProjection,
|
||||
Node,
|
||||
ProjectSnapshot,
|
||||
ProposalWriter,
|
||||
|
|
@ -44,6 +51,134 @@ class Loader:
|
|||
return self.projection
|
||||
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AdapterContractTests(unittest.TestCase):
|
||||
def projection(self, root: Path) -> AdapterProjection:
|
||||
foundation = Node(
|
||||
|
|
@ -157,6 +292,92 @@ class AdapterContractTests(unittest.TestCase):
|
|||
with self.assertRaisesRegex(DocForgeError, "confined"):
|
||||
AdapterProject(Loader(self.projection(root)), cache_root=outside)
|
||||
|
||||
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"])
|
||||
loader.extract_calls.clear()
|
||||
|
||||
second = index.build()
|
||||
self.assertEqual([], loader.extract_calls)
|
||||
self.assertEqual(0, second["build"]["reparsed_sources"])
|
||||
self.assertEqual(2, second["build"]["cache_hits"])
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
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"])
|
||||
|
||||
def test_artifact_comparison_is_complete_and_byte_exact(self) -> None:
|
||||
reference = (
|
||||
ShadowArtifact("manual", b"same"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue