#!/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 --in : every file in must be mentioned in . 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 "; 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 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 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 [ "$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)."