"""Report sibling projects whose copies of the guideline documents are out of date. This repository is the source of truth for ``CLAUDE.md``, ``AGENTS.md`` and the ``DESIGN_DOCUMENT*.md`` family. Every project keeps a committed copy in its root, so the copies drift as soon as a document here is bumped. This script walks the sibling directories of this repository, reads the ``Document Version`` header of every copy it finds and reports the ones that are behind, missing, or modified locally. Standard library only, so it runs without a virtual environment: python check_versions.py python check_versions.py --root .. # scan a different directory python check_versions.py --quiet # print only projects needing attention """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass from pathlib import Path # Guideline documents owned by this repository, mapped to their master copy. MASTERS: dict[str, str] = { "CLAUDE.md": "Claude/CLAUDE.md", "AGENTS.md": "Claude/AGENTS.md", "DESIGN_DOCUMENT.md": "Python/DESIGN_DOCUMENT.md", "DESIGN_DOCUMENT_MODULE.md": "Python/DESIGN_DOCUMENT_MODULE.md", "DESIGN_DOCUMENT_LIB.md": "Rust/DESIGN_DOCUMENT_LIB.md", "DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md", } # A Rust application uses the same file name as a Python one; the language decides which master # applies. Detected from the project manifest. RUST_OVERRIDES: dict[str, str] = {"DESIGN_DOCUMENT.md": "Rust/DESIGN_DOCUMENT.md"} VERSION_PATTERN = re.compile(r"^\*\*Document Version:\*\*\s*(v\d+)", re.MULTILINE) STATUS_OK = "ok" STATUS_BEHIND = "behind" STATUS_MODIFIED = "modified" STATUS_UNVERSIONED = "unversioned" @dataclass(frozen=True) class Result: """Outcome of comparing one project copy against its master.""" project: str document: str project_version: str | None master_version: str | None status: str @property def needs_attention(self) -> bool: return self.status != STATUS_OK def read_text(path: Path) -> str: """Read a document, tolerating the BOM some editors leave behind.""" return path.read_text(encoding="utf-8-sig") def normalise(text: str) -> str: """Strip line-ending differences so CRLF and LF copies compare equal.""" return text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") def read_version(path: Path) -> str | None: """Return the ``Document Version`` declared in a guideline document, if it has one.""" match = VERSION_PATTERN.search(read_text(path)) return match.group(1) if match else None def version_key(version: str | None) -> int: """Order versions numerically; a missing version sorts lowest.""" return int(version[1:]) if version else -1 def is_rust_project(project: Path) -> bool: return (project / "Cargo.toml").is_file() def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: """Map each document name to its master path and version.""" masters: dict[str, tuple[Path, str | None]] = {} for name, relative in MASTERS.items(): path = repo / relative if path.is_file(): masters[name] = (path, read_version(path)) return masters def check_project(project: Path, repo: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: """Compare every guideline document present in one project against its master.""" results: list[Result] = [] rust = is_rust_project(project) for name, (master_path, master_version) in masters.items(): copy = project / name if not copy.is_file(): continue if rust and name in RUST_OVERRIDES: master_path = repo / RUST_OVERRIDES[name] master_version = read_version(master_path) copy_version = read_version(copy) if copy_version is None or master_version is None: status = STATUS_UNVERSIONED elif version_key(copy_version) < version_key(master_version): status = STATUS_BEHIND elif normalise(read_text(copy)) != normalise(read_text(master_path)): # Same version number but different content — someone edited the copy in place. status = STATUS_MODIFIED else: status = STATUS_OK results.append(Result(project.name, name, copy_version, master_version, status)) return results def find_projects(root: Path, repo: Path) -> list[Path]: """Return candidate project directories: siblings of this repository, excluding itself.""" return sorted( path for path in root.iterdir() if path.is_dir() and path.resolve() != repo.resolve() and not path.name.startswith(".") ) def format_row(result: Result) -> str: marker = { STATUS_OK: "ok ", STATUS_BEHIND: "BEHIND ", STATUS_MODIFIED: "MODIFIED", STATUS_UNVERSIONED: "NO VER. ", }[result.status] versions = f"{result.project_version or '-'} -> {result.master_version or '-'}" return f" {marker} {result.document:<26} {versions}" def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( "--root", type=Path, default=None, help="directory holding the projects (default: the parent of this repository)", ) parser.add_argument("--quiet", action="store_true", help="list only projects that need attention") args = parser.parse_args() repo = Path(__file__).resolve().parent root = (args.root or repo.parent).resolve() masters = load_masters(repo) if not masters: print(f"No master documents found in {repo}", file=sys.stderr) return 2 print(f"Master documents in {repo.name}:") for name, (_, version) in sorted(masters.items()): print(f" {name:<26} {version or '(no version header)'}") print() stale = 0 unmanaged: list[str] = [] for project in find_projects(root, repo): results = check_project(project, repo, masters) if not results: unmanaged.append(project.name) continue attention = [result for result in results if result.needs_attention] stale += len(attention) if args.quiet and not attention: continue print(f"{project.name}") for result in results if not args.quiet else attention: print(format_row(result)) print() if unmanaged: print(f"No guideline documents: {', '.join(unmanaged)}") print() if stale: print(f"{stale} document(s) need attention.") return 1 print("All projects are up to date.") return 0 if __name__ == "__main__": raise SystemExit(main())