Commit guideline copies with projects and allow single-number versioning
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user