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

446 lines
19 KiB
Python
Raw Normal View History

from __future__ import annotations
import contextlib
import io
import json
import shutil
import sqlite3
import sys
import tempfile
import unittest
2026-07-29 03:45:09 -04:00
from collections.abc import Iterator
from dataclasses import replace
from pathlib import Path
2026-07-29 03:45:09 -04:00
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
2026-07-29 04:00:23 -04:00
from docforge.models import Edge, ProjectState # noqa: E402
2026-07-29 03:45:09 -04:00
from docforge.project import Project, validate_graph # noqa: E402
FIXTURES = ROOT / "tests" / "fixtures"
2026-07-29 03:45:09 -04:00
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))
2026-07-29 04:00:23 -04:00
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()
2026-07-29 04:00:23 -04:00
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"],
)
2026-07-29 03:45:09 -04:00
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",
)
2026-07-29 04:00:23 -04:00
for index in range(10_000)
2026-07-29 03:45:09 -04:00
)
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"])
2026-07-29 04:00:23 -04:00
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"]]
)
2026-07-29 04:09:28 -04:00
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"]],
)
2026-07-29 04:09:28 -04:00
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.assertLessEqual(
limited["examined_edges"],
limited["examined_edges_limit"],
)
complete_limit = index.dependencies("guide.workflow", depth=2, limit=1)
self.assertEqual(1, complete_limit["count"])
self.assertFalse(complete_limit["truncated"])
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"])
def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
2026-07-29 04:00:23 -04:00
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 (
2026-07-29 04:00:23 -04:00
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")
2026-07-29 04:00:23 -04:00
sources, directories = project._canonical_inventory()
invented = project.descriptor.root / "docs" / "content" / "invented.md"
with (
mock.patch.object(
2026-07-29 04:00:23 -04:00
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()