377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Report sibling projects whose copies of the guideline documents are out of date, and offer to sync them.
|
|
|
|
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.
|
|
|
|
It also finds copies still using a pre-rename file name (``DESIGN_DOCUMENT.md`` and friends, from
|
|
before the language became part of the name) and reports them as ``OLD NAME``.
|
|
|
|
``--sync`` offers to overwrite the outdated copies with the master version, renaming the old names on
|
|
the way. Only ``BEHIND`` and ``OLD NAME`` copies are ever touched: a ``MODIFIED`` copy holds local
|
|
edits that overwriting would discard, an unversioned copy cannot be compared, and ``DUPLICATE`` means
|
|
both the old and the new name exist, which needs a human to decide.
|
|
|
|
Standard library only, so it runs without a virtual environment:
|
|
|
|
python check_versions.py # report only
|
|
python check_versions.py --sync # report, then offer to copy the outdated ones
|
|
python check_versions.py --sync --yes # copy without asking (for scripts)
|
|
python check_versions.py --sync --dry-run # show what --sync would copy, change nothing
|
|
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 os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
# Guideline documents owned by this repository, mapped to their master copy. The language is part of
|
|
# every design document name, so a project may carry several of them without any collision.
|
|
MASTERS: dict[str, str] = {
|
|
"CLAUDE.md": "Claude/CLAUDE.md",
|
|
"AGENTS.md": "Claude/AGENTS.md",
|
|
"DESIGN_DOCUMENT_PYTHON.md": "Python/DESIGN_DOCUMENT_PYTHON.md",
|
|
"DESIGN_DOCUMENT_PYTHON_MODULE.md": "Python/DESIGN_DOCUMENT_PYTHON_MODULE.md",
|
|
"DESIGN_DOCUMENT_RUST.md": "Rust/DESIGN_DOCUMENT_RUST.md",
|
|
"DESIGN_DOCUMENT_RUST_LIB.md": "Rust/DESIGN_DOCUMENT_RUST_LIB.md",
|
|
"DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md",
|
|
}
|
|
|
|
# Names used before the language became part of the file name. A project still carrying one of these
|
|
# is reported as LEGACY and renamed by --sync. This mapping is migration code: once every project has
|
|
# been synchronised it can be deleted, and with it the last place where the old collision matters.
|
|
LEGACY_NAMES: dict[str, str] = {
|
|
"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_PYTHON.md",
|
|
"DESIGN_DOCUMENT_MODULE.md": "DESIGN_DOCUMENT_PYTHON_MODULE.md",
|
|
"DESIGN_DOCUMENT_LIB.md": "DESIGN_DOCUMENT_RUST_LIB.md",
|
|
}
|
|
|
|
# The old DESIGN_DOCUMENT.md is ambiguous on its own - Python and Rust applications shared the name.
|
|
# Only the project manifest can tell them apart, and only for legacy copies.
|
|
LEGACY_RUST_NAMES: dict[str, str] = {"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_RUST.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"
|
|
STATUS_LEGACY = "legacy"
|
|
STATUS_DUPLICATE = "duplicate"
|
|
|
|
|
|
@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
|
|
copy_path: Path
|
|
master_path: Path
|
|
target_path: Path
|
|
|
|
@property
|
|
def needs_attention(self) -> bool:
|
|
return self.status != STATUS_OK
|
|
|
|
@property
|
|
def can_sync(self) -> bool:
|
|
"""Behind and legacy copies can be replaced; anything else may hold work we would destroy."""
|
|
return self.status in {STATUS_BEHIND, STATUS_LEGACY}
|
|
|
|
@property
|
|
def is_rename(self) -> bool:
|
|
return self.copy_path != self.target_path
|
|
|
|
|
|
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 detect_newline(path: Path) -> str:
|
|
"""Return the line ending a file already uses, so rewriting it does not churn every line."""
|
|
data = path.read_bytes()
|
|
crlf = data.count(b"\r\n")
|
|
lf = data.count(b"\n") - crlf
|
|
if crlf and crlf >= lf:
|
|
return "\r\n"
|
|
if lf:
|
|
return "\n"
|
|
return os.linesep
|
|
|
|
|
|
def sync_document(master: Path, copy: Path, target: Path) -> None:
|
|
"""Overwrite a project copy with its master, renaming it first when the file name changed.
|
|
|
|
The copy keeps the line endings it already used: writing the master's bytes verbatim would flip
|
|
every line on a project that uses the other convention, turning a one-line version bump into a
|
|
whole-file diff. Renaming before writing lets git see one rename instead of a delete plus an add.
|
|
"""
|
|
newline = detect_newline(copy)
|
|
backup = copy.with_suffix(copy.suffix + ".bak")
|
|
shutil.copyfile(copy, backup)
|
|
renamed = False
|
|
try:
|
|
if copy != target:
|
|
os.replace(copy, target)
|
|
renamed = True
|
|
target.write_text(normalise(read_text(master)) + "\n", encoding="utf-8", newline=newline)
|
|
except OSError:
|
|
if renamed and target.exists():
|
|
os.replace(target, copy)
|
|
shutil.copyfile(backup, copy)
|
|
raise
|
|
backup.unlink()
|
|
|
|
|
|
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, masters: dict[str, tuple[Path, str | None]]) -> list[Result]:
|
|
"""Compare every guideline document present in one project against its master."""
|
|
results: list[Result] = []
|
|
|
|
for name, (master_path, master_version) in masters.items():
|
|
copy = project / name
|
|
if not copy.is_file():
|
|
continue
|
|
|
|
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, copy, master_path, copy))
|
|
|
|
results.extend(check_legacy(project, masters))
|
|
return results
|
|
|
|
|
|
def check_legacy(project: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]:
|
|
"""Report copies still using a pre-rename file name, and where each one should move to."""
|
|
results: list[Result] = []
|
|
rust = is_rust_project(project)
|
|
|
|
for old_name, new_name in LEGACY_NAMES.items():
|
|
copy = project / old_name
|
|
if not copy.is_file():
|
|
continue
|
|
|
|
if rust and old_name in LEGACY_RUST_NAMES:
|
|
new_name = LEGACY_RUST_NAMES[old_name]
|
|
|
|
target = project / new_name
|
|
master_path, master_version = masters[new_name]
|
|
|
|
# Both names present: the rename already happened and the old file is a leftover. Removing
|
|
# it is not this script's call, so only report it.
|
|
status = STATUS_DUPLICATE if target.is_file() else STATUS_LEGACY
|
|
label = f"{old_name} -> {new_name}"
|
|
results.append(
|
|
Result(project.name, label, read_version(copy), master_version, status, copy, master_path, target)
|
|
)
|
|
|
|
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. ",
|
|
STATUS_LEGACY: "OLD NAME ",
|
|
STATUS_DUPLICATE: "DUPLICATE",
|
|
}[result.status]
|
|
versions = f"{result.project_version or '-'} -> {result.master_version or '-'}"
|
|
return f" {marker} {result.document:<50} {versions}"
|
|
|
|
|
|
def confirm(prompt: str) -> bool:
|
|
"""Ask a yes/no question. Anything other than an explicit yes - including a closed stdin - is no."""
|
|
if not sys.stdin or not sys.stdin.isatty():
|
|
print(f"{prompt} [y/N] (stdin is not a terminal, assuming no; use --yes to copy)")
|
|
return False
|
|
try:
|
|
return input(f"{prompt} [y/N] ").strip().lower() in {"y", "yes"}
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return False
|
|
|
|
|
|
def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int:
|
|
"""Copy the masters over the outdated project copies. Returns the number of documents left stale."""
|
|
renames = sum(1 for result in pending if result.is_rename)
|
|
print(f"{len(pending)} document(s) can be updated from this repository:")
|
|
labels = {result: f"{result.project}/{result.document}" for result in pending}
|
|
width = max(len(label) for label in labels.values())
|
|
for result in pending:
|
|
note = " (rename)" if result.is_rename else ""
|
|
print(f" {labels[result]:<{width}} {result.project_version} -> {result.master_version}{note}")
|
|
print()
|
|
|
|
if dry_run:
|
|
print("Dry run - nothing was written.")
|
|
return len(pending)
|
|
|
|
action = f"Overwrite {len(pending)} file(s)"
|
|
if renames:
|
|
action += f", renaming {renames} of them"
|
|
if not assume_yes and not confirm(f"{action}?"):
|
|
print("Nothing was written.")
|
|
return len(pending)
|
|
|
|
failed = 0
|
|
for result in pending:
|
|
try:
|
|
sync_document(result.master_path, result.copy_path, result.target_path)
|
|
except OSError as error:
|
|
print(f" FAILED {result.project}/{result.document}: {error}", file=sys.stderr)
|
|
failed += 1
|
|
else:
|
|
verb = "renamed" if result.is_rename else "updated"
|
|
print(f" {verb} {result.project}/{result.target_path.name} -> {result.master_version}")
|
|
|
|
print()
|
|
print(f"{len(pending) - failed} document(s) updated." if not failed else f"{failed} document(s) failed to update.")
|
|
print("Review and commit the changes in each project (a sync is its own commit).")
|
|
return failed
|
|
|
|
|
|
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")
|
|
parser.add_argument("--sync", action="store_true", help="offer to overwrite outdated copies with the master")
|
|
parser.add_argument("--yes", action="store_true", help="with --sync, copy without asking for confirmation")
|
|
parser.add_argument("--dry-run", action="store_true", help="with --sync, show what would be copied and stop")
|
|
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}:")
|
|
width = max(len(name) for name in masters)
|
|
for name, (_, version) in sorted(masters.items()):
|
|
print(f" {name:<{width}} {version or '(no version header)'}")
|
|
print()
|
|
|
|
attention: list[Result] = []
|
|
unmanaged: list[str] = []
|
|
|
|
for project in find_projects(root, repo):
|
|
results = check_project(project, masters)
|
|
if not results:
|
|
unmanaged.append(project.name)
|
|
continue
|
|
|
|
stale = [result for result in results if result.needs_attention]
|
|
attention.extend(stale)
|
|
|
|
if args.quiet and not stale:
|
|
continue
|
|
|
|
print(f"{project.name}")
|
|
for result in results if not args.quiet else stale:
|
|
print(format_row(result))
|
|
print()
|
|
|
|
if unmanaged:
|
|
print(f"No guideline documents: {', '.join(unmanaged)}")
|
|
print()
|
|
|
|
if not attention:
|
|
print("All projects are up to date.")
|
|
return 0
|
|
|
|
syncable = [result for result in attention if result.can_sync]
|
|
blocked = len(attention) - len(syncable)
|
|
|
|
if args.sync and syncable:
|
|
remaining = run_sync(syncable, args.yes, args.dry_run) + blocked
|
|
else:
|
|
if syncable and not args.sync:
|
|
print(f"Run with --sync to copy {len(syncable)} outdated document(s) from this repository.")
|
|
remaining = len(attention)
|
|
|
|
if blocked:
|
|
print(f"{blocked} document(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,")
|
|
print("and DUPLICATE means both the old and the new file name exist; remove the old one by hand.")
|
|
|
|
if remaining:
|
|
print(f"{remaining} document(s) need attention.")
|
|
return 1
|
|
|
|
print("All projects are up to date.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|