**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
410 lines
17 KiB
Python
410 lines
17 KiB
Python
"""Docs guard: what `README.md` tells a human to type must be real (phase 01 task 04).
|
|
|
|
Documentation rots in two ways this file makes impossible:
|
|
|
|
1. **Pseudo-commands.** A fenced block that reads plausibly but cannot run — an invented
|
|
script, a tool that is not part of this project, a placeholder like `make deploy`.
|
|
Every command line inside a `bash`/`sh`/`console`/`shell` fence of `README.md` or
|
|
`docs/*.md` must start with a known-safe prefix, and whatever it launches through
|
|
`uv run` must be a console script or a dependency declared in `pyproject.toml`. That is
|
|
what keeps the Quickstart true after a rename — and what stops a promise (e.g. the
|
|
phase-02 `undercooked-levels` CLI) from being written as a runnable command before it
|
|
exists.
|
|
2. **Config drift.** A variable documented but not implemented, or implemented but never
|
|
documented. AGENTS.md §5 requires the `Settings` field, the `ENV_KEYS` entry, the
|
|
`.env.example` line and the README row to land together; here the documentation side is
|
|
compared against `config.py` and `.env.example`, so they cannot disagree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import tomllib
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from undercooked.config import ENV_KEYS
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
README = REPO_ROOT / "README.md"
|
|
ENV_EXAMPLE = REPO_ROOT / ".env.example"
|
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
|
|
#: Markdown documents whose command blocks a human may copy.
|
|
DOCUMENTED_MARKDOWN: tuple[Path, ...] = (README, *sorted((REPO_ROOT / "docs").glob("*.md")))
|
|
|
|
#: Shell fences that promise "type this".
|
|
COMMAND_FENCE_LANGUAGES = frozenset({"bash", "sh", "console", "shell"})
|
|
|
|
#: The only things a documented command line may start with. Anything else is prose
|
|
#: wearing a code fence, and it will be deleted rather than run.
|
|
SAFE_PREFIXES = ("uv ", "podman ", "bash .agents/validate.sh", "curl ", "cd ", "#")
|
|
|
|
#: `FOO=bar cmd …` is still a command; only documented variables may lead a line.
|
|
ENV_ASSIGNMENT = re.compile(r"""^(?:[A-Z][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S)+\s+)+""")
|
|
|
|
#: Every `UNDERCOOKED_*` / `DEBUGPY*` name written anywhere in prose, a table or a fence.
|
|
#: A name has to start with a real character, so the `UNDERCOOKED_*` wildcard in prose is
|
|
#: not read as a variable nobody implemented.
|
|
DOCUMENTED_VARIABLE = re.compile(r"\bUNDERCOOKED_[A-Z0-9_]*[A-Z0-9]\b|\bDEBUGPY(?:_[A-Z0-9]+)?\b")
|
|
|
|
#: `uv run` options that consume the token after them, so the program name is further along.
|
|
UV_VALUE_OPTIONS = frozenset({"--extra", "--python", "--with", "--directory", "--project", "-m"})
|
|
|
|
#: `KEY=value` lines of an env template.
|
|
_TEMPLATE_VARIABLE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Command:
|
|
"""One logical command line from a fenced block (continuations joined)."""
|
|
|
|
document: str
|
|
line: int
|
|
text: str
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.document}:{self.line}: {self.text}"
|
|
|
|
|
|
def _strip_assignment(command: str) -> tuple[tuple[str, ...], str]:
|
|
"""Split `A=1 B=2 uv run …` into its variable names and the command underneath."""
|
|
names: list[str] = []
|
|
while match := ENV_ASSIGNMENT.match(command):
|
|
names.extend(part.split("=", 1)[0] for part in match.group(0).split())
|
|
command = command[match.end(0) :].lstrip()
|
|
return tuple(names), command
|
|
|
|
|
|
def fenced_commands(markdown: str, *, document: str = "<memory>") -> list[Command]:
|
|
"""Every command inside a `bash`-family fence, with the line it started on.
|
|
|
|
A trailing-backslash continuation becomes one `Command`, because only the first line
|
|
of a wrapped command carries the program name.
|
|
"""
|
|
commands: list[Command] = []
|
|
fence: str | None = None
|
|
pending: list[str] = []
|
|
pending_line = 0
|
|
for number, raw in enumerate(markdown.splitlines(), start=1):
|
|
marker = re.match(r"^\s*```(\w*)\s*$", raw)
|
|
if marker:
|
|
fence = None if fence is not None else marker.group(1).lower()
|
|
if pending:
|
|
commands.append(Command(document, pending_line, " ".join(pending)))
|
|
pending = []
|
|
continue
|
|
if fence is None or fence not in COMMAND_FENCE_LANGUAGES:
|
|
continue
|
|
stripped = raw.strip()
|
|
if not stripped:
|
|
if pending:
|
|
commands.append(Command(document, pending_line, " ".join(pending)))
|
|
pending = []
|
|
continue
|
|
if not pending:
|
|
pending_line = number
|
|
if stripped.endswith("\\"):
|
|
pending.append(stripped[:-1].strip())
|
|
continue
|
|
pending.append(stripped)
|
|
commands.append(Command(document, pending_line, " ".join(pending)))
|
|
pending = []
|
|
if pending: # pragma: no cover - a document ending mid-continuation
|
|
commands.append(Command(document, pending_line, " ".join(pending)))
|
|
return commands
|
|
|
|
|
|
def uv_run_program(command: str) -> str | None:
|
|
"""The program a `uv run …` line launches, with uv's own options skipped; else `None`.
|
|
|
|
That program must be declared in `pyproject.toml`, which is what makes a doc line
|
|
inventing a script fail the build.
|
|
"""
|
|
if not command.startswith("uv run"):
|
|
return None
|
|
tokens = command.split()[2:]
|
|
index = 0
|
|
while index < len(tokens):
|
|
token = tokens[index]
|
|
if token in UV_VALUE_OPTIONS:
|
|
index += 2
|
|
continue
|
|
if token.startswith("-"):
|
|
index += 1
|
|
continue
|
|
return token.lower()
|
|
return None
|
|
|
|
|
|
def declared_uv_programs() -> frozenset[str]:
|
|
"""Program names `uv run` may launch: this project's scripts plus its dependencies."""
|
|
|
|
def names(requirements: list[str]) -> set[str]:
|
|
return {re.split(r"[<>=!;\[ ]", name)[0].strip().lower() for name in requirements}
|
|
|
|
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
|
project = data["project"]
|
|
packages = names(list(project.get("dependencies", [])))
|
|
for extra in project.get("optional-dependencies", {}).values():
|
|
packages |= names(list(extra))
|
|
return frozenset(set(project.get("scripts", {})) | packages | {"python"})
|
|
|
|
|
|
def template_variables() -> set[str]:
|
|
"""Names declared in the committed `.env.example` template (comments ignored)."""
|
|
return {
|
|
match.group(1)
|
|
for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines()
|
|
if (match := _TEMPLATE_VARIABLE.match(line.strip()))
|
|
}
|
|
|
|
|
|
def variables_in(path: Path) -> set[str]:
|
|
"""Every configuration variable named anywhere in one markdown file."""
|
|
return set(DOCUMENTED_VARIABLE.findall(path.read_text(encoding="utf-8")))
|
|
|
|
|
|
def _fail(header: str, offenders: list[str]) -> None:
|
|
pytest.fail(f"{header}\n" + "\n".join(f" {offender}" for offender in offenders))
|
|
|
|
|
|
def unsafe_commands(commands: Iterable[Command]) -> list[str]:
|
|
"""Commands whose first token is none of `SAFE_PREFIXES`, once env assignments are peeled."""
|
|
offenders: list[str] = []
|
|
for command in commands:
|
|
_, bare = _strip_assignment(command.text)
|
|
if not bare.startswith(SAFE_PREFIXES):
|
|
offenders.append(str(command))
|
|
return offenders
|
|
|
|
|
|
def undeclared_programs(commands: Iterable[Command], allowed: frozenset[str]) -> list[str]:
|
|
"""`uv run <thing>` lines whose `<thing>` pyproject.toml does not declare."""
|
|
offenders: list[str] = []
|
|
for command in commands:
|
|
_, bare = _strip_assignment(command.text)
|
|
program = uv_run_program(bare)
|
|
if program is not None and program not in allowed:
|
|
offenders.append(f"{command} -> {program!r} is not in pyproject.toml")
|
|
return offenders
|
|
|
|
|
|
def documented_but_unimplemented(documented: set[str], implemented: set[str]) -> list[str]:
|
|
"""Variables the docs name and `config.py` does not read."""
|
|
return sorted(documented - implemented)
|
|
|
|
|
|
def implemented_but_undocumented(implemented: set[str], documented: set[str]) -> list[str]:
|
|
"""Variables `config.py` reads and the docs never mention."""
|
|
return sorted(set(implemented) - documented)
|
|
|
|
|
|
# --------------------------------------------------------------------------- command blocks
|
|
|
|
|
|
def test_readme_has_command_blocks_worth_checking() -> None:
|
|
"""Non-vacuous: if the Quickstart stops being a bash fence, this has to notice."""
|
|
commands = fenced_commands(README.read_text(encoding="utf-8"), document="README.md")
|
|
assert len(commands) >= 8, f"README.md has lost its command blocks: {commands}"
|
|
assert any(command.text.startswith("uv run undercooked") for command in commands)
|
|
assert any(command.text.startswith("uv run pytest") for command in commands)
|
|
|
|
|
|
def test_documented_command_lines_start_with_a_known_prefix() -> None:
|
|
"""No pseudo-commands in the docs a player or an operator copy-pastes from."""
|
|
offenders: list[str] = []
|
|
for document in DOCUMENTED_MARKDOWN:
|
|
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
|
|
offenders.extend(unsafe_commands(commands))
|
|
if offenders:
|
|
_fail(f"documented commands must start with one of {SAFE_PREFIXES}:", offenders)
|
|
|
|
|
|
def test_documented_commands_only_name_declared_programs() -> None:
|
|
"""`uv run <thing>` may only launch a script or dependency of this project."""
|
|
allowed = declared_uv_programs()
|
|
offenders: list[str] = []
|
|
for document in DOCUMENTED_MARKDOWN:
|
|
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
|
|
offenders.extend(undeclared_programs(commands, allowed))
|
|
if offenders:
|
|
_fail(f"`uv run` may only run {sorted(allowed)}:", offenders)
|
|
|
|
|
|
def test_command_fence_is_a_fence_and_not_a_table() -> None:
|
|
"""Pins the extractor: fenced bash is a command, indented prose and other fences are not."""
|
|
sample = "\n".join(
|
|
[
|
|
"text `not-a-command --yolo`", # 1: inline code is not a command
|
|
"",
|
|
"```bash", # 3
|
|
"uv run undercooked \\", # 4 \\ the continuation joins the next line
|
|
" --port 8137", # 5
|
|
"", # 6: a blank line ends a command, it never joins across it
|
|
"uv run pytest -q", # 7
|
|
"# a comment", # 8
|
|
"uv run ruff check \\", # 9 \\ a continuation cut off by a blank line still counts
|
|
"",
|
|
"uv run pyright \\", # 11 \\ and one cut off by the closing fence counts too
|
|
"```",
|
|
"",
|
|
"```text", # 14: not a command fence
|
|
"made-up-command --yolo",
|
|
"```",
|
|
]
|
|
)
|
|
assert fenced_commands(sample) == [
|
|
Command("<memory>", 4, "uv run undercooked --port 8137"),
|
|
Command("<memory>", 7, "uv run pytest -q"),
|
|
Command("<memory>", 8, "# a comment"),
|
|
Command("<memory>", 9, "uv run ruff check"),
|
|
Command("<memory>", 11, "uv run pyright"),
|
|
]
|
|
assert str(Command("README.md", 12, "uv run undercooked")) == "README.md:12: uv run undercooked"
|
|
|
|
|
|
def test_leading_environment_assignments_are_peeled_off() -> None:
|
|
names, bare = _strip_assignment("DEBUGPY=1 UNDERCOOKED_PORT=8137 uv run undercooked")
|
|
assert names == ("DEBUGPY", "UNDERCOOKED_PORT")
|
|
assert bare == "uv run undercooked"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("command", "program"),
|
|
[
|
|
("uv run pytest -q", "pytest"),
|
|
("uv run --extra dev ruff check .", "ruff"),
|
|
("uv run undercooked --reload", "undercooked"),
|
|
("uv run python -c 'print(1)'", "python"),
|
|
],
|
|
)
|
|
def test_the_uv_run_extractor_finds_the_program(command: str, program: str) -> None:
|
|
assert uv_run_program(command) == program
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["podman build .", "uv sync --extra dev", "cd .", "uv run", ""])
|
|
def test_the_uv_run_extractor_returns_none_for_everything_else(command: str) -> None:
|
|
assert uv_run_program(command) is None
|
|
|
|
|
|
def test_the_declared_programs_cover_the_real_toolchain() -> None:
|
|
"""Derived from pyproject.toml, so a dependency removal breaks the docs loudly."""
|
|
allowed = declared_uv_programs()
|
|
assert {"undercooked", "pytest", "ruff", "pyright", "playwright", "python"} <= allowed
|
|
assert "undercooked-levels" in allowed # declared now, importable from phase 02
|
|
|
|
|
|
# --------------------------------------------------------------------------- variable names
|
|
|
|
|
|
def test_every_variable_named_in_the_docs_is_implemented() -> None:
|
|
"""README and docs may only name a variable `config.py` or `.env.example` defines."""
|
|
implemented = set(ENV_KEYS.values()) | template_variables()
|
|
offenders = [
|
|
f"{document.name}: {name}"
|
|
for document in DOCUMENTED_MARKDOWN
|
|
for name in documented_but_unimplemented(variables_in(document), implemented)
|
|
]
|
|
if offenders:
|
|
_fail("documented but not implemented:", offenders)
|
|
|
|
|
|
def test_every_implemented_variable_is_documented() -> None:
|
|
"""The reverse direction: a knob nobody documented is a knob nobody can find."""
|
|
documented = variables_in(README) | variables_in(ENV_EXAMPLE)
|
|
missing = implemented_but_undocumented(set(ENV_KEYS.values()), documented)
|
|
if missing:
|
|
_fail("config.py defines variables neither README.md nor .env.example mentions:", missing)
|
|
|
|
|
|
def test_readme_and_env_template_name_the_same_variables() -> None:
|
|
"""The two operator-facing lists cannot diverge from each other."""
|
|
documented, template = variables_in(README), variables_in(ENV_EXAMPLE)
|
|
only_readme = sorted(documented - template)
|
|
only_template = sorted(template - documented)
|
|
assert not only_readme, f"named in README.md but absent from .env.example: {only_readme}"
|
|
assert not only_template, f"named in .env.example but absent from README.md: {only_template}"
|
|
|
|
|
|
# --------------------------------------------------------------------------- the gate is documented
|
|
|
|
|
|
def test_readme_documents_the_gate_and_the_cdn_rule() -> None:
|
|
"""The commands AGENTS.md calls binding are findable in the human-facing docs."""
|
|
readme = README.read_text(encoding="utf-8")
|
|
assert "bash .agents/validate.sh" in readme, "the pipeline gate is not documented in README"
|
|
assert re.search(r"no external cdns", readme, re.IGNORECASE), "A12 is not stated in README"
|
|
|
|
|
|
def test_a_pseudo_command_is_reported() -> None:
|
|
"""The prefix rule actually fires: planted prose-commands are named, real ones are not."""
|
|
sample = "\n".join(
|
|
[
|
|
"```bash", # 1
|
|
"uv run undercooked --port 8137", # 2
|
|
"DEBUGPY=1 uv run undercooked", # 3: an env lead is still a command
|
|
"make deploy-to-staging", # 4
|
|
"kubectl port-forward svc/undercooked 8000", # 5
|
|
"# a comment is a valid line", # 6
|
|
"```",
|
|
]
|
|
)
|
|
offenders = unsafe_commands(fenced_commands(sample, document="<memory>"))
|
|
assert offenders == [
|
|
"<memory>:4: make deploy-to-staging",
|
|
"<memory>:5: kubectl port-forward svc/undercooked 8000",
|
|
]
|
|
|
|
|
|
def test_an_invented_program_is_reported() -> None:
|
|
"""A doc line cannot run a script pyproject.toml does not declare."""
|
|
sample = "\n".join(
|
|
[
|
|
"```bash",
|
|
"uv run --frozen undercooked-levels validate",
|
|
"uv run made-up-cli validate",
|
|
"```",
|
|
]
|
|
)
|
|
commands = fenced_commands(sample, document="<memory>")
|
|
offenders = undeclared_programs(commands, frozenset({"undercooked-levels"}))
|
|
assert len(offenders) == 1, offenders
|
|
assert "made-up-cli" in offenders[0]
|
|
|
|
|
|
def test_the_variable_diffs_work_in_both_directions() -> None:
|
|
"""The two drift directions are tested, not just asserted to be empty on today's docs."""
|
|
implemented = {"UNDERCOOKED_PORT", "DEBUGPY"}
|
|
documented = {"UNDERCOOKED_PORT", "DEBUGPY", "UNDERCOOKED_MADE_UP", "DEBUGPY_PORT"}
|
|
assert documented_but_unimplemented(documented, implemented) == [
|
|
"DEBUGPY_PORT",
|
|
"UNDERCOOKED_MADE_UP",
|
|
]
|
|
assert implemented_but_undocumented(implemented, documented) == []
|
|
assert implemented_but_undocumented({"UNDERCOOKED_NEW_KNOB"}, documented) == [
|
|
"UNDERCOOKED_NEW_KNOB"
|
|
]
|
|
|
|
|
|
def test_the_failure_helper_lists_every_offender() -> None:
|
|
"""`_fail` is the message an executor reads; it has to enumerate, not truncate."""
|
|
with pytest.raises(pytest.fail.Exception, match="two"):
|
|
_fail("broken:", ["one", "two"])
|
|
|
|
|
|
def test_agents_md_only_names_test_paths_that_exist() -> None:
|
|
"""Every concrete `tests/**.py|md` path AGENTS.md cites is a real file."""
|
|
agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
cited = re.findall(r"tests/[A-Za-z0-9_./-]+\.(?:py|md)", agents)
|
|
assert "tests/unit/test_no_external_assets.py" in cited
|
|
assert cited, "AGENTS.md cites no test files at all"
|
|
missing = sorted({path for path in cited if not (REPO_ROOT / path).is_file()})
|
|
assert missing == [], f"AGENTS.md points at files that do not exist: {missing}"
|
|
|