"""PLAN §13 doc-index guard: the promised documentation set and the `docs/` tree cannot drift. `.agents/PLAN.md` §13 closes with the canonical documentation index and the sentence "`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical, so neither can drift silently." This is that file. It closes both directions of drift: * **A promised doc is missing.** Every index row whose creating phase has already completed (according to the `.agents/phases/complete/` tree) must exist on disk, so no phase can reference a document that was never written. A range in the "Created by" column — `phase 02/03` — promises the file by the *end* of that range, so its obligation starts at the later phase; a `→` marks the stub-then-fill pattern, where the earlier phase owes the stub. * **A doc exists that nothing promises.** Every `docs/*.md` must be listed in the index or be a document a phase file of the roadmap itself creates — `docs/ROOMS.md` comes from phase 05 and is not in §13, so the roadmap may extend the doc set, but an untracked file may not appear there silently. Only markdown is compared: the index promises *documents*, and a diagram or fixture a phase puts under `docs/` is not a documentation promise. """ from __future__ import annotations import re from collections.abc import Callable from dataclasses import dataclass from pathlib import Path import pytest pytestmark = pytest.mark.unit REPO_ROOT = Path(__file__).resolve().parents[2] PLAN = REPO_ROOT / ".agents/PLAN.md" DOCS_DIR = REPO_ROOT / "docs" PHASES_DIR = REPO_ROOT / ".agents/phases" COMPLETE_DIR = PHASES_DIR / "complete" INDEX_HEADING = "### Documentation index (canonical)" PHASE_DIRECTORY = re.compile(r"^(\d{2})_") PHASE_REFERENCE = re.compile(r"phase\s+(\d{1,2})", re.IGNORECASE) NUMBER = re.compile(r"\d{1,2}") DOC_REFERENCE = re.compile(r"docs/[A-Za-z0-9_-]+\.md") #: The index exactly as PLAN §13 writes it. Changing this set means editing PLAN.md first. CANONICAL_DOCS = frozenset( { "README.md", "AGENTS.md", "BUDGETS.md", "docs/DESIGN.md", "docs/SIMULATION.md", "docs/LEVELS.md", "docs/PROTOCOL.md", "docs/CLIENT.md", "docs/DEPLOYMENT.md", "docs/TESTING.md", "docs/ACCESSIBILITY.md", "docs/SECURITY.md", "tests/e2e/README.md", } ) @dataclass(frozen=True, slots=True) class DocRow: """One row of the PLAN §13 index: path, contents, and the phase(s) that create it.""" path: str contents: str created_by: str @property def promised_from(self) -> int: """First phase after which this file must exist. The first comma/arrow-separated group is the creation promise. Inside it, a slash range (`phase 02/03`) is owed by the end of the range, so the later number wins; anything else is owed by the earliest number mentioned. """ first_group = re.split(r"[→,;]", self.created_by, maxsplit=1)[0] numbers = [int(number) for number in NUMBER.findall(first_group)] if not numbers: numbers = [int(number) for number in PHASE_REFERENCE.findall(self.created_by)] if not numbers: pytest.fail(f"PLAN index row {self.path!r} names no phase: {self.created_by!r}") return numbers[-1] if "/" in first_group and len(numbers) > 1 else numbers[0] def parse_documentation_index(plan_text: str) -> list[DocRow]: """Read the canonical documentation-index table out of PLAN.md text.""" lines = plan_text.splitlines() try: start = lines.index(INDEX_HEADING) except ValueError: # pragma: no cover - PLAN is locked; fail loudly if it is renamed pytest.fail(f".agents/PLAN.md has no {INDEX_HEADING!r} heading") rows: list[DocRow] = [] for raw in lines[start + 1 :]: stripped = raw.strip() if not stripped.startswith("|"): if rows: break continue cells = [cell.strip() for cell in stripped.strip("|").split("|")] if len(cells) < 3 or cells[0].strip("`") in {"Path", ""} or set(cells[0]) <= {"-", ":"}: continue rows.append(DocRow(path=cells[0].strip("`"), contents=cells[1], created_by=cells[2])) return rows def latest_completed_phase(complete_dir: Path = COMPLETE_DIR) -> int: """Highest phase number with anything in `complete/` (0 before the first phase finishes).""" return max( ( int(match.group(1)) for entry in complete_dir.glob("*") if (match := PHASE_DIRECTORY.match(entry.name)) ), default=0, ) def roadmap_promised_docs(phases_dir: Path = PHASES_DIR) -> set[str]: """Every `docs/*.md` a phase overview or task file itself names.""" return { match.group(0) for phase_file in sorted(phases_dir.rglob("*.md")) for match in DOC_REFERENCE.finditer(phase_file.read_text(encoding="utf-8")) } def documentation_index(plan: Path = PLAN) -> list[DocRow]: return parse_documentation_index(plan.read_text(encoding="utf-8")) def written_docs(docs_dir: Path = DOCS_DIR, *, root: Path = REPO_ROOT) -> set[str]: """The markdown currently in `docs/`, as repo-relative paths.""" return {path.relative_to(root).as_posix() for path in sorted(docs_dir.rglob("*.md"))} # --------------------------------------------------------------------------- the index itself def test_the_documentation_index_is_parseable() -> None: rows = documentation_index() assert len(rows) >= len(CANONICAL_DOCS), rows paths = [row.path for row in rows] assert len(paths) == len(set(paths)), f"duplicate index rows: {paths}" assert all(row.contents and row.created_by for row in rows), paths def test_the_index_still_lists_the_canonical_doc_set() -> None: """Adding or dropping a row is a PLAN §13 change and must fail a test first.""" assert {row.path for row in documentation_index()} == CANONICAL_DOCS @pytest.mark.parametrize( ("created_by", "expected"), [ ("phase 01", 1), ("phase 01, refined throughout", 1), ("phase 01 (stub) \u2192 phase 09", 1), ("phase 02, phase 11", 2), ("phase 02/03", 3), ("phase 14", 14), ("refined throughout, phase 05", 5), ], ) def test_the_promised_from_rule_reads_the_phase_column(created_by: str, expected: int) -> None: row = DocRow(path="docs/X.md", contents="whatever", created_by=created_by) assert row.promised_from == expected def test_an_index_row_that_names_no_phase_fails_loudly() -> None: row = DocRow(path="docs/X.md", contents="whatever", created_by="someday, maybe") with pytest.raises(pytest.fail.Exception, match="names no phase"): _ = row.promised_from # --------------------------------------------------------------------------- promised vs present def test_every_doc_promised_by_a_completed_phase_exists() -> None: """No phase may reference a document whose creating phase is already in `complete/`.""" completed = latest_completed_phase() assert completed >= 1, "no completed phase found — the guard would be vacuous" missing = [ f"{row.path} (owed by phase {row.promised_from:02d}, {completed:02d} is complete)" for row in documentation_index() if row.promised_from <= completed and not (REPO_ROOT / row.path).is_file() ] report = "\n ".join(missing) assert missing == [], f"documentation the project owes but does not have:\n {report}" def test_no_doc_file_exists_that_nothing_promises() -> None: """The `docs/` tree is exactly the index plus what the phase roadmap creates.""" promised = {row.path for row in documentation_index()} | roadmap_promised_docs() orphans = sorted(written_docs() - promised) report = "\n ".join(orphans) assert orphans == [], ( f"unpromised files under docs/ — add each to PLAN §13's index or to the phase " f"that creates it:\n {report}" ) def test_the_docs_on_disk_are_the_shipped_documents() -> None: """Pins today's tree so that adding a document is a deliberate, named act.""" assert written_docs() == { "docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/LEVELS.md", "docs/PROTOCOL.md", "docs/ROOMS.md", "docs/SIMULATION.md", "docs/TESTING.md", } def stub_without_owner(relative: str, text: str) -> str | None: """One sentence of complaint about a document, or `None` when it is honest. Split out of the test below so a planted stub can prove each check fires; a guard that only ever sees healthy input is not a guard. """ if not text.startswith("# "): return f"{relative} does not open with a title" if "## Purpose" not in text: return f"{relative} has no Purpose section" purpose = text.split("## Purpose", 1)[1].split("\n##", 1)[0].strip() if len(purpose.split()) < 25: return f"{relative}'s Purpose paragraph is too thin to guide" names_stub = re.search(r"\bstub\b", text, re.IGNORECASE) is not None has_owner = re.search(r"[Ff]illed in by phase \d\d", text) is not None if names_stub and not has_owner: return f"{relative} is a stub with no 'Filled in by phase NN' marker" return None def dishonest_references( references: dict[str, set[str]], *, exists: Callable[[str], bool], owed_later: set[str] ) -> list[str]: """`document: path` for each cited doc that neither exists nor is owed by a later phase.""" offenders: list[str] = [] for name, cited in sorted(references.items()): offenders.extend( f"{name}: {path}" for path in sorted(cited) if not exists(path) and path not in owed_later ) return offenders def test_the_stub_docs_say_who_fills_them_in() -> None: """A stub with no owner stays a stub forever, so each one names its phase.""" complaints = [ complaint for relative in sorted(written_docs()) if (complaint := stub_without_owner(relative, (REPO_ROOT / relative).read_text("utf-8"))) ] report = "\n ".join(complaints) assert complaints == [], f"documents that are not self-describing:\n {report}" def test_readme_and_agents_reference_only_honest_doc_paths() -> None: """A doc path in README/AGENTS either exists or is owed by a phase that has not run.""" completed = latest_completed_phase() rows = documentation_index() owed_later = {row.path for row in rows if row.promised_from > completed} owed_later |= roadmap_promised_docs() root_docs = {row.path for row in rows if not row.path.startswith("docs/")} references: dict[str, set[str]] = {} for name in ("README.md", "AGENTS.md"): text = (REPO_ROOT / name).read_text(encoding="utf-8") references[name] = set(DOC_REFERENCE.findall(text)) | { path for path in root_docs if path in text } offenders = dishonest_references( references, exists=lambda path: (REPO_ROOT / path).is_file(), owed_later=owed_later ) report = "\n ".join(offenders) assert offenders == [], f"references to documentation nothing promises to create:\n {report}" # --------------------------------------------------------------------------- the guard itself def test_a_missing_promised_doc_is_named(tmp_path: Path) -> None: """Plant a row whose phase already shipped: the parse must expose it as owed.""" plan = tmp_path / "PLAN.md" plan.write_text( "\n".join( [ "## Risks", "", INDEX_HEADING, "", "| Path | Contents | Created by |", "|---|---|---|", "| `docs/GONE.md` | whatever it says | phase 01 |", "| `docs/LATER.md` | not owed yet | phase 09 |", "", "prose after the table", ] ), encoding="utf-8", ) rows = parse_documentation_index(plan.read_text(encoding="utf-8")) assert [row.path for row in rows] == ["docs/GONE.md", "docs/LATER.md"] owed = [row.path for row in rows if row.promised_from <= 1] assert owed == ["docs/GONE.md"] assert not (tmp_path / owed[0]).is_file() def test_an_unpromised_doc_file_is_reported(tmp_path: Path) -> None: """The orphan rule works on a temp tree too — it is not README-specific magic.""" (tmp_path / "docs").mkdir() (tmp_path / "docs" / "WANTED.md").write_text("# WANTED\n", encoding="utf-8") (tmp_path / "docs" / "MYSTERY.md").write_text("# MYSTERY\n", encoding="utf-8") (tmp_path / "docs" / "tile.svg").write_text("", encoding="utf-8") assert written_docs(tmp_path, root=tmp_path) == { "docs/MYSTERY.md", "docs/WANTED.md", } assert written_docs(tmp_path, root=tmp_path) - {"docs/WANTED.md"} == {"docs/MYSTERY.md"} def test_an_unhonest_document_is_described_precisely() -> None: """Each complaint the stub check can make is exercised on planted text.""" body = "word " * 30 good = f"# Title\n\n## Purpose\n{body}\nA stub here. Filled in by phase 09.\n" assert stub_without_owner("docs/GOOD.md", good) is None assert stub_without_owner("docs/ORPHAN.md", good.replace(" Filled in by phase 09.", "")) == ( "docs/ORPHAN.md is a stub with no 'Filled in by phase NN' marker" ) assert stub_without_owner("docs/UNTITLED.md", f"## Purpose\n{body}") == ( "docs/UNTITLED.md does not open with a title" ) assert stub_without_owner("docs/NOPURPOSE.md", "# Title\nProse only.\n") == ( "docs/NOPURPOSE.md has no Purpose section" ) assert stub_without_owner("docs/THIN.md", "# Title\n\n## Purpose\ntoo short\n") == ( "docs/THIN.md's Purpose paragraph is too thin to guide" ) def test_a_reference_to_a_doc_nobody_promises_is_named() -> None: references = { "AGENTS.md": {"docs/HERE.md"}, "README.md": {"docs/HERE.md", "docs/GHOST.md", "docs/OWED.md"}, } offenders = dishonest_references( references, exists=lambda path: path in {"docs/HERE.md", "docs/OWED.md"}, owed_later={"docs/OWED.md"}, ) assert offenders == ["README.md: docs/GHOST.md"] def test_the_roadmap_extension_of_the_doc_set_is_tracked() -> None: """`docs/ROOMS.md` is phase 05's, not PLAN §13's — and must not read as an orphan.""" roadmap = roadmap_promised_docs() assert "docs/ROOMS.md" in roadmap assert "docs/ROOMS.md" not in CANONICAL_DOCS assert latest_completed_phase() >= 1