mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +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
|
||||
|
||||
# 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
#:
|
||||
|
||||
@ -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/<name>)"
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
#
|
||||
# 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user