mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
md-naming: ban TODO*.md, retire marker rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af3c98a50e
commit
20a825e249
7
.github/workflows/md-naming.yml
vendored
7
.github/workflows/md-naming.yml
vendored
@ -1,6 +1,6 @@
|
|||||||
name: md-naming
|
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
|
# 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
|
# `pre-commit install` has no hooks at all), so the convention needs a second
|
||||||
# gate that no one can forget to install.
|
# gate that no one can forget to install.
|
||||||
@ -31,8 +31,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Enforce the naming convention
|
- name: Enforce the naming convention
|
||||||
run: bash .utils/scripts/check_md_naming.sh --all
|
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
|
|
||||||
|
|||||||
@ -27,8 +27,8 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
# Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned
|
||||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
# (tasks live in the todo app).
|
||||||
# Shared gate in ~/src/utils owns the namespaces and the exemption list, so
|
# 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.
|
# 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
|
# Since the 2026-09-11 reorganisation ~ holds eleven visible entries and
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Exemption tables and namespace rules for the markdown-naming convention.
|
"""Exemption tables and namespace rules for the markdown-naming convention.
|
||||||
|
|
||||||
The data half: which basenames are reserved by external tooling, which
|
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.rules` holds the predicates that read these, and
|
||||||
:mod:`md_naming.config` re-exports both as the public surface.
|
:mod:`md_naming.config` re-exports both as the public surface.
|
||||||
|
|
||||||
@ -15,25 +15,22 @@ from __future__ import annotations
|
|||||||
import re
|
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.
|
#: README -- what this directory is and how to run it.
|
||||||
#: CLAUDE* -- instructions Claude must read.
|
#: CLAUDE* -- instructions Claude must read.
|
||||||
#: DOCS* -- reference, findings, design records. Permanent.
|
#: 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.
|
#: Legacy: the removal marker of the retired TODO convention. No gate reads it
|
||||||
#: CI greps this exact string, so it cannot be paraphrased.
|
#: any more; only the one-shot migrate/apply.py (kept as a record) imports it.
|
||||||
MARKER = "REMOVE ME AFTER FINISH"
|
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"
|
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
|
#: Basenames owned by external tooling. Renaming these breaks the tool, so
|
||||||
#: they are exempt from the namespace rule rather than migrated.
|
#: they are exempt from the namespace rule rather than migrated.
|
||||||
#:
|
#:
|
||||||
|
|||||||
@ -3,14 +3,12 @@
|
|||||||
Invoked by `scripts/check_md_naming.sh`, which pre-commit calls with the
|
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.
|
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*.
|
1. Naming -- a markdown file must be README / CLAUDE* / DOCS*.
|
||||||
2. Marker -- a TODO* file must contain the removal marker.
|
2. TODO ban -- no TODO*.md at all. Tasks live in the todo app, not in files.
|
||||||
3. Reverse -- a file containing the marker must be named TODO*.
|
|
||||||
|
|
||||||
Rule 3 is what stops the convention leaking: without it a task file can be
|
The old "REMOVE ME AFTER FINISH" marker rules are retired with TODO files.
|
||||||
renamed DOCS-something.md, keep its marker, and quietly become permanent.
|
|
||||||
|
|
||||||
The check is deliberately deterministic code rather than a judgement call --
|
The check is deliberately deterministic code rather than a judgement call --
|
||||||
adjudication belongs in an exit code, not in a model.
|
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 file_length.check import git_ignored, iter_all
|
||||||
from md_naming.config import (
|
from md_naming.config import (
|
||||||
MARKER,
|
|
||||||
contains_marker,
|
|
||||||
has_allowed_name,
|
has_allowed_name,
|
||||||
is_exempt,
|
is_exempt,
|
||||||
is_markdown,
|
is_markdown,
|
||||||
is_todo,
|
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/<name>)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def absolutize(path: Path) -> Path:
|
def absolutize(path: Path) -> Path:
|
||||||
"""`path` anchored at the cwd, without resolving symlinks.
|
"""`path` anchored at the cwd, without resolving symlinks.
|
||||||
@ -58,21 +60,12 @@ def violations_for(path: Path) -> list[str]:
|
|||||||
full = absolutize(path)
|
full = absolutize(path)
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
|
|
||||||
if is_markdown(path) and not is_exempt(full) and not has_allowed_name(path):
|
if not is_markdown(path) or is_exempt(full):
|
||||||
problems.append("name must start with README / CLAUDE / DOCS / TODO")
|
return problems
|
||||||
|
if is_todo(path):
|
||||||
# The marker rules apply even to exempt-by-name files: a CONTRIBUTING.md
|
problems.append(TODO_BANNED)
|
||||||
# carrying "REMOVE ME AFTER FINISH" is a task wearing a reserved name, and
|
elif not has_allowed_name(path):
|
||||||
# that is precisely the leak rule 3 exists to catch.
|
problems.append("name must start with README / CLAUDE / DOCS")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
@ -118,7 +111,7 @@ def main() -> int:
|
|||||||
for path, problem in sorted(found, key=lambda item: str(item[0])):
|
for path, problem in sorted(found, key=lambda item: str(item[0])):
|
||||||
print(f" {path}: {problem}", file=sys.stderr)
|
print(f" {path}: {problem}", file=sys.stderr)
|
||||||
print(
|
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",
|
"See ~/.claude/skills/md-file-conventions/SKILL.md",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"""Shared configuration for the markdown-naming convention.
|
"""Shared configuration for the markdown-naming convention.
|
||||||
|
|
||||||
Single source of truth for the four namespaces, the removal marker and the
|
Single source of truth for the three namespaces, the TODO ban and the
|
||||||
exemption list, imported by the pre-commit gate (`check.py`) and the staleness
|
exemption list, imported by the pre-commit gate (`check.py`). This module is
|
||||||
reporter (`stale.py`). This module is the public surface:
|
the public surface:
|
||||||
:mod:`md_naming._tables` holds the data, :mod:`md_naming.rules` the predicates.
|
: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,
|
EXTRA_THIRD_PARTY,
|
||||||
HARNESS_NAMES,
|
HARNESS_NAMES,
|
||||||
MARKER,
|
MARKER,
|
||||||
STALE_DAYS,
|
|
||||||
TODO_PREFIX,
|
TODO_PREFIX,
|
||||||
)
|
)
|
||||||
from md_naming.rules import (
|
from md_naming.rules import (
|
||||||
contains_marker,
|
|
||||||
has_allowed_name,
|
has_allowed_name,
|
||||||
is_exempt,
|
is_exempt,
|
||||||
is_markdown,
|
is_markdown,
|
||||||
@ -33,9 +31,7 @@ __all__ = [
|
|||||||
"EXTRA_THIRD_PARTY",
|
"EXTRA_THIRD_PARTY",
|
||||||
"HARNESS_NAMES",
|
"HARNESS_NAMES",
|
||||||
"MARKER",
|
"MARKER",
|
||||||
"STALE_DAYS",
|
|
||||||
"TODO_PREFIX",
|
"TODO_PREFIX",
|
||||||
"contains_marker",
|
|
||||||
"has_allowed_name",
|
"has_allowed_name",
|
||||||
"is_exempt",
|
"is_exempt",
|
||||||
"is_markdown",
|
"is_markdown",
|
||||||
|
|||||||
@ -25,7 +25,6 @@ from md_naming._tables import (
|
|||||||
DATED_BACKUP,
|
DATED_BACKUP,
|
||||||
EXTRA_THIRD_PARTY,
|
EXTRA_THIRD_PARTY,
|
||||||
HARNESS_NAMES,
|
HARNESS_NAMES,
|
||||||
MARKER,
|
|
||||||
TODO_PREFIX,
|
TODO_PREFIX,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -89,22 +88,11 @@ def is_exempt(path: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def has_allowed_name(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))
|
return bool(ALLOWED_PATTERN.match(path.name))
|
||||||
|
|
||||||
|
|
||||||
def is_todo(path: Path) -> bool:
|
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)
|
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
|
|
||||||
|
|||||||
@ -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())
|
|
||||||
@ -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
|
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"
|
assert check.absolutize(Path("b.md")) == tmp_path / "b.md"
|
||||||
|
|
||||||
|
|
||||||
def test_good_todo_has_no_violations(tmp_path: Path) -> None:
|
@pytest.mark.parametrize(
|
||||||
path = write(tmp_path, "TODO-a.md", "# a\n\nREMOVE ME AFTER FINISH\n")
|
("name", "body"),
|
||||||
assert check.violations_for(path) == []
|
[
|
||||||
|
("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:
|
def test_todo_message_points_at_the_todo_app() -> None:
|
||||||
path = write(tmp_path, "TODO-a.md", "# a\n")
|
assert "add_task" in check.TODO_BANNED
|
||||||
(problem,) = check.violations_for(path)
|
assert "todo_note.dart --repo" in check.TODO_BANNED
|
||||||
assert "must contain" in problem
|
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")
|
path = write(tmp_path, "DOCS-a.md", "REMOVE ME AFTER FINISH\n")
|
||||||
(problem,) = check.violations_for(path)
|
assert check.violations_for(path) == []
|
||||||
assert "not named TODO" in problem
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_name(tmp_path: Path) -> None:
|
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
|
assert "must start with" in problem
|
||||||
|
|
||||||
|
|
||||||
def test_exempt_name_skips_naming_but_not_marker(tmp_path: Path) -> None:
|
def test_exempt_name_skips_naming(tmp_path: Path) -> None:
|
||||||
"""A reserved name carrying the marker is a task in disguise."""
|
|
||||||
clean = write(tmp_path, "CONTRIBUTING.md", "# c\n")
|
clean = write(tmp_path, "CONTRIBUTING.md", "# c\n")
|
||||||
assert check.violations_for(clean) == []
|
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:
|
def test_non_markdown_ignored(tmp_path: Path) -> None:
|
||||||
path = write(tmp_path, "script.py", "REMOVE ME AFTER FINISH\n")
|
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:
|
def test_main_clean_tree(repo: Path, monkeypatch, capsys) -> None:
|
||||||
write(repo, "README.md")
|
write(repo, "README.md")
|
||||||
write(repo, "TODO-a.md", "REMOVE ME AFTER FINISH\n")
|
write(repo, "DOCS-a.md")
|
||||||
monkeypatch.chdir(repo)
|
monkeypatch.chdir(repo)
|
||||||
monkeypatch.setattr("sys.argv", ["check", "--all"])
|
monkeypatch.setattr("sys.argv", ["check", "--all"])
|
||||||
assert check.main() == 0
|
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
|
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:
|
def test_main_named_paths(repo: Path, monkeypatch, capsys) -> None:
|
||||||
write(repo, "notes.md")
|
write(repo, "notes.md")
|
||||||
monkeypatch.chdir(repo)
|
monkeypatch.chdir(repo)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from md_naming import rules
|
from md_naming import rules
|
||||||
from md_naming.tests.conftest import write
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -17,9 +16,7 @@ from md_naming.tests.conftest import write
|
|||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
"CLAUDE-extra.md",
|
"CLAUDE-extra.md",
|
||||||
"DOCS-x.md",
|
"DOCS-x.md",
|
||||||
"TODO-y.md",
|
|
||||||
"DOCS.md",
|
"DOCS.md",
|
||||||
"TODO.md",
|
|
||||||
"README.markdown",
|
"README.markdown",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -37,6 +34,8 @@ def test_allowed_names(name: str) -> None:
|
|||||||
"MIGRATION.md",
|
"MIGRATION.md",
|
||||||
"refactor_claude_todo.md",
|
"refactor_claude_todo.md",
|
||||||
"my-TODO.md",
|
"my-TODO.md",
|
||||||
|
"TODO-y.md",
|
||||||
|
"TODO.md",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_rejected_names(name: str) -> None:
|
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"))
|
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"])
|
@pytest.mark.parametrize("repo", [".nvm", ".oh-my-zsh", ".cargo", ".pyenv"])
|
||||||
def test_dotfile_clones_are_third_party(repo: str) -> None:
|
def test_dotfile_clones_are_third_party(repo: str) -> None:
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -3,10 +3,8 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Fail if any markdown file breaks the shared naming convention.
|
# Fail if any markdown file breaks the shared naming convention.
|
||||||
#
|
#
|
||||||
# Four namespaces -- README.md, CLAUDE*.md, DOCS*.md, TODO*.md -- so a future
|
# Three namespaces -- README.md, CLAUDE*.md, DOCS*.md. TODO*.md is banned:
|
||||||
# session can tell a live task from a finished record without re-deriving it.
|
# tasks live in the todo app (MCP add_task, or todo_note.dart --repo).
|
||||||
# TODO files carry "REMOVE ME AFTER FINISH" and are deleted when the work
|
|
||||||
# lands; nothing else may carry that marker.
|
|
||||||
#
|
#
|
||||||
# This is the REAL gate. Per-repo scripts/check_md_naming.sh files are thin
|
# 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
|
# shims that exec this one -- do not overwrite this file with a shim, or every
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user