Project-Template/docs/architecture/scripts/doc-claims.sh

304 lines
11 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
#
# Every file a document names must exist.
#
# ## The failure this catches
#
# Documentation makes claims about code, and the claims rot silently because
# nothing executes them. Three real instances, all found by hand in one
# afternoon on one repository:
#
# - A source comment stating that `tests/notice-security.test.ts` pinned a
# security rule. **That file had never existed.** The rule was real and
# enforced by nothing, and the sentence had been reassuring every reader who
# checked for two batches.
# - A reference manual whose migration table stopped at 0050 while the
# repository was at 0056. Six migrations behind, and every reader in between
# trusted it.
# - A manual promising a watchdog the code structurally could not fire.
#
# The first two are mechanically checkable and this checks them. The third is
# not — a claim about behaviour needs a person or a test — which is worth
# knowing about this script's limits: **it proves a path exists, never that the
# sentence around it is true.**
#
# ## What it looks at
#
# Anything that looks like a repository path inside backticks or a markdown
# link, in the files you point it at. A path is checked when it looks like one:
# it contains a slash or a known source extension, and it is not a URL, not a
# glob, and not obviously prose.
#
# bash scripts/doc-claims.sh # every tracked .md
# bash scripts/doc-claims.sh docs/ # one tree
# bash scripts/doc-claims.sh README.md # one file
# DOC_CLAIMS_ALSO_SRC=1 bash scripts/doc-claims.sh # also scan source comments
# DOC_CLAIMS_EXCLUDE='' bash scripts/doc-claims.sh # include forward-looking specs
#
# ## The inverse: is everything that exists written down?
#
# bash scripts/doc-claims.sh --covers src/db/migrations --in docs/MANUAL.md
#
# The check above asks whether every path a document *names* exists. This asks
# whether every file that exists is *named* — and it is the one that actually
# bit. A reference manual's migration table stopped at 0050 while the repository
# was at 0056: six rows missing, every path in the document perfectly valid, and
# no existence check can see an absent row.
#
# `docs/proposed/` is skipped by default: a specification naming the files it
# would create is not a claim that they exist.
#
# Exit codes: 0 every named path exists. 1 at least one does not. 2 nothing was
# scanned, which is not a pass — an empty run and a clean run must not look the
# same, for the same reason `verify.sh` refuses to report zero checks as green.
# ---------------------------------------------------------------------------
set -uo pipefail
# --covers <dir> --in <doc>: every file in <dir> must be mentioned in <doc>.
COVERS=""
COVERS_IN=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--covers) COVERS="${2:-}"; shift 2 ;;
--in) COVERS_IN="${2:-}"; shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]+"${ARGS[@]}"}"
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
printf 'doc-claims: not a git repository.\n' >&2
exit 2
}
say() { printf 'doc-claims: %s\n' "$*" >&2; }
if [ -n "$COVERS" ]; then
[ -n "$COVERS_IN" ] || { say "--covers needs --in <document>"; exit 2; }
[ -d "$COVERS" ] || { say "--covers: $COVERS is not a directory"; exit 2; }
[ -f "$COVERS_IN" ] || { say "--in: $COVERS_IN does not exist"; exit 2; }
unmentioned=0
total=0
doc_body="$(cat "$COVERS_IN")"
for entry in "$COVERS"/*; do
[ -e "$entry" ] || continue
total=$((total + 1))
name="$(basename "$entry")"
stem="${name%.*}"
prefix="${stem%%_*}"
# Three spellings, because documents legitimately use all of them and
# demanding the longest reports a perfectly correct document as broken.
# The first draft of this checked only the full name and the stem, and
# reported 57 of 58 migrations missing from a table that lists every one —
# because that table writes `0057`, not `0057_digest_recipients.sql`.
#
# The prefix is only accepted when it is distinctive: a bare `0057` is, a
# bare `route` would not be, and matching on the latter would let a document
# pass by coincidence.
case "$doc_body" in
*"$name"*|*"$stem"*) continue ;;
esac
case "$prefix" in
"$stem") ;; # no underscore; nothing new to try
[0-9][0-9][0-9]*|v[0-9]*)
case "$doc_body" in *"$prefix"*) continue ;; esac ;;
esac
printf '%s: %s is not mentioned in %s\n' "$COVERS" "$name" "$COVERS_IN"
unmentioned=$((unmentioned + 1))
done
if [ "$total" -eq 0 ]; then
say "$COVERS is empty; nothing to cover."
exit 2
fi
if [ "$unmentioned" -gt 0 ]; then
say "$unmentioned of $total entr(ies) in $COVERS are absent from $COVERS_IN."
say "A list that is missing rows reads as complete — that is the whole"
say "problem with it."
exit 1
fi
say "all $total entr(ies) in $COVERS are mentioned in $COVERS_IN."
exit 0
fi
TARGETS=("$@")
if [ ${#TARGETS[@]} -eq 0 ]; then
# Tracked files only. An untracked scratch document is not a claim this
# repository is making.
mapfile -t FILES < <(git ls-files '*.md')
else
mapfile -t FILES < <(git ls-files "${TARGETS[@]}" | grep -E '\.md$')
fi
if [ -n "${DOC_CLAIMS_ALSO_SRC:-}" ]; then
# Source comments make the same claims and rot the same way — the missing
# test file above was named in a docblock, not in a document.
mapfile -t -O "${#FILES[@]}" FILES < <(git ls-files '*.ts' '*.tsx' '*.mjs' '*.js' '*.sh')
fi
# Forward-looking documents are excluded, and this is the difference between a
# useful run and a noisy one. A specification naming the files it *would* create
# is not a rotted claim — it is the whole point of a specification. On the
# repository this was written against, every finding under `docs/proposed/` was
# of that kind and they outnumbered the real ones four to one.
# Two kinds of tree are excluded by default, and both for the same reason: the
# paths in them do not resolve against *this* repository.
#
# docs/proposed/ a specification naming the files it would create
# project-template/ a vendored copy of another project's docs, whose paths
# vendor/ resolve against whatever scaffolds from it
#
# The template copy alone accounted for 14 of 33 findings on the repository this
# was written against — every one of them a README correctly describing scripts
# that live in the template folder rather than here.
EXCLUDE="${DOC_CLAIMS_EXCLUDE:-docs/proposed/|project-template/|vendor/}"
if [ -n "$EXCLUDE" ]; then
mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | grep -vE "$EXCLUDE" || true)
fi
if [ ${#FILES[@]} -eq 0 ]; then
say "no files to scan."
exit 2
fi
# A token worth checking, and the filter matters more than the check.
#
# The first draft flagged 684 of 1142 tokens on a real repository — routes like
# `/agent/notices`, absolute paths like `~/.config/thing`, and bare filenames.
# A guard that is wrong six times in ten is one people learn to skip, so the
# rule is now deliberately narrow: **a token is only checked when its first
# segment is something that actually exists at the top of this repository.**
#
# That excludes URL routes (their first segment is empty), home-relative paths,
# and prose, and it means a genuinely missing path is reported against a
# background of near-silence.
#
# Bare filenames with a source extension — `api-handler.ts` — are resolved by
# basename anywhere in the tree, which is what a reader would do.
mapfile -t TOPLEVEL < <(git ls-tree --name-only HEAD)
is_toplevel() {
local first="${1%%/*}"
local entry
for entry in "${TOPLEVEL[@]}"; do
[ "$first" = "$entry" ] && return 0
done
return 1
}
worth_checking() {
case "$1" in
http://*|https://*|*@*) return 1 ;; # links and addresses
/*|~*|.*) return 1 ;; # routes, home paths, relative noise
*\**|*\?*|*'<'*|*'>'*|*' '*) return 1 ;; # globs, placeholders, prose
"") return 1 ;;
esac
# A path into this repository.
is_toplevel "$1" && return 0
# Or a bare source filename, resolved by basename below.
case "$1" in
*/*) return 1 ;;
*.ts|*.tsx|*.mjs|*.js|*.sh|*.py|*.sql|*.md) return 0 ;;
*) return 1 ;;
esac
}
resolves() {
local token="$1" doc="$2"
[ -e "$token" ] && return 0
[ -e "$(dirname "$doc")/$token" ] && return 0
# Bare filename: does anything in the repository carry that basename?
case "$token" in
*/*) return 1 ;;
*) git ls-files "*/$token" "$token" | grep -q . && return 0 ;;
esac
return 1
}
missing=0
checked=0
fix(guards): a bare filename is a weaker claim than a path doc-claims treated `release.sh` mentioned in prose exactly as it treated `docs/qa/ClaudeReport.md`. The second asserts something about this repository; the first is usually a reference to a script the template offers and this project has not adopted yet -- scaffold.sh ships no scripts on purpose, and TOOLS.md says so outright: "the table is a menu rather than an inventory here". So every freshly scaffolded project began with a red doc-claims over documents that were correct. That is the condition audit-gate.mjs argues about for npm advisories: a gate that is red from the first day is one everybody learns to ignore, and it takes the true findings with it. Bare filenames are now reported and counted separately, and do not fail the run. Paths still do. The strictness that matters is untouched, and I checked rather than assumed: the finding this script was written for -- a comment claiming tests/notice-security.test.ts pinned a security rule, for a file that had never existed -- is a path, so it would still fail today. Verified on a scaffolded project with a real commit, because doc-claims reads git ls-tree HEAD and an uncommitted scratch repo has no HEAD at all: with no commit it checks no paths whatsoever and reports a confident pass. My first attempt at this verification did exactly that and had to be redone. The skill's warning to commit before running a doc checker is about the review checker; it applies here for the same reason. This is part of #16, not all of it. A committed fresh scaffold still exits 1 on four PATH claims -- docs/architecture/scripts, docs/architecture/githooks, and docs/architecture/scripts/release.sh, named by DOC_TRUST_MAP.md, TOOLS.md and WORK_CYCLE.md. Those are the same root cause and need the decision #16 asks for, so the issue stays open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:45:02 -05:00
notes=0
for file in "${FILES[@]}"; do
[ -f "$file" ] || continue
# Backticked spans and markdown link targets. Line numbers and anchors are
# trimmed: `src/lib/foo.ts:42` and `foo.md#heading` name a real file.
while IFS= read -r raw; do
token="${raw%%:*}"
token="${token%%#*}"
token="${token%/}"
worth_checking "$token" || continue
checked=$((checked + 1))
resolves "$token" "$file" && continue
fix(guards): a bare filename is a weaker claim than a path doc-claims treated `release.sh` mentioned in prose exactly as it treated `docs/qa/ClaudeReport.md`. The second asserts something about this repository; the first is usually a reference to a script the template offers and this project has not adopted yet -- scaffold.sh ships no scripts on purpose, and TOOLS.md says so outright: "the table is a menu rather than an inventory here". So every freshly scaffolded project began with a red doc-claims over documents that were correct. That is the condition audit-gate.mjs argues about for npm advisories: a gate that is red from the first day is one everybody learns to ignore, and it takes the true findings with it. Bare filenames are now reported and counted separately, and do not fail the run. Paths still do. The strictness that matters is untouched, and I checked rather than assumed: the finding this script was written for -- a comment claiming tests/notice-security.test.ts pinned a security rule, for a file that had never existed -- is a path, so it would still fail today. Verified on a scaffolded project with a real commit, because doc-claims reads git ls-tree HEAD and an uncommitted scratch repo has no HEAD at all: with no commit it checks no paths whatsoever and reports a confident pass. My first attempt at this verification did exactly that and had to be redone. The skill's warning to commit before running a doc checker is about the review checker; it applies here for the same reason. This is part of #16, not all of it. A committed fresh scaffold still exits 1 on four PATH claims -- docs/architecture/scripts, docs/architecture/githooks, and docs/architecture/scripts/release.sh, named by DOC_TRUST_MAP.md, TOOLS.md and WORK_CYCLE.md. Those are the same root cause and need the decision #16 asks for, so the issue stays open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:45:02 -05:00
# A bare filename is a weaker claim than a path, and is reported without
# failing the run.
#
# `docs/qa/ClaudeReport.md` asserts something about THIS repository. But
# `release.sh` in prose is usually a reference to a script the template
# offers and this project may not have adopted yet -- scaffold.sh
# deliberately ships no scripts, and TOOLS.md says so: "the table is a menu
# rather than an inventory here". Treating those as failures made every
# freshly scaffolded project start with a red guard, over documents that
# were correct, and a gate that is red from day one is one nobody reads.
#
# The strictness that matters is untouched. The finding this script was
# written for -- a comment claiming `tests/notice-security.test.ts` pinned a
# security rule, for a file that had never existed -- is a path, and paths
# still fail.
case "$token" in
*/*) ;;
*) printf '%s: mentions %s, which is not in this repository (yet)\n' "$file" "$token"
notes=$((notes + 1))
continue ;;
esac
printf '%s: names %s, which does not exist\n' "$file" "$token"
missing=$((missing + 1))
done < <(grep -oE '`[^`]+`|\]\([^)]+\)' "$file" 2>/dev/null \
| sed -E 's/^`//; s/`$//; s/^\]\(//; s/\)$//')
done
if [ "$checked" -eq 0 ]; then
say "scanned ${#FILES[@]} file(s) and found no paths to check."
exit 2
fi
fix(guards): a bare filename is a weaker claim than a path doc-claims treated `release.sh` mentioned in prose exactly as it treated `docs/qa/ClaudeReport.md`. The second asserts something about this repository; the first is usually a reference to a script the template offers and this project has not adopted yet -- scaffold.sh ships no scripts on purpose, and TOOLS.md says so outright: "the table is a menu rather than an inventory here". So every freshly scaffolded project began with a red doc-claims over documents that were correct. That is the condition audit-gate.mjs argues about for npm advisories: a gate that is red from the first day is one everybody learns to ignore, and it takes the true findings with it. Bare filenames are now reported and counted separately, and do not fail the run. Paths still do. The strictness that matters is untouched, and I checked rather than assumed: the finding this script was written for -- a comment claiming tests/notice-security.test.ts pinned a security rule, for a file that had never existed -- is a path, so it would still fail today. Verified on a scaffolded project with a real commit, because doc-claims reads git ls-tree HEAD and an uncommitted scratch repo has no HEAD at all: with no commit it checks no paths whatsoever and reports a confident pass. My first attempt at this verification did exactly that and had to be redone. The skill's warning to commit before running a doc checker is about the review checker; it applies here for the same reason. This is part of #16, not all of it. A committed fresh scaffold still exits 1 on four PATH claims -- docs/architecture/scripts, docs/architecture/githooks, and docs/architecture/scripts/release.sh, named by DOC_TRUST_MAP.md, TOOLS.md and WORK_CYCLE.md. Those are the same root cause and need the decision #16 asks for, so the issue stays open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:45:02 -05:00
if [ "$notes" -gt 0 ]; then
say "$notes bare filename(s) above are mentioned but not present. Not a"
say "failure: a project adopts the scripts it needs one at a time, and the"
say "documents naming them are a menu rather than an inventory."
fi
if [ "$missing" -gt 0 ]; then
say "$missing claimed path(s) do not exist, of $checked checked."
say "A document naming a file that is not there is worse than one saying"
say "nothing: somebody checked, and was reassured."
exit 1
fi
say "$checked claimed path(s), all present, across ${#FILES[@]} file(s)."