**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""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/CLIENT.md",
|
|
"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("<svg/>", 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
|