utils/md_naming/migrate/run.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

86 lines
2.9 KiB
Python

"""CLI: dry-run or apply the markdown-naming migration.
python3 -m md_naming.migrate.run # show what would happen
python3 -m md_naming.migrate.run --apply # do it
Triaged classifications that are not in classify.py are loaded from a JSON
sidecar, so a re-triage does not require editing code.
"""
from __future__ import annotations
import argparse
import json
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.migrate import apply as apply_mod
from md_naming.migrate import classify
from md_naming.migrate.plan import HOME, add_auto, build
from md_naming.migrate.survey import gate_violations
TRIAGE = Path(__file__).parent / "triage.json"
def load_triage() -> tuple[dict[str, str], dict[str, str]]:
"""Extra task/record classifications from the JSON sidecar."""
if not TRIAGE.is_file():
return {}, {}
data = json.loads(TRIAGE.read_text(encoding="utf-8"))
return data.get("tasks", {}), data.get("records", {})
def main() -> int:
parser = argparse.ArgumentParser(description="Migrate markdown filenames.")
parser.add_argument(
"--apply", action="store_true", help="perform the moves (default: dry run)"
)
args = parser.parse_args()
extra_tasks, extra_records = load_triage()
plan = build(extra_tasks, extra_records)
# Anything the gate still flags that no table named: rename it
# mechanically. Computed live from the gate rather than a saved list, so
# the migration cannot drift from what the gate actually rejects.
classified = {str(move.old) for move in plan.moves} | {
str(HOME / rel) for rel in (*classify.PROVEN_DONE, *classify.CLAIMED_DONE)
}
remaining = [
str(path.relative_to(HOME))
for path in gate_violations()
if str(path) not in classified
]
add_auto(plan, remaining)
if plan.unresolved:
print(f"WARNING: {len(plan.unresolved)} classified path(s) missing:")
for path in plan.unresolved:
print(f" {path}")
# References first: a half-applied run must never leave the harness
# config pointing at a file that has already moved.
for line in apply_mod.repair_references(plan, apply=args.apply):
print(line)
for move in plan.moves:
print(apply_mod.git_mv(move, apply=args.apply))
target = move.new if args.apply and move.new.is_file() else move.old
if move.needs_marker and target.is_file():
note = apply_mod.ensure_marker(target, apply=args.apply)
if note:
print(f" {note}")
tasks = sum(1 for m in plan.moves if m.kind == "task")
records = len(plan.moves) - tasks
mode = "APPLIED" if args.apply else "DRY RUN"
print(f"\n{mode}: {len(plan.moves)} moves ({tasks} TODO, {records} DOCS)")
return 0
if __name__ == "__main__":
raise SystemExit(main())