diff --git a/MEMORY.md b/MEMORY.md index 3580fe3..d5ce558 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -47,6 +47,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - **Radio submit fix (v0.8.1):** `RadioPanel.action_button` now connects `pressed` for normal reports; `button_down/up` remains exclusive to emergency hold-to-confirm. Regression test covers both modes. - **Campaign/v0.8.0:** `Campaign` owns Hollywood → Joke Shop → TV Studio progression. `campaign.json`, `shift_results.json`, `settings.json` and versioned `shift_state.json` persist separately; pause saves a checkpoint, review Continue advances, Settings resets with confirmation. Difficulty keys are `easy/normal/hard` (`ai_difficulty`); scripted QA bypasses tutorial unless `--tutorial` is passed. Help is searchable and generated from `InputBindings.REMAPPABLE`. - **Tutorial (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is the pure state machine, `TutorialOverlay` is the non-blocking card, and `GameManager` wires it with a safe visitor, suppressed spawns, persisted completion/skip and Settings replay. Scripted test/capture runs bypass it unless `--tutorial` is passed; `smoke_tutorial.gd` covers completion/replay/skip. +- **Docs validation (2026-11-02):** `python3 tools/check_docs.py` requires a README + only in directories containing tracked files, verifies local links and requires + every tracked image in a folder to be linked from its README. Gitignored + generated files (notably `docs/screens/realism/`) are exempt; the tool also has + unit tests in `tools/test_check_docs.py`. - **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`. - **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006. - **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a diff --git a/docs/getting-started.md b/docs/getting-started.md index 6de7cda..8afb45f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,6 +20,9 @@ godot --headless --path . --quit-after 120 # Run all unit tests headlessly (exit code 0 = pass) godot --headless --path . --script res://src/test/test_runner.gd +# Check docs README coverage, tracked images and local links +python3 tools/check_docs.py + # Save a screenshot to user:// (windowed) godot --path . --script res://src/test/take_screenshot.gd diff --git a/tools/check_docs.py b/tools/check_docs.py new file mode 100644 index 0000000..1e0856c --- /dev/null +++ b/tools/check_docs.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Validate README coverage and local links in the tracked docs tree. + +Only directories containing tracked files are required to have a README, so +generated/gitignored folders such as docs/screens/realism are exempt. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parent.parent +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") +HTML_SRC = re.compile(r"""(?:src|href)=["']([^"']+)["']""") + + +def _git_tracked(root: Path) -> list[Path]: + """Return repository-relative tracked paths (fallback: all docs files).""" + try: + out = subprocess.check_output( + ["git", "-C", str(root), "ls-files", "-z", "docs"], + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError): + return [p.relative_to(root) for p in (root / "docs").rglob("*") if p.is_file()] + return [Path(item.decode()) for item in out.split(b"\0") if item] + + +def _is_ignored(root: Path, target: Path) -> bool: + """True for local generated files excluded from the repository (e.g. realism/previews).""" + try: + rel = target.resolve().relative_to(root.resolve()) + except ValueError: + return False + try: + return subprocess.run( + ["git", "-C", str(root), "check-ignore", "-q", "--", str(rel)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode == 0 + except OSError: + return False + + +def _local_target(readme: Path, target: str) -> Path | None: + target = unquote(target.split("#", 1)[0].strip()) + if not target or "://" in target or target.startswith(("mailto:", "tel:")): + return None + return (readme.parent / target).resolve() + + +def check_docs(root: Path = ROOT, tracked: list[Path] | None = None) -> list[str]: + """Return validation errors; an empty list means the docs tree is valid.""" + root = root.resolve() + docs = root / "docs" + errors: list[str] = [] + if not docs.is_dir(): + return [f"missing docs directory: {docs}"] + + files = tracked if tracked is not None else _git_tracked(root) + tracked_files = {root / p for p in files if str(p).startswith("docs/") or str(p) == "docs"} + tracked_dirs = {p.parent for p in tracked_files if p.parent != docs} + tracked_dirs.add(docs) + + readmes: dict[Path, str] = {} + for directory in sorted(tracked_dirs): + readme = directory / "README.md" + if not readme.is_file(): + errors.append(f"missing README.md in tracked docs directory: {directory.relative_to(root)}") + continue + text = readme.read_text(encoding="utf-8") + readmes[directory] = text + linked_names = set() + for pattern in (MARKDOWN_LINK, HTML_SRC): + for match in pattern.finditer(text): + target = _local_target(readme, match.group(1)) + if target is None: + continue + linked_names.add(target.name) + if not target.exists() and not _is_ignored(root, target): + errors.append(f"{readme.relative_to(root)}: broken link {match.group(1)}") + # Every tracked image in the folder should be visible from its README. + for image in sorted(tracked_files): + if image.parent == directory and image.suffix.lower() in IMAGE_EXTS \ + and image.name not in linked_names: + errors.append(f"{readme.relative_to(root)}: image not linked: {image.name}") + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT, help="repository root") + args = parser.parse_args(argv) + errors = check_docs(args.root) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + print(f"check_docs: {len(errors)} error(s)", file=sys.stderr) + return 1 + print("check_docs: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_check_docs.py b/tools/test_check_docs.py new file mode 100644 index 0000000..4ff2cfe --- /dev/null +++ b/tools/test_check_docs.py @@ -0,0 +1,52 @@ +import tempfile +import unittest +from pathlib import Path + +import check_docs + + +class CheckDocsTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + def _file(self, rel, text=""): + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def test_accepts_tracked_directory_with_links_and_images(self): + self._file("docs/README.md", "[x](guide.md)") + self._file("docs/guide.md", "guide") + self._file("docs/images/preview.png", "png") + self._file("docs/images/README.md", "![preview](preview.png)") + tracked = [Path("docs/README.md"), Path("docs/guide.md"), + Path("docs/images/preview.png"), Path("docs/images/README.md")] + self.assertEqual(check_docs.check_docs(self.root, tracked), []) + + def test_requires_readme_only_in_tracked_dirs(self): + self._file("docs/README.md") + self._file("docs/ignored/file.txt") + tracked = [Path("docs/README.md"), Path("docs/ignored/file.txt")] + errors = check_docs.check_docs(self.root, tracked) + self.assertTrue(any("docs/ignored" in e for e in errors)) + # A directory with no tracked files is not required to have a README. + self._file("docs/generated/file.txt") + tracked = [Path("docs/README.md")] + self.assertEqual(check_docs.check_docs(self.root, tracked), []) + + def test_reports_broken_link_and_unlinked_image(self): + self._file("docs/README.md", "[broken](missing.md)") + self._file("docs/image.png", "png") + tracked = [Path("docs/README.md"), Path("docs/image.png")] + errors = check_docs.check_docs(self.root, tracked) + self.assertTrue(any("broken link" in e for e in errors)) + self.assertTrue(any("image not linked" in e for e in errors)) + + +if __name__ == "__main__": + unittest.main()