Put the language in design document names and add a sync mode
This commit is contained in:
+203
-31
@@ -1,4 +1,4 @@
|
||||
"""Report sibling projects whose copies of the guideline documents are out of date.
|
||||
"""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
|
||||
@@ -6,9 +6,20 @@ drift as soon as a document here is bumped. This script walks the sibling direct
|
||||
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
|
||||
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
|
||||
"""
|
||||
@@ -16,24 +27,37 @@ Standard library only, so it runs without a virtual environment:
|
||||
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.
|
||||
# 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.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_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",
|
||||
}
|
||||
|
||||
# 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"}
|
||||
# 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)
|
||||
|
||||
@@ -41,6 +65,8 @@ STATUS_OK = "ok"
|
||||
STATUS_BEHIND = "behind"
|
||||
STATUS_MODIFIED = "modified"
|
||||
STATUS_UNVERSIONED = "unversioned"
|
||||
STATUS_LEGACY = "legacy"
|
||||
STATUS_DUPLICATE = "duplicate"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,11 +78,23 @@ class Result:
|
||||
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."""
|
||||
@@ -83,6 +121,42 @@ 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]] = {}
|
||||
@@ -93,20 +167,15 @@ def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]:
|
||||
return masters
|
||||
|
||||
|
||||
def check_project(project: Path, repo: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]:
|
||||
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] = []
|
||||
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:
|
||||
@@ -114,12 +183,40 @@ def check_project(project: Path, repo: Path, masters: dict[str, tuple[Path, str
|
||||
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.
|
||||
# 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))
|
||||
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
|
||||
|
||||
@@ -135,13 +232,66 @@ def find_projects(root: Path, repo: Path) -> list[Path]:
|
||||
|
||||
def format_row(result: Result) -> str:
|
||||
marker = {
|
||||
STATUS_OK: "ok ",
|
||||
STATUS_BEHIND: "BEHIND ",
|
||||
STATUS_MODIFIED: "MODIFIED",
|
||||
STATUS_UNVERSIONED: "NO VER. ",
|
||||
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:<26} {versions}"
|
||||
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:
|
||||
@@ -153,6 +303,9 @@ def main() -> int:
|
||||
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
|
||||
@@ -164,27 +317,28 @@ def main() -> int:
|
||||
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:<26} {version or '(no version header)'}")
|
||||
print(f" {name:<{width}} {version or '(no version header)'}")
|
||||
print()
|
||||
|
||||
stale = 0
|
||||
attention: list[Result] = []
|
||||
unmanaged: list[str] = []
|
||||
|
||||
for project in find_projects(root, repo):
|
||||
results = check_project(project, repo, masters)
|
||||
results = check_project(project, masters)
|
||||
if not results:
|
||||
unmanaged.append(project.name)
|
||||
continue
|
||||
|
||||
attention = [result for result in results if result.needs_attention]
|
||||
stale += len(attention)
|
||||
stale = [result for result in results if result.needs_attention]
|
||||
attention.extend(stale)
|
||||
|
||||
if args.quiet and not attention:
|
||||
if args.quiet and not stale:
|
||||
continue
|
||||
|
||||
print(f"{project.name}")
|
||||
for result in results if not args.quiet else attention:
|
||||
for result in results if not args.quiet else stale:
|
||||
print(format_row(result))
|
||||
print()
|
||||
|
||||
@@ -192,8 +346,26 @@ def main() -> int:
|
||||
print(f"No guideline documents: {', '.join(unmanaged)}")
|
||||
print()
|
||||
|
||||
if stale:
|
||||
print(f"{stale} document(s) need attention.")
|
||||
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.")
|
||||
|
||||
Reference in New Issue
Block a user