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

304 lines
11 KiB
Bash
Executable File

#!/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
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
# 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
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)."