477 lines
21 KiB
Python
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()
|