Files
Varaverk/Plugin/unraid/Tools/js_check.sh
T
Gmer4Lfe d9c37ac763 Add a checker for the two JS faults this plugin has actually shipped
A cross-scope identifier and a fetch chain ending in an empty catch both pass
php -l and node --check, and together they turned a ReferenceError into what
looked like a slow load for hours. --self-test asserts both detectors still
find a known fault, because a checker that silently stops working reports a
confident zero.
2026-08-21 09:32:04 -04:00

338 lines
17 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ============================= WebGUI JavaScript Checker ======================================
# ==============================================================================================
#
# PURPOSE
# ==============================================================================================
# Finds the two JavaScript faults this plugin has actually shipped, neither of which any syntax
# check can see, because both are runtime behaviour:
#
# 1. An identifier declared inside one function and referenced from another. Throws
# ReferenceError on every call, kills the rest of the render, and passes php -l and
# node --check without complaint.
#
# 2. A fetch chain ending in an empty catch. Not error handling — error deletion. The request
# fails, nothing renders, nothing is logged, and the surface sits on "Loading…" forever.
#
# The two compound: on 2026-08-20 a cross-scope ReferenceError in the mesh chat was swallowed by
# an empty catch on every render. It presented as "the chat takes a minute to load" — the minute
# was the poller's backoff — and hours went into profiling PHP that was never slow. Once a catch
# reported the error, the fault named itself in one line.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Extract the JS from each <script> block in a PHP/page file
# 2. Strip PHP tags, comments, string literals, template-literal text and object keys
# 3. Cross-scope pass — declarations per function, then uses judged against them
# 4. Catch pass — an empty .catch() within 25 lines of a fetch()
#
# Reads only. Prints findings and exits non-zero when any are found, so it can gate a commit.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# A Checker That Cannot Fail Loudly Is Worse Than No Checker
# --self-test runs both detectors against a fixture carrying both faults and asserts they are
# found. It is not decoration. The first version of this scan reported a confident zero across
# the whole plugin because a regex was silently broken; the fixture is what catches that.
#
# Preprocessing Is Where The False Positives Die
# A raw scan produced ~1600 candidates, nearly all of them HTML attribute names, CSS keywords
# and English prose living inside template literals. Keeping only the ${...} expressions cut
# that to double digits. What remains after preprocessing is worth a human's attention.
#
# Report, Never Rewrite
# This prints file and line. It does not edit. A tool that silently "fixes" a false positive in
# a render path is a worse outcome than the fault it was hunting.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Escaped $ In Every Perl Character Class
# [A-Za-z_$] makes Perl interpolate $] — its version variable — and silently mangles the
# pattern into something that matches nothing. That mistake produced a false clean result twice
# while this was being written. Every class here writes \$, and --self-test would catch it
# returning.
#
# Known Parser Gaps, Stated Rather Than Hidden
# This is regex and brace counting, not a JavaScript parser. It over-reports and never
# under-reports, which is the safe direction: a clean run is meaningful, a dirty one needs a
# human. Preprocessing took the raw count from ~1600 to single digits by handling template
# literals, string literals, object keys, regex literals, destructuring, nested-function
# parameters and multi-declarator const.
#
# BASELINE as of 2026-08-21: three known false positives on a healthy tree —
# setup.php `to`, `id` in vvRenderOnboardPanel()
# Varaverk.page `s` in vvRenderMirrorOnboard()
# Each was read and confirmed harmless. Findings beyond those three are new and worth opening.
# If that baseline ever reaches zero, delete this paragraph rather than letting it rot.
#
# Non-Zero Exit On Findings
# So it can sit in front of a commit. A checker whose output has to be noticed by a human is a
# checker that stops being run.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# js_check.sh
# Scan every pages/*.php, include/*.php and Varaverk.page.
#
# js_check.sh --scope | --catches
# One pass only.
#
# js_check.sh --self-test
# Verify both detectors against the built-in fixture. Run this after editing this file.
#
# js_check.sh <file> [...]
# Scan named files instead of the whole plugin.
#
# ==============================================================================================
set -uo pipefail
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
DO_SCOPE=true; DO_CATCH=true; SELF_TEST=false
FILES=()
for arg in "$@"; do
case "$arg" in
--scope) DO_CATCH=false ;;
--catches) DO_SCOPE=false ;;
--self-test) SELF_TEST=true ;;
-*) echo "Unknown option: $arg" >&2; exit 1 ;;
*) FILES+=("$arg") ;;
esac
done
# ── Preprocessor ──────────────────────────────────────────────────────────────────────────────
cat > "$WORK/prep.pl" <<'PREP'
# NOTE: every character class escapes $ as \$. Bare [A-Za-z_$] interpolates $] (Perl's version)
# and silently matches nothing — see OPERATIONAL SAFEGUARDS.
local $/; my $s = <>;
$s =~ s{<\?=.*?\?>}{0}gs;
$s =~ s{<\?php.*?\?>}{}gs;
$s =~ s{/\*.*?\*/}{}gs;
$s =~ s{//[^\n]*}{}g;
# Template literals carry HTML, CSS and prose. Only the ${...} expressions are code.
$s =~ s{`((?:[^`\\]|\\.)*)`}{ " " . join(" ", $1 =~ m{\$\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}}g) . " " }ges;
$s =~ s{'(?:[^'\\\n]|\\.)*'}{""}g;
$s =~ s{"(?:[^"\\\n]|\\.)*"}{""}g;
# Regex literals. Only where one can legally begin — after ( = , : return — so division is not
# mistaken for a pattern. Their contents and flags are not identifiers: /\s*os|windows/i was
# reporting s, os and windows as undeclared variables.
$s =~ s{(=>\s*|[=(,:\[;?!]\s*|[&|]{2}\s*|\breturn\s+)/(?:[^/\\\n]|\\.)+/[gimsuy]*}{$1 0}g;
# Object-literal keys are not references. Anchored to { or , so a ternary keeps its operand.
$s =~ s/([{,]\s*)[A-Za-z_\$][A-Za-z0-9_\$]*\s*:/$1 /g;
print $s;
PREP
# ── Cross-scope detector ──────────────────────────────────────────────────────────────────────
cat > "$WORK/scope.awk" <<'SCOPE'
function fname(l, m) { if (match(l, /function[ \t]+[A-Za-z_$][A-Za-z0-9_$]*/)) { m=substr(l,RSTART,RLENGTH); sub(/function[ \t]+/,"",m); return m } return "" }
BEGIN {
# Built-ins and browser globals. Belt and braces — a parser slip that registers one of these as
# a local would otherwise report it in every other function in the file, which is exactly how
# Math appeared fifteen times while this was being written.
split("Math JSON Object Array String Number Boolean Date RegExp Error Promise Set Map WeakMap " \
"Symbol BigInt Intl console document window navigator location history screen localStorage " \
"sessionStorage fetch setTimeout setInterval clearTimeout clearInterval requestAnimationFrame " \
"parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent URLSearchParams " \
"FormData Headers Request Response AbortController CustomEvent Event Node Element " \
"getComputedStyle matchMedia structuredClone queueMicrotask btoa atob crypto performance " \
"globalThis undefined NaN Infinity ResizeObserver MutationObserver Uint32Array swal", g, " ")
for (gi in g) glob[g[gi]]=1
}
{ L[NR]=$0 }
END {
depth=0; cur=""
for (n=1; n<=NR; n++) {
line=L[n]
if (depth==0) { f=fname(line); if (f!="") cur=f }
scope = (cur=="" ? "(module)" : cur)
# Parameters of ANY function on this line, nested helpers included. Attributing a nested
# helper's params to the enclosing function is an approximation, and the right one: it can
# only suppress a report, never invent one, and nested params were the largest false-positive
# class in this codebase.
if (match(line, /function[ \t]*[A-Za-z0-9_$]*[ \t]*\([^)]*\)/)) {
pp=substr(line,RSTART,RLENGTH); sub(/.*\(/,"",pp); sub(/\).*/,"",pp)
c=split(pp,pa,","); for (k=1;k<=c;k++) { gsub(/[ \t]/,"",pa[k]); sub(/=.*/,"",pa[k]); if (pa[k]!="") decl[scope,pa[k]]=n } }
# Every declarator in the statement, not just the first: `const s = a, n = b` declares both,
# and reading only `s` left `n` looking undeclared wherever it was used.
if (match(line, /(const|let|var)[ \t]+/)) {
rest=substr(line,RSTART+RLENGTH)
# Destructuring binds every name inside the brackets: `for (const [folder, items] of …)`
# and `const { a, b } = obj`. Handled before the comma split, which cannot read them.
if (match(rest, /^[ \t]*[\[{][^\]}]*[\]}]/)) {
db=substr(rest,RSTART,RLENGTH)
while (match(db, /[A-Za-z_$][A-Za-z0-9_$]*/)) {
nm2=substr(db,RSTART,RLENGTH)
decl[scope,nm2]=n; if (scope=="(module)") mod[nm2]=n
db=substr(db,RSTART+RLENGTH) }
}
cc=split(rest,dl,",")
for (kk=1;kk<=cc;kk++) {
# The identifier must be followed by "=" or end the segment. Without that test a comma
# inside the initialiser — `const pct = Math.min(a, Math.round(b))` — makes the second
# argument look like a second declarator, and Math gets registered as a local.
if (match(dl[kk], /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*(=[^=]|=$|$)/)) {
nm=dl[kk]; sub(/^[ \t]*/,"",nm); sub(/[ \t]*=.*$/,"",nm); gsub(/[ \t]/,"",nm)
if (nm!="") { decl[scope,nm]=n; if (scope=="(module)") mod[nm]=n }
} else break # stop at the first non-declarator; the rest is an expression
}
}
tmp=line
while (match(tmp, /(const|let|var)[ \t]+[A-Za-z_$][A-Za-z0-9_$]*/)) {
d=substr(tmp,RSTART,RLENGTH); sub(/(const|let|var)[ \t]+/,"",d)
decl[scope,d]=n; if (scope=="(module)") mod[d]=n
tmp=substr(tmp,RSTART+RLENGTH) }
tmp=line
while (match(tmp, /\([^)]*\)[ \t]*=>/)) {
d=substr(tmp,RSTART,RLENGTH); gsub(/[()=>\t]/," ",d); gsub(/[{}\[\]]/," ",d)
c2=split(d,ap,","); for (k2=1;k2<=c2;k2++) { gsub(/[ \t]/,"",ap[k2]); sub(/=.*/,"",ap[k2]); if (ap[k2]!="") decl[scope,ap[k2]]=n }
tmp=substr(tmp,RSTART+RLENGTH) }
tmp=line
while (match(tmp, /[A-Za-z_$][A-Za-z0-9_$]*[ \t]*=>/)) {
d=substr(tmp,RSTART,RLENGTH); gsub(/[ \t=>]/,"",d); if (d!="") decl[scope,d]=n
tmp=substr(tmp,RSTART+RLENGTH) }
if (match(line, /for[ \t]*\([ \t]*(const|let|var)[ \t]+[A-Za-z_$][A-Za-z0-9_$]*/)) {
d=substr(line,RSTART,RLENGTH); sub(/.*[ \t]/,"",d); decl[scope,d]=n }
if (match(line, /catch[ \t]*\([ \t]*[A-Za-z_$][A-Za-z0-9_$]*/)) {
d=substr(line,RSTART,RLENGTH); sub(/.*\([ \t]*/,"",d); decl[scope,d]=n }
if (fname(line)!="") isfn[fname(line)]=1
for (i=1;i<=length(line);i++) { c=substr(line,i,1); if (c=="{") depth++; else if (c=="}") { depth--; if (depth<=0) { depth=0; cur="" } } }
}
depth=0; cur=""
for (n=1; n<=NR; n++) {
line=L[n]
if (depth==0) { f=fname(line); if (f!="") cur=f }
scope = (cur=="" ? "(module)" : cur)
if (scope!="(module)") {
tmp=line
while (match(tmp, /[A-Za-z_$][A-Za-z0-9_$]*/)) {
id=substr(tmp,RSTART,RLENGTH); pre=substr(tmp,RSTART-1,1)
tmp=substr(tmp,RSTART+RLENGTH)
if (pre=="." || id in isfn || id in mod || id in glob) continue
if ((scope,id) in decl) continue
for (o in decl) { split(o,q,SUBSEP); if (q[2]==id && q[1]!=scope && q[1]!="(module)") {
key=id SUBSEP scope; if (!(rep[key]++)) printf " line %d: %s used in %s() — declared only inside %s()\n", n, id, scope, q[1]; break } }
}
}
for (i=1;i<=length(line);i++) { c=substr(line,i,1); if (c=="{") depth++; else if (c=="}") { depth--; if (depth<=0) { depth=0; cur="" } } }
}
}
SCOPE
scan_scope() {
local f="$1"
awk '/<script/{p=1;next} /<\/script>/{p=0;print ""} p' "$f" | perl "$WORK/prep.pl" > "$WORK/x.js" 2>/dev/null
awk -f "$WORK/scope.awk" "$WORK/x.js" 2>/dev/null
}
# An empty catch within 25 lines of a fetch. Line distance rather than real chain parsing: the
# chains here are short, and a bare `catch {}` far from any request is usually a deliberate
# localStorage or execCommand guard, which this must not report.
scan_catches() {
local f="$1"
awk '/fetch\(|XMLHttpRequest/ { inf=NR }
/\.catch\(\s*\(\s*[a-z_]*\s*\)\s*=>\s*\{\s*\}\s*\)/ {
if (inf && NR-inf <= 25) printf " line %d: fetch chain ends in an empty catch\n", NR }' "$f"
}
# ── Self-test ─────────────────────────────────────────────────────────────────────────────────
if [[ "$SELF_TEST" == true ]]; then
cat > "$WORK/fixture.php" <<'FIX'
<script>
function outer() {
const paletteMap = { a: '#fff' };
return paletteMap;
}
function styler(x) {
// reads a const that lives in outer() — ReferenceError at runtime
if (paletteMap[x]) return paletteMap[x];
return '';
}
function loader() {
fetch('/api/thing')
.then(r => r.json())
.then(d => { render(d); })
.catch(() => {});
}
</script>
FIX
fails=0
echo "── self-test ────────────────────────────────────────────────"
if scan_scope "$WORK/fixture.php" | grep -q "paletteMap"; then
echo " cross-scope detector PASS"
else
echo " cross-scope detector FAIL — known fault not reported"; fails=1
fi
if scan_catches "$WORK/fixture.php" | grep -q "empty catch"; then
echo " empty-catch detector PASS"
else
echo " empty-catch detector FAIL — known fault not reported"; fails=1
fi
# A clean file must stay clean, or the detector is merely reporting everything.
cat > "$WORK/clean.php" <<'CLN'
<script>
const paletteMap = { a: '#fff' };
function styler(x) { return paletteMap[x] || ''; }
function loader() {
fetch('/api/thing').then(r => r.json()).then(d => { styler(d); })
.catch(e => report('thing', e));
}
</script>
CLN
if [[ -z "$(scan_scope "$WORK/clean.php")$(scan_catches "$WORK/clean.php")" ]]; then
echo " clean file stays clean PASS"
else
echo " clean file stays clean FAIL — false positive on correct code"; fails=1
fi
echo "─────────────────────────────────────────────────────────────"
[[ "$fails" -eq 0 ]] && echo "self-test OK" || echo "SELF-TEST FAILED — do not trust a clean scan"
exit "$fails"
fi
# ── Scan ──────────────────────────────────────────────────────────────────────────────────────
if [[ "${#FILES[@]}" -eq 0 ]]; then
while IFS= read -r p; do FILES+=("$p"); done < <(
find "$PLUGIN_DIR/pages" "$PLUGIN_DIR/include" -maxdepth 1 -name '*.php' 2>/dev/null
[[ -f "$PLUGIN_DIR/Varaverk.page" ]] && echo "$PLUGIN_DIR/Varaverk.page"
)
fi
found=0
for f in "${FILES[@]}"; do
[[ -f "$f" ]] || continue
grep -q "<script" "$f" 2>/dev/null || continue
out=""
[[ "$DO_SCOPE" == true ]] && out+="$(scan_scope "$f")"
if [[ "$DO_CATCH" == true ]]; then
c="$(scan_catches "$f")"
[[ -n "$c" ]] && out+=$'\n'"$c"
fi
out="$(echo "$out" | sed '/^$/d')"
if [[ -n "$out" ]]; then
echo "### $(basename "$f")"
echo "$out"
found=$(( found + $(echo "$out" | grep -c '^ line') ))
fi
done
echo
if [[ "$found" -eq 0 ]]; then
echo "clean — no cross-scope references, no silent fetch catches"
else
echo "$found finding(s). Cross-scope hits over-report: regex literals, nested-function"
echo "parameters, destructured for-of and multi-declarator const each read as undeclared."
echo "Read each before changing anything."
fi
exit $(( found > 0 ? 1 : 0 ))