Commit guideline copies with projects and allow single-number versioning

This commit is contained in:
Jan Doubravský
2026-08-18 13:08:06 +02:00
parent 78f1f863bf
commit 950e946004
11 changed files with 300 additions and 25 deletions
+204
View File
@@ -0,0 +1,204 @@
"""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())