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