Privacy-Period-Tracker/scripts/doc-claims.sh

304 lines
11 KiB
Bash
Raw Normal View History

chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
#!/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)."