diff --git a/docs/DOC_TRUST_MAP.md b/docs/DOC_TRUST_MAP.md index 0770d4f..28c03bc 100644 --- a/docs/DOC_TRUST_MAP.md +++ b/docs/DOC_TRUST_MAP.md @@ -108,6 +108,19 @@ the first sixteen lines of the file, and one carrying `Status` without `Review trigger` is reported as incomplete — that combination looks finished and is not. +**Governs** is a comma-separated list, and an entry may explain itself after the +glob with a spaced dash — `docs/data/** — the assets privacyllc.dev renders for +this project`. `scripts/doc-triggers.py` cuts the entry there and reads the globs +from the left of it. **Use that form and no other.** A gloss in parentheses, or +after a colon, is not recognised: the whole entry becomes the glob, matches +nothing, and the document is silently never fired — not reported as skipped +either, because it still looks like a path. Three documents here sat in exactly +that state from the first commit, `docs/data/img/README.md` among them, so +editing the branding assets never once prompted the document that specifies their +names and sizes. A trailing `/` on a glob means the directory and everything in +it, which is how this file's `.githooks/` entry reaches a project's installed +hooks. + **Fires on** is optional, and only for the case where `Governs:` is much broader than the trigger. `Governs:` says *where* a document is authoritative; the trigger says *which kinds of change* to that place should bring somebody back, diff --git a/docs/architecture/scripts/doc-triggers.py b/docs/architecture/scripts/doc-triggers.py index 459fe9b..fa9e70a 100755 --- a/docs/architecture/scripts/doc-triggers.py +++ b/docs/architecture/scripts/doc-triggers.py @@ -155,6 +155,36 @@ def looks_like_path(glob: str) -> bool: 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`. @@ -167,6 +197,10 @@ def matches(path: str, glob: str) -> bool: 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) @@ -320,8 +354,11 @@ def main() -> int: 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)] + 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) continue