#!/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) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES" ;; tools) echo "PURPOSE|OPERATIONAL MODEL|DESIGN PRINCIPLES|OPERATIONAL SAFEGUARDS|RUNTIME MODES/ARGUMENTS" ;; 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() { # A PHP file that refuses to run outside the CLI is a script, wherever it is filed. # api/docker_pull_worker.php lives beside the endpoints because docker_action.php spawns it, # but it takes argv and never a request — judging it on REQUEST and RESPONSE would mean # inventing a contract it does not have. if [[ "$1" == *.php ]] && grep -q "PHP_SAPI !== 'cli'" "$1" 2>/dev/null; then echo "tools"; return fi 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 # A spec entry may name alternatives with "/". RUNTIME MODES/ARGUMENTS is the only one: # a worker that is spawned rather than run has no modes an operator can choose, and both # spawned workers in the tree document ARGUMENTS instead. Demanding RUNTIME MODES there # would mean writing an invocation nobody performs. found="" IFS='/' read -ra alts <<< "$s" for a in "${alts[@]}"; do n=$(grep -c "^$m $a\$" "$f") if [[ "$n" -gt 1 ]]; then present+=("$a"); problems+=("duplicate section: $a appears $n times"); found="$a"; break elif [[ "$n" -eq 1 ]]; then present+=("$a"); found="$a"; break fi done [[ -z "$found" ]] && missing+=("${s//\// or }") 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