1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tests/test_core.py

477 lines
21 KiB
Python

from __future__ import annotations
import contextlib
import io
import json
import shutil
import sqlite3
import sys
import tempfile
import unittest
from collections.abc import Iterator
from dataclasses import replace
from pathlib import Path
from typing import TypeVar, cast
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from docforge.cli import main # noqa: E402
from docforge.context import compile_context # noqa: E402
from docforge.errors import DocForgeError # noqa: E402
from docforge.index import ProjectIndex # noqa: E402
from docforge.models import Edge, ProjectState # noqa: E402
from docforge.project import Project, validate_graph # noqa: E402
FIXTURES = ROOT / "tests" / "fixtures"
T = TypeVar("T")
class CountingTuple(tuple[T, ...]):
"""Count complete iteration passes without changing tuple behavior."""
iterations: int
def __new__(cls, values: tuple[T, ...]) -> CountingTuple[T]:
instance = super().__new__(cls, values)
instance.iterations = 0
return instance
def __iter__(self) -> Iterator[T]:
self.iterations += 1
return super().__iter__()
class DocForgeCoreTests(unittest.TestCase):
def copy_fixture(self, name: str, destination: Path) -> Path:
root = destination / name
shutil.copytree(FIXTURES / name, root)
return root
def test_two_projects_load_distinct_confined_graphs(self) -> None:
alpha = Project.open(FIXTURES / "alpha").load()
beta = Project.open(FIXTURES / "beta").load()
self.assertEqual("alpha-docs", alpha.descriptor.project_id)
self.assertEqual(
["guide.foundation", "guide.workflow", "proof.validation"],
[node.node_id for node in alpha.nodes],
)
self.assertEqual(["research.question"], [node.node_id for node in beta.nodes])
self.assertNotEqual(alpha.source_hash, beta.source_hash)
self.assertNotIn("research.question", {node.node_id for node in alpha.nodes})
def test_missing_revision_tool_does_not_block_project_loading(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
with mock.patch("docforge.project.subprocess.run", side_effect=OSError):
snapshot = Project.open(root).load()
self.assertEqual("unversioned", snapshot.revision)
def test_descriptor_rejects_parent_and_absolute_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
original = descriptor.read_text(encoding="utf-8")
for unsafe in ("../outside", "/tmp/outside"):
with self.subTest(unsafe=unsafe):
descriptor.write_text(
original.replace(
'content_roots = ["docs/content"]', f'content_roots = ["{unsafe}"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "inside the project root"):
Project.open(root)
def test_descriptor_rejects_symbolic_link_escape(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
root = self.copy_fixture("alpha", parent)
outside = parent / "outside"
outside.mkdir()
link = root / "escaped"
link.symlink_to(outside, target_is_directory=True)
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
'content_roots = ["docs/content"]', 'content_roots = ["escaped"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "outside the project root"):
Project.open(root)
def test_descriptor_rejects_unknown_fields_cache_overlap_and_mid_session_change(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
original = descriptor.read_text(encoding="utf-8")
descriptor.write_text(original + "\nunknown_setting = true\n", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
Project.open(root)
descriptor.write_text(
original.replace(
'cache_root = ".docforge/cache"', 'cache_root = "docs/content/cache"'
).replace(
'index = ".docforge/cache/index.sqlite3"',
'index = "docs/content/cache/index.sqlite3"',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "must not overlap"):
Project.open(root)
descriptor.write_text(original, encoding="utf-8")
project = Project.open(root)
descriptor.write_text(original + "\n", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "changed after"):
project.load()
with self.assertRaisesRegex(DocForgeError, "does not exist"):
Project.open(root / "missing")
def test_duplicate_nodes_broken_edges_and_dependency_cycles_fail(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
snapshot = Project.open(root).load()
content = root / "docs" / "content"
duplicate = content / "duplicate.md"
duplicate.write_text((content / "foundation.md").read_text(), encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "unique"):
Project.open(root).load()
duplicate.unlink()
workflow = content / "workflow.md"
original = workflow.read_text(encoding="utf-8")
workflow.write_text(
original.replace(
'depends_on = ["guide.foundation"]', 'depends_on = ["missing.node"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "missing nodes"):
Project.open(root).load()
workflow.write_text(original, encoding="utf-8")
foundation = content / "foundation.md"
foundation.write_text(
foundation.read_text(encoding="utf-8").replace(
'summary = "Defines which Alpha files own documentation facts."',
'summary = "Defines which Alpha files own documentation facts."\n'
'depends_on = ["guide.workflow"]',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "cycle"):
Project.open(root).load()
with self.assertRaises(DocForgeError) as missing_source:
validate_graph(
snapshot.nodes,
(
Edge(
"missing.source",
"depends_on",
snapshot.nodes[0].node_id,
),
),
)
self.assertEqual("broken_edge", missing_source.exception.code)
self.assertEqual(
["missing.source"],
missing_source.exception.details["sources"],
)
def test_graph_validation_uses_a_bounded_number_of_edge_passes(self) -> None:
snapshot = Project.open(FIXTURES / "alpha").load()
nodes = tuple(
replace(
snapshot.nodes[0],
node_id=f"linear.node-{index:05d}",
source_path=f"docs/node-{index:05d}.md",
)
for index in range(10_000)
)
edges = CountingTuple(
tuple(
Edge(
f"linear.node-{index:05d}",
"depends_on",
f"linear.node-{index - 1:05d}",
)
for index in range(1, len(nodes))
)
)
validate_graph(nodes, cast(tuple[Edge, ...], edges))
self.assertLessEqual(edges.iterations, 4)
def test_index_build_is_repeatable_and_validates_every_row(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
first = index.build()
second = index.build()
validated = index.check()
for key in ("source_hash", "node_hash", "node_count", "edge_hash", "edge_count"):
self.assertEqual(first[key], second[key])
self.assertEqual(first[key], validated[key])
self.assertEqual(3, validated["node_count"])
self.assertEqual(2, validated["edge_count"])
def test_persisted_generic_generation_avoids_warm_source_loading(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
fresh_project = Project.open(root)
fresh_index = ProjectIndex(fresh_project)
with mock.patch.object(
fresh_project,
"load",
side_effect=AssertionError("warm reads must not load canonical sources"),
):
self.assertEqual(
"Editing workflow",
fresh_index.get_node("guide.workflow")["node"]["title"],
)
self.assertEqual(1, fresh_index.search("canonical nodes")["count"])
self.assertEqual(1, fresh_index.filter_nodes(family="proof")["count"])
self.assertEqual(1, len(fresh_index.backlinks("guide.workflow")["edges"]))
self.assertEqual(1, fresh_index.dependencies("guide.workflow")["count"])
self.assertEqual(2, fresh_index.impact("guide.foundation")["count"])
self.assertEqual("active", compile_context(fresh_index, "active")["profile"])
self.assertEqual("current", fresh_index.synchronize()["synchronization"]["action"])
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nChanged after generation.\n",
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "does not match"):
fresh_index.get_node("guide.workflow")
def test_missing_or_corrupt_generation_falls_back_and_repairs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
index.build()
generation_path = project.generation_path
for raw in (None, "{not-json"):
with self.subTest(raw=raw):
if raw is None:
generation_path.unlink(missing_ok=True)
else:
generation_path.write_text(raw, encoding="utf-8")
with mock.patch.object(project, "load", wraps=project.load) as load:
checked = index.check(verify_rows=False)
self.assertEqual("ok", checked["status"])
self.assertGreaterEqual(load.call_count, 1)
self.assertIsNotNone(project.incremental_state())
def test_index_rejects_tampered_rows_and_another_project_cache(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
alpha_root = self.copy_fixture("alpha", parent)
beta_root = self.copy_fixture("beta", parent)
alpha = ProjectIndex(Project.open(alpha_root))
alpha.build()
with contextlib.closing(sqlite3.connect(alpha.path)) as connection:
connection.execute(
"UPDATE nodes SET title = 'Tampered' WHERE node_id = 'guide.workflow'"
)
connection.commit()
with self.assertRaisesRegex(DocForgeError, "do not match"):
alpha.check()
alpha.build()
beta = ProjectIndex(Project.open(beta_root))
beta.path.parent.mkdir(parents=True)
shutil.copy2(alpha.path, beta.path)
with self.assertRaisesRegex(DocForgeError, "does not match"):
beta.check()
def test_stale_source_fails_closed_and_failed_rebuild_preserves_index(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
index_bytes = index.path.read_bytes()
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text() + "\nChanged after indexing.\n", encoding="utf-8"
)
with self.assertRaisesRegex(DocForgeError, "does not match"):
index.check()
workflow.write_text("invalid", encoding="utf-8")
with self.assertRaises(DocForgeError):
index.build()
self.assertEqual(index_bytes, index.path.read_bytes())
def test_lookup_search_filter_and_traversal_are_deterministic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
node = index.get_node("guide.workflow")["node"]
self.assertEqual("Editing workflow", node["title"])
search = index.search("canonical nodes", limit=10)
self.assertEqual(["guide.workflow"], [item["node_id"] for item in search["results"]])
filtered = index.filter_nodes(family="proof", tag="validation")
self.assertEqual(
["proof.validation"], [item["node_id"] for item in filtered["results"]]
)
limited_filter = index.filter_nodes(limit=1)
self.assertEqual(1, limited_filter["count"])
self.assertTrue(limited_filter["truncated"])
dependencies = index.dependencies("guide.workflow", depth=2)
self.assertEqual(
["guide.foundation"], [item["node_id"] for item in dependencies["results"]]
)
backlinks = index.backlinks("guide.workflow")
self.assertEqual(
["proof.validation"], [edge["source_id"] for edge in backlinks["edges"]]
)
impact = index.impact("guide.foundation", depth=2)
self.assertEqual(
["guide.workflow", "proof.validation"],
[item["node_id"] for item in impact["results"]],
)
limited = index.impact("guide.foundation", depth=2, limit=1)
self.assertEqual(["guide.workflow"], [item["node_id"] for item in limited["results"]])
self.assertEqual(1, limited["limit"])
self.assertTrue(limited["truncated"])
self.assertEqual("result_limit", limited["truncation_reason"])
self.assertLessEqual(
limited["candidate_edges_consumed"],
limited["candidate_edges_limit"],
)
complete_limit = index.dependencies("guide.workflow", depth=2, limit=1)
self.assertEqual(1, complete_limit["count"])
self.assertFalse(complete_limit["truncated"])
self.assertIsNone(complete_limit["truncation_reason"])
limited_backlinks = index.backlinks("guide.workflow", limit=1)
self.assertEqual(1, limited_backlinks["count"])
self.assertEqual(1, limited_backlinks["limit"])
self.assertFalse(limited_backlinks["truncated"])
for operation in (
lambda: index.backlinks("guide.workflow", limit=0),
lambda: index.dependencies("guide.workflow", limit=True),
lambda: index.impact("guide.workflow", limit=101),
):
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as invalid:
operation()
self.assertEqual("invalid_limit", invalid.exception.code)
def test_incoming_traversal_uses_source_ordered_covering_index(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
connection = sqlite3.connect(index.path)
try:
plan = connection.execute(
"EXPLAIN QUERY PLAN "
"SELECT source_id, relation, target_id FROM edges "
"WHERE target_id = ? "
"ORDER BY source_id, relation, target_id LIMIT ?",
("guide.foundation", 101),
).fetchall()
finally:
connection.close()
details = " ".join(str(row[3]) for row in plan)
self.assertIn("edges_target_source", details)
self.assertNotIn("USE TEMP B-TREE", details)
def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
index.build()
current = project.incremental_state()
self.assertIsNotNone(current)
changed = ProjectState(
source_hash="0" * 64,
revision=current.revision,
)
with (
mock.patch.object(
project,
"incremental_state",
side_effect=[current, changed],
),
self.assertRaisesRegex(DocForgeError, "changed during the query"),
):
index.get_node("guide.workflow")
def test_source_set_change_during_load_fails_closed(self) -> None:
project = Project.open(FIXTURES / "alpha")
sources, directories = project._canonical_inventory()
invented = project.descriptor.root / "docs" / "content" / "invented.md"
with (
mock.patch.object(
project,
"_canonical_inventory",
side_effect=[
(sources, directories),
((*sources, invented), directories),
],
),
self.assertRaisesRegex(DocForgeError, "source set changed"),
):
project.load()
def test_context_is_bounded_cited_deterministic_and_reports_omissions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
first = compile_context(index, "active", budget=180)
second = compile_context(index, "active", budget=180)
self.assertEqual(first, second)
self.assertLessEqual(first["estimated_tokens"], 180)
self.assertEqual("guide.workflow", first["entries"][0]["node_id"])
self.assertEqual("required by profile", first["entries"][0]["reason"])
self.assertTrue(first["entries"][0]["source_path"])
self.assertTrue(first["entries"][0]["content_hash"])
self.assertTrue(first["omissions"])
with self.assertRaisesRegex(DocForgeError, "required node"):
compile_context(index, "active", budget=10)
def test_cli_json_is_repeatable_and_project_scoped(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("beta", Path(directory))
outputs: list[str] = []
for _ in range(2):
stream = io.StringIO()
with contextlib.redirect_stdout(stream):
self.assertEqual(0, main(["--project-root", str(root), "validate"]))
outputs.append(stream.getvalue())
self.assertEqual(outputs[0], outputs[1])
result = json.loads(outputs[0])
self.assertEqual("beta-notes", result["project_id"])
self.assertEqual(1, result["node_count"])
if __name__ == "__main__":
unittest.main()