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

296 lines
13 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
from pathlib import Path
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.project import Project # noqa: E402
FIXTURES = ROOT / "tests" / "fixtures"
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))
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()
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_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"]]
)
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"]],
)
def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
checked = index.build()
changed = {**checked, "source_hash": "0" * 64}
with (
mock.patch.object(index, "check", side_effect=[checked, 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-22 02:58:51 -04:00
sources = project.canonical_source_paths()
invented = project.descriptor.root / "docs" / "content" / "invented.md"
with (
mock.patch.object(
2026-07-22 02:58:51 -04:00
project, "canonical_source_paths", side_effect=[sources, (*sources, invented)]
),
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()