471 lines
20 KiB
Python
Executable File
471 lines
20 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.
|
||
|
||
## Matching the trigger's verb, not only its glob
|
||
|
||
A document is fired when a changed path matches its `Governs:` **and** the kind
|
||
of change matches its `Fires on:`. That second field is optional and almost
|
||
never needed; it exists for the case where `Governs:` is far broader than the
|
||
trigger. `DOC_TRUST_MAP.md` is the extreme — it governs `docs/**` while its
|
||
trigger is *any doc added, deleted or moved* — so on the glob alone it fired on
|
||
every edit to every document, forever, and correctly by the only rule there was.
|
||
A prompt that always fires is one people stop reading, and this one exits 0 by
|
||
design, so nothing forces the reading.
|
||
|
||
The kinds are `added`, `deleted`, `moved` and `changed`, read from git's own
|
||
status letter. Absent, empty or unparseable means every kind, which is the
|
||
behaviour every document without the line still has.
|
||
|
||
## 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
|
||
|
||
def _find_root() -> pathlib.Path:
|
||
"""The repository root, found rather than assumed.
|
||
|
||
This was `parents[3]`, which is correct only while the script sits at
|
||
`docs/architecture/scripts/` — its home in the template. The moment a
|
||
project copies it to `scripts/`, as the template's own adoption
|
||
instructions say to, `parents[3]` climbs out of the repository entirely: in
|
||
a checkout at `~/Projects/thing/scripts/`, it resolves to `~/`, and the
|
||
script reports "no docs/ directory" about a directory two levels above the
|
||
project it was run in.
|
||
|
||
So: walk up from the script looking for a directory that has both `docs/`
|
||
and `.git`, then fall back to either alone, then to git's own answer.
|
||
"""
|
||
here = pathlib.Path(__file__).resolve()
|
||
|
||
for parent in here.parents:
|
||
if (parent / "docs").is_dir() and (parent / ".git").exists():
|
||
return parent
|
||
|
||
for parent in here.parents:
|
||
if (parent / "docs").is_dir():
|
||
return parent
|
||
|
||
result = subprocess.run(
|
||
["git", "rev-parse", "--show-toplevel"],
|
||
cwd=here.parent,
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
return pathlib.Path(result.stdout.strip())
|
||
|
||
return here.parents[1]
|
||
|
||
|
||
ROOT = _find_root()
|
||
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|Fires on):\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
|
||
|
||
|
||
# `Status` must be one of these four. `DOC_TRUST_MAP.md` states it as a rule with
|
||
# a checker behind it, and it is the one field that distinguishes a document from
|
||
# a template for one: `project-readme-template.md` carries
|
||
# `Status: <Current | Draft | Superseded | Archived>`, and its `Governs` describes
|
||
# the README of the project that copies it, not anything in this repository.
|
||
STATUS_WORDS = {"Current", "Draft", "Superseded", "Archived"}
|
||
|
||
|
||
def governing_documents() -> list[pathlib.Path]:
|
||
"""Every document that can fire, root ones included.
|
||
|
||
The walk used to start at `docs/`, so the documents at the repository root
|
||
were not read at all — `README.md` and the two `START-HERE-*.md` carry a full
|
||
status header, govern real paths, and fired nothing ever, while the output
|
||
said "No document's Governs matched these paths". True of the tool and false
|
||
of the repository, which is the same shape as the gloss bug one level out.
|
||
|
||
The root is read **non-recursively**: `ROOT.glob`, not `rglob`. A vendored
|
||
copy of this template, a scratch checkout, or somebody's directory of notes
|
||
would otherwise enrol its documents as governing this repository.
|
||
"""
|
||
docs = sorted(ROOT.glob("*.md"))
|
||
if DOCS.is_dir():
|
||
docs += sorted(DOCS.rglob("*.md"))
|
||
return docs
|
||
|
||
|
||
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
|
||
|
||
|
||
# A `Governs:` entry is split on commas, so an entry that explains itself after
|
||
# the glob arrives whole: `docs/data/** — the assets privacyllc.dev renders for
|
||
# this project`. Used as a glob that matches nothing, ever, and because it
|
||
# contains a slash `looks_like_path` calls it a path — so the document was
|
||
# neither fired nor listed among the ones no change can fire mechanically. It was
|
||
# simply absent, which is the one outcome a reader cannot notice.
|
||
#
|
||
# Three of the seven path-governing documents here were in that state from the
|
||
# first commit, `docs/data/img/README.md` among them: editing the branding assets
|
||
# had never once prompted the document that specifies their names and sizes.
|
||
GLOSS = re.compile(r"\s+(?:—|–|--)\s+")
|
||
|
||
|
||
def globs_in(entry: str) -> list[str]:
|
||
"""The globs inside one `Governs:` entry, with any trailing gloss removed.
|
||
|
||
The cut requires whitespace on both sides of the dash: `source-grep` and
|
||
`doc-claims` appear in these headers and a bare `-` would halve them. Tokens
|
||
are taken from the left of the gloss rather than from the whole entry,
|
||
because prose on the right can itself look like a path — `privacyllc.dev`
|
||
passes the extension test and would become a glob that fires on a file
|
||
nobody has.
|
||
|
||
An entry yielding no token falls back to itself, so a shape not foreseen here
|
||
behaves exactly as it did before.
|
||
"""
|
||
head = GLOSS.split(entry, 1)[0]
|
||
return [tok for tok in head.split() if looks_like_path(tok)] or [entry]
|
||
|
||
|
||
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 glob.endswith("/"):
|
||
# A bare directory, as `githooks/README.md` governs ".githooks/". fnmatch
|
||
# would not match a file inside it.
|
||
return path.startswith(glob)
|
||
if "/**/" in glob:
|
||
head, tail = glob.split("/**/", 1)
|
||
return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail)
|
||
return fnmatch.fnmatch(path, glob)
|
||
|
||
|
||
# `Governs:` says *where* a document is authoritative; `Fires on:` says which
|
||
# kinds of change to that place its `Review trigger` actually names. The two come
|
||
# apart badly at the extreme: `DOC_TRUST_MAP.md` governs `docs/**`, the broadest
|
||
# glob in the tree, while its trigger is one of the narrowest — *any doc added,
|
||
# deleted or moved*. Matching on the glob alone fires it on every edit to every
|
||
# document forever, and a prompt that always fires is one people stop reading,
|
||
# which takes the true positives with it.
|
||
#
|
||
# Why a declared field rather than reading the trigger prose. The obvious first
|
||
# cut — look for `added`/`deleted`/`moved` and no `changed`/`change to` — was
|
||
# tried against the seven path-governing documents here and misclassified the one
|
||
# it exists to fix. `DOC_TRUST_MAP.md`'s trigger ends "any change to which doc
|
||
# owns a subject", so it reads as a change-verb; the clause is about which
|
||
# document owns a subject, not about a file being edited. Nothing lexical
|
||
# separates it from `architecture/README.md`'s "any change to a module boundary
|
||
# or a data shape", which genuinely does mean modification. Guessing at English
|
||
# and getting it wrong here is silent in the expensive direction: the document
|
||
# stops being prompted for and goes quietly stale.
|
||
#
|
||
# So the narrowing is declared or it does not happen. Absent, unparseable, or
|
||
# empty means fire on everything, which is the old behaviour — a document is only
|
||
# ever quietened by someone writing the line deliberately.
|
||
KIND_LETTERS = {
|
||
"added": {"A", "C"},
|
||
"deleted": {"D"},
|
||
"moved": {"R"},
|
||
"changed": {"M", "T"},
|
||
}
|
||
ALL_KINDS = {letter for letters in KIND_LETTERS.values() for letter in letters}
|
||
KIND_OF = {letter: word for word, letters in KIND_LETTERS.items() for letter in letters}
|
||
|
||
|
||
def fires_on(header: dict[str, str]) -> tuple[set[str], list[str]]:
|
||
"""The status letters a document accepts, and any words not understood.
|
||
|
||
Returns every letter when nothing is declared or the declaration cannot be
|
||
read, so the failure mode of a typo is a document that is prompted for too
|
||
often rather than one that is silently dropped.
|
||
"""
|
||
raw = header.get("Fires on", "").strip()
|
||
if not raw:
|
||
return ALL_KINDS, []
|
||
|
||
words = [w.strip().lower().rstrip(".") for w in re.split(r"[,;]|\band\b", raw)]
|
||
words = [w for w in words if w]
|
||
|
||
letters: set[str] = set()
|
||
unknown: list[str] = []
|
||
for word in words:
|
||
if word in KIND_LETTERS:
|
||
letters |= KIND_LETTERS[word]
|
||
else:
|
||
unknown.append(word)
|
||
|
||
if unknown or not letters:
|
||
return ALL_KINDS, unknown or ["(empty)"]
|
||
return letters, []
|
||
|
||
|
||
def _git(*args: str) -> str:
|
||
result = subprocess.run(
|
||
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
|
||
)
|
||
return result.stdout
|
||
|
||
|
||
def _name_status(*args: str) -> list[tuple[str, str]]:
|
||
"""(letter, path) from a `--name-status` listing.
|
||
|
||
A rename arrives as `R100<TAB>old<TAB>new`, so the path is taken from the
|
||
last field: the new name governs, as it did when only names were read.
|
||
"""
|
||
pairs = []
|
||
for line in _git(*args, "--name-status").splitlines():
|
||
if not line.strip():
|
||
continue
|
||
fields = line.split("\t")
|
||
if len(fields) < 2 or not fields[0].strip():
|
||
continue
|
||
pairs.append((fields[0].strip()[0].upper(), fields[-1].strip()))
|
||
return pairs
|
||
|
||
|
||
def _status_of_named(path: str) -> str:
|
||
"""The kind of change a path named on the command line represents.
|
||
|
||
There is no diff to read here, so it is inferred: gone from disk is a
|
||
deletion, present but untracked is an addition, and anything else is a
|
||
modification — the usual reason to ask about a path by name.
|
||
"""
|
||
if not (ROOT / path).exists():
|
||
return "D"
|
||
return "M" if _git("ls-files", "--", path).strip() else "A"
|
||
|
||
|
||
def changed_paths(argv: list[str]) -> tuple[list[tuple[str, str]], str]:
|
||
if argv and argv[0] == "--staged":
|
||
return _name_status("diff", "--cached"), "staged"
|
||
if argv and argv[0] == "--range":
|
||
if len(argv) < 2:
|
||
sys.exit("doc-triggers: --range needs a revision range")
|
||
return _name_status("diff", argv[1]), f"range {argv[1]}"
|
||
if argv:
|
||
return [(_status_of_named(a), a) for a in 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`.
|
||
pairs = []
|
||
for line in _git("status", "--porcelain").splitlines():
|
||
if not line.strip():
|
||
continue
|
||
index, worktree = line[0], line[1]
|
||
path = line[3:].strip()
|
||
if " -> " in path: # a rename; the new name governs
|
||
path = path.split(" -> ", 1)[1]
|
||
if "?" in (index, worktree):
|
||
letter = "A" # untracked: a file that is new
|
||
else:
|
||
letter = (index if index != " " else worktree).upper()
|
||
pairs.append((letter, path.strip('"')))
|
||
return pairs, "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[tuple[str, str]], str]] = []
|
||
subject_only: list[str] = []
|
||
wrong_kind: list[tuple[str, str]] = []
|
||
unfilled: list[tuple[str, str]] = []
|
||
|
||
for doc in governing_documents():
|
||
rel = str(doc.relative_to(ROOT))
|
||
header = header_of(doc)
|
||
governs = header.get("Governs", "")
|
||
if not governs:
|
||
continue
|
||
|
||
filled = header.get("Status", "") in STATUS_WORDS
|
||
|
||
entries = [g.strip() for g in governs.split(",") if g.strip()]
|
||
# Classification reads the whole entry and extraction reads inside it:
|
||
# deciding "path or subject?" on a token would move documents between the
|
||
# two lists as a side effect of this fix.
|
||
path_globs = [g for e in entries if looks_like_path(e) for g in globs_in(e)]
|
||
if not path_globs:
|
||
if filled:
|
||
subject_only.append(rel)
|
||
continue
|
||
|
||
letters, unknown = fires_on(header)
|
||
if unknown:
|
||
print(
|
||
f"doc-triggers: {rel} declares 'Fires on: "
|
||
f"{header.get('Fires on', '')}' — {', '.join(unknown)} not "
|
||
f"understood, so it fires on everything.\n"
|
||
)
|
||
|
||
matched = {(s, p) for s, p in paths for g in path_globs if matches(p, g)}
|
||
|
||
if not filled:
|
||
# A template for a document rather than a document. Named only when it
|
||
# would otherwise have fired: a line on every run, about a file that is
|
||
# supposed to look like this, is the noise this tool keeps being fixed
|
||
# for.
|
||
if matched:
|
||
unfilled.append((rel, header.get("Status", "") or "(none)"))
|
||
continue
|
||
|
||
hits = sorted({(s, p) for s, p in matched if s in letters}, key=lambda x: x[1])
|
||
if hits:
|
||
fired.append((rel, hits, header.get("Review trigger", "(none stated)")))
|
||
elif matched:
|
||
# Governed, and deliberately not prompted for: the paths changed in a
|
||
# way this document's trigger does not name. Said out loud, because a
|
||
# reader who saw nothing would have to guess whether it was checked.
|
||
wrong_kind.append((rel, header.get("Fires on", "").strip()))
|
||
|
||
for rel, hits, trigger in fired:
|
||
print(f"\033[1m{rel}\033[0m")
|
||
for letter, hit in hits[:6]:
|
||
print(f" {KIND_OF.get(letter, letter.lower()):>7} {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.")
|
||
elif wrong_kind or unfilled:
|
||
# Distinct from matching nothing, and worth separating: a path here *is*
|
||
# governed, and the reason nothing fired is a declaration somebody wrote
|
||
# or a header nobody filled in, not an area no document claims.
|
||
print("Nothing fired, but these paths are governed — see below for which")
|
||
print("documents matched them and why each was not raised.")
|
||
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 wrong_kind:
|
||
print("\nGovern a path in this change but do not fire on this kind of")
|
||
print("change, by their own Fires on declaration:")
|
||
for rel, declared in wrong_kind:
|
||
print(f" {rel} — fires on {declared}")
|
||
|
||
if unfilled:
|
||
print("\nGovern a path in this change but their status header is still a")
|
||
print("template, so they are not treated as documents of this repository:")
|
||
for rel, status in unfilled:
|
||
print(f" {rel} — Status: {status}")
|
||
|
||
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())
|