212 lines
8.2 KiB
Python
Executable File
212 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Which documents does a change fire, while there is still time to update them?
|
|
|
|
python3 scripts/doc-triggers.py # everything dirty right now
|
|
python3 scripts/doc-triggers.py --staged # what is staged
|
|
python3 scripts/doc-triggers.py <path> [path…] # specific paths
|
|
python3 scripts/doc-triggers.py --range HEAD~3..HEAD
|
|
|
|
## The failure this catches
|
|
|
|
Every document in this tree carries a `Review trigger:` — the change that should
|
|
send somebody back to it — and `WORK_CYCLE.md` requires the triggered documents
|
|
to be updated *in the same commit as the code*. Deciding which fired means
|
|
reading every `Governs:` line and matching globs in your head, once per commit.
|
|
It is a check with no output of its own, so it is the one that gets skipped when
|
|
the code is already green, and the cost is invisible until a reader trusts a
|
|
document that stopped being true: a reference manual six migrations behind, and
|
|
every reader in between believing it.
|
|
|
|
**This is not the doc-review check.** That one asks which baselined documents are
|
|
*overdue*, from committed history — a governed path with a commit newer than the
|
|
document's `Last reviewed` date. It is a different question and it can only be
|
|
asked after the fact. By the time a change is committed without its document, the
|
|
thing this catches has already happened.
|
|
|
|
Exit status is always 0. This is a prompt, not a gate: a trigger asks a human
|
|
whether the prose is still true, and a check that failed the build for that would
|
|
be bumped past rather than read.
|
|
|
|
## Two things it deliberately does not do
|
|
|
|
**It does not read `Exempt:` declarations.** Those mark a *required* document as
|
|
deliberately absent from a repository, and a document that does not exist cannot
|
|
govern a path. There is nothing here for them to change.
|
|
|
|
**It cannot fire a document whose `Governs:` is prose.** Several in this template
|
|
govern a subject rather than a set of paths — `GUARDS.md` governs "structural
|
|
tests, source-grep assertions, probes, and any check whose passing is taken as
|
|
evidence", which is the honest description and matches no glob. Those documents
|
|
are listed separately at the end rather than silently ignored, because a reader
|
|
who sees only the matched list would reasonably conclude the others were checked
|
|
and cleared.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
DOCS = ROOT / "docs"
|
|
|
|
# The status header is a fenced block immediately after the H1, and a long value
|
|
# wraps onto continuation lines indented by two spaces:
|
|
#
|
|
# Governs: structural tests, source-grep assertions, probes, and any check whose
|
|
# passing is taken as evidence
|
|
#
|
|
# A regex that reads one line per field — the obvious implementation — truncates
|
|
# at the wrap and silently under-reports, which for this tool means quietly
|
|
# failing to name a document that should have been updated. So fields are
|
|
# assembled line by line instead.
|
|
FIELD_START = re.compile(r"^(Status|Owner|Last reviewed|Governs|Review trigger):\s*(.*)$")
|
|
HEADER_LINES = 16
|
|
|
|
|
|
def header_of(doc: pathlib.Path) -> dict[str, str]:
|
|
"""The status header, with wrapped values joined."""
|
|
try:
|
|
lines = doc.read_text(encoding="utf-8").splitlines()[:HEADER_LINES]
|
|
except (OSError, UnicodeDecodeError):
|
|
return {}
|
|
|
|
fields: dict[str, str] = {}
|
|
current: str | None = None
|
|
for line in lines:
|
|
match = FIELD_START.match(line)
|
|
if match:
|
|
current = match.group(1)
|
|
fields[current] = match.group(2).strip()
|
|
elif current and line.startswith((" ", "\t")) and line.strip():
|
|
fields[current] = f"{fields[current]} {line.strip()}".strip()
|
|
elif line.strip().startswith("```") and fields:
|
|
break
|
|
return fields
|
|
|
|
|
|
def looks_like_path(glob: str) -> bool:
|
|
"""Whether a `Governs:` entry is a path pattern rather than a subject.
|
|
|
|
The same test `doc-claims.sh` uses: a slash, a wildcard, or a file
|
|
extension. Prose about what a document is authoritative for will have none
|
|
of them, and must not be treated as a glob that simply never matches.
|
|
"""
|
|
glob = glob.strip()
|
|
if not glob or " " in glob and "/" not in glob and "*" not in glob:
|
|
return False
|
|
return "/" in glob or "*" in glob or re.search(r"\.\w{1,5}$", glob) is not None
|
|
|
|
|
|
def matches(path: str, glob: str) -> bool:
|
|
"""Whether `path` is governed by `glob`.
|
|
|
|
`**` means "and everything below", which `fnmatch` does not implement: its
|
|
`*` already crosses separators, so `a/**` never matches `a/b/c`. The two
|
|
forms these headers actually use are reduced to prefix tests.
|
|
"""
|
|
glob = glob.strip()
|
|
if not glob:
|
|
return False
|
|
if glob.endswith("/**"):
|
|
return path.startswith(glob[:-2]) or path == glob[:-3]
|
|
if "/**/" in glob:
|
|
head, tail = glob.split("/**/", 1)
|
|
return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail)
|
|
return fnmatch.fnmatch(path, glob)
|
|
|
|
|
|
def _git(*args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def changed_paths(argv: list[str]) -> tuple[list[str], str]:
|
|
if argv and argv[0] == "--staged":
|
|
return [p for p in _git("diff", "--cached", "--name-only").splitlines() if p], "staged"
|
|
if argv and argv[0] == "--range":
|
|
if len(argv) < 2:
|
|
sys.exit("doc-triggers: --range needs a revision range")
|
|
return [p for p in _git("diff", "--name-only", argv[1]).splitlines() if p], f"range {argv[1]}"
|
|
if argv:
|
|
return list(argv), "named paths"
|
|
|
|
# Untracked files are included on purpose: a brand-new module is the case
|
|
# most likely to need a document and least likely to be remembered, and it
|
|
# is invisible to `git diff`.
|
|
paths = []
|
|
for line in _git("status", "--porcelain").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
path = line[3:].strip()
|
|
if " -> " in path: # a rename; the new name governs
|
|
path = path.split(" -> ", 1)[1]
|
|
paths.append(path.strip('"'))
|
|
return paths, "working tree"
|
|
|
|
|
|
def main() -> int:
|
|
if not DOCS.is_dir():
|
|
print(f"doc-triggers: no docs/ directory at {DOCS}")
|
|
return 0
|
|
|
|
paths, source = changed_paths(sys.argv[1:])
|
|
if not paths:
|
|
print(f"doc-triggers: nothing changed in the {source}.")
|
|
return 0
|
|
|
|
print(f"doc-triggers: {len(paths)} path(s) from the {source}\n")
|
|
|
|
fired: list[tuple[str, list[str], str]] = []
|
|
subject_only: list[str] = []
|
|
|
|
for doc in sorted(DOCS.rglob("*.md")):
|
|
rel = str(doc.relative_to(ROOT))
|
|
header = header_of(doc)
|
|
governs = header.get("Governs", "")
|
|
if not governs:
|
|
continue
|
|
|
|
globs = [g.strip() for g in governs.split(",") if g.strip()]
|
|
path_globs = [g for g in globs if looks_like_path(g)]
|
|
if not path_globs:
|
|
subject_only.append(rel)
|
|
continue
|
|
|
|
hits = sorted({p for p in paths for g in path_globs if matches(p, g)})
|
|
if hits:
|
|
fired.append((rel, hits, header.get("Review trigger", "(none stated)")))
|
|
|
|
for rel, hits, trigger in fired:
|
|
print(f"\033[1m{rel}\033[0m")
|
|
for hit in hits[:6]:
|
|
print(f" {hit}")
|
|
if len(hits) > 6:
|
|
print(f" … and {len(hits) - 6} more")
|
|
print(f" trigger: {trigger}\n")
|
|
|
|
if fired:
|
|
print(f"{len(fired)} document(s) govern something in this change.")
|
|
print("Read each trigger and decide — the rule is to update them in the")
|
|
print("SAME commit as the code, not afterwards.")
|
|
else:
|
|
print("No document's Governs matched these paths. Worth a second look if")
|
|
print("this change added a module, a migration, or a new boundary — an")
|
|
print("unmatched path can also mean no document claims that area yet.")
|
|
|
|
if subject_only:
|
|
print("\nNot checked here — these govern a subject rather than paths, so")
|
|
print("no change can fire them mechanically. Judge them yourself:")
|
|
for rel in subject_only:
|
|
print(f" {rel}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|