Add versioned task context capsules
This commit is contained in:
parent
34cd5f74c1
commit
4cc6277054
18 changed files with 2834 additions and 10 deletions
|
|
@ -55,8 +55,10 @@ from docforge.visualization import VisualizationIndexSnapshot
|
|||
class Loader:
|
||||
def __init__(self, projection: AdapterProjection) -> None:
|
||||
self.projection = projection
|
||||
self.load_calls = 0
|
||||
|
||||
def load_projection(self) -> AdapterProjection:
|
||||
self.load_calls += 1
|
||||
return self.projection
|
||||
|
||||
|
||||
|
|
@ -866,8 +868,9 @@ class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
fixture = AdapterContractTests()
|
||||
loader = Loader(fixture.projection(root))
|
||||
project = AdapterProject(
|
||||
Loader(fixture.projection(root)),
|
||||
loader,
|
||||
cache_root=root / ".cache" / "adapter-read-only",
|
||||
)
|
||||
index = ProjectIndex(project)
|
||||
|
|
@ -903,6 +906,14 @@ class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
context = await session.call_tool(
|
||||
"docforge_get_context", {"profile": "fixture", "budget": 321}
|
||||
)
|
||||
load_calls = loader.load_calls
|
||||
task_context = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Do not load the legacy projection for this capability error",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(READ_TOOLS, tuple(tool.name for tool in tools.tools))
|
||||
self.assertEqual("adapter-fixture", info.structuredContent["project_id"])
|
||||
|
|
@ -915,6 +926,12 @@ class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertFalse(contract.structuredContent["isolated_changeset_writes_allowed"])
|
||||
self.assertEqual("fixture", context.structuredContent["profile"])
|
||||
self.assertEqual([("fixture", 321)], calls)
|
||||
self.assertEqual("error", task_context.structuredContent["status"])
|
||||
self.assertEqual(
|
||||
"task_context_unavailable",
|
||||
task_context.structuredContent["error"]["code"],
|
||||
)
|
||||
self.assertEqual(load_calls, loader.load_calls)
|
||||
self.assertFalse(project.descriptor.changeset_root.exists())
|
||||
|
||||
async def test_adapter_project_proposals_require_explicit_policy_and_stay_isolated(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from contextlib import contextmanager
|
|||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
|
@ -34,6 +35,9 @@ from docforge.viewer_manager import ViewerManager
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
CAPSULE_SCHEMA = json.loads(
|
||||
(ROOT / "schemas" / "context-capsule.schema.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
|
@ -85,6 +89,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||
for name in (
|
||||
"docforge_get_context",
|
||||
"docforge_get_task_context",
|
||||
"docforge_list_changesets",
|
||||
"docforge_get_changeset",
|
||||
"docforge_validate_changeset",
|
||||
|
|
@ -93,6 +98,22 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
for field in ("limit", "cursor"):
|
||||
self.assertIn(field, tools[name].inputSchema["properties"])
|
||||
self.assertNotIn(field, tools[name].inputSchema.get("required", []))
|
||||
self.assertEqual(
|
||||
{"task_kind", "task"},
|
||||
set(tools["docforge_get_task_context"].inputSchema["required"]),
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"change",
|
||||
"implementation",
|
||||
"failure",
|
||||
"ownership",
|
||||
"test",
|
||||
"operation",
|
||||
"release",
|
||||
],
|
||||
tools["docforge_get_task_context"].inputSchema["properties"]["task_kind"]["enum"],
|
||||
)
|
||||
self.assertIn(
|
||||
"deep",
|
||||
tools["docforge_render_status"].inputSchema["properties"],
|
||||
|
|
@ -160,6 +181,316 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(0, diagnostics["counters"]["project_loads"])
|
||||
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
|
||||
|
||||
async def test_task_context_is_hash_stable_paged_and_generation_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root, capability_mode="read", diagnostics=True),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
first = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
},
|
||||
)
|
||||
first_capsule = first.structuredContent["capsule"]
|
||||
Draft202012Validator(CAPSULE_SCHEMA).validate(first_capsule)
|
||||
cursor = first_capsule["pagination"]["next_cursor"]
|
||||
hashes = {
|
||||
first_capsule["capsule_hash"],
|
||||
first_capsule["collection_hash"],
|
||||
first_capsule["plan"]["plan_hash"],
|
||||
}
|
||||
evidence_ids = [item["node_id"] for item in first_capsule["evidence"]]
|
||||
while cursor is not None:
|
||||
page = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 2,
|
||||
"cursor": cursor,
|
||||
},
|
||||
)
|
||||
capsule = page.structuredContent["capsule"]
|
||||
self.assertEqual(first_capsule["capsule_hash"], capsule["capsule_hash"])
|
||||
self.assertEqual(
|
||||
first_capsule["collection_hash"],
|
||||
capsule["collection_hash"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first_capsule["plan"]["plan_hash"],
|
||||
capsule["plan"]["plan_hash"],
|
||||
)
|
||||
evidence_ids.extend(item["node_id"] for item in capsule["evidence"])
|
||||
cursor = capsule["pagination"]["next_cursor"]
|
||||
|
||||
self.assertEqual(
|
||||
["guide.workflow", "guide.foundation", "proof.validation"],
|
||||
evidence_ids,
|
||||
)
|
||||
self.assertEqual(3, len(hashes))
|
||||
self.assertEqual(
|
||||
"mcp.task_context",
|
||||
first.structuredContent["diagnostics"]["operation"],
|
||||
)
|
||||
counters = first.structuredContent["diagnostics"]["counters"]
|
||||
self.assertEqual(0, counters["project_loads"])
|
||||
self.assertEqual(0, counters["source_files_parsed"])
|
||||
self.assertEqual(0, counters["adapter_projection_loads"])
|
||||
self.assertEqual(0, counters["adapter_source_extractions"])
|
||||
self.assertEqual(0, counters["index_builds"])
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(first.structuredContent, separators=(",", ":"))),
|
||||
Project.open(root).descriptor.limits.max_tool_output_chars,
|
||||
)
|
||||
|
||||
for changed_arguments in (
|
||||
{"task": "A different task"},
|
||||
{"task_kind": "failure"},
|
||||
{"focus_node_id": "guide.foundation"},
|
||||
{"budget": 100},
|
||||
):
|
||||
arguments = {
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
"cursor": first_capsule["pagination"]["next_cursor"],
|
||||
**changed_arguments,
|
||||
}
|
||||
changed_cursor = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
arguments,
|
||||
)
|
||||
self.assertEqual(
|
||||
"stale_cursor",
|
||||
changed_cursor.structuredContent["error"]["code"],
|
||||
)
|
||||
|
||||
different_policy = DocForgeService(
|
||||
Project.open(root),
|
||||
capability_mode_name="proposal",
|
||||
).task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=1,
|
||||
cursor=first_capsule["pagination"]["next_cursor"],
|
||||
)
|
||||
self.assertEqual("stale_cursor", different_policy["error"]["code"])
|
||||
|
||||
changed = root / "docs/content/foundation.md"
|
||||
changed.write_text(
|
||||
changed.read_text(encoding="utf-8") + "\nNew generation.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stale = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"limit": 1,
|
||||
"cursor": first_capsule["pagination"]["next_cursor"],
|
||||
},
|
||||
)
|
||||
self.assertEqual("error", stale.structuredContent["status"])
|
||||
self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"])
|
||||
|
||||
async def test_custom_context_policy_does_not_silently_gain_task_planning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(
|
||||
project,
|
||||
context_provider=lambda index, profile, budget: {
|
||||
"profile": profile,
|
||||
"budget": budget,
|
||||
"entries": [],
|
||||
"omissions": [],
|
||||
},
|
||||
capability_mode_name="read",
|
||||
)
|
||||
async with create_connected_server_and_client_session(
|
||||
_create_bound_server(service, read_only=True),
|
||||
raise_exceptions=True,
|
||||
) as session:
|
||||
bootstrap = await session.call_tool("docforge_bootstrap", {})
|
||||
with (
|
||||
mock.patch.object(
|
||||
service.project,
|
||||
"load",
|
||||
side_effect=AssertionError("capability errors must not load"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"check",
|
||||
side_effect=AssertionError("capability errors must not check"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"build",
|
||||
side_effect=AssertionError("capability errors must not build"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"synchronize",
|
||||
side_effect=AssertionError("capability errors must not synchronize"),
|
||||
),
|
||||
):
|
||||
result = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Do not widen the adapter context policy",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
"docforge_get_context",
|
||||
bootstrap.structuredContent["session_contract"]["recommended_first_operation"][
|
||||
"tool"
|
||||
],
|
||||
)
|
||||
self.assertFalse(bootstrap.structuredContent["capabilities"]["task_context"]["enabled"])
|
||||
self.assertEqual("error", result.structuredContent["status"])
|
||||
self.assertEqual(
|
||||
"task_context_unavailable",
|
||||
result.structuredContent["error"]["code"],
|
||||
)
|
||||
|
||||
def test_task_context_page_hash_binds_final_page_envelope(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_context_tokens = 2000",
|
||||
"max_context_tokens = 2000\nmax_tool_output_chars = 8000",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(project, capability_mode_name="read")
|
||||
one = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=1,
|
||||
)
|
||||
two = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
limit=2,
|
||||
)
|
||||
one_capsule = one["capsule"]
|
||||
two_capsule = two["capsule"]
|
||||
self.assertEqual(
|
||||
["guide.workflow"],
|
||||
[item["node_id"] for item in one_capsule["evidence"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
["guide.workflow"],
|
||||
[item["node_id"] for item in two_capsule["evidence"]],
|
||||
)
|
||||
self.assertEqual("complete", one_capsule["page_state"])
|
||||
self.assertEqual("incomplete", two_capsule["page_state"])
|
||||
self.assertNotEqual(one_capsule["page_hash"], two_capsule["page_hash"])
|
||||
self.assertNotEqual(one_capsule["pagination"], two_capsule["pagination"])
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(one, sort_keys=True, separators=(",", ":"))),
|
||||
8_000,
|
||||
)
|
||||
self.assertLessEqual(
|
||||
len(json.dumps(two, sort_keys=True, separators=(",", ":"))),
|
||||
8_000,
|
||||
)
|
||||
|
||||
def test_task_context_default_page_clamps_to_small_project_limit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_results = 20",
|
||||
"max_results = 2",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
result = DocForgeService(
|
||||
project,
|
||||
capability_mode_name="read",
|
||||
).task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
)
|
||||
self.assertEqual(2, result["pagination"]["limit"])
|
||||
self.assertLessEqual(result["pagination"]["returned_count"], 2)
|
||||
|
||||
def test_oversized_task_evidence_advances_once_as_an_omission(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_context_tokens = 2000",
|
||||
"max_context_tokens = 50000\nmax_tool_output_chars = 8000",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
workflow = root / "docs" / "content" / "workflow.md"
|
||||
workflow.write_text(
|
||||
workflow.read_text(encoding="utf-8") + "\n" + ("large evidence " * 2_000),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
service = DocForgeService(project, capability_mode_name="read")
|
||||
first = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
budget=50_000,
|
||||
limit=1,
|
||||
)
|
||||
first_capsule = first["capsule"]
|
||||
self.assertEqual([], first_capsule["evidence"])
|
||||
self.assertEqual("response_limit", first_capsule["omissions"][0]["code"])
|
||||
self.assertEqual("guide.workflow", first_capsule["omissions"][0]["subject"])
|
||||
self.assertEqual(1, first_capsule["pagination"]["returned_count"])
|
||||
self.assertTrue(first_capsule["pagination"]["has_more"])
|
||||
second = service.task_context(
|
||||
"change",
|
||||
"Change the editing workflow",
|
||||
focus_node_id="guide.workflow",
|
||||
budget=50_000,
|
||||
limit=1,
|
||||
cursor=first_capsule["pagination"]["next_cursor"],
|
||||
)
|
||||
self.assertNotEqual(first_capsule["page_hash"], second["capsule"]["page_hash"])
|
||||
self.assertNotIn(
|
||||
"guide.workflow",
|
||||
[
|
||||
item.get("node_id", item.get("subject"))
|
||||
for item in (
|
||||
*second["capsule"]["evidence"],
|
||||
*second["capsule"]["omissions"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def test_context_pagination_is_complete_and_stale_cursors_fail_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
|
|
@ -304,6 +635,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
logic = await session.call_tool(
|
||||
"docforge_get_logic", {"owner_node_id": "guide.workflow"}
|
||||
)
|
||||
task_context = await session.call_tool(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow without AST analysis",
|
||||
"focus_node_id": "guide.workflow",
|
||||
},
|
||||
)
|
||||
|
||||
policy = bootstrap.structuredContent["adapter_policy"]
|
||||
self.assertEqual("preserve-no-ast", policy["mode"])
|
||||
|
|
@ -329,6 +668,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
"adapter_policy_forbids_logic",
|
||||
logic.structuredContent["error"]["code"],
|
||||
)
|
||||
self.assertEqual("ok", task_context.structuredContent["status"])
|
||||
self.assertNotIn(
|
||||
"logic",
|
||||
{
|
||||
step["operation"]
|
||||
for step in task_context.structuredContent["capsule"]["plan"]["steps"]
|
||||
},
|
||||
)
|
||||
|
||||
async def test_every_read_tool_returns_scoped_structured_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -358,6 +705,14 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
("docforge_visualization_status", {}),
|
||||
("docforge_bootstrap", {}),
|
||||
("docforge_sync", {}),
|
||||
(
|
||||
"docforge_get_task_context",
|
||||
{
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
},
|
||||
),
|
||||
)
|
||||
with self.running_manager(Path(directory) / "viewer-manager.json"):
|
||||
service = DocForgeService(Project.open(root))
|
||||
|
|
@ -413,6 +768,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
context = results[9].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
self.assertEqual("complete", results[17].structuredContent["capsule"]["state"])
|
||||
|
||||
async def test_invalid_traversal_limit_is_a_structured_domain_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class EffectivePolicyTests(unittest.TestCase):
|
|||
self.assertNotIn("docforge_register_changes", result["recommended_workflow"])
|
||||
self.assertNotIn("docforge_apply_changeset", result["recommended_workflow"])
|
||||
self.assertEqual(
|
||||
"docforge_get_context",
|
||||
"docforge_get_task_context",
|
||||
result["session_contract"]["recommended_first_operation"]["tool"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ PUBLIC_IMPORTS = {
|
|||
"capability_mode",
|
||||
"compose_effective_policy",
|
||||
),
|
||||
"docforge.retrieval": (
|
||||
"ContextCapsuleV1",
|
||||
"RetrievalPlanV1",
|
||||
"build_retrieval_plan",
|
||||
"relation_category",
|
||||
"validate_retrieval_plan",
|
||||
),
|
||||
"docforge.render_contract": (
|
||||
"GenericHtmlRenderer",
|
||||
"PreparedRender",
|
||||
|
|
@ -132,6 +139,7 @@ EXPECTED_MCP_TOOLS = {
|
|||
"docforge_get_changeset",
|
||||
"docforge_get_changeset_diff",
|
||||
"docforge_get_context",
|
||||
"docforge_get_task_context",
|
||||
"docforge_get_contract",
|
||||
"docforge_get_logic",
|
||||
"docforge_get_node",
|
||||
|
|
|
|||
464
tests/test_retrieval.py
Normal file
464
tests/test_retrieval.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.models import ProjectState
|
||||
from docforge.policy import compose_effective_policy
|
||||
from docforge.project import Project
|
||||
from docforge.retrieval import (
|
||||
BASE_RELATION_CATEGORIES,
|
||||
MAX_TASK_CANDIDATE_EDGES,
|
||||
MAX_TASK_EVIDENCE,
|
||||
RELATION_CATEGORIES,
|
||||
TASK_KINDS,
|
||||
TASK_REQUIREMENTS,
|
||||
build_retrieval_plan,
|
||||
relation_category,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
CAPSULE_SCHEMA = json.loads(
|
||||
(ROOT / "schemas" / "context-capsule.schema.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
class TaskRetrievalTests(unittest.TestCase):
|
||||
def copy_fixture(self, name: str, destination: Path) -> Path:
|
||||
root = destination / name
|
||||
shutil.copytree(FIXTURES / name, root)
|
||||
return root
|
||||
|
||||
@staticmethod
|
||||
def effective_policy() -> dict[str, object]:
|
||||
return compose_effective_policy(
|
||||
selected_mode="read",
|
||||
capability_source="explicit",
|
||||
no_ast=False,
|
||||
diagnostics=False,
|
||||
render_configured=True,
|
||||
application_enabled=False,
|
||||
).as_dict()
|
||||
|
||||
def plan(
|
||||
self,
|
||||
project: Project,
|
||||
*,
|
||||
task_kind: str = "change",
|
||||
task: str = "Change the editing workflow",
|
||||
focus_node_id: str | None = "guide.workflow",
|
||||
budget: int | None = None,
|
||||
limit: int | None = None,
|
||||
):
|
||||
return build_retrieval_plan(
|
||||
project.descriptor,
|
||||
task_kind=task_kind,
|
||||
task=task,
|
||||
focus_node_id=focus_node_id,
|
||||
budget=budget,
|
||||
limit=limit,
|
||||
effective_policy=self.effective_policy(),
|
||||
)
|
||||
|
||||
def test_every_task_plan_is_closed_deterministic_and_hash_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
hashes = {}
|
||||
relation_hashes = set()
|
||||
for task_kind in TASK_KINDS:
|
||||
first = self.plan(project, task_kind=task_kind)
|
||||
second = self.plan(project, task_kind=task_kind)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(TASK_REQUIREMENTS[task_kind], first.category_order[:1])
|
||||
self.assertEqual(
|
||||
{
|
||||
*BASE_RELATION_CATEGORIES,
|
||||
"unclassified",
|
||||
},
|
||||
set(first.category_order),
|
||||
)
|
||||
self.assertEqual(64, len(first.plan_hash))
|
||||
relation_hashes.update(
|
||||
step.relation_set_hash
|
||||
for step in first.steps
|
||||
if step.relation_scope == "project_allowed"
|
||||
)
|
||||
hashes[task_kind] = first.plan_hash
|
||||
self.assertEqual(len(TASK_KINDS), len(set(hashes.values())))
|
||||
self.assertEqual(1, len(relation_hashes))
|
||||
self.assertEqual(
|
||||
{
|
||||
"change": ("dependency",),
|
||||
"implementation": ("implementation",),
|
||||
"failure": ("execution",),
|
||||
"ownership": ("structure",),
|
||||
"test": ("evidence",),
|
||||
"operation": ("execution",),
|
||||
"release": ("evidence",),
|
||||
},
|
||||
TASK_REQUIREMENTS,
|
||||
)
|
||||
|
||||
mapped = [
|
||||
relation for relations in RELATION_CATEGORIES.values() for relation in relations
|
||||
]
|
||||
self.assertEqual(len(mapped), len(set(mapped)))
|
||||
self.assertEqual(
|
||||
{
|
||||
"structure": ("contains", "defined_in", "defines", "owns"),
|
||||
"implementation": (
|
||||
"implemented_by",
|
||||
"implements",
|
||||
"inherits",
|
||||
"inherits_from",
|
||||
),
|
||||
"dependency": ("depends_on", "imports"),
|
||||
"execution": (
|
||||
"activates",
|
||||
"calls",
|
||||
"dispatches_to",
|
||||
"launches",
|
||||
),
|
||||
"data": ("reads", "writes"),
|
||||
"evidence": (
|
||||
"documents",
|
||||
"governs",
|
||||
"proves",
|
||||
"tested_by",
|
||||
"verifies",
|
||||
),
|
||||
"context": ("relates_to",),
|
||||
"unclassified": (),
|
||||
},
|
||||
RELATION_CATEGORIES,
|
||||
)
|
||||
self.assertEqual("dependency", relation_category("depends_on"))
|
||||
self.assertEqual("evidence", relation_category("proves"))
|
||||
self.assertEqual("unclassified", relation_category("owns_database"))
|
||||
self.assertEqual("unclassified", relation_category("when_true"))
|
||||
|
||||
def test_exact_capsule_is_schema_valid_stable_and_explainable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
plan = self.plan(project)
|
||||
first = index.task_context(plan)
|
||||
second = index.task_context(plan)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
capsule = first["capsule"]
|
||||
Draft202012Validator(CAPSULE_SCHEMA).validate(capsule)
|
||||
self.assertEqual("complete", capsule["state"])
|
||||
self.assertEqual("resolved", capsule["focus"]["state"])
|
||||
self.assertEqual("guide.workflow", capsule["focus"]["node_id"])
|
||||
self.assertEqual(
|
||||
["guide.workflow", "guide.foundation", "proof.validation"],
|
||||
[item["node_id"] for item in capsule["evidence"]],
|
||||
)
|
||||
dependency = capsule["evidence"][1]["relationship_path"][0]
|
||||
self.assertEqual("depends_on", dependency["relation"])
|
||||
self.assertEqual("dependency", dependency["category"])
|
||||
self.assertEqual("outgoing", dependency["direction"])
|
||||
workflow_reasons = capsule["evidence"][0]["relationship_reasons"]
|
||||
self.assertIn(
|
||||
("depends_on", "outgoing"),
|
||||
{
|
||||
(relationship["relation"], relationship["direction"])
|
||||
for relationship in workflow_reasons
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
("proves", "incoming"),
|
||||
{
|
||||
(relationship["relation"], relationship["direction"])
|
||||
for relationship in workflow_reasons
|
||||
},
|
||||
)
|
||||
foundation_reason = capsule["evidence"][1]["relationship_reasons"][0]
|
||||
self.assertEqual("depends_on", foundation_reason["relation"])
|
||||
self.assertEqual("incoming", foundation_reason["direction"])
|
||||
self.assertNotIn(
|
||||
"no_selected_evidence",
|
||||
[gap["code"] for gap in capsule["gaps"]],
|
||||
)
|
||||
self.assertEqual(64, len(capsule["collection_hash"]))
|
||||
self.assertEqual(64, len(capsule["capsule_hash"]))
|
||||
|
||||
def test_gaps_distinguish_undeclared_complete_and_incomplete_checks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
|
||||
undeclared = index.task_context(self.plan(project, task_kind="implementation"))[
|
||||
"capsule"
|
||||
]
|
||||
self.assertIn(
|
||||
"category_not_declared",
|
||||
[gap["code"] for gap in undeclared["gaps"]],
|
||||
)
|
||||
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8").replace(
|
||||
'"returns_to"]',
|
||||
'"returns_to", "calls"]',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
declared_project = Project.open(root)
|
||||
declared_index = ProjectIndex(declared_project)
|
||||
declared_index.build()
|
||||
missing = declared_index.task_context(
|
||||
self.plan(
|
||||
declared_project,
|
||||
task_kind="failure",
|
||||
focus_node_id="guide.foundation",
|
||||
)
|
||||
)["capsule"]
|
||||
self.assertIn(
|
||||
"no_selected_evidence",
|
||||
[gap["code"] for gap in missing["gaps"]],
|
||||
)
|
||||
|
||||
incomplete = declared_index.task_context(self.plan(declared_project, budget=1))[
|
||||
"capsule"
|
||||
]
|
||||
self.assertEqual("incomplete", incomplete["state"])
|
||||
self.assertTrue(incomplete["omissions"])
|
||||
self.assertIn(
|
||||
"evidence_incomplete",
|
||||
[gap["code"] for gap in incomplete["gaps"]],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"no_selected_evidence",
|
||||
[gap["code"] for gap in incomplete["gaps"]],
|
||||
)
|
||||
|
||||
def test_unclassified_relation_is_preserved_without_guessed_semantics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("awesome-ski-game", Path(directory)))
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
plan = build_retrieval_plan(
|
||||
project.descriptor,
|
||||
task_kind="change",
|
||||
task="Change the first descent session",
|
||||
focus_node_id="session.first-descent",
|
||||
budget=None,
|
||||
limit=None,
|
||||
effective_policy=self.effective_policy(),
|
||||
)
|
||||
capsule = index.task_context(plan)["capsule"]
|
||||
self.assertIn("informs", capsule["summary"]["unclassified_relations"])
|
||||
relation = next(
|
||||
relationship
|
||||
for item in capsule["evidence"]
|
||||
for relationship in item["relationship_reasons"]
|
||||
if relationship["relation"] == "informs"
|
||||
)
|
||||
self.assertEqual("unclassified", relation["category"])
|
||||
self.assertIn(
|
||||
"unclassified_relation",
|
||||
[gap["code"] for gap in capsule["gaps"]],
|
||||
)
|
||||
|
||||
def test_plan_is_compact_at_large_valid_relation_scale_and_limits_are_fixed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
relation_names = tuple(f"relation-{position:05d}" for position in range(33_005))
|
||||
descriptor = replace(
|
||||
project.descriptor,
|
||||
allowed_relations=relation_names,
|
||||
limits=replace(
|
||||
project.descriptor.limits,
|
||||
max_results=2**63,
|
||||
),
|
||||
)
|
||||
plan = build_retrieval_plan(
|
||||
descriptor,
|
||||
task_kind="change",
|
||||
task="Exercise a very large valid relation policy",
|
||||
focus_node_id="guide.workflow",
|
||||
budget=None,
|
||||
limit=None,
|
||||
effective_policy=self.effective_policy(),
|
||||
)
|
||||
self.assertLessEqual(plan.max_evidence, MAX_TASK_EVIDENCE)
|
||||
self.assertLessEqual(plan.max_candidate_edges, MAX_TASK_CANDIDATE_EDGES)
|
||||
traversal = [step for step in plan.steps if step.operation in {"outgoing", "incoming"}]
|
||||
self.assertEqual(2, len(traversal))
|
||||
self.assertTrue(all(step.relation_scope == "project_allowed" for step in traversal))
|
||||
self.assertTrue(all(len(step.relation_set_hash or "") == 64 for step in traversal))
|
||||
self.assertLess(len(json.dumps(plan.as_dict())), 5_000)
|
||||
|
||||
def test_executor_rejects_tampered_public_plan_objects(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
plan = self.plan(project)
|
||||
tampered = (
|
||||
replace(plan, steps=plan.steps[:-1]),
|
||||
replace(plan, requirements=()),
|
||||
replace(plan, task_query="Different task"),
|
||||
replace(plan, category_order=tuple(reversed(plan.category_order))),
|
||||
replace(plan, max_evidence=plan.max_evidence + 1),
|
||||
replace(plan, effective_policy_hash="0" * 64),
|
||||
replace(plan, plan_hash="0" * 64),
|
||||
)
|
||||
for candidate in tampered:
|
||||
with self.subTest(candidate=candidate):
|
||||
with self.assertRaises(DocForgeError) as invalid:
|
||||
index.task_context(candidate)
|
||||
self.assertEqual("invalid_retrieval_plan", invalid.exception.code)
|
||||
|
||||
def test_work_and_unclassified_limits_are_explicit_and_bounded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
relation_names = [f"relation-{position:03d}" for position in range(450)]
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
raw_descriptor = descriptor.read_text(encoding="utf-8")
|
||||
raw_relations = ", ".join(
|
||||
json.dumps(relation)
|
||||
for relation in (
|
||||
"depends_on",
|
||||
"proves",
|
||||
"supersedes",
|
||||
"relates_to",
|
||||
"returns_to",
|
||||
*relation_names,
|
||||
)
|
||||
)
|
||||
descriptor.write_text(
|
||||
raw_descriptor.replace(
|
||||
'"depends_on", "proves", "supersedes", "relates_to", "returns_to"',
|
||||
raw_relations,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
workflow = root / "docs" / "content" / "workflow.md"
|
||||
raw_workflow = workflow.read_text(encoding="utf-8")
|
||||
relationships = "".join(
|
||||
f'{relation} = ["guide.foundation"]\n' for relation in relation_names
|
||||
)
|
||||
workflow.write_text(
|
||||
raw_workflow.replace("+++\n\nEditors", f"{relationships}+++\n\nEditors", 1),
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
capsule = index.task_context(self.plan(project))["capsule"]
|
||||
Draft202012Validator(CAPSULE_SCHEMA).validate(capsule)
|
||||
omission_codes = {omission["code"] for omission in capsule["omissions"]}
|
||||
self.assertIn("edge_examination_limit", omission_codes)
|
||||
self.assertIn("unclassified_relation_limit", omission_codes)
|
||||
self.assertEqual(
|
||||
project.descriptor.limits.max_results,
|
||||
len(capsule["summary"]["unclassified_relations"]),
|
||||
)
|
||||
self.assertLessEqual(
|
||||
capsule["summary"]["examined_edge_count"],
|
||||
MAX_TASK_CANDIDATE_EDGES,
|
||||
)
|
||||
|
||||
def test_lexical_focus_blocks_missing_and_ambiguous_selection(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
index = ProjectIndex(project)
|
||||
index.build()
|
||||
missing = index.task_context(
|
||||
self.plan(
|
||||
project,
|
||||
task="Words absent from every indexed node",
|
||||
focus_node_id=None,
|
||||
)
|
||||
)["capsule"]
|
||||
self.assertEqual("blocked", missing["state"])
|
||||
self.assertEqual("not_found", missing["focus"]["state"])
|
||||
self.assertEqual("focus_not_found", missing["gaps"][0]["code"])
|
||||
|
||||
duplicate = root / "docs" / "content" / "workflow-copy.md"
|
||||
duplicate.write_text(
|
||||
(root / "docs" / "content" / "workflow.md")
|
||||
.read_text(encoding="utf-8")
|
||||
.replace('id = "guide.workflow"', 'id = "guide.workflow-copy"'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
duplicate_project = Project.open(root)
|
||||
duplicate_index = ProjectIndex(duplicate_project)
|
||||
duplicate_index.build()
|
||||
ambiguous = duplicate_index.task_context(
|
||||
self.plan(
|
||||
duplicate_project,
|
||||
task="Editing workflow",
|
||||
focus_node_id=None,
|
||||
)
|
||||
)["capsule"]
|
||||
self.assertEqual("blocked", ambiguous["state"])
|
||||
self.assertEqual("ambiguous", ambiguous["focus"]["state"])
|
||||
self.assertEqual("focus_ambiguous", ambiguous["gaps"][0]["code"])
|
||||
|
||||
def test_plan_rejects_unbounded_or_untyped_inputs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
cases = (
|
||||
({"task_kind": "unknown"}, "invalid_task_kind"),
|
||||
({"task": " "}, "invalid_task_focus"),
|
||||
({"focus_node_id": ""}, "invalid_task_focus"),
|
||||
({"budget": True}, "invalid_budget"),
|
||||
({"budget": 0}, "invalid_budget"),
|
||||
({"limit": True}, "invalid_limit"),
|
||||
({"limit": project.descriptor.limits.max_results + 1}, "invalid_limit"),
|
||||
)
|
||||
for overrides, code in cases:
|
||||
arguments = {
|
||||
"task_kind": "change",
|
||||
"task": "Change the editing workflow",
|
||||
"focus_node_id": "guide.workflow",
|
||||
"budget": None,
|
||||
"limit": None,
|
||||
**overrides,
|
||||
}
|
||||
with self.subTest(arguments=overrides):
|
||||
with self.assertRaises(DocForgeError) as invalid:
|
||||
self.plan(project, **arguments)
|
||||
self.assertEqual(code, invalid.exception.code)
|
||||
|
||||
def test_final_generation_change_rejects_the_whole_capsule(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture("alpha", Path(directory)))
|
||||
index = ProjectIndex(project)
|
||||
built = index.build()
|
||||
current = ProjectState(
|
||||
revision=built["revision"],
|
||||
source_hash=built["source_hash"],
|
||||
)
|
||||
changed = ProjectState(
|
||||
revision=current.revision,
|
||||
source_hash="0" * 64,
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
project,
|
||||
"incremental_state",
|
||||
side_effect=(current, changed),
|
||||
),
|
||||
self.assertRaises(DocForgeError) as stale,
|
||||
):
|
||||
index.task_context(self.plan(project))
|
||||
self.assertEqual("source_changed", stale.exception.code)
|
||||
Loading…
Add table
Add a link
Reference in a new issue