fix(scripts): close gates that passed while the thing they guard was disabled
Four repo gates reported success in states they exist to reject. `check-vale-style-sync.sh` passed while a Kyberforge lint rule was silenced. The check matched a blocklist of severity values, but Vale's semantic is an allowlist: anything that is not exactly YES/error/warning/suggestion disables the rule. So `= false`, `= 0`, `= garbage`, an empty value and — worst — a lowercase `= yes` all killed enforcement while reading as "enabled" to a human. Inverted to an allowlist. Two sibling holes: dropping `KyberforgeCopilot` from `BasedOnStyles` unloaded the Copilot-only check silently, and narrowing a section glob to a location made Vale lint zero files, which is the "0 files, hook Passed" failure the script's own comment says it exists to catch. `sync-marketplace-mirror.sh --check` failed open when its source was missing, while its sibling correctly errored in the same state. `check-scope-walkup-sync.sh` wrote to hardcoded `/tmp/fN.out` paths and read one back, making it non-reentrant — a concurrent instance can flip a verdict, and this branch made the test runner concurrent. Now per-run `mktemp -d`. `check-manifests.sh` had no disk-to-marketplace pass, so a plugin directory absent from `marketplace.json` passed every gate while the `validate-plugins` hook globbed it. The "listed" match is restricted to remote-source entry names; matching any entry name let a genuine orphan through on a name coincidence. `run-bats.sh` reported an empty TAP stream as `0 tests, 0 failures`, exit 0 — a total harness failure reading as a pass. The test-side changes are the larger half, because the guards were the real problem. `test-sync-marketplace-mirror.sh` could overwrite the live tracked mirror under an inherited GIT_DIR, which is precisely the git-hook context it runs in. The bash-3.2 scan hand-maintained its file list, omitting the new shared runner, and had no rule for `wait -n` or `nproc` — the two hazards the previous review round found live. It now derives 43 files across three globs with per-glob floors. Several assertions were decoration: the concurrency checks caught the reentrancy defect 0 times in 10, the leak fix was green either way, and two manifest fixtures passed with the code they claimed to cover deleted. Every assertion now has a revert it provably fails against. Refs: #90 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
@@ -442,77 +442,214 @@ fi
|
||||
# is safe on 3.2. Neither is an array seeded with at least one element where it
|
||||
# is declared and never reset to empty: it cannot be empty at any expansion
|
||||
# site, so the construct is not a hazard there and demanding the guarded form
|
||||
# would be a wrong test. The file list covers every script this repo ships or
|
||||
# runs that a macOS user reaches: the wrapper itself, the two pre-commit hook
|
||||
# scripts, the test runner AGENTS.md tells contributors to run by hand, its
|
||||
# bats-dispatch companion, and the three test-*.sh scripts whose
|
||||
# `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` cleanup traps were unguarded (PR
|
||||
# #95 review finding #7 named two of them; a repo-wide grep for the same
|
||||
# pattern turned up test-check-release-needed.sh as a third) until they were
|
||||
# switched to the guarded form.
|
||||
# `mapfile` is checked alongside, because it is bash 4.0+ and the expansion scan
|
||||
# cannot see it — run-tests.sh carried one until it was replaced with a
|
||||
# `while read` loop, and nothing would have caught its return. `declare -A`
|
||||
# (bash 4.0+ associative arrays) is checked for the same reason — the
|
||||
# expansion scan cannot see it, and check-vale-style-sync.sh carried a pair of
|
||||
# them until they were replaced with index-scanned plain arrays.
|
||||
# would be a wrong test. Neither is an expansion whose own line first proves the
|
||||
# array non-empty (`[[ ${#a[@]} -eq 0 ]] || rm -rf "${a[@]}"`) — the short-circuit
|
||||
# means the expansion is unreachable when the array is empty.
|
||||
#
|
||||
# The scanned file list is DERIVED, not hand-maintained. A hardcoded list only
|
||||
# guards the scripts someone remembered to add to it, and it silently fails to
|
||||
# cover anything new: it omitted scripts/lib/batch-run.sh — the shared runner
|
||||
# this branch introduced, whose own header (batch-run.sh:9-11) documents it as
|
||||
# bash-3.2-safe — along with four other scripts/*.sh. Deriving the list means a
|
||||
# new script is covered the moment it lands. AGENTS.md names bash 3.2 as an
|
||||
# explicit repo target, so the scope is three globs, each floor-asserted below:
|
||||
# - scripts/**/*.sh — repo tooling and pre-commit hook scripts
|
||||
# - tests/*.sh — the runners and every regression test
|
||||
# - plugins/*/.apm/**/*.sh — the scripts plugins ship to users
|
||||
# plugins/*/skills/** is deliberately NOT scanned: it is the generated mirror of
|
||||
# .apm/, so scanning both double-reports every finding, and mirror-vs-source
|
||||
# drift is already sync-plugin-content.sh --check's job. Scanning .apm/ is what
|
||||
# closed the gap where skill-audit's vale-wrap.sh was covered but agent-audit's
|
||||
# byte-identical copy of it was not.
|
||||
# providers/**/*.sh is the one shipped script deliberately left out:
|
||||
# providers/claude-code/statusline-command.sh seeds `parts=()` empty at :83 and
|
||||
# expands it unguarded at :96. That is a real latent hazard rather than a false
|
||||
# positive — it just cannot abort today because the file enables no `set -u`.
|
||||
# Fixing it is a change to a file this case does not own; once :96 uses the
|
||||
# guarded form, add a `providers` glob to the table below.
|
||||
#
|
||||
# Four constructs are checked, because the expansion scan cannot see any of the
|
||||
# other three:
|
||||
# - `mapfile`/`readarray` — bash 4.0+ builtins. run-tests.sh carried one until
|
||||
# it was replaced with a `while read` loop.
|
||||
# - `declare -A` — bash 4.0+ associative arrays. check-vale-style-sync.sh
|
||||
# carried a pair until they became index-scanned plain arrays.
|
||||
# - `wait -n` — bash 4.3+. A prior review round found this live in run-bats.sh.
|
||||
# - `nproc` — GNU coreutils, absent on macOS entirely. Same review round, same
|
||||
# file. `getconf _NPROCESSORS_ONLN` is the portable spelling batch-run.sh
|
||||
# settled on.
|
||||
# The last two had no static guard anywhere before this, and
|
||||
# `shellcheck --severity=warning` (.pre-commit-config.yaml) pins no target
|
||||
# version, so it does not catch them either — meaning the guard against the last
|
||||
# regression would not have caught the last regression.
|
||||
echo ""
|
||||
echo "--- no unguarded array expansion remains in the macOS-facing scripts ---"
|
||||
echo "--- no bash-4-only construct remains in the macOS-facing scripts ---"
|
||||
# Files a script pulls in via `source`, as named by its `# shellcheck source=`
|
||||
# directives. Array seeding often lives in the sourced file (install.sh's
|
||||
# DEPLOY_* come from deploy-manifest.sh), so the seeding check has to look
|
||||
# there too or it reports a false hazard. shellcheck resolves these paths
|
||||
# against either the repo root or the script's own directory depending on
|
||||
# configuration, so both are tried and whichever exists is used.
|
||||
sourced_files() {
|
||||
local file="$1" rel cand
|
||||
while IFS= read -r rel; do
|
||||
for cand in "$REPO_ROOT/$rel" "$(dirname "$file")/$rel"; do
|
||||
if [[ -f "$cand" ]]; then
|
||||
printf '%s\n' "$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done < <(
|
||||
grep -oE '^[[:space:]]*#[[:space:]]*shellcheck[[:space:]]+source=[^[:space:]]+' "$file" \
|
||||
| sed -E 's/.*source=//' || true
|
||||
)
|
||||
return 0
|
||||
}
|
||||
unguarded_expansions() {
|
||||
local file="$1" hit name
|
||||
local file="$1" hit name seed_file
|
||||
local seed_files
|
||||
seed_files=("$file")
|
||||
while IFS= read -r seed_file; do
|
||||
seed_files+=("$seed_file")
|
||||
done < <(sourced_files "$file")
|
||||
while IFS= read -r hit; do
|
||||
name="$(printf '%s\n' "$hit" \
|
||||
| grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' | head -1 \
|
||||
| sed -E 's/^\$\{//; s/\[@\]\}$//')"
|
||||
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\([^)]" "$file" \
|
||||
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$file"; then
|
||||
# Shell-maintained arrays are never seeded by a `NAME=(...)` line, so the
|
||||
# seeding exemption below can never clear them: without this case they are
|
||||
# permanent false positives. Exempted are the ones measured non-empty inside
|
||||
# a running script -- element counts taken at a script's top level:
|
||||
# PIPESTATUS >=1 once any command has run (0 only before the very first,
|
||||
# where the variable is meaningless anyway)
|
||||
# BASH_SOURCE 1 (one frame per sourced/executed file)
|
||||
# BASH_LINENO 1 (maintained in parallel with BASH_SOURCE)
|
||||
# BASH_VERSINFO 6 (always exactly six)
|
||||
# GROUPS 1
|
||||
# DIRSTACK 1 (always holds at least the current directory)
|
||||
#
|
||||
# FUNCNAME, BASH_ARGV, BASH_ARGC, BASH_REMATCH and COMP_WORDS are
|
||||
# deliberately NOT exempted despite being shell-maintained, because they are
|
||||
# genuinely empty in reachable states: FUNCNAME is 0 outside a function,
|
||||
# BASH_ARGV is 0 without `shopt -s extdebug`, BASH_ARGC is 0 *inside a
|
||||
# function* (it looks safe when measured at top level, where it is 1 -- it is
|
||||
# not), BASH_REMATCH is 0 until a `=~` match succeeds, COMP_WORDS is 0
|
||||
# outside completion. Expanding any of those bare really does abort on bash
|
||||
# 3.2 under `set -u`, so flagging them is the correct answer rather than a
|
||||
# false positive. Case 26 pins this split so neither half drifts.
|
||||
case "$name" in
|
||||
PIPESTATUS|BASH_SOURCE|BASH_LINENO|BASH_VERSINFO|GROUPS|DIRSTACK) continue ;;
|
||||
esac
|
||||
# Same-line emptiness short-circuit: the expansion cannot be reached empty.
|
||||
if printf '%s\n' "$hit" \
|
||||
| grep -qE "\\\$\{#$name\[@\]\}[[:space:]]*-(eq|lt)[[:space:]]*[01][^|]*\|\|"; then
|
||||
continue
|
||||
fi
|
||||
for seed_file in ${seed_files[@]+"${seed_files[@]}"}; do
|
||||
# `([^)]|$)` after the paren, not just `[^)]`: a multi-line declaration
|
||||
# (`DEPLOY_FILES=(` with its elements on the following lines) ends the line
|
||||
# right there, and requiring a character after the paren missed it. An
|
||||
# empty `name=()` still does not match, which is what the check is for.
|
||||
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\(([^)]|$)" "$seed_file" \
|
||||
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$seed_file"; then
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
printf '%s:%s\n' "${file##*/}" "$hit"
|
||||
done < <(
|
||||
# Blank out whole-line comments (keeping line numbers), delete every
|
||||
# correctly guarded expansion, then anything still matching is a candidate.
|
||||
# correctly guarded expansion — in both its bare spelling and the
|
||||
# backslash-escaped one that appears inside this file's own PASS message —
|
||||
# then anything still matching is a candidate.
|
||||
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$file" \
|
||||
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \
|
||||
| sed -E 's/\\?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+\\?"\\?\$\{\1\[@\]\}\\?"\}//g' \
|
||||
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true
|
||||
)
|
||||
}
|
||||
# Blanks WHOLE-LINE comments only (keeping line numbers so hits stay locatable),
|
||||
# so prose naming a hazard on its own line is not a hit.
|
||||
#
|
||||
# KNOW THIS BEFORE YOU EDIT ANY SCANNED FILE. Nothing else is stripped — this is
|
||||
# a line-based scanner, not a shell parser — so all of the following DO trip the
|
||||
# case even though none is a real hazard:
|
||||
# true # avoid nproc on macOS <- trailing comment naming a hazard
|
||||
# echo "avoid mapfile in scripts" <- hazard named inside a string
|
||||
# <<EOF ... nproc ... EOF <- hazard named in a heredoc body
|
||||
# foo # see ${x[@]} <- trailing comment holding an expansion
|
||||
# Teaching it to parse shell would cost far more than it returns, so the rule is:
|
||||
# put the mention on its own comment line, or break the token with a one-character
|
||||
# bracket class the way the `npro[c]` rule below does — `npro[c]` matches exactly
|
||||
# what a bare spelling would while containing no bare spelling itself. This
|
||||
# matters because a static check that cries wolf is a static check someone
|
||||
# eventually deletes.
|
||||
strip_comments() { awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$1"; }
|
||||
bash32_glob() {
|
||||
case "$1" in
|
||||
scripts) find "$REPO_ROOT/scripts" -name '*.sh' -type f ;;
|
||||
tests) find "$REPO_ROOT/tests" -maxdepth 1 -name '*.sh' -type f ;;
|
||||
plugins) find "$REPO_ROOT/plugins" -path '*/.apm/*' -name '*.sh' -type f ;;
|
||||
*) echo "bash32_glob: unknown glob '$1'" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
# Floors are PER GLOB, not on the merged total. A single total floor cannot
|
||||
# detect the failure this assertion exists to name: with 12 + 17 + 13 files,
|
||||
# losing the whole `scripts` glob still leaves 30 and losing the whole `tests`
|
||||
# glob still leaves 25, so any total floor low enough to survive normal churn
|
||||
# is too low to notice an entire glob silently resolving to nothing. Each floor
|
||||
# sits a little under its current count so ordinary file removal does not trip
|
||||
# it, but a broken or renamed path does. Parallel arrays rather than an
|
||||
# associative one — `declare -A` is bash 4.0+, which this very case forbids.
|
||||
BASH32_GLOB_NAMES=(scripts tests plugins)
|
||||
BASH32_GLOB_FLOORS=(10 14 10)
|
||||
BASH32_SCRIPTS=()
|
||||
BASH32_IDX=0
|
||||
while [[ $BASH32_IDX -lt ${#BASH32_GLOB_NAMES[@]} ]]; do
|
||||
BASH32_GLOB="${BASH32_GLOB_NAMES[$BASH32_IDX]}"
|
||||
BASH32_FLOOR="${BASH32_GLOB_FLOORS[$BASH32_IDX]}"
|
||||
BASH32_COUNT=0
|
||||
while IFS= read -r BASH32_FOUND; do
|
||||
BASH32_SCRIPTS+=("$BASH32_FOUND")
|
||||
BASH32_COUNT=$((BASH32_COUNT + 1))
|
||||
done < <(bash32_glob "$BASH32_GLOB" | sort)
|
||||
if [[ $BASH32_COUNT -lt $BASH32_FLOOR ]]; then
|
||||
fail "the bash-3.2 scan's '$BASH32_GLOB' glob derived $BASH32_COUNT file(s), under its floor of $BASH32_FLOOR — that path is wrong, so those scripts are silently unscanned"
|
||||
fi
|
||||
BASH32_IDX=$((BASH32_IDX + 1))
|
||||
done
|
||||
HAZARDS16=""
|
||||
for BASH32_SCRIPT in \
|
||||
"$SCRIPT" \
|
||||
"$REPO_ROOT/scripts/skill-size-check.sh" \
|
||||
"$REPO_ROOT/scripts/check-release-needed.sh" \
|
||||
"$REPO_ROOT/scripts/check-vale-style-sync.sh" \
|
||||
"$REPO_ROOT/tests/run-tests.sh" \
|
||||
"$REPO_ROOT/tests/run-bats.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-marketplace-mirror.sh" \
|
||||
"$REPO_ROOT/tests/test-sync-plugin-content.sh" \
|
||||
"$REPO_ROOT/tests/test-check-release-needed.sh"; do
|
||||
for BASH32_SCRIPT in ${BASH32_SCRIPTS[@]+"${BASH32_SCRIPTS[@]}"}; do
|
||||
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
|
||||
if [[ -n "$FOUND16" ]]; then
|
||||
HAZARDS16+="$FOUND16 "
|
||||
fi
|
||||
# `mapfile`/`readarray` are bash 4.0+ builtins with no 3.2 fallback. Whole-line
|
||||
# comments are blanked first so prose naming the builtin is not a hit.
|
||||
FOUND16B="$(awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$BASH32_SCRIPT" \
|
||||
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)[[:space:]]' || true)"
|
||||
if [[ -n "$FOUND16B" ]]; then
|
||||
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16B "
|
||||
fi
|
||||
# `declare -A` (associative arrays) is bash 4.0+ with no 3.2 fallback. The
|
||||
# flag cluster can carry other letters in any order (-Ag, -rA, ...); what
|
||||
# matters is a literal uppercase A appearing in it, so match on that rather
|
||||
# than the exact string "-A".
|
||||
FOUND16C="$(awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$BASH32_SCRIPT" \
|
||||
| grep -nE '(^|[^[:alnum:]_])declare[[:space:]]+-[a-zA-Z]*A[a-zA-Z]*([[:space:]]|$)' || true)"
|
||||
if [[ -n "$FOUND16C" ]]; then
|
||||
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16C "
|
||||
fi
|
||||
# `mapfile`/`readarray`: bash 4.0+ builtins with no 3.2 fallback.
|
||||
# `declare -A`: bash 4.0+ associative arrays. The flag cluster can carry other
|
||||
# letters in any order (-Ag, -rA, ...); what matters is a literal uppercase A
|
||||
# appearing in it, so match on that rather than the exact string "-A".
|
||||
# `wait -n`: bash 4.3+. `nproc`: GNU coreutils, not present on macOS.
|
||||
# The last two close on `[^[:alnum:]_]`, not `[[:space:]]`: the real spellings
|
||||
# are `$(nproc)` and `wait -n;`, and a whitespace-only terminator misses both.
|
||||
# `npro[c]` matches exactly what `nproc` would, but keeps the literal string
|
||||
# "nproc" out of this file — the scan reads this file too, so a bare spelling
|
||||
# here would report itself as a hazard.
|
||||
for BASH32_RULE in \
|
||||
'(^|[^[:alnum:]_])(mapfile|readarray)[[:space:]]' \
|
||||
'(^|[^[:alnum:]_])declare[[:space:]]+-[a-zA-Z]*A[a-zA-Z]*([[:space:]]|$)' \
|
||||
'(^|[^[:alnum:]_])wait[[:space:]]+-n([^[:alnum:]_]|$)' \
|
||||
'(^|[^[:alnum:]_])npro[c]([^[:alnum:]_]|$)'; do
|
||||
FOUND16B="$(strip_comments "$BASH32_SCRIPT" | grep -nE "$BASH32_RULE" || true)"
|
||||
if [[ -n "$FOUND16B" ]]; then
|
||||
HAZARDS16+="${BASH32_SCRIPT##*/}:$FOUND16B "
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [[ -n "$HAZARDS16" ]]; then
|
||||
fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')"
|
||||
fail "bash-4-only construct(s) found in ${#BASH32_SCRIPTS[@]} scanned script(s): $(echo "$HAZARDS16" | tr '\n' ' ')"
|
||||
else
|
||||
pass "every array expansion uses the bash-3.2-safe \${arr[@]+\"\${arr[@]}\"} form"
|
||||
# This message deliberately names none of the four hazards in their literal
|
||||
# spelling: the scan reads this file too, so a literal here is indistinguishable
|
||||
# from a real occurrence and the case would fail on its own success message.
|
||||
pass "all ${#BASH32_SCRIPTS[@]} scanned scripts are free of every bash-4-only construct this case checks for"
|
||||
fi
|
||||
|
||||
# --- 17. The invocations whose arrays are closest to empty actually run. Under
|
||||
@@ -594,11 +731,14 @@ DESC19_A="Use when the caller helps with a specific job"
|
||||
DESC19_B="and the second physical line will utilize the wrap"
|
||||
|
||||
# make_form_fixture spells the same two-clause description in one YAML scalar
|
||||
# form: single, folded, plain, dquote, squote, or keyonly.
|
||||
# form: single, folded, plain, dquote, squote, or keyonly. It prints the fixture
|
||||
# dir and does NOT call new_fixture itself: every caller invokes it inside `$( )`,
|
||||
# so a registration made in here would land in the command substitution's
|
||||
# subshell and never reach cleanup_all -- which leaked all six fixtures per run.
|
||||
# Registration is therefore the caller's job, in the caller's shell.
|
||||
make_form_fixture() {
|
||||
local form="$1" dir
|
||||
dir="$(mktemp -d)"
|
||||
new_fixture "$dir"
|
||||
(cd "$dir" && git init -q)
|
||||
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
|
||||
{
|
||||
@@ -636,7 +776,13 @@ alert_text() {
|
||||
|
||||
echo ""
|
||||
echo "--- every multi-line description form reports what its single-line form reports ---"
|
||||
# FORM_FIXTURES19 is bookkeeping for case 25, which asserts every dir recorded
|
||||
# here also reached EXTRA_FIXTURES. It is appended to independently of
|
||||
# new_fixture so that dropping the new_fixture calls is detectable.
|
||||
FORM_FIXTURES19=()
|
||||
FIXTURE19_SINGLE="$(make_form_fixture single)"
|
||||
FORM_FIXTURES19+=("$FIXTURE19_SINGLE")
|
||||
new_fixture "$FIXTURE19_SINGLE"
|
||||
BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")"
|
||||
if [[ -z "$BASELINE19" ]]; then
|
||||
# The loop below has to be skipped, not merely reported on: an empty baseline
|
||||
@@ -646,6 +792,8 @@ if [[ -z "$BASELINE19" ]]; then
|
||||
else
|
||||
for FORM19 in folded plain dquote squote keyonly; do
|
||||
DIR19="$(make_form_fixture "$FORM19")"
|
||||
FORM_FIXTURES19+=("$DIR19")
|
||||
new_fixture "$DIR19"
|
||||
BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)"
|
||||
GOT19="$(alert_text "$(run_wrap "$DIR19" --config "$VALE_CONFIG" "$REL_SKILL19")")"
|
||||
if echo "$BARE19" | grep -q "VagueWording"; then
|
||||
@@ -864,6 +1012,85 @@ for FORM24 in "--output line" "--output=line" "--output JSON" "--output=JSON"; d
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 25. Every fixture this run created is on the cleanup list. make_form_fixture
|
||||
# used to call `new_fixture` itself, but every caller invokes it as
|
||||
# `$(make_form_fixture ...)` — a command substitution — so the append landed in a
|
||||
# subshell and was gone by the time the caller resumed. cleanup_all then removed
|
||||
# five of the six form fixtures it never heard about, leaking one temp dir per
|
||||
# YAML form per run (six in total, measured under a private TMPDIR).
|
||||
#
|
||||
# Nothing in the suite noticed: a leak fails no assertion, and the run reported
|
||||
# "39 passed, 0 failed" with the bug present exactly as it does with the bug
|
||||
# fixed. Leak-freeness was only ever observable by watching TMPDIR from outside,
|
||||
# which is not a regression test. This case makes it one — it compares the dirs
|
||||
# case 19 created against the dirs registered for cleanup, so re-introducing the
|
||||
# subshell registration fails the run rather than quietly leaking again.
|
||||
echo ""
|
||||
echo "--- every fixture created by this run is registered for cleanup ---"
|
||||
UNREGISTERED25=""
|
||||
for DIR25 in ${FORM_FIXTURES19[@]+"${FORM_FIXTURES19[@]}"}; do
|
||||
REGISTERED25=0
|
||||
for KNOWN25 in ${EXTRA_FIXTURES[@]+"${EXTRA_FIXTURES[@]}"}; do
|
||||
if [[ "$KNOWN25" == "$DIR25" ]]; then
|
||||
REGISTERED25=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $REGISTERED25 -eq 0 ]]; then
|
||||
UNREGISTERED25+="$DIR25 "
|
||||
fi
|
||||
done
|
||||
if [[ ${#FORM_FIXTURES19[@]} -ne 6 ]]; then
|
||||
fail "expected 6 form fixtures to have been created, saw ${#FORM_FIXTURES19[@]} — case 25 is not checking what it claims"
|
||||
elif [[ -n "$UNREGISTERED25" ]]; then
|
||||
fail "fixture(s) created but never registered for cleanup, so they leak: $UNREGISTERED25"
|
||||
else
|
||||
pass "all ${#FORM_FIXTURES19[@]} form fixtures are registered for cleanup"
|
||||
fi
|
||||
|
||||
# --- 26. Case 16's shell-special-array exemption is pinned to a fixture. No file
|
||||
# the scan currently reads expands any of those arrays, so the exemption is inert
|
||||
# in practice: it could be deleted, or quietly widened to cover an array that can
|
||||
# genuinely be empty, and every existing case would still pass. This drives the
|
||||
# real unguarded_expansions over a fixture holding one expansion of each, and
|
||||
# asserts the split in BOTH directions -- exempt arrays absent from the findings,
|
||||
# never-safe arrays present. The membership is not cosmetic: the exempt ones are
|
||||
# shell-maintained and always non-empty, while the flagged ones are empty in
|
||||
# reachable states (see the comment on the exemption for the measured counts), so
|
||||
# widening the list to include one of the latter would suppress a real abort.
|
||||
echo ""
|
||||
echo "--- the shell-special-array exemption covers exactly the never-empty arrays ---"
|
||||
FIXTURE26="$(mktemp -d)"
|
||||
new_fixture "$FIXTURE26"
|
||||
EXEMPT26="PIPESTATUS BASH_SOURCE BASH_LINENO BASH_VERSINFO GROUPS DIRSTACK"
|
||||
FLAGGED26="FUNCNAME BASH_ARGV BASH_ARGC BASH_REMATCH COMP_WORDS"
|
||||
{
|
||||
echo "#!/usr/bin/env bash"
|
||||
for ARR26 in $EXEMPT26 $FLAGGED26; do
|
||||
echo "echo \"\${${ARR26}[@]}\""
|
||||
done
|
||||
} > "$FIXTURE26/probe.sh"
|
||||
FOUND26="$(unguarded_expansions "$FIXTURE26/probe.sh")"
|
||||
MISSING26=""
|
||||
LEAKED26=""
|
||||
for ARR26 in $EXEMPT26; do
|
||||
if echo "$FOUND26" | grep -q "{$ARR26\[@\]}"; then
|
||||
LEAKED26+="$ARR26 "
|
||||
fi
|
||||
done
|
||||
for ARR26 in $FLAGGED26; do
|
||||
if ! echo "$FOUND26" | grep -q "{$ARR26\[@\]}"; then
|
||||
MISSING26+="$ARR26 "
|
||||
fi
|
||||
done
|
||||
if [[ -n "$LEAKED26" ]]; then
|
||||
fail "always-non-empty shell array(s) reported as hazards, so the exemption is not applying: $LEAKED26"
|
||||
elif [[ -n "$MISSING26" ]]; then
|
||||
fail "shell array(s) that CAN be empty were exempted, suppressing a real bash-3.2 abort: $MISSING26"
|
||||
else
|
||||
pass "all 6 never-empty shell arrays are exempt and all 5 sometimes-empty ones are still flagged"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user