Compare commits

...
7 Commits
7 changed files with 460 additions and 92 deletions
+27 -5
View File
@@ -624,6 +624,12 @@ cleanup_deployed_stack_on_remote() {
cleanup_deployed_stack_locally() {
local owner_ip="$1" ssh_key="$2"
local -a xml_names=()
# Callers write `cleanup_deployed_stack_locally … || STEP_STACK_CLEANUP_OK=false`, so the
# exit status is what the offboard summary prints. Every removal below warns and carries on
# — one container that will not die must not abandon the rest of the stack — which meant the
# function ended on a `done` and could only ever return 0. Step 3 reported ✅ even when every
# docker rm and every rm -rf had failed. Failures are collected here and reported at the end.
local _rc=0
if [[ -n "$owner_ip" ]]; then
local -a auth_arr arr_arr
@@ -647,8 +653,13 @@ cleanup_deployed_stack_locally() {
fi
if [[ ${#xml_names[@]} -eq 0 ]]; then
log "Could not read deployed stack from owner — skipping auth/arr/services cleanup"
return 0
# Not a success. OWNER_REACHABLE only means a probe answered — the three SSH reads above
# can still time out or come back empty, and then nothing was cleaned. The caller's own
# unreachable-owner branch sets STEP_STACK_CLEANUP_OK=false for exactly this situation,
# so returning 0 here made the summary claim a cleanup that never ran.
warn "Could not read deployed stack from owner — auth/arr/services cleanup did not run"
warn "Containers will remain — re-run when the owner answers over SSH"
return 1
fi
local _local_short
@@ -685,17 +696,28 @@ cleanup_deployed_stack_locally() {
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
_PM_TRAP_STOPPED+=("$cname")
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
echo " $cname removed ✅" || warn " $cname rm failed"
if timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1; then
echo " $cname removed ✅"
else
warn " $cname rm failed"
_rc=1
fi
else
log " $cname not found locally — skipping"
fi
while IFS= read -r path; do
[[ -z "$path" ]] && continue
rm -rf "$path" && echo " Appdata removed: $path" || warn " Failed to remove: $path"
if rm -rf "$path"; then
echo " Appdata removed: $path"
else
warn " Failed to remove: $path"
_rc=1
fi
done <<< "$appdata_paths"
done
return "$_rc"
}
# ==============================================================================================
-66
View File
@@ -1,66 +0,0 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Remote node metrics endpoint. The monitor page's partner cards — CPU, memory, storage,
// uptime and container counts for every host other than this one.
//
// OPERATIONAL MODEL
// Split from monitor.php on cost, not on subject. Local metrics are cheap file reads;
// remote metrics are SSH round trips to every partner. Keeping them on separate URLs lets
// the page poll local stats often and remote stats rarely, and lets a dark partner slow
// only its own request. Served from a 1-hour cache by default; ?live bypasses it for the
// page's explicit refresh button.
//
// DESIGN PRINCIPLES
// The cache is the default and the live call is the exception.
// An hour is deliberately long. Partner hardware stats do not move fast enough to
// justify paying SSH latency on every page load, and the refresh button exists for the
// moment someone actually needs current numbers.
//
// Every payload carries its own timestamp.
// ts is written into the cached document, so the page can render the age rather than
// presenting hour-old numbers as current.
//
// The live path writes the cache too.
// A manual refresh benefits every subsequent visitor instead of being discarded.
//
// OPERATIONAL SAFEGUARDS
// Cache miss is distinguished from empty payload.
// vv_cache_read() returns null on a miss, expiry, or unparseable file, and the check is
// an explicit !== null. A legitimately empty result — the single-host case, where there
// are no remote hosts at all — is served from cache rather than being mistaken for a
// miss and forced onto the SSH path on every single poll.
//
// Read-only over SSH. The remote commands are stat collection only; nothing is started,
// stopped, or written on a partner.
//
// Unreachable partners degrade per node inside vv_remote_hosts_stats(), so one dark host
// cannot empty the other cards.
//
// HTTP caching is disabled even though the payload is cached server-side.
// The two are not the same lever. The server-side cache has an age the page can see and
// a bypass it can trigger; a browser or proxy cache has neither, and would defeat ?live
// entirely.
//
// REQUEST
// GET served from the 3600s cache when one is present
// GET ?live bypass the cache, collect fresh, and rewrite it
//
// RESPONSE
// {"remote_hosts":{…},"ts":epoch}
//
// DEPENDS ON
// include/monitor.php vv_remote_hosts_stats(), vv_cache_read(), vv_cache_write()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store');
require_once dirname(__DIR__) . '/include/monitor.php';
if (!isset($_GET['live'])) {
$cached = vv_cache_read('monitor_remote', 3600);
if ($cached !== null) { echo json_encode($cached); exit; }
}
$data = ['remote_hosts' => vv_remote_hosts_stats(), 'ts' => time()];
vv_cache_write('monitor_remote', $data);
echo json_encode($data);
+33 -7
View File
@@ -25,9 +25,18 @@
// of every orchestrator. That is a distinct intent from conf_toggle.php's commenting
// out — this removes the line, that disables it in place.
//
// Indentation is normalised on re-insertion.
// The moved line is rewritten as two spaces and the quoted path, so a script does not
// carry its old array's formatting into its new one.
// Entry text moves verbatim; only a fresh entry is written from the bare path.
// An entry may carry inline arguments ("Media/media_cleaner.sh anime"), a trailing
// comment and its own indentation. Regenerating the line from the script path drops
// all three — six live entries in master.conf carry arguments, and stripping them
// would leave media_cleaner.sh with no share and fallback.sh without --stop. The
// matched line is therefore carried across untouched, which is the same rule
// reorderarray.php follows. A script that was in no array is written fresh, indented
// to match the entries already in the target.
//
// One path may hold several entries, and they move together.
// "Media/media_cleaner.sh anime" and "… media" are two jobs sharing a path. Every
// match is collected and re-inserted, rather than collapsing them into one.
//
// A move that finds nothing to move still succeeds.
// The removal pass is best-effort; only a missing *target* is an error. A script that
@@ -116,16 +125,28 @@ if (!$lines) {
}
$scriptEsc = preg_quote($script, '/');
$removedLine = null;
$removedLines = [];
$inArray = false;
// Step 1: find and remove the script line from whatever array it is currently in.
// Step 1: find and remove the script's line(s) from whatever array they are in.
//
// The original text is carried across verbatim. An entry is not just a path — it may hold
// inline arguments ("Media/media_cleaner.sh anime"), a trailing comment, and the file's
// indentation, and regenerating the line from the bare path silently drops all three. Six
// live entries in master.conf carry arguments; a move that strips them would leave
// media_cleaner.sh with no share to clean and fallback.sh without --stop.
//
// reorderarray.php preserves entry text for exactly this reason. A move must not be the one
// operation that loses it.
//
// A path can legitimately appear more than once in the same array with different arguments,
// so every match is collected and re-inserted together rather than collapsing to one.
$newLines = [];
foreach ($lines as $line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if ($inArray && preg_match('/^\s*(?:#\s*)?"' . $scriptEsc . '(?:\s[^"]*)?"/', $line)) {
$removedLine = ' "' . $script . '"' . "\n"; // normalise indentation when re-inserting
$removedLines[] = $line;
continue; // drop from current location
}
$newLines[] = $line;
@@ -136,10 +157,15 @@ if ($toArray) {
$resultLines = [];
$inTarget = false;
$inserted = false;
$indent = ' '; // master.conf indents array entries eight spaces
foreach ($newLines as $line) {
if (preg_match('/^\s*' . preg_quote($toArray, '/') . '\s*=\s*\(/', $line)) $inTarget = true;
// Match the indentation the target array actually uses rather than assuming it.
if ($inTarget && preg_match('/^(\s+)(?:#\s*)?"/', $line, $im)) $indent = $im[1];
if ($inTarget && !$inserted && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) {
$resultLines[] = $removedLine ?? (' "' . $script . '"' . "\n");
foreach ($removedLines ?: [$indent . '"' . $script . '"' . "\n"] as $moved) {
$resultLines[] = $moved;
}
$inTarget = false;
$inserted = true;
}
+1 -1
View File
@@ -95,7 +95,7 @@
//
// DEPENDS ON
// include/config.php vv_conf_vars(), SCRIPTS_DIR
// Media/upgrade_webhook_handler.sh the backgrounded handler
// Arrs_Stack/upgrade_webhook_handler.sh the backgrounded handler
// master.conf DOWNLOAD_WEBHOOK_ENABLED
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
+32 -11
View File
@@ -4661,21 +4661,42 @@ async function vvSaveArrange() {
if (!arrayMap.has(primaryArray)) arrayMap.set(primaryArray, []);
});
let failed = false;
const fail = () => {
btn.textContent = 'Error!';
btn.style.color = '#f44336';
setTimeout(() => { btn.textContent = 'Save Arrangement'; btn.disabled = false; btn.style.color = ''; }, 2500);
};
// Phase 1 — relocations, one atomic call each.
//
// A cross-array move changes two arrays. Expressing it as two reorderarray.php calls means
// a failure between them leaves master.conf half-written: the script removed from its old
// orchestrator and never added to the new one, or present in both and running twice. There
// is no rollback, and the page still shows the intended arrangement, so the operator re-drags
// from a view that no longer matches the file.
//
// movescript.php does the whole relocation inside one guarded write, so it either happens or
// it does not. Phase 2 can then only get the ordering wrong, never the membership.
const moves = new Map(); // script → final array ('' = out of every orchestrator)
for (const p of vvArrangePending) {
if (p.fromArray === p.toArray) continue; // pure reorder — phase 2 owns it
moves.set(p.script, p.toArray || '');
}
for (const [script, toArray] of moves) {
const r = await vvPost('/plugins/varaverk/api/movescript.php', { script, to_array: toArray })
.catch(() => ({ ok: false }));
if (!r.ok) return fail();
}
// Phase 2 — order and enabled state, per array. Membership is already correct.
for (const [arrayName, scripts] of arrayMap) {
const r = await vvPost('/plugins/varaverk/api/reorderarray.php', {
array_name: arrayName,
scripts: JSON.stringify(scripts)
}).then(r => r.json()).catch(() => ({ ok: false }));
if (!r.ok) { failed = true; break; }
}).catch(() => ({ ok: false }));
if (!r.ok) return fail();
}
if (failed) {
btn.textContent = 'Error!';
btn.style.color = '#f44336';
setTimeout(() => { btn.textContent = 'Save Arrangement'; btn.disabled = false; btn.style.color = ''; }, 2500);
return;
}
location.reload();
}
@@ -4905,7 +4926,7 @@ async function _vvFolderDrop(e) {
const folders = vvGetCurrentFolders();
const r = await vvPost('/plugins/varaverk/api/savefolders.php', {
folders: JSON.stringify(folders)
}).then(r => r.json()).catch(() => ({ ok: false }));
}).catch(() => ({ ok: false }));
if (!r.ok) {
oldParent.appendChild(srcEl);
@@ -5062,7 +5083,7 @@ async function _vvDoCreateFolder(name, wrap) {
folders[name] = [];
const r = await vvPost('/plugins/varaverk/api/savefolders.php', {
folders: JSON.stringify(folders)
}).then(r => r.json()).catch(() => ({ ok: false }));
}).catch(() => ({ ok: false }));
if (!r.ok) { vvAlert('Failed to create folder.'); return; }
wrap.remove();
// Add folder group to DOM
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════════════════════
# PURPOSE
# Find guards that cannot fire. A script that writes
#
# do_the_work || STEP_OK=false
#
# is only telling the truth if do_the_work can actually return non-zero. When it cannot, the
# flag stays true no matter what happened, and the run reports a step it never completed.
#
# This is the most expensive bug shape in this repo — a summary that says ✅ is the thing the
# operator trusts instead of reading the log. adapter.sh already carries the scar: "every
# caller that wrote `platform_push_setup_state || X=false` was testing a constant."
#
# Written 2026-08-24. Its first run found partnership_offboard.sh Step 3 reporting a stack
# cleanup that could not fail and, on a second path, one that had not run at all.
#
# OPERATIONAL MODEL
# For every `cmd || VAR=false` in the tree, the command left of the || is resolved and
# classified:
#
# CANFAIL a shell function with an explicit non-zero exit path — the guard is real
# EXTERNAL chown, docker, ssh, rsync and friends — can fail, nothing to check
# IDIOM [[ … ]] && X=true || X=false — a conditional, not a guard
# NEVERFAILS a function with no non-zero path — the guard is decorative
# UNKNOWN could not be resolved; reported rather than assumed either way
#
# A function "can fail" if it contains `return` or `exit` with a non-zero literal or any
# variable. The variable case matters: cleanup_partner_containers ends `return "$_rc"`, and a
# pattern that only looked for digits reported a working guard as broken.
#
# UNKNOWN is a first-class verdict, not a failure. Deciding what a bare `done` returns means
# evaluating the last command of the last loop, and a checker that guesses at that would
# produce confident wrong answers in both directions.
#
# DESIGN PRINCIPLES
# Resolve the command, never word-split it.
# An early build stripped a leading "do" to handle `for x in …; do cmd || F=false; done`
# and turned do_final_sync into a phantom _final_sync that resolved to nothing. The
# inline-loop case is matched on "; do " with required trailing space; nothing else is
# trimmed from a command name.
#
# Comments are excluded before anything else.
# Two of the matches in this repo are prose describing the bug, in adapter.sh and
# rsync_stop.sh. A checker that reports the documentation of a fixed bug as the bug
# teaches the operator to skim its output.
#
# Every verdict names where it looked.
# A NEVERFAILS line carries the file and line of the function it judged, because the
# first question is always "which definition did it find" — a name can exist twice.
#
# OPERATIONAL SAFEGUARDS
# Read-only. Greps and reads; writes nothing, executes nothing it finds.
# Exits 1 when any NEVERFAILS is reported, 0 otherwise — safe to gate a commit on.
# UNKNOWN never fails the run. It is a prompt to look, not a defect claim.
#
# RUNTIME MODES
# ══════════════════════════════════════════════════════════════════════════════════════════════
#
# audit_guards.sh
# Classify every guard in the repo. Exits 1 if any guard cannot fire.
#
# audit_guards.sh --all
# Show every verdict, including the guards that are sound.
#
# audit_guards.sh --unknown
# Show only the guards that could not be resolved.
#
# ══════════════════════════════════════════════════════════════════════════════════════════════
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SHOW_ALL=false
ONLY_UNKNOWN=false
for a in "$@"; do
case "$a" in
--all) SHOW_ALL=true ;;
--unknown) ONLY_UNKNOWN=true ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
cd "$REPO_ROOT" || { echo "cannot reach repo root: $REPO_ROOT" >&2; exit 2; }
# Locate a function definition and print "file:line" followed by its body.
_fn_body() {
local fn="$1" loc f ln
loc=$(grep -rnE "^[[:space:]]*(function[[:space:]]+)?${fn}[[:space:]]*\(\)" \
--include='*.sh' . 2>/dev/null | grep -v '^./.git' | head -1)
[[ -z "$loc" ]] && return 1
f=${loc%%:*}
ln=$(echo "$loc" | cut -d: -f2)
echo "${f#./}:$ln"
awk -v s="$ln" 'NR>=s { print; if (NR > s && /^\}/) exit }' "$f"
}
dead=0 sound=0 unknown=0 other=0
while IFS= read -r hit; do
file=${hit%%:*}
lno=$(echo "$hit" | cut -d: -f2)
code=$(echo "$hit" | cut -d: -f3-)
# Everything left of the ||, with an inline loop header removed. The trailing space after
# "do" is required — without it this eats the prefix of do_final_sync.
cmd=$(echo "$code" | sed 's/[[:space:]]*||.*//' \
| sed 's/^[[:space:]]*//' \
| sed 's/^.*;[[:space:]]*do[[:space:]]\{1,\}//')
head=$(echo "$cmd" | awk '{print $1}')
verdict=""; detail=""
case "$head" in
'[['|'[' | test )
verdict=IDIOM; detail="conditional, not a guard" ;;
chown|chmod|find|rm|mv|cp|docker|ssh|rsync|systemctl|timeout|curl|git|mkdir|ln|tar )
verdict=EXTERNAL; detail="external command, can fail" ;;
-*|2\>*|1\>*|\>* )
# A guard written across several lines: the match landed on a continuation of an
# external command (find … -exec …, a redirect) rather than on its head. Reading
# back to the head would mean parsing line continuations; the classification is
# the same either way, so it is recorded as what it is.
verdict=EXTERNAL; detail="continuation of a multi-line external command" ;;
esac
if [[ -z "$verdict" ]]; then
if body=$(_fn_body "$head"); then
where=$(echo "$body" | head -1)
body=$(echo "$body" | tail -n +2)
# Non-zero literal, or any variable — quoted or bare.
if echo "$body" | grep -qE '^[[:space:]]*(return|exit)[[:space:]]+("?\$|[1-9])'; then
verdict=CANFAIL; detail="$where"
else
verdict=NEVERFAILS
detail="$where — no non-zero exit path"
fi
else
verdict=UNKNOWN; detail="could not resolve '$head'"
fi
fi
case "$verdict" in
NEVERFAILS) dead=$((dead+1)) ;;
CANFAIL) sound=$((sound+1)) ;;
UNKNOWN) unknown=$((unknown+1)) ;;
*) other=$((other+1)) ;;
esac
if $ONLY_UNKNOWN; then
show=false
[[ "$verdict" == UNKNOWN ]] && show=true
elif $SHOW_ALL || [[ "$verdict" == NEVERFAILS ]]; then
show=true
else
show=false
fi
if $show; then
printf '%-11s %s:%s\n %s\n %s\n' \
"$verdict" "$file" "$lno" "$(echo "$cmd" | cut -c1-76)" "$detail"
fi
done < <(grep -rnE '\|\|[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=false' --include='*.sh' . 2>/dev/null \
| grep -v '^./.git' \
| grep -vE ':[0-9]+:[[:space:]]*#' \
| sed 's|^\./||')
echo
echo "──────────────────────────────────────────────────────────────"
printf 'sound %s external/idiom %s unresolved %s CANNOT FIRE %s\n' \
"$sound" "$other" "$unknown" "$dead"
[[ $dead -gt 0 ]] && exit 1
exit 0
+194
View File
@@ -0,0 +1,194 @@
#!/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