diff --git a/docs/DOC_TRUST_MAP.md b/docs/DOC_TRUST_MAP.md index 28c03bc..bfda332 100644 --- a/docs/DOC_TRUST_MAP.md +++ b/docs/DOC_TRUST_MAP.md @@ -80,7 +80,12 @@ written. The tracker is describing now. ## The status header -Every document in this tree opens with one, immediately after its H1: +Every document in this tree opens with one, immediately after its H1 — and so +do the documents at the repository root, which `scripts/doc-triggers.py` reads +alongside this tree. The root is read one level deep only, so a vendored copy +of this template cannot enrol its documents as governing the project holding +it. + ``` Status: Current | Draft | Superseded | Archived @@ -93,7 +98,12 @@ Fires on: **Exactly those four status words.** They are not a suggestion: a checker reads them, and a document whose status falls outside the list is reported as having -an unknown one rather than being quietly accepted. `Historical` is the word this +an unknown one rather than being quietly accepted. It is also what separates a +document from a *template for* one: `project-readme-template.md` carries +`Status: ` and a `Governs:` describing +the README of whichever project copies it, so `doc-triggers.py` does not treat it +as governing anything here — and says so when it would otherwise have fired, +rather than dropping it quietly. `Historical` is the word this convention used to have and deliberately dropped — `Archived` covers it, and a vocabulary a script enforces beats one described in prose, which drifts the moment somebody writes something adjacent to it. `Draft` earns its place by diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9d9ee54..70b7cb7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -52,7 +52,7 @@ can stop you, and where to start in a fresh clone. | `scripts/controls.sh` | which operational controls this project actually has, each row saying **how** it is known: measured, declared, n/a, or unknown. An unknown is never rendered as absent — "I could not tell" and "it is not there" send people to different places. | | `scripts/dev.sh` | bring the local stack up | | `scripts/scaffold.sh` | lay out a new project in this shape | -| `scripts/doc-triggers.py` | which documents a **pending** change fires, read from their `Governs:` headers and narrowed by the optional `Fires on:` — the kinds of change (added, deleted, moved, changed) a document's trigger actually names, so one governing `docs/**` for existence changes alone does not fire on every edit. The `Review trigger` on each document names the change that should send somebody back to it; this is the check that asks before the commit rather than after | +| `scripts/doc-triggers.py` | which documents a **pending** change fires, read from the `Governs:` headers of everything under `docs/` and of the documents at the repository root, narrowed by the optional `Fires on:` — the kinds of change (added, deleted, moved, changed) a document's trigger actually names, so one governing `docs/**` for existence changes alone does not fire on every edit. The `Review trigger` on each document names the change that should send somebody back to it; this is the check that asks before the commit rather than after | | `scripts/prove-guard.sh` | breaks the thing a guard protects, requires the guard to go red, restores the file from a trap. `GUARDS.md` §1 written out as a command, including the count — one failing test reported on six lines is not six failures | | `scripts/commit-mine.sh` | commits only the paths you name, by pathspec, after the secret scan. For a tree something else is also writing: what anyone else has staged is reported and left exactly as it was | | `scripts/doc-claims.sh` | every file a document names must exist, and (`--covers`) every file that exists is named — the second is the one that catches a list missing rows | diff --git a/docs/architecture/scripts/doc-triggers.py b/docs/architecture/scripts/doc-triggers.py index fa9e70a..29443d1 100755 --- a/docs/architecture/scripts/doc-triggers.py +++ b/docs/architecture/scripts/doc-triggers.py @@ -142,6 +142,33 @@ def header_of(doc: pathlib.Path) -> dict[str, str]: 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: `, 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. @@ -346,21 +373,25 @@ def main() -> int: 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 sorted(DOCS.rglob("*.md")): + 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: - subject_only.append(rel) + if filled: + subject_only.append(rel) continue letters, unknown = fires_on(header) @@ -372,6 +403,16 @@ def main() -> int: ) 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)"))) @@ -393,12 +434,12 @@ def main() -> int: 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: + 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, - # not an area no document claims. - print("Nothing fired. The paths in this change are governed, but only by") - print("documents whose Fires on declaration excludes this kind of change.") + # 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") @@ -410,6 +451,12 @@ def main() -> int: 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:")