205 lines
6.7 KiB
Python
205 lines
6.7 KiB
Python
"""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())
|