Files
Dokumentace/check_versions.py

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())