fix(tools): three documents doc-triggers has never once fired

docs/data/README.md, docs/data/img/README.md and
docs/architecture/githooks/README.md have never fired for anything, since the
first commit. They were not reported as skipped either -- they fell into neither
list, so nothing on screen said they had not been checked.

Each carries a Governs entry that explains itself after the glob:

    Governs: docs/data/** -- the assets privacyllc.dev renders for this project

Governs is split on commas only, so that is one entry and the whole string was
used as the glob. It contains a slash, so looks_like_path() called it a path and
the document was classified as path-governing -- which also kept it out of the
"govern a subject rather than paths, judge them yourself" list, the one that
exists so a reader does not conclude everything was checked. Then matches()
tested the file against a glob ending "renders for this project", which is false
and always would be.

Same class as the previous commit and the opposite sign, which makes it worse.
That one fired a document when it should not: a false prompt, costing a glance.
This one silently did not fire when it should, costing a document that goes
quietly stale while the tool reports success. GUARDS.md opens with the sentence
that applies -- a guard that cannot fail is worse than no guard, because it is
trusted.

docs/data/img/README.md governs the branding assets, which is the subject of open
issue #14. Editing them had never once prompted the document that specifies their
names, dimensions and ceilings.

The glob is now extracted from the entry: cut at the first spaced em dash, en
dash or --, then take the tokens on the left that themselves look like paths,
falling back to the entry unchanged if that yields nothing.

Three details are load-bearing:

- The cut requires whitespace both sides. A bare - would halve source-grep and
  doc-claims, both of which appear in these headers.
- Tokens come from the left of the gloss, not the whole entry. privacyllc.dev in
  the docs/data gloss passes looks_like_path on the extension rule and would
  otherwise become a glob firing on a file nobody has.
- Classification still reads the whole entry. Deciding path-or-subject on a token
  would move documents between the two lists as a side effect of this fix.

A trailing / on a glob now means the directory and everything under it.
githooks/README.md governs "the .githooks/ a project installs", which extraction
yields as a bare .githooks/, and fnmatch would not match a file inside it.

DOC_TRUST_MAP.md owns the header schema, so it now states the gloss form and that
it is the only one recognised -- a gloss in parentheses or after a colon puts a
document straight back into silence, which is the failure that was invisible here
for the life of the repository.

Verified: both docs/data documents fire on docs/data/img/icon.webp when it is
added, deleted and modified; githooks/README.md fires on
docs/architecture/githooks/pre-commit and on .githooks/pre-commit; privacyllc.dev
matches nothing; the split stays 7 path-governing and 12 subject-governing,
exactly as before. The previous commit's behaviour is unchanged -- a modified
script still fires TOOLS.md and architecture/README.md and not DOC_TRUST_MAP.md.

closes #21

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-17 23:53:12 -05:00
parent 81e326829b
commit 2577cb9ed8
2 changed files with 52 additions and 2 deletions

View File

@ -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 `Review trigger` is reported as incomplete — that combination looks finished and
is not. 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 **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 than the trigger. `Governs:` says *where* a document is authoritative; the
trigger says *which kinds of change* to that place should bring somebody back, trigger says *which kinds of change* to that place should bring somebody back,

View File

@ -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 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: def matches(path: str, glob: str) -> bool:
"""Whether `path` is governed by `glob`. """Whether `path` is governed by `glob`.
@ -167,6 +197,10 @@ def matches(path: str, glob: str) -> bool:
return False return False
if glob.endswith("/**"): if glob.endswith("/**"):
return path.startswith(glob[:-2]) or path == glob[:-3] 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: if "/**/" in glob:
head, tail = glob.split("/**/", 1) head, tail = glob.split("/**/", 1)
return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail) return path.startswith(head + "/") and fnmatch.fnmatch(path, "*" + tail)
@ -320,8 +354,11 @@ def main() -> int:
if not governs: if not governs:
continue continue
globs = [g.strip() for g in governs.split(",") if g.strip()] entries = [g.strip() for g in governs.split(",") if g.strip()]
path_globs = [g for g in globs if looks_like_path(g)] # 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 not path_globs:
subject_only.append(rel) subject_only.append(rel)
continue continue