diff --git a/.github/workflows/md-naming.yml b/.github/workflows/md-naming.yml index 1bb197c..e9b56e5 100644 --- a/.github/workflows/md-naming.yml +++ b/.github/workflows/md-naming.yml @@ -1,6 +1,6 @@ name: md-naming -# The four markdown namespaces, enforced on push as well as at commit time. +# The markdown namespaces (README / CLAUDE* / DOCS*, no TODO*), enforced on push as well as at commit time. # The local pre-commit hook can be skipped (a fresh clone that never ran # `pre-commit install` has no hooks at all), so the convention needs a second # gate that no one can forget to install. @@ -31,8 +31,3 @@ jobs: - name: Enforce the naming convention run: bash .utils/scripts/check_md_naming.sh --all - - # Warns only: no machine can prove a task is finished, so an old TODO - # gets a nudge, never a failure. - - name: Warn about stale TODOs - run: PYTHONPATH=.utils python3 .utils/md_naming/stale.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e543f2..8c465db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,8 +27,8 @@ repos: language: system pass_filenames: true - # Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the - # rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may. + # Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned + # (tasks live in the todo app). # Shared gate in ~/src/utils owns the namespaces and the exemption list, so # one repo's idea of a valid name cannot drift from every other repo's. # Since the 2026-09-11 reorganisation ~ holds eleven visible entries and diff --git a/md_naming/_tables.py b/md_naming/_tables.py index be2c0b4..c5ccd1a 100644 --- a/md_naming/_tables.py +++ b/md_naming/_tables.py @@ -1,7 +1,7 @@ """Exemption tables and namespace rules for the markdown-naming convention. The data half: which basenames are reserved by external tooling, which -directory trees are exempt, and the literal marker every TODO file carries. +directory trees are exempt, and the prefix of the banned TODO files. :mod:`md_naming.rules` holds the predicates that read these, and :mod:`md_naming.config` re-exports both as the public surface. @@ -15,25 +15,22 @@ from __future__ import annotations import re -#: The four namespaces. A markdown file in a kuhy-owned repo must match one. +#: The three namespaces. A markdown file in a kuhy-owned repo must match one. #: #: README -- what this directory is and how to run it. #: CLAUDE* -- instructions Claude must read. #: DOCS* -- reference, findings, design records. Permanent. -#: TODO* -- outstanding work. Carries MARKER; deleted when the work lands. -ALLOWED_PATTERN = re.compile(r"^(README|CLAUDE.*|DOCS.*|TODO.*)\.(md|markdown)$") +#: +#: TODO*.md is banned outright (tasks live in the todo app). +ALLOWED_PATTERN = re.compile(r"^(README|CLAUDE.*|DOCS.*)\.(md|markdown)$") -#: The literal every TODO file must contain, and that no other file may. -#: CI greps this exact string, so it cannot be paraphrased. +#: Legacy: the removal marker of the retired TODO convention. No gate reads it +#: any more; only the one-shot migrate/apply.py (kept as a record) imports it. MARKER = "REMOVE ME AFTER FINISH" -#: Prefix identifying a task file, for the reverse check (marker => TODO*). +#: Prefix identifying a banned task file. TODO_PREFIX = "TODO" -#: A TODO older than this many days gets a CI ::warning::. Never a failure: -#: no machine can prove a task is finished, so the honest mechanism is a nudge. -STALE_DAYS = 90 - #: Basenames owned by external tooling. Renaming these breaks the tool, so #: they are exempt from the namespace rule rather than migrated. #: diff --git a/md_naming/check.py b/md_naming/check.py index cc46de5..d6c47fd 100644 --- a/md_naming/check.py +++ b/md_naming/check.py @@ -3,14 +3,12 @@ Invoked by `scripts/check_md_naming.sh`, which pre-commit calls with the staged files. Exits 1 and lists every violation; exits 0 silently otherwise. -Three rules, checked together because they only work as a set: +Two rules: -1. Naming -- a markdown file must be README / CLAUDE* / DOCS* / TODO*. -2. Marker -- a TODO* file must contain the removal marker. -3. Reverse -- a file containing the marker must be named TODO*. +1. Naming -- a markdown file must be README / CLAUDE* / DOCS*. +2. TODO ban -- no TODO*.md at all. Tasks live in the todo app, not in files. -Rule 3 is what stops the convention leaking: without it a task file can be -renamed DOCS-something.md, keep its marker, and quietly become permanent. +The old "REMOVE ME AFTER FINISH" marker rules are retired with TODO files. The check is deliberately deterministic code rather than a judgement call -- adjudication belongs in an exit code, not in a model. @@ -27,14 +25,18 @@ if __package__ in (None, ""): # pragma: no cover - plain-script import path from file_length.check import git_ignored, iter_all from md_naming.config import ( - MARKER, - contains_marker, has_allowed_name, is_exempt, is_markdown, is_todo, ) +#: One line, because it is the whole instruction a blocked committer sees. +TODO_BANNED = ( + "TODO*.md files are banned: add a task with the todo MCP add_task " + "(or todo_note.dart --repo kuhyx/)" +) + def absolutize(path: Path) -> Path: """`path` anchored at the cwd, without resolving symlinks. @@ -58,21 +60,12 @@ def violations_for(path: Path) -> list[str]: full = absolutize(path) problems: list[str] = [] - if is_markdown(path) and not is_exempt(full) and not has_allowed_name(path): - problems.append("name must start with README / CLAUDE / DOCS / TODO") - - # The marker rules apply even to exempt-by-name files: a CONTRIBUTING.md - # carrying "REMOVE ME AFTER FINISH" is a task wearing a reserved name, and - # that is precisely the leak rule 3 exists to catch. - if is_markdown(path): - marked = contains_marker(full) - if is_todo(path) and not marked: - problems.append(f'TODO file must contain the line "{MARKER}"') - if marked and not is_todo(path): - problems.append( - f'contains "{MARKER}" but is not named TODO*.md ' - "-- rename it, or drop the marker if it is not a task" - ) + if not is_markdown(path) or is_exempt(full): + return problems + if is_todo(path): + problems.append(TODO_BANNED) + elif not has_allowed_name(path): + problems.append("name must start with README / CLAUDE / DOCS") return problems @@ -118,7 +111,7 @@ def main() -> int: for path, problem in sorted(found, key=lambda item: str(item[0])): print(f" {path}: {problem}", file=sys.stderr) print( - "\nNamespaces: README.md, CLAUDE*.md, DOCS*.md, TODO*.md\n" + "\nNamespaces: README.md, CLAUDE*.md, DOCS*.md (no TODO*.md)\n" "See ~/.claude/skills/md-file-conventions/SKILL.md", file=sys.stderr, ) diff --git a/md_naming/config.py b/md_naming/config.py index 5b3790a..a6740f8 100644 --- a/md_naming/config.py +++ b/md_naming/config.py @@ -1,8 +1,8 @@ """Shared configuration for the markdown-naming convention. -Single source of truth for the four namespaces, the removal marker and the -exemption list, imported by the pre-commit gate (`check.py`) and the staleness -reporter (`stale.py`). This module is the public surface: +Single source of truth for the three namespaces, the TODO ban and the +exemption list, imported by the pre-commit gate (`check.py`). This module is +the public surface: :mod:`md_naming._tables` holds the data, :mod:`md_naming.rules` the predicates. """ @@ -15,11 +15,9 @@ from md_naming._tables import ( EXTRA_THIRD_PARTY, HARNESS_NAMES, MARKER, - STALE_DAYS, TODO_PREFIX, ) from md_naming.rules import ( - contains_marker, has_allowed_name, is_exempt, is_markdown, @@ -33,9 +31,7 @@ __all__ = [ "EXTRA_THIRD_PARTY", "HARNESS_NAMES", "MARKER", - "STALE_DAYS", "TODO_PREFIX", - "contains_marker", "has_allowed_name", "is_exempt", "is_markdown", diff --git a/md_naming/rules.py b/md_naming/rules.py index 1e59e1c..02ce056 100644 --- a/md_naming/rules.py +++ b/md_naming/rules.py @@ -25,7 +25,6 @@ from md_naming._tables import ( DATED_BACKUP, EXTRA_THIRD_PARTY, HARNESS_NAMES, - MARKER, TODO_PREFIX, ) @@ -89,22 +88,11 @@ def is_exempt(path: Path) -> bool: def has_allowed_name(path: Path) -> bool: - """True if the basename matches one of the four namespaces.""" + """True if the basename matches one of the three namespaces.""" return bool(ALLOWED_PATTERN.match(path.name)) def is_todo(path: Path) -> bool: - """True if `path` is a task file by name.""" + """True if `path` is a (banned) task file by name.""" return path.name.startswith(TODO_PREFIX) and is_markdown(path) - -def contains_marker(path: Path) -> bool: - """True if `path` contains the removal marker. - - Unreadable and binary files answer False rather than raising: a file the - gate cannot read is reported by the naming rule, not crashed on here. - """ - try: - return MARKER in path.read_text(encoding="utf-8", errors="replace") - except OSError: - return False diff --git a/md_naming/stale.py b/md_naming/stale.py deleted file mode 100644 index e9212a9..0000000 --- a/md_naming/stale.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Report TODO files that have sat untouched for too long. - -CI cannot prove a task is finished -- no exit code exists for "done" -- so the -honest mechanism is a nudge, not a gate. This module only ever emits GitHub -`::warning::` lines and exits 0, deliberately: a build that fails because a -task is merely *old* trains people to ignore it. - -Ages come from the last git commit that touched the file, not mtime, because -a checkout rewrites every mtime to the clone time. -""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -if __package__ in (None, ""): # pragma: no cover - plain-script import path - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from file_length.check import iter_all -from md_naming.config import STALE_DAYS, is_todo - - -def last_commit_epoch(path: Path, root: Path) -> int | None: - """Unix time of the last commit touching `path`, or None if unknown.""" - try: - result = subprocess.run( - ["git", "-C", str(root), "log", "-1", "--format=%ct", "--", str(path)], - capture_output=True, - text=True, - check=False, - ) - except OSError: # git not installed - return None - if result.returncode != 0: - return None - stamp = result.stdout.strip() - return int(stamp) if stamp.isdigit() else None - - -def age_days(path: Path, root: Path, now: int) -> int | None: - """Whole days since `path` was last committed, or None if unknown.""" - epoch = last_commit_epoch(path, root) - if epoch is None: - return None - return (now - epoch) // 86_400 - - -def stale_todos(root: Path, limit: int, now: int) -> list[tuple[Path, int]]: - """Every TODO under `root` older than `limit` days, oldest first.""" - found: list[tuple[Path, int]] = [] - for path in iter_all(root): - if not is_todo(path): - continue - days = age_days(path, root, now) - if days is not None and days >= limit: - found.append((path, days)) - return sorted(found, key=lambda item: -item[1]) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Warn about TODO files older than the staleness limit." - ) - parser.add_argument("--days", type=int, default=STALE_DAYS) - args = parser.parse_args() - - root = Path.cwd() - now = int(datetime.now(tz=timezone.utc).timestamp()) - for path, days in stale_todos(root, args.days, now): - rel = path.relative_to(root) if path.is_relative_to(root) else path - print( - f"::warning file={rel}::{rel} is {days} days old. " - "Still outstanding? If the work landed, delete this file." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/md_naming/tests/test_check.py b/md_naming/tests/test_check.py index f8f0588..033e6d0 100644 --- a/md_naming/tests/test_check.py +++ b/md_naming/tests/test_check.py @@ -1,4 +1,4 @@ -"""The gate itself: the three rules and the CLI.""" +"""The gate itself: the naming and TODO-ban rules and the CLI.""" from __future__ import annotations @@ -19,21 +19,30 @@ def test_absolutize_anchors_relative(tmp_path: Path, monkeypatch) -> None: assert check.absolutize(Path("b.md")) == tmp_path / "b.md" -def test_good_todo_has_no_violations(tmp_path: Path) -> None: - path = write(tmp_path, "TODO-a.md", "# a\n\nREMOVE ME AFTER FINISH\n") - assert check.violations_for(path) == [] +@pytest.mark.parametrize( + ("name", "body"), + [ + ("TODO-a.md", "# a\n\nREMOVE ME AFTER FINISH\n"), + ("TODO-a.md", "# a\n"), + ("TODO.md", "# a\n"), + ("sub/TODO-b.markdown", "# b\n"), + ], +) +def test_any_todo_file_is_banned(tmp_path: Path, name: str, body: str) -> None: + """Tasks live in the todo app; a TODO*.md fails with or without a marker.""" + (problem,) = check.violations_for(write(tmp_path, name, body)) + assert problem == check.TODO_BANNED -def test_todo_without_marker(tmp_path: Path) -> None: - path = write(tmp_path, "TODO-a.md", "# a\n") - (problem,) = check.violations_for(path) - assert "must contain" in problem +def test_todo_message_points_at_the_todo_app() -> None: + assert "add_task" in check.TODO_BANNED + assert "todo_note.dart --repo" in check.TODO_BANNED + assert "\n" not in check.TODO_BANNED -def test_marker_outside_todo(tmp_path: Path) -> None: +def test_marker_is_no_longer_special(tmp_path: Path) -> None: path = write(tmp_path, "DOCS-a.md", "REMOVE ME AFTER FINISH\n") - (problem,) = check.violations_for(path) - assert "not named TODO" in problem + assert check.violations_for(path) == [] def test_bad_name(tmp_path: Path) -> None: @@ -42,15 +51,10 @@ def test_bad_name(tmp_path: Path) -> None: assert "must start with" in problem -def test_exempt_name_skips_naming_but_not_marker(tmp_path: Path) -> None: - """A reserved name carrying the marker is a task in disguise.""" +def test_exempt_name_skips_naming(tmp_path: Path) -> None: clean = write(tmp_path, "CONTRIBUTING.md", "# c\n") assert check.violations_for(clean) == [] - sneaky = write(tmp_path, "SECURITY.md", "REMOVE ME AFTER FINISH\n") - (problem,) = check.violations_for(sneaky) - assert "not named TODO" in problem - def test_non_markdown_ignored(tmp_path: Path) -> None: path = write(tmp_path, "script.py", "REMOVE ME AFTER FINISH\n") @@ -59,7 +63,7 @@ def test_non_markdown_ignored(tmp_path: Path) -> None: def test_main_clean_tree(repo: Path, monkeypatch, capsys) -> None: write(repo, "README.md") - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") + write(repo, "DOCS-a.md") monkeypatch.chdir(repo) monkeypatch.setattr("sys.argv", ["check", "--all"]) assert check.main() == 0 @@ -74,6 +78,14 @@ def test_main_reports_violations(repo: Path, monkeypatch, capsys) -> None: assert "must start with" in capsys.readouterr().err +def test_main_reports_banned_todo(repo: Path, monkeypatch, capsys) -> None: + write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") + monkeypatch.chdir(repo) + monkeypatch.setattr("sys.argv", ["check", "TODO-a.md"]) + assert check.main() == 1 + assert "TODO*.md files are banned" in capsys.readouterr().err + + def test_main_named_paths(repo: Path, monkeypatch, capsys) -> None: write(repo, "notes.md") monkeypatch.chdir(repo) diff --git a/md_naming/tests/test_rules.py b/md_naming/tests/test_rules.py index 8a1ea13..abef78c 100644 --- a/md_naming/tests/test_rules.py +++ b/md_naming/tests/test_rules.py @@ -7,7 +7,6 @@ from pathlib import Path import pytest from md_naming import rules -from md_naming.tests.conftest import write @pytest.mark.parametrize( @@ -17,9 +16,7 @@ from md_naming.tests.conftest import write "CLAUDE.md", "CLAUDE-extra.md", "DOCS-x.md", - "TODO-y.md", "DOCS.md", - "TODO.md", "README.markdown", ], ) @@ -37,6 +34,8 @@ def test_allowed_names(name: str) -> None: "MIGRATION.md", "refactor_claude_todo.md", "my-TODO.md", + "TODO-y.md", + "TODO.md", ], ) def test_rejected_names(name: str) -> None: @@ -112,17 +111,6 @@ def test_is_todo() -> None: assert not rules.is_todo(Path("TODO-x.txt")) -def test_contains_marker(tmp_path: Path) -> None: - marked = write(tmp_path, "a.md", "text\nREMOVE ME AFTER FINISH\n") - plain = write(tmp_path, "b.md", "text\n") - assert rules.contains_marker(marked) - assert not rules.contains_marker(plain) - - -def test_contains_marker_unreadable(tmp_path: Path) -> None: - """A path that cannot be read answers False rather than raising.""" - assert not rules.contains_marker(tmp_path / "missing.md") - @pytest.mark.parametrize("repo", [".nvm", ".oh-my-zsh", ".cargo", ".pyenv"]) def test_dotfile_clones_are_third_party(repo: str) -> None: diff --git a/md_naming/tests/test_stale.py b/md_naming/tests/test_stale.py deleted file mode 100644 index 2756133..0000000 --- a/md_naming/tests/test_stale.py +++ /dev/null @@ -1,105 +0,0 @@ -"""The staleness reporter: warns, never fails.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -from md_naming import stale -from md_naming.tests.conftest import write - -NOW = 1_800_000_000 - - -def commit(repo: Path, when: int) -> None: - """Commit everything in `repo` with both dates pinned to `when`.""" - stamp = f"{when} +0000" - env = {"GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp} - subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) - subprocess.run( - [ - "git", - "-C", - str(repo), - "-c", - "user.email=t@t", - "-c", - "user.name=t", - "commit", - "-qm", - "x", - ], - check=True, - env={**dict(__import__("os").environ), **env}, - ) - - -def test_old_todo_is_reported(repo: Path) -> None: - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") - commit(repo, NOW - 100 * 86_400) - found = stale.stale_todos(repo, limit=90, now=NOW) - assert [p.name for p, _ in found] == ["TODO-a.md"] - assert found[0][1] == 100 - - -def test_fresh_todo_is_not_reported(repo: Path) -> None: - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") - commit(repo, NOW - 5 * 86_400) - assert stale.stale_todos(repo, limit=90, now=NOW) == [] - - -def test_non_todo_ignored(repo: Path) -> None: - write(repo, "DOCS-a.md") - commit(repo, NOW - 500 * 86_400) - assert stale.stale_todos(repo, limit=90, now=NOW) == [] - - -def test_uncommitted_todo_has_unknown_age(repo: Path) -> None: - """A file git has never seen yields no date, so it is not reported.""" - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") - assert stale.stale_todos(repo, limit=0, now=NOW) == [] - - -def test_age_days_outside_repo(tmp_path: Path) -> None: - assert stale.age_days(tmp_path / "x.md", tmp_path, NOW) is None - - -def test_main_always_exits_zero(repo: Path, monkeypatch, capsys) -> None: - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") - commit(repo, NOW - 999 * 86_400) - monkeypatch.chdir(repo) - monkeypatch.setattr("sys.argv", ["stale", "--days", "1"]) - assert stale.main() == 0 - assert "::warning" in capsys.readouterr().out - - -def test_last_commit_epoch_when_git_missing(repo: Path, monkeypatch) -> None: - """No git binary means unknown age, not a crash.""" - - def boom(*args, **kwargs): - raise OSError("no git") - - monkeypatch.setattr(stale.subprocess, "run", boom) - assert stale.last_commit_epoch(repo / "TODO-a.md", repo) is None - - -def test_last_commit_epoch_non_numeric(repo: Path, monkeypatch) -> None: - """Garbage on stdout is treated as unknown rather than parsed.""" - - class Result: - returncode = 0 - stdout = "not-a-number\n" - - monkeypatch.setattr(stale.subprocess, "run", lambda *a, **k: Result()) - assert stale.last_commit_epoch(repo / "TODO-a.md", repo) is None - - -def test_main_reports_absolute_path_outside_root( - repo: Path, monkeypatch, capsys -) -> None: - """A TODO outside the walk root still prints, using its full path.""" - write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n") - commit(repo, NOW - 999 * 86_400) - monkeypatch.chdir(repo) - monkeypatch.setattr("sys.argv", ["stale"]) - assert stale.main() == 0 diff --git a/scripts/check_md_naming.sh b/scripts/check_md_naming.sh index 60b2385..0a20b00 100755 --- a/scripts/check_md_naming.sh +++ b/scripts/check_md_naming.sh @@ -3,10 +3,8 @@ # ============================================================================ # Fail if any markdown file breaks the shared naming convention. # -# Four namespaces -- README.md, CLAUDE*.md, DOCS*.md, TODO*.md -- so a future -# session can tell a live task from a finished record without re-deriving it. -# TODO files carry "REMOVE ME AFTER FINISH" and are deleted when the work -# lands; nothing else may carry that marker. +# Three namespaces -- README.md, CLAUDE*.md, DOCS*.md. TODO*.md is banned: +# tasks live in the todo app (MCP add_task, or todo_note.dart --repo). # # This is the REAL gate. Per-repo scripts/check_md_naming.sh files are thin # shims that exec this one -- do not overwrite this file with a shim, or every