utils/md_naming/migrate/apply.py
Krzysztof kuhy Rudnicki a10d190b65
Some checks failed
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
design_system tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Add the shared markdown-naming gate and migrate utils' own docs
Markdown across every repo had accumulated a dozen ad-hoc names
(refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION,
HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a
finished record. An audit found docs asserting work was pending that
shipped months earlier, and one asserting completion while the work
was open.

Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task
files carry the literal "REMOVE ME AFTER FINISH" and are deleted when
the work lands.

Enforced bidirectionally -- a TODO without the marker fails, AND any
file carrying the marker that is not named TODO*.md fails. Without the
reverse rule a task can be renamed into the permanent namespace and
quietly live forever, which is the failure being fixed.

md_naming/ mirrors the file_length topology and imports its tables
(EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN)
rather than restating them, so the two gates cannot disagree about the
same file. 71 tests, 100% branch coverage.

stale.py warns on TODOs past 90 days but never fails: no machine can
prove a task is finished, so the honest mechanism is a nudge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:53:12 +02:00

119 lines
4.1 KiB
Python

"""Perform the rename plan: git mv, insert markers, repair references.
Ordering matters and is not arbitrary. References are rewritten BEFORE the
moves land, in the same run, because a half-applied migration leaves the
harness pointing at files that no longer exist -- and ~/.claude/CLAUDE.md is
read on every session, so that window is not theoretical.
Dry-run is the default. Nothing moves without --apply.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
if __package__ in (None, ""): # pragma: no cover - plain-script import path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from md_naming.config import MARKER
from md_naming.migrate.plan import REFERENCE_ROOTS, Move, Plan, rewrite
from md_naming.rules import in_excluded_dir, is_third_party
MARKER_BLOCK = f"\n{MARKER}\n"
def repo_of(path: Path) -> Path:
"""The git repo root owning `path`, or its parent if it is not in one."""
try:
result = subprocess.run(
["git", "-C", str(path.parent), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
except OSError: # pragma: no cover - git missing
return path.parent
if result.returncode != 0:
return path.parent
return Path(result.stdout.strip())
def git_mv(move: Move, *, apply: bool) -> str:
"""Move one file with git so history follows. Returns a log line."""
if move.new.exists():
return f"SKIP (target exists) {move.old} -> {move.new.name}"
if not apply:
return f"would mv {move.old} -> {move.new.name}"
root = repo_of(move.old)
result = subprocess.run(
["git", "-C", str(root), "mv", str(move.old), str(move.new)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
# Untracked files are not known to git; fall back to a plain rename
# rather than skipping, so untracked TODOs migrate too.
move.old.rename(move.new)
return f"mv (untracked) {move.old} -> {move.new.name}"
return f"mv {move.old} -> {move.new.name}"
def ensure_marker(path: Path, *, apply: bool) -> str | None:
"""Append the removal marker to a task file that lacks it."""
text = path.read_text(encoding="utf-8", errors="replace")
if MARKER in text:
return None
if not apply:
return f"would mark {path.name}"
if not text.endswith("\n"):
text += "\n"
path.write_text(text + MARKER_BLOCK, encoding="utf-8")
return f"marked {path.name}"
def reference_files(plan: Plan) -> list[Path]:
"""Every file that could mention a renamed path: docs plus harness config."""
found: set[Path] = set()
for root in REFERENCE_ROOTS:
if root.is_file():
found.add(root)
elif root.is_dir():
found.update(p for p in root.rglob("*.md") if p.is_file())
for move in plan.moves:
repo = repo_of(move.old)
found.update(
p
for p in repo.rglob("*.md")
if p.is_file() and not in_excluded_dir(p) and not is_third_party(p)
)
return sorted(found)
def repair_references(plan: Plan, *, apply: bool) -> list[str]:
"""Rewrite mentions of every renamed file. Returns log lines."""
logs: list[str] = []
targets = reference_files(plan)
moved = {move.old for move in plan.moves}
for path in targets:
if path in moved:
continue # its own rename is handled by git_mv
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError: # pragma: no cover - unreadable
continue
updated = text
touched: list[str] = []
for move in plan.moves:
if move.old.name in updated and move.old.name != move.new.name:
updated = rewrite(updated, move.old, move.new)
touched.append(move.old.name)
if touched and updated != text:
if apply:
path.write_text(updated, encoding="utf-8")
verb = "ref" if apply else "would ref"
logs.append(f"{verb} {path}: {', '.join(sorted(set(touched)))}")
return logs