Add a docs README and link checker
Add tools/check_docs.py to require READMEs only in tracked docs directories, validate local links and verify every tracked image is linked. Gitignored generated files such as realism runs are exempt. Document the command and add unit tests. Python 97/97.
This commit is contained in:
1 parent
7401d4bca0
commit
49bfccc808
4 files changed
+170
No files matched your search
@@ -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.
|
- **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`.
|
- **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.
|
- **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`.
|
- **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.
|
- **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
|
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ godot --headless --path . --quit-after 120
|
|||||||
# Run all unit tests headlessly (exit code 0 = pass)
|
# Run all unit tests headlessly (exit code 0 = pass)
|
||||||
godot --headless --path . --script res://src/test/test_runner.gd
|
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)
|
# Save a screenshot to user:// (windowed)
|
||||||
godot --path . --script res://src/test/take_screenshot.gd
|
godot --path . --script res://src/test/take_screenshot.gd
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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", "")
|
||||||
|
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()
|
||||||
Reference in new issue
Block a user