Files
Varaverk/Tools/audit_headers.sh
T

195 lines
8.5 KiB
Bash
Executable File

#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════════════════════
# PURPOSE
# Hold the documentation standard still. Every .sh and .php file in the repo opens with a
# structured header, and the AI index routes retrieval on those section names across both
# languages — a renamed or missing section is not a cosmetic problem, it is a file the
# assistant can no longer find by intent.
#
# Written 2026-08-24 after a three-week sprint at ~140 commits/week left 47 files drifted.
# Drift concentrates in the newest code, which is exactly the code nobody re-reads.
#
# OPERATIONAL MODEL
# Role decides the specification. The first four sections are identical everywhere, so one
# query answers "what does this do" for a bash script and a PHP endpoint alike. Sections 5+
# describe the shape of the thing — an endpoint has a REQUEST and a RESPONSE, a library has
# EXPORTS, a page RENDERS. Those tails never cross: REQUEST appears in 53 api/ files and
# zero others, RENDERS in 11 pages/ files and zero others.
#
# OPERATIONAL MODEL is not required of include/ or pages/. A pure function library has no
# lifecycle to describe, and on a page the section has come to mean something different —
# pages/auth.php and pages/scheduler.php use it to state blast radius, not mechanics. That
# is worth keeping rare; mandatory on all eleven pages it would become filler.
#
# Conditional sections are omitted, never stubbed. A file with no CONFIGURATION section
# reads no conf vars, and that absence is information worth being able to grep for. A
# section whose body is "None" destroys it.
#
# DESIGN PRINCIPLES
# Report the file, not a total.
# A count tells the operator a number; a list tells them what to open. Every failure
# names the file, the role it was judged as, and which rule it broke.
#
# Order is checked against present sections only.
# A file missing REQUEST should be told it is missing REQUEST, once — not told that
# and then told its order is wrong as a consequence. One defect, one line.
#
# The conf check is soft and says so.
# Detecting "this file reads configuration" is a heuristic on variable naming, and a
# heuristic that reports as a hard failure trains the operator to ignore the tool.
#
# It lints itself.
# This file is in scope and conforms. A standards checker exempt from its own standard
# is a checker nobody believes.
#
# OPERATIONAL SAFEGUARDS
# Read-only. Opens files, writes nothing, touches no conf and no state.
# Exits 1 when any file fails, 0 when the repo is clean — safe to gate a commit on.
# Unknown paths are skipped rather than guessed at, so a new directory is never judged
# against a specification that was not written for it.
#
# RUNTIME MODES
# ══════════════════════════════════════════════════════════════════════════════════════════════
#
# audit_headers.sh
# Audit the whole repo. Lists every non-conforming file and exits 1 if any.
#
# audit_headers.sh --verbose
# Also list the files that pass, with the role each was judged as.
#
# audit_headers.sh --role=api
# Audit one role only: sh | tools | api | include | pages
#
# audit_headers.sh --spec
# Print the specification this build enforces, and exit.
#
# ══════════════════════════════════════════════════════════════════════════════════════════════
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ONLY_ROLE=""
VERBOSE=false
SHOW_SPEC=false
for a in "$@"; do
case "$a" in
--verbose) VERBOSE=true ;;
--spec) SHOW_SPEC=true ;;
--role=*) ONLY_ROLE="${a#--role=}" ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
# Mandatory sections, in required order. Conditional sections are deliberately absent here
# and handled as soft checks — see OPERATIONAL MODEL.
spec_for() {
case "$1" in
sh|tools) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES" ;;
api) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|REQUEST|RESPONSE|DEPENDS ON" ;;
include) echo "PURPOSE|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|EXPORTS" ;;
pages) echo "PURPOSE|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RENDERS|DEPENDS ON" ;;
esac
}
role_for() {
case "$1" in
*.sh) echo "sh" ;;
*/Plugin/*/Tools/*.php) echo "tools" ;;
*/Plugin/*/api/*.php) echo "api" ;;
*/Plugin/*/include/*.php) echo "include" ;;
*/Plugin/*/pages/*.php) echo "pages" ;;
esac
}
if $SHOW_SPEC; then
echo "Header specification — first four sections identical across all roles"
echo
for r in sh tools api include pages; do
printf ' %-8s %s\n' "$r" "$(spec_for "$r" | sed 's@|@ > @g')"
done
echo
echo " Conditional (omit when nothing to say): STATE FILES, CONFIGURATION, STACK SWITCHING"
echo " OPERATIONAL MODEL is not required of include/ or pages/."
exit 0
fi
cd "$REPO_ROOT" || { echo "cannot reach repo root: $REPO_ROOT" >&2; exit 2; }
fail=0 clean=0 total=0
while IFS= read -r f; do
role=$(role_for "$f")
[[ -z "$role" ]] && continue
[[ -n "$ONLY_ROLE" && "$role" != "$ONLY_ROLE" ]] && continue
spec=$(spec_for "$role")
total=$((total + 1))
problems=()
if [[ "$f" == *.sh ]]; then m='#'; else m='//'; fi
IFS='|' read -ra want <<< "$spec"
present=()
missing=()
for s in "${want[@]}"; do
n=$(grep -c "^$m $s\$" "$f")
case "$n" in
0) missing+=("$s") ;;
1) present+=("$s") ;;
*) present+=("$s"); problems+=("duplicate section: $s appears $n times") ;;
esac
done
if [[ ${#missing[@]} -gt 0 ]]; then
problems+=("missing: $(IFS=', '; echo "${missing[*]}")")
fi
# Order — compare the spec sections in file order against spec order, present ones only.
if [[ ${#present[@]} -gt 1 ]]; then
keep=$(IFS='|'; echo "${present[*]}")
got=$(grep -oE "^$m [A-Z][A-Z /&-]{3,40}\$" "$f" \
| sed "s@^$m @@" \
| awk -v ok="$keep" 'BEGIN{n=split(ok,a,"|"); for(i=1;i<=n;i++) k[a[i]]=1} k[$0] && !seen[$0]++')
exp=$(printf '%s\n' "${present[@]}")
if [[ "$got" != "$exp" ]]; then
problems+=("order: $(echo "$got" | tr '\n' '>' | sed 's@>$@@')")
fi
fi
# Placement — nothing but <?php or a shebang may precede the header.
hl=$(grep -n "^$m PURPOSE\$" "$f" | head -1 | cut -d: -f1)
if [[ -n "$hl" && "$hl" -gt 1 ]]; then
above=$(head -n $((hl - 1)) "$f" | grep -vE "^(<\?php|#!/|[[:space:]]*\$|$m)" | head -1)
if [[ -n "$above" ]]; then
problems+=("placement: '$(echo "$above" | cut -c1-46)' precedes the header")
fi
fi
# Soft — CONFIGURATION only belongs to the roles whose tail defines it.
case "$role" in
sh|tools|include)
reads=""
if [[ "$f" == *.sh ]]; then
grep -qE '\$\{?(HOST[12]|RSYNC|AI|WATCHDOG|PARTNERSHIP|FALLBACK|CONF)_[A-Z0-9_]+' "$f" && reads=y
else
grep -q 'vv_conf_vars(' "$f" && reads=y
fi
if [[ "$reads" == y ]] && ! grep -q "^$m CONFIGURATION\$" "$f"; then
problems+=("soft: reads conf vars, no CONFIGURATION section")
fi
;;
esac
if [[ ${#problems[@]} -gt 0 ]]; then
fail=$((fail + 1))
printf '%s [%s]\n' "${f#./}" "$role"
for p in "${problems[@]}"; do printf ' %s\n' "$p"; done
else
clean=$((clean + 1))
$VERBOSE && printf ' ok %-52s [%s]\n' "${f#./}" "$role"
fi
done < <(find . \( -name '*.sh' -o -name '*.php' \) -not -path './.git/*' | sort)
echo
echo "──────────────────────────────────────────────────────────────"
printf 'conforming %s / %s non-conforming %s\n' "$clean" "$total" "$fail"
[[ $fail -gt 0 ]] && exit 1
exit 0