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.
This commit is contained in:
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/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 ))
|
||||
Reference in New Issue
Block a user