chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
#!/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
|
|
|
|
|
|
fix(tools): doc-triggers found the repository by depth and left it
ROOT was `Path(__file__).resolve().parents[3]`, which is correct only while the
script sits at its template home, docs/architecture/scripts/. TOOLS.md tells an
adopting project to take scripts one at a time into its own scripts/, and from
<project>/scripts/doc-triggers.py that expression resolves to the *parent of the
project* -- outside the repository entirely.
The failure is silent and reads as a pass. main() opens with a `not DOCS.is_dir()`
guard that prints and returns 0, so an adopting project got exit 0 and one line
naming a directory two levels above the code it was asked about. The check that
enforces "update the triggered documents in the same commit as the code" had
quietly stopped running, in exactly the projects that took the template's advice.
That is the failure GUARDS.md opens with -- a guard that cannot fail is worse
than no guard, because it is trusted -- landed on the tool that polices the
documents. It could not be caught by running it here, because here parents[3] is
right; it takes a copy at the documented location to see it.
The root is now found rather than assumed: walk up from __file__ for a directory
holding both docs/ and .git, then either alone, then `git rev-parse
--show-toplevel`, then give up to the script's own parent. Both directories are
required before docs/ alone so that a repository vendoring a docs/ in some
subdirectory does not anchor on it.
Reproduced in a scratch repository with the script at scripts/, a document
governing src/**, and src/a.py staged: before, "no docs/ directory at <tmp>/docs";
after, the document and its trigger. The in-place layout is unchanged and still
fires DOC_TRUST_MAP.md, TOOLS.md and architecture/README.md.
Exit status is still always 0. This is a prompt, not a gate, so the fix belongs
in root resolution and not in the exit code.
closes #17
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:39:13 -05:00
|
|
|
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()
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
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())
|