"""Tests for scripts/check_docs.py: generation, drift detection and tool name mentions.""" from __future__ import annotations import importlib.util import re import sys from pathlib import Path from types import ModuleType import pytest from aegis_gitea_mcp.catalog import load_catalog REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts" / "check_docs.py" README_TEMPLATE = ( "# Title\n\nIntro.\n\n\nold\n\n\nOutro.\n" ) @pytest.fixture(scope="module") def script() -> ModuleType: spec = importlib.util.spec_from_file_location("check_docs_script", SCRIPT) assert spec is not None assert spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module # dataclasses resolves string annotations through it spec.loader.exec_module(module) return module @pytest.fixture def tree(script: ModuleType, tmp_path: Path) -> Path: """A minimal docs tree in which the generated files are up to date.""" (tmp_path / "docs").mkdir() (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") script.write(tmp_path) return tmp_path def test_repository_docs_are_in_sync(script: ModuleType) -> None: # docs/security.md still describes the 0.x design until the security phase rewrites it; # its stale tool names are the only accepted problems. Drop the filter after that rewrite. problems = [p for p in script.check(REPO_ROOT) if not p.startswith("docs/security.md:")] assert problems == [] def test_tools_md_lists_every_tool_once(script: ModuleType) -> None: catalog = load_catalog() text = script.render_tools_md(catalog) rows = re.findall(r"^\| `(gitea_[a-z0-9_]+)` \|", text, flags=re.MULTILINE) expected = {op.tool_name for op in catalog.operations} | {"gitea_api_request"} assert len(rows) == len(expected) == len(catalog.operations) + 1 assert set(rows) == expected assert f"There are {len(expected)} tools" in text assert catalog.spec_version in text def test_tools_md_row_content(script: ModuleType) -> None: catalog = load_catalog() text = script.render_tools_md(catalog) delete = next(op for op in catalog.operations if op.tool_name == "gitea_repo_delete") row = next(line for line in text.splitlines() if line.startswith("| `gitea_repo_delete` |")) assert f"| {delete.method} | `{delete.path}` |" in row assert "destructive" in row raw_row = next(line for line in text.splitlines() if line.startswith("| `gitea_api_request`")) assert "any" in raw_row assert "destructive" in raw_row def test_flags_cover_admin_upload_and_pagination(script: ModuleType) -> None: catalog = load_catalog() rows = {row.name: row for row in script.build_rows(catalog)} admin = next(op.tool_name for op in catalog.operations if op.domain == "admin") assert "admin" in rows[admin].flags assert any("file upload" in row.flags for row in rows.values()) assert any("paginated" in row.flags for row in rows.values()) assert any("read-only" in row.flags for row in rows.values()) def test_output_is_deterministic(script: ModuleType) -> None: catalog = load_catalog() assert script.render_tools_md(catalog) == script.render_tools_md(catalog) assert script.render_readme_block(catalog) == script.render_readme_block(catalog) def test_summary_table_counts_match_catalog(script: ModuleType) -> None: catalog = load_catalog() block = script.render_readme_block(catalog) total = len(catalog.operations) + 1 assert f"{total} tools" in block misc = sum(1 for op in catalog.operations if op.domain == "misc") + 1 assert f"| misc | {misc} |" in block assert f"| **all** | **{total}** |" in block def test_table_cells_escape_pipes_and_newlines(script: ModuleType) -> None: assert script._cell("a | b\nc") == "a \\| b c" def test_write_mode_creates_files_and_replaces_only_the_block( script: ModuleType, tmp_path: Path ) -> None: (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") written = script.write(tmp_path) assert {path.name for path in written} == {"tools.md", "README.md"} readme = (tmp_path / "README.md").read_text(encoding="utf-8") assert readme.startswith("# Title\n\nIntro.\n\n") assert readme.endswith("\n\nOutro.\n") assert "old" not in readme assert (tmp_path / "docs" / "tools.md").read_text(encoding="utf-8").startswith("# Tool") def test_write_mode_is_idempotent(script: ModuleType, tree: Path) -> None: before = (tree / "README.md").read_text(encoding="utf-8") script.write(tree) assert (tree / "README.md").read_text(encoding="utf-8") == before def test_write_requires_markers(script: ModuleType, tmp_path: Path) -> None: (tmp_path / "README.md").write_text("# no markers\n", encoding="utf-8") with pytest.raises(ValueError, match="tool-summary"): script.write(tmp_path) assert script.main(["--write"], root=tmp_path) == 1 def test_main_write_then_check( script: ModuleType, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") assert script.main(["--write"], root=tmp_path) == 0 assert "wrote docs/tools.md" in capsys.readouterr().out assert script.main([], root=tmp_path) == 0 assert "docs check passed" in capsys.readouterr().out def test_clean_tree_passes(script: ModuleType, tree: Path) -> None: assert script.check(tree) == [] def test_detects_stale_tools_md( script: ModuleType, tree: Path, capsys: pytest.CaptureFixture[str] ) -> None: path = tree / "docs" / "tools.md" path.write_text(path.read_text(encoding="utf-8").replace("| repo |", "| repos |")) problems = script.check(tree) assert len(problems) == 1 assert "docs/tools.md is out of date" in problems[0] assert "check_docs.py --write" in problems[0] assert "-| repos |" in problems[0] assert "+| repo |" in problems[0] assert script.main([], root=tree) == 1 assert "docs check failed" in capsys.readouterr().err def test_detects_missing_tools_md(script: ModuleType, tree: Path) -> None: (tree / "docs" / "tools.md").unlink() problems = script.check(tree) assert any("docs/tools.md is missing" in problem for problem in problems) def test_detects_stale_readme_block(script: ModuleType, tree: Path) -> None: readme = tree / "README.md" readme.write_text(readme.read_text(encoding="utf-8").replace("| misc |", "| miscx |")) problems = script.check(tree) assert len(problems) == 1 assert "tool summary in README.md is out of date" in problems[0] def test_detects_missing_readme_markers(script: ModuleType, tree: Path) -> None: (tree / "README.md").write_text("# no block here\n", encoding="utf-8") problems = script.check(tree) assert any("no generated block" in problem for problem in problems) def test_detects_reversed_markers(script: ModuleType, tree: Path) -> None: (tree / "README.md").write_text( "\n\n", encoding="utf-8" ) assert any("no generated block" in problem for problem in script.check(tree)) def test_detects_unknown_tool_name_in_readme(script: ModuleType, tree: Path) -> None: readme = tree / "README.md" readme.write_text(readme.read_text(encoding="utf-8") + "\nUse `gitea_made_up_tool`.\n") problems = script.check(tree) assert len(problems) == 1 assert "README.md" in problems[0] assert "'gitea_made_up_tool' is not a registered tool name" in problems[0] def test_detects_unknown_tool_name_in_docs(script: ModuleType, tree: Path) -> None: (tree / "docs" / "guide.md").write_text("one\ntwo `gitea_request` three\n", encoding="utf-8") problems = script.check(tree) assert problems == ["docs/guide.md:2: 'gitea_request' is not a registered tool name"] def test_registered_names_and_prefixes_are_accepted(script: ModuleType, tree: Path) -> None: (tree / "docs" / "guide.md").write_text( "`gitea_repo_get`, `gitea_api_request` and the gitea_admin_ tools.\n", encoding="utf-8" ) assert script.check(tree) == [] def test_unknown_prefix_is_rejected(script: ModuleType, tree: Path) -> None: (tree / "docs" / "guide.md").write_text("the gitea_bogus_ tools\n", encoding="utf-8") assert len(script.check(tree)) == 1 def test_similar_identifiers_are_not_tool_mentions(script: ModuleType) -> None: names = script.registered_names(load_catalog()) text = "aegis_gitea_mcp, my_gitea_thing, GITEA_URL and gitea__\n" assert script.find_unknown_mentions(text, names) == [] def test_excluded_documents_may_name_old_tools(script: ModuleType, tree: Path) -> None: (tree / "docs" / "audit.md").write_text("old `gitea_request`\n", encoding="utf-8") (tree / "docs" / "adr").mkdir() (tree / "docs" / "adr" / "0001.md").write_text("old `gitea_request`\n", encoding="utf-8") (tree / "docs" / "research").mkdir() (tree / "docs" / "research" / "notes.md").write_text("`gitea_request`\n", encoding="utf-8") assert script.check(tree) == [] scanned = {path.name for path in script.doc_files(tree)} assert scanned == {"README.md", "tools.md"} def test_missing_readme_is_reported(script: ModuleType, tree: Path) -> None: (tree / "README.md").unlink() assert any("README.md is missing" in problem for problem in script.check(tree)) def test_long_diffs_are_truncated(script: ModuleType) -> None: current = "\n".join(f"old {i}" for i in range(200)) expected = "\n".join(f"new {i}" for i in range(200)) summary = script._diff_summary("x", current, expected) assert "more diff lines" in summary assert len(summary.splitlines()) <= 30