304 lines
11 KiB
Bash
Executable File
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)."
|