Why: a review of4de5b6band3c5f6a6found seven places that still described the retired external hook contract as current, or that could pass without checking anything. Implementation Notes: - gates.md: drop the "one caveat below" pointer; the caveat is gone. - SIMPLIFICATION-AUDIT.md: the §7 note now says all but one entry is closed and lists 36 (4de5b6b) as struck. Finding 2's count chain gets a closing note: pre-push is 8 repo-authored hooks (10 reported). - .pre-commit-config.yaml: the check-vale-style-sync comment points at case 32 (one-plugin narrowing guard), not the deleted case 33. - ADR-0014: the retirement pointer now covers the ADR-0025 amendment above it too, naming case 33 and the exported hook IDs. ADR-0022 gets a reciprocal amended-by note on its check-release-needed comparison. Historical body text is unchanged. - test-vale-wrap.sh case 32: property 3 fails when a class's corpus regex matches no tracked file, instead of passing vacuously after a layout move. The corpus regexes are now globals so a new Part D can point them at a missing layout and require that failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2375 lines
118 KiB
Bash
Executable File
2375 lines
118 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression test for scripts/vale-wrap.sh: Vale's `text.frontmatter.description`
|
|
# NLP scope silently stops matching when the description value is a YAML block
|
|
# scalar spanning 2+ physical lines. vale-wrap.sh flattens it to one line before
|
|
# handing off to the real vale binary — this asserts that actually happens.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
# ADR-0025 merged skill-audit and agent-audit, so there is now ONE vale-wrap.sh and
|
|
# ONE .vale.ini. Every fixture below is a SKILL.md, matched by the merged config's
|
|
# [**/SKILL.md] section — which is the same section the pre-merge skill-audit config
|
|
# carried, unchanged, so no fixture's expected verdict moves.
|
|
FACTORY_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit"
|
|
SCRIPT="$FACTORY_AUDIT/scripts/vale-wrap.sh"
|
|
VALE_CONFIG="$FACTORY_AUDIT/assets/vale/.vale.ini"
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
|
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
|
|
|
# Vale absent skips the Vale-DEPENDENT cases, not the suite. An early `exit 77`
|
|
# here used to skip everything, including the checks that are plain greps and
|
|
# awk over the config and the hook config (cases 16, 26-28's static
|
|
# halves, 31 Parts A/B, 32) -- so a machine without vale reported a skip
|
|
# while never looking at a manifest it could have read. Those still run; the
|
|
# suite exits 77 at the end only if they all passed, so run-tests.sh keeps
|
|
# reporting SKIPPED and `--strict` keeps turning that skip into a failure. A
|
|
# static FAIL still exits 1, because a real defect is not a setup error.
|
|
VALE_AVAILABLE=true
|
|
if ! command -v vale &>/dev/null; then
|
|
VALE_AVAILABLE=false
|
|
echo "SKIP: vale is not installed — Vale-dependent cases skipped (matches factory-audit's own fallback behavior); the static cases still run"
|
|
fi
|
|
|
|
# The fixture handles the skippable regions below create. Seeded empty so the
|
|
# EXIT trap's `rm -rf` does not trip `set -u` on a region that never ran.
|
|
FIXTURE1="" FIXTURE2="" FIXTURE3="" FIXTURE4="" FIXTURE5="" FIXTURE6=""
|
|
FIXTURE7="" FIXTURE8="" FIXTURE10="" FIXTURE11="" FIXTURE12="" STUB13=""
|
|
FIXTURE14="" FIXTURE17="" FIXTURE18=""
|
|
|
|
# --- 0. The shipped Vale config can load at all ------------------------------
|
|
# A missing `.vale.ini`, a missing `StylesPath`, or a `BasedOnStyles` naming a
|
|
# style directory that is not there all stop vale before it lints anything
|
|
# (`path ... does not exist`, `style 'Kyberforge' does not exist on
|
|
# StylesPath`, rc 2). Every Vale-dependent case below then failed on its own
|
|
# generic symptom -- nine "vale printed no summary line" failures across cases
|
|
# 28-31 alone, none naming the cause. This names it once and holds the
|
|
# Vale-dependent cases back instead. Static on purpose: it needs no vale, so it
|
|
# runs on the machines that skip everything else.
|
|
#
|
|
# `BasedOnStyles =` left EMPTY is deliberately not a defect here: vale loads
|
|
# that config and lints the file with no style, which is the silent case 28
|
|
# exists to catch, and catching it here instead would leave 28's
|
|
# style-not-loaded branch untested.
|
|
vale_config_defects0() {
|
|
local cfg="$1" dir sp line names name bad=""
|
|
if [[ ! -f "$cfg" ]]; then
|
|
printf '%s' "[$cfg does not exist] "
|
|
return 0
|
|
fi
|
|
# Decided by ACTUALLY READING the file, not by `[[ -r ]]`. `-r` is access(2),
|
|
# which answers "would the permission bits allow it" -- and for uid 0 that is
|
|
# yes even on a mode-000 file. This repo's dev environment is root, so an
|
|
# `[[ ! -r ]]` guard could never fire in the one place it exists to fire: it
|
|
# was untestable because it was dead. A read attempt is also the stricter
|
|
# question, catching EISDIR and EIO, which access(2) reports on neither.
|
|
# `cat`, not a bare `< "$cfg"` redirect: opening a directory for reading
|
|
# succeeds, only the read fails. (scripts/check-vale-style-sync.sh carried
|
|
# this reasoning before ADR-0025 deleted it; the hazard did not go with it.)
|
|
if ! cat "$cfg" > /dev/null 2>&1; then
|
|
printf '%s' "[$cfg exists but could not be read] "
|
|
return 0
|
|
fi
|
|
dir="$(dirname "$cfg")"
|
|
sp="$({ grep -E '^[[:space:]]*StylesPath[[:space:]]*=' "$cfg" || true; } | tail -1)"
|
|
sp="${sp#*=}"
|
|
sp="${sp#"${sp%%[![:space:]]*}"}"
|
|
sp="${sp%"${sp##*[![:space:]]}"}"
|
|
if [[ -z "$sp" ]]; then
|
|
printf '%s' "[${cfg##*/} sets no StylesPath, so vale cannot find any style it names] "
|
|
return 0
|
|
fi
|
|
[[ "$sp" == /* ]] || sp="$dir/$sp"
|
|
if [[ ! -d "$sp" ]]; then
|
|
printf '%s' "[StylesPath resolves to $sp, which is not a directory] "
|
|
return 0
|
|
fi
|
|
while IFS= read -r line; do
|
|
names="${line#*=}"
|
|
while IFS= read -r name; do
|
|
name="${name#"${name%%[![:space:]]*}"}"
|
|
name="${name%"${name##*[![:space:]]}"}"
|
|
# `Vale` is vale's built-in style and has no directory.
|
|
[[ -n "$name" && "$name" != "Vale" ]] || continue
|
|
[[ -d "$sp/$name" ]] || bad+="[BasedOnStyles names '$name', but $sp/$name does not exist] "
|
|
done <<EOF_NAMES0
|
|
$(printf '%s\n' "$names" | tr ',' '\n')
|
|
EOF_NAMES0
|
|
done <<EOF_LINES0
|
|
$({ grep -E '^[[:space:]]*BasedOnStyles[[:space:]]*=' "$cfg" || true; })
|
|
EOF_LINES0
|
|
printf '%s' "$bad"
|
|
}
|
|
|
|
echo ""
|
|
echo "--- the shipped .vale.ini loads: it exists, sets StylesPath, and every style it names ships ---"
|
|
VALE_CONFIG_DEFECTS="$(vale_config_defects0 "$VALE_CONFIG")"
|
|
VALE_CONFIG_OK=true
|
|
if [[ -n "$VALE_CONFIG_DEFECTS" ]]; then
|
|
VALE_CONFIG_OK=false
|
|
fail "the shipped Vale config cannot load, so every Vale-dependent case below is held back rather than reported as a pile of generic 'no summary line' failures: $VALE_CONFIG_DEFECTS"
|
|
else
|
|
pass "the shipped .vale.ini exists, sets a StylesPath that is a directory, and every style it names ships under it"
|
|
fi
|
|
|
|
VALE_READY=false
|
|
[[ "$VALE_AVAILABLE" == true && "$VALE_CONFIG_OK" == true ]] && VALE_READY=true
|
|
|
|
make_fixture() {
|
|
local dir desc_lines file
|
|
dir="$(mktemp -d)"
|
|
(cd "$dir" && git init -q)
|
|
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
|
|
desc_lines="$1"
|
|
file="$dir/plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
{
|
|
echo "---"
|
|
echo "name: zzzskill"
|
|
echo "description: >"
|
|
for ((i = 1; i <= desc_lines; i++)); do
|
|
echo " Line $i mentions helps with and utilize, plus a colon: like this."
|
|
done
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$file"
|
|
echo "$dir"
|
|
}
|
|
|
|
# Every Kyberforge rule is `level: error`, so vale exits non-zero whenever a
|
|
# fixture trips one — which is the expected outcome for nearly every case here.
|
|
# run_wrap therefore captures output and swallows the exit status; assertions
|
|
# are made on the report text. Cases that genuinely care about the exit code
|
|
# capture it explicitly instead.
|
|
run_wrap() {
|
|
local dir="$1"
|
|
shift
|
|
(cd "$dir" && bash "$SCRIPT" "$@" 2>&1) || true
|
|
}
|
|
|
|
# ===== BEGIN VALE-DEPENDENT REGION (cases 1-15) ==============================
|
|
# Not re-indented, so the case bodies stay diffable against their history. The
|
|
# matching `fi` is marked END with the same case range.
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
|
|
# --- 1. A known-bad single-line description is caught (sanity check on Vale itself) ---
|
|
echo ""
|
|
echo "--- catches vague wording in a single-line description ---"
|
|
FIXTURE1="$(make_fixture 1)"
|
|
trap 'rm -rf "$FIXTURE1"' EXIT
|
|
if run_wrap "$FIXTURE1" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md | grep -q "VagueWording"; then
|
|
pass "flags vague wording when description is a single physical line"
|
|
else
|
|
fail "did not flag known-bad single-line description"
|
|
fi
|
|
|
|
# --- 2. The same known-bad wording across 2+ physical lines is still caught ---
|
|
echo ""
|
|
echo "--- catches vague wording in a multi-line folded description ---"
|
|
FIXTURE2="$(make_fixture 2)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2"' EXIT
|
|
if run_wrap "$FIXTURE2" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md | grep -q "VagueWording"; then
|
|
pass "flags vague wording when description spans 2+ physical lines"
|
|
else
|
|
fail "silently missed known-bad wording in a multi-line description — the bug this test guards against"
|
|
fi
|
|
|
|
# --- 3. Line count is preserved so unrelated report line numbers don't shift ---
|
|
echo ""
|
|
echo "--- preserves total line count when flattening ---"
|
|
FIXTURE3="$(make_fixture 3)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3"' EXIT
|
|
ORIG_LINES=$(wc -l < "$FIXTURE3/plugins/testplugin/skills/zzzskill/SKILL.md")
|
|
OUT=$(run_wrap "$FIXTURE3" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md)
|
|
MAX_LINE=$(echo "$OUT" | grep -oE '^[[:space:]]*[0-9]+:[0-9]+' | tr -d '[:space:]' | cut -d: -f1 | sort -n | tail -1)
|
|
if [[ -n "$MAX_LINE" ]] && (( MAX_LINE <= ORIG_LINES )); then
|
|
pass "reported line numbers stay within the original file's line count"
|
|
else
|
|
fail "reported line number ($MAX_LINE) exceeds original file line count ($ORIG_LINES)"
|
|
fi
|
|
|
|
# make_raw_fixture writes stdin verbatim to a fresh fixture's SKILL.md, for
|
|
# cases where the exact description body needs to be hand-crafted rather than
|
|
# generated from the desc_lines loop above.
|
|
make_raw_fixture() {
|
|
local dir
|
|
dir="$(mktemp -d)"
|
|
(cd "$dir" && git init -q)
|
|
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
|
|
cat > "$dir/plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
echo "$dir"
|
|
}
|
|
|
|
# --- 4. A folded description containing a double quote is still caught ---
|
|
# This is the exact case that silently passed (zero alerts) before switching
|
|
# from json.dumps (double-quoted, backslash-escaped) to a single-quoted scalar.
|
|
echo ""
|
|
echo "--- catches vague wording when the folded description contains a double quote ---"
|
|
FIXTURE4="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Use when the user says "audit this skill" and helps with and utilize things.
|
|
Second line continues the same folded scalar for flattening.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4"' EXIT
|
|
if run_wrap "$FIXTURE4" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md | grep -q "VagueWording"; then
|
|
pass "flags vague wording when the description contains a double quote"
|
|
else
|
|
fail "silently missed vague wording in a description containing a double quote — the bug this test guards against"
|
|
fi
|
|
|
|
# --- 5. A folded description containing an apostrophe fires and stays valid YAML ---
|
|
echo ""
|
|
echo "--- catches vague wording when the folded description contains an apostrophe ---"
|
|
FIXTURE5="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Use when the user's task helps with and utilize things across two lines.
|
|
Second continuation line for the fold.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5"' EXIT
|
|
OUT5=$(run_wrap "$FIXTURE5" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md)
|
|
if grep -q "VagueWording" <<< "$OUT5"; then
|
|
pass "flags vague wording when the description contains an apostrophe"
|
|
else
|
|
fail "silently missed vague wording in a description containing an apostrophe"
|
|
fi
|
|
if grep -qi "yaml:" <<< "$OUT5"; then
|
|
fail "flattened copy with an apostrophe produced a YAML parse error"
|
|
else
|
|
pass "flattened copy with an apostrophe is valid YAML (no parse error)"
|
|
fi
|
|
|
|
# --- 6. A folded description with a backslash and a non-ASCII character ---
|
|
echo ""
|
|
echo "--- catches vague wording when the folded description has a backslash and non-ASCII text ---"
|
|
FIXTURE6="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Use when the café résumé naïve thing helps with and utilize things here.
|
|
Path is C:\Users\test and this is the second continuation line.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6"' EXIT
|
|
if run_wrap "$FIXTURE6" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md | grep -q "VagueWording"; then
|
|
pass "flags vague wording when the description has a backslash and non-ASCII text"
|
|
else
|
|
fail "silently missed vague wording in a description with a backslash and non-ASCII text"
|
|
fi
|
|
|
|
# --- 7. A folded description with a blank line between two paragraphs ---
|
|
echo ""
|
|
echo "--- handles a blank line inside a folded description without crashing ---"
|
|
FIXTURE7="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Use when the user needs a general helper.
|
|
|
|
Do not use when this helps with and utilize things instead.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7"' EXIT
|
|
OUT7=$(run_wrap "$FIXTURE7" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md)
|
|
if grep -q "Traceback" <<< "$OUT7"; then
|
|
fail "crashed while flattening a description with a blank line between paragraphs"
|
|
elif grep -q "VagueWording" <<< "$OUT7"; then
|
|
pass "still flags vague wording in the second paragraph after a blank line"
|
|
else
|
|
fail "silently missed vague wording in the second paragraph after a blank line — the bug this test guards against"
|
|
fi
|
|
|
|
# --- 8. Relative paths resolve against the caller's cwd, exactly as bare vale
|
|
# resolves them. Every path below is deliberately relative to $SUBDIR8, not to
|
|
# the fixture's repo root: an earlier version of the wrapper resolved relative
|
|
# paths against the git toplevel instead, which (a) hard-errored on a
|
|
# `--config ../../..` that bare vale accepts and (b) silently dropped file
|
|
# arguments that didn't resolve from the repo root, skipping the flattening the
|
|
# wrapper exists to perform. The old tests only ever passed repo-root-relative
|
|
# paths from a subdirectory, so neither failure mode was caught.
|
|
echo ""
|
|
echo "--- resolves a cwd-relative --config from a subdirectory (equals and two-argv forms) ---"
|
|
FIXTURE8="$(mktemp -d)"
|
|
(cd "$FIXTURE8" && git init -q)
|
|
cp "$VALE_CONFIG" "$FIXTURE8/.vale.ini"
|
|
cp -r "$FACTORY_AUDIT/assets/vale/styles" "$FIXTURE8/styles"
|
|
mkdir -p "$FIXTURE8/plugins/testplugin/skills/zzzskill"
|
|
{
|
|
echo "---"
|
|
echo "name: zzzskill"
|
|
echo "description: >"
|
|
echo " Line one mentions helps with and utilize, plus a colon: like this."
|
|
echo " Line two continues the same folded scalar for flattening."
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$FIXTURE8/plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8"' EXIT
|
|
SUBDIR8="$FIXTURE8/plugins/testplugin/skills/zzzskill"
|
|
# Both paths are relative to $SUBDIR8 (four levels below the fixture root).
|
|
REL_CFG8="../../../../.vale.ini"
|
|
REL_FILE8="SKILL.md"
|
|
OUT_EQ=$(run_wrap "$SUBDIR8" "--config=$REL_CFG8" "$REL_FILE8")
|
|
OUT_TWO=$(run_wrap "$SUBDIR8" --config "$REL_CFG8" "$REL_FILE8")
|
|
if grep -q "VagueWording" <<< "$OUT_EQ" && [[ "$OUT_EQ" == "$OUT_TWO" ]]; then
|
|
pass "cwd-relative --config resolves from a subdirectory in both argv forms"
|
|
else
|
|
fail "cwd-relative --config did not resolve from a subdirectory (equals form vs two-argv form)"
|
|
fi
|
|
|
|
# --- 8b. A cwd-relative --config matches the equivalent absolute invocation ---
|
|
# Regression for failure mode (a): resolving --config against the repo root made
|
|
# `--config ../../../../.vale.ini` expand to a path above the toplevel, and vale
|
|
# hard-errored with "does not exist" (exit 2) on args bare vale handles fine.
|
|
echo ""
|
|
echo "--- a cwd-relative --config produces the same result as the absolute-path form ---"
|
|
set +e
|
|
OUT_REL_CFG=$(cd "$SUBDIR8" && bash "$SCRIPT" --config "$REL_CFG8" "$REL_FILE8" 2>&1)
|
|
RC_REL_CFG=$?
|
|
OUT_ABS_CFG=$(cd "$SUBDIR8" && bash "$SCRIPT" --config "$FIXTURE8/.vale.ini" "$REL_FILE8" 2>&1)
|
|
RC_ABS_CFG=$?
|
|
set -e
|
|
if grep -qi "does not exist" <<< "$OUT_REL_CFG"; then
|
|
fail "cwd-relative --config hard-errored ('does not exist') — the bug this test guards against"
|
|
elif [[ "$OUT_REL_CFG" == "$OUT_ABS_CFG" && "$RC_REL_CFG" -eq "$RC_ABS_CFG" ]]; then
|
|
pass "cwd-relative --config matches the absolute-path invocation (output and exit code)"
|
|
else
|
|
fail "cwd-relative --config (rc=$RC_REL_CFG) diverged from the absolute-path form (rc=$RC_ABS_CFG)"
|
|
fi
|
|
|
|
# --- 8c. A cwd-relative FILE argument is still flattened, not silently skipped ---
|
|
# Regression for failure mode (b): a relative file path that didn't resolve from
|
|
# the repo root failed the wrapper's file test, fell through to the vale flag
|
|
# list, and left the file list empty — so the wrapper exec'd bare vale and
|
|
# silently skipped the flattening. Bare vale reports nothing here, so asserting
|
|
# on the alert (not just the exit code) is what makes the silence detectable.
|
|
echo ""
|
|
echo "--- flattens a cwd-relative file argument passed from a subdirectory ---"
|
|
WRAPPED_REL=$(run_wrap "$SUBDIR8" --config "$FIXTURE8/.vale.ini" "$REL_FILE8")
|
|
BARE_REL=$(cd "$SUBDIR8" && vale --config "$FIXTURE8/.vale.ini" "$REL_FILE8" 2>&1 || true)
|
|
if ! grep -q "VagueWording" <<< "$WRAPPED_REL"; then
|
|
fail "cwd-relative file argument produced no alert — flattening was silently skipped, the bug this test guards against"
|
|
elif grep -q "VagueWording" <<< "$BARE_REL"; then
|
|
fail "bare vale already flags this fixture, so the test can't detect a silently-skipped flattening"
|
|
else
|
|
pass "cwd-relative file argument is flattened and flagged where bare vale reports nothing"
|
|
fi
|
|
|
|
# --- 9. Zero file args (or a file list that filters to nothing) exits promptly ---
|
|
echo ""
|
|
echo "--- exits promptly instead of hanging on stdin when no files are passed ---"
|
|
if timeout 5 bash "$SCRIPT" --config "$VALE_CONFIG" < <(sleep 100) >/dev/null 2>&1; then
|
|
pass "exits promptly with zero file args"
|
|
else
|
|
RC=$?
|
|
if [[ $RC -eq 124 ]]; then
|
|
fail "hung waiting on stdin with zero file args — the bug this test guards against"
|
|
else
|
|
pass "exits promptly (nonzero exit) with zero file args"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- exits promptly when a file list filters down to nothing ---"
|
|
if timeout 5 bash "$SCRIPT" --config "$VALE_CONFIG" --no-such-flag < <(sleep 100) >/dev/null 2>&1; then
|
|
pass "exits promptly when no file-shaped args remain"
|
|
else
|
|
RC=$?
|
|
if [[ $RC -eq 124 ]]; then
|
|
fail "hung waiting on stdin when the file list filtered to nothing — the bug this test guards against"
|
|
else
|
|
pass "exits promptly (nonzero exit) when the file list filters to nothing"
|
|
fi
|
|
fi
|
|
|
|
# --- 10. An absolute path to the fixture SKILL.md is still linted, not skipped ---
|
|
echo ""
|
|
echo "--- lints an absolute path to a skill file instead of silently skipping it ---"
|
|
FIXTURE10="$(make_fixture 2)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10"' EXIT
|
|
ABS_FILE10="$FIXTURE10/plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
if run_wrap "$FIXTURE10" --config "$VALE_CONFIG" "$ABS_FILE10" | grep -q "VagueWording"; then
|
|
pass "an absolute path is linted, not silently skipped"
|
|
else
|
|
fail "an absolute path was silently skipped — the bug this test guards against"
|
|
fi
|
|
|
|
# --- 11. A literal (|) block scalar passes through unflattened. Unlike every
|
|
# other multi-line form, `|` is not broken in Vale: its parsed value keeps the
|
|
# same line breaks the source has, so the description scope still matches. The
|
|
# second assertion pins that down — without it, a wrapper that broke `|` and a
|
|
# Vale that never matched `|` would agree on zero alerts and the comparison
|
|
# would pass vacuously.
|
|
echo ""
|
|
echo "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---"
|
|
FIXTURE11="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: |
|
|
Line one mentions helps with and utilize things here.
|
|
Line two continues the literal block scalar for this test.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11"' EXIT
|
|
REL11="plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11")
|
|
BARE_OUT=$(cd "$FIXTURE11" && vale --config "$VALE_CONFIG" "$REL11" 2>&1 || true)
|
|
if ! grep -q "VagueWording" <<< "$BARE_OUT"; then
|
|
fail "bare vale reports nothing for a literal (|) block scalar — the 'literal blocks are not broken' premise is wrong"
|
|
elif [[ "$WRAPPED_OUT" == "$BARE_OUT" ]]; then
|
|
pass "literal (|) block scalar output matches bare vale exactly — untouched by flattening"
|
|
else
|
|
fail "wrapper altered output for a literal (|) block scalar description — should be left untouched"
|
|
fi
|
|
|
|
# --- 12. With no --config at all, the wrapper falls back to its own sibling
|
|
# assets/vale/.vale.ini. factory-audit's Step 1 and both prefilter hooks rely
|
|
# on this: they pass no --config. A published hook manifest would too, were it
|
|
# restored (ADR-0014): pre-commit prefixes only entry[0] with the hook-repo clone
|
|
# path, so a --config argument there resolves against the consuming repo and
|
|
# hard-errors (E100) for every external consumer.
|
|
echo ""
|
|
echo "--- defaults --config to the wrapper's own sibling assets/vale/.vale.ini ---"
|
|
FIXTURE12="$(make_fixture 2)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12"' EXIT
|
|
OUT12=$(run_wrap "$FIXTURE12" plugins/testplugin/skills/zzzskill/SKILL.md)
|
|
if grep -q "VagueWording" <<< "$OUT12"; then
|
|
pass "a --config-less invocation uses the wrapper's bundled config"
|
|
else
|
|
fail "a --config-less invocation found no config — factory-audit's Step 1 and both prefilter hooks pass no --config, so they would get E100"
|
|
fi
|
|
|
|
# --- 13. No GNU-only `realpath -m`. macOS ships the BSD realpath, which has no
|
|
# -m (canonicalize-missing) — and every scratch destination is a path that does
|
|
# not exist yet, so a plain `realpath` exits 1 and set -e aborts the hook.
|
|
echo ""
|
|
echo "--- runs with a BSD realpath that has no -m option ---"
|
|
STUB13="$(mktemp -d)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13"' EXIT
|
|
REAL_REALPATH="$(command -v realpath || echo /bin/false)"
|
|
cat > "$STUB13/realpath" <<EOF
|
|
#!/usr/bin/env bash
|
|
for a in "\$@"; do
|
|
case "\$a" in
|
|
-m|--canonicalize-missing)
|
|
echo "realpath: illegal option -- m" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
exec "$REAL_REALPATH" "\$@"
|
|
EOF
|
|
chmod +x "$STUB13/realpath"
|
|
OUT13=$(cd "$FIXTURE12" && PATH="$STUB13:$PATH" bash "$SCRIPT" --config "$VALE_CONFIG" \
|
|
plugins/testplugin/skills/zzzskill/SKILL.md 2>&1 || true)
|
|
if grep -q "illegal option" <<< "$OUT13"; then
|
|
fail "invoked realpath -m — fails on macOS's BSD realpath, the bug this test guards against"
|
|
elif grep -q "VagueWording" <<< "$OUT13"; then
|
|
pass "flattens and flags with no GNU realpath available"
|
|
else
|
|
fail "produced no alert under a BSD-style realpath: $OUT13"
|
|
fi
|
|
|
|
# --- 14. A directory argument is walked and its files flattened. The classifier
|
|
# used to accept only regular files, so a directory fell through to the vale
|
|
# flag list, left the file list empty, and exec'd bare vale — silently skipping
|
|
# the flattening. `lint`'s vale-run skill documents `vale <path-or-glob>` as
|
|
# normal usage, so this is a reachable path.
|
|
echo ""
|
|
echo "--- flattens files reached through a directory argument ---"
|
|
FIXTURE14="$(make_fixture 2)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" "$FIXTURE14"' EXIT
|
|
WRAPPED_DIR=$(run_wrap "$FIXTURE14" --config "$VALE_CONFIG" plugins)
|
|
BARE_DIR=$(cd "$FIXTURE14" && vale --config "$VALE_CONFIG" plugins 2>&1 || true)
|
|
if ! grep -q "VagueWording" <<< "$WRAPPED_DIR"; then
|
|
fail "a directory argument produced no alert — flattening was silently skipped, the bug this test guards against"
|
|
elif grep -q "VagueWording" <<< "$BARE_DIR"; then
|
|
fail "bare vale already flags this fixture, so the test can't detect a silently-skipped flattening"
|
|
else
|
|
pass "a directory argument is walked and its files flattened"
|
|
fi
|
|
|
|
# --- 15. Directory walking must survive paths with spaces ---
|
|
echo ""
|
|
echo "--- walks a directory containing a path with spaces ---"
|
|
SPACED15="$FIXTURE14/plugins/testplugin/skills/zzz skill"
|
|
mkdir -p "$SPACED15"
|
|
cp "$FIXTURE14/plugins/testplugin/skills/zzzskill/SKILL.md" "$SPACED15/SKILL.md"
|
|
rm -rf "$FIXTURE14/plugins/testplugin/skills/zzzskill"
|
|
OUT15=$(run_wrap "$FIXTURE14" --config "$VALE_CONFIG" plugins/testplugin/skills)
|
|
if grep -q "zzz skill" <<< "$OUT15" && grep -q "VagueWording" <<< "$OUT15"; then
|
|
pass "a file under a directory whose name contains a space is walked and flattened"
|
|
else
|
|
fail "a path with a space was dropped from the directory walk"
|
|
fi
|
|
|
|
fi
|
|
# ===== END VALE-DEPENDENT REGION (cases 1-15) ================================
|
|
|
|
# --- 16. No unguarded `"${arr[@]}"` expansion survives in any script that runs
|
|
# on macOS. bash before 4.4 — including the 3.2 that macOS still ships as
|
|
# /bin/bash — treats that form on an *empty* array as an unbound variable under
|
|
# `set -u` and aborts. The portable form is `${arr[@]+"${arr[@]}"}`. This is a
|
|
# static check because no bash 5 host can reproduce the abort at runtime: the
|
|
# construct is only fatal on the older shell, so absence of the construct is the
|
|
# property to assert. `${#arr[@]}` is deliberately not flagged — the count form
|
|
# 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. 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. The script headers name bash 3.2 as
|
|
# an explicit repo target -- scripts/lib/batch-run.sh:9 ("both callers are
|
|
# explicitly bash-3.2-safe") and providers/claude-code/statusline-command.sh:100
|
|
# ("macOS's system bash, and an explicit repo target") -- so the scope is four
|
|
# 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
|
|
# - providers/**/*.sh — the scripts install.sh deploys to user machines
|
|
# .apm/ is the only plugin content scanned, and it is the only plugin content
|
|
# there is: the generated flat plugins/*/skills/** mirror was deleted with
|
|
# native plugin-install support, so there is no second copy to double-report.
|
|
# 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 was excluded until issue #96: statusline-command.sh seeded
|
|
# `parts=()` empty and expanded it unguarded, a real latent hazard rather than a
|
|
# false positive, in a file this case did not own. That expansion is now guarded,
|
|
# so the glob is in the table below and the deployed provider scripts get the
|
|
# same coverage as scripts/, tests/ and plugins/*/.apm/. This is the glob most
|
|
# worth having: install.sh copies providers/ content onto every user machine, and
|
|
# it is also the glob most likely to look redundant to a future reader, because
|
|
# the hazard it catches is invisible on any modern bash — bash 4.4 stopped
|
|
# treating an empty-array expansion as unbound, so a bare "${a[@]}" under `set -u`
|
|
# runs clean on the maintainer's bash 5 and aborts only on macOS's bash 3.2.
|
|
# A runtime test cannot demonstrate that without a 3.2 binary; this static scan
|
|
# is the enforcement, which is why its floor must never be dropped to zero.
|
|
#
|
|
# 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 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 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_]*\[@\]\}' || true; } | head -1 \
|
|
| sed -E 's/^\$\{//; s/\[@\]\}$//')"
|
|
# 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 grep -qE "\\\$\{#$name\[@\]\}[[:space:]]*-(eq|lt)[[:space:]]*[01][^|]*\|\|" <<< "$hit"; 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 — 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' \
|
|
| 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 ;;
|
|
providers) find "$REPO_ROOT/providers" -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 + 18 + 13 + 1 files,
|
|
# losing the whole `scripts` glob still leaves 32 and losing the whole `tests`
|
|
# glob still leaves 26, 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.
|
|
#
|
|
# `providers` is floored at 1 rather than "current count minus slack" because it
|
|
# holds exactly one file: any slack at all would be a floor of 0, which passes on
|
|
# a renamed or deleted directory and is precisely the silent-zero failure the
|
|
# per-glob floors exist to catch. A floor of 1 means removing the last provider
|
|
# script trips this assertion — correct, because at that point the glob is dead
|
|
# weight and should be deleted from the table deliberately, not left to pass
|
|
# vacuously.
|
|
#
|
|
# `scripts` dropped from 10 to 8 with ADR-0025: the merge retired
|
|
# scripts/check-vale-style-sync.sh and scripts/sync-vale-styles.sh, both of which
|
|
# existed only to keep two copies of the Vale config in step, leaving 9 files.
|
|
# The floor moves with the count on a deliberate deletion — it is a guard against
|
|
# a broken or renamed PATH resolving to nothing, never a headcount to maintain.
|
|
BASH32_GLOB_NAMES=(scripts tests plugins providers)
|
|
BASH32_GLOB_FLOORS=(8 14 10 1)
|
|
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 ${BASH32_SCRIPTS[@]+"${BASH32_SCRIPTS[@]}"}; do
|
|
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
|
|
if [[ -n "$FOUND16" ]]; then
|
|
HAZARDS16+="$FOUND16 "
|
|
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 "bash-4-only construct(s) found in ${#BASH32_SCRIPTS[@]} scanned script(s): $(echo "$HAZARDS16" | tr '\n' ' ')"
|
|
else
|
|
# 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
|
|
|
|
# ===== BEGIN VALE-DEPENDENT REGION (cases 17-18) =============================
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
|
|
# --- 17. The invocations whose arrays are closest to empty actually run. Under
|
|
# a bash older than 4.4 this is genuine macOS-shell coverage; on a modern bash it
|
|
# degrades to a smoke test, so the pass message names the shell that really ran.
|
|
# Point VALE_WRAP_TEST_BASH at a 3.2 build to get the real thing in CI.
|
|
echo ""
|
|
echo "--- degenerate invocations survive on the oldest available bash ---"
|
|
OLD_BASH="bash"
|
|
OLD_BASH_VER="$(bash -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"')"
|
|
for CAND in "${VALE_WRAP_TEST_BASH:-}" bash-3.2 bash3 /bin/bash /usr/local/bin/bash; do
|
|
[[ -n "$CAND" ]] && command -v "$CAND" >/dev/null 2>&1 || continue
|
|
CAND_VER="$("$CAND" -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"' 2>/dev/null)" || continue
|
|
[[ -n "$CAND_VER" ]] || continue
|
|
if (( ${CAND_VER%.*} * 100 + ${CAND_VER#*.} < ${OLD_BASH_VER%.*} * 100 + ${OLD_BASH_VER#*.} )); then
|
|
OLD_BASH="$CAND"
|
|
OLD_BASH_VER="$CAND_VER"
|
|
fi
|
|
done
|
|
FIXTURE17="$(make_fixture 2)"
|
|
mkdir -p "$FIXTURE17/emptydir"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" "$FIXTURE14" "$FIXTURE17"' EXIT
|
|
# Zero args, flags with no path, and a directory that walks to nothing are the
|
|
# three shapes that leave vale_args/path_args/argv_paths at their emptiest.
|
|
OUT17=""
|
|
for ARGS17 in "" "--config $VALE_CONFIG" "--config $VALE_CONFIG emptydir"; do
|
|
# shellcheck disable=SC2086 # deliberate word splitting of the argv fixture
|
|
OUT17+="$( (cd "$FIXTURE17" && "$OLD_BASH" "$SCRIPT" $ARGS17 </dev/null 2>&1) || true)"
|
|
done
|
|
if grep -q "unbound variable" <<< "$OUT17"; then
|
|
fail "aborted with 'unbound variable' on bash $OLD_BASH_VER — the bug this test guards against"
|
|
else
|
|
pass "degenerate invocations run clean under bash $OLD_BASH_VER ($OLD_BASH)"
|
|
fi
|
|
|
|
# --- 18. The guarded expansion must keep argv word boundaries intact. Dropping
|
|
# the quotes (`${arr[@]}`) also silences the unbound-variable abort, so it is the
|
|
# tempting wrong fix — and it splits any path containing a space into two bogus
|
|
# arguments. Case 15 covers spaces found by the directory walk; this covers a
|
|
# space in the path argument itself, which is what argv_paths expands.
|
|
echo ""
|
|
echo "--- a path argument containing a space survives the guarded expansion ---"
|
|
FIXTURE18="$(make_fixture 2)"
|
|
trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" "$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" "$FIXTURE14" "$FIXTURE17" "$FIXTURE18"' EXIT
|
|
SPACED18="$FIXTURE18/plugins/testplugin/skills/zzz skill dir"
|
|
mkdir -p "$SPACED18"
|
|
mv "$FIXTURE18/plugins/testplugin/skills/zzzskill/SKILL.md" "$SPACED18/SKILL.md"
|
|
OUT18=$(run_wrap "$FIXTURE18" --config "$VALE_CONFIG" "plugins/testplugin/skills/zzz skill dir/SKILL.md")
|
|
if grep -q "zzz skill dir/SKILL.md" <<< "$OUT18" && grep -q "VagueWording" <<< "$OUT18"; then
|
|
pass "a path argument with a space is passed to vale as one word"
|
|
else
|
|
fail "a path argument with a space was split by the array expansion: $OUT18"
|
|
fi
|
|
|
|
fi
|
|
# ===== END VALE-DEPENDENT REGION (cases 17-18) ===============================
|
|
|
|
# The cases below share one cleanup list. The per-case trap rebuilding above
|
|
# does not scale past the fixture count it already carries, and this trap is
|
|
# installed last, so it is the one that runs.
|
|
EXTRA_FIXTURES=()
|
|
new_fixture() { EXTRA_FIXTURES+=("$1"); }
|
|
cleanup_all() {
|
|
rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" \
|
|
"$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" \
|
|
"$FIXTURE14" "$FIXTURE17" "$FIXTURE18" \
|
|
${EXTRA_FIXTURES[@]+"${EXTRA_FIXTURES[@]}"}
|
|
}
|
|
trap cleanup_all EXIT
|
|
|
|
# ===== BEGIN VALE-DEPENDENT REGION (cases 19-25) =============================
|
|
# Case 25 is static, but it audits the fixtures case 19 creates, so it cannot
|
|
# run without 19 and is held back with it.
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
|
|
# --- 19. Every YAML form whose parsed value is joined back out of 2+ physical
|
|
# lines breaks the `text.frontmatter.description` scope identically, not just
|
|
# the `>` folded block the flattener originally handled: a plain scalar wrapped
|
|
# onto continuation lines, a double-quoted one, a single-quoted one, and a bare
|
|
# `description:` whose value starts on the next line all report zero alerts
|
|
# under bare vale. Each must come back with the same alerts as the single-line
|
|
# spelling of the same sentence. Line and column numbers legitimately move (the
|
|
# value lands on one physical line), so the comparison drops the `line:col`
|
|
# prefix and compares the alert text — message, matched token, and rule name.
|
|
REL_SKILL19="plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
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. 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)"
|
|
(cd "$dir" && git init -q)
|
|
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
|
|
{
|
|
echo "---"
|
|
echo "name: zzzskill"
|
|
case "$form" in
|
|
single) echo "description: $DESC19_A $DESC19_B" ;;
|
|
folded) echo "description: >"; echo " $DESC19_A"; echo " $DESC19_B" ;;
|
|
plain) echo "description: $DESC19_A"; echo " $DESC19_B" ;;
|
|
dquote) echo "description: \"$DESC19_A"; echo " $DESC19_B\"" ;;
|
|
squote) echo "description: '$DESC19_A"; echo " $DESC19_B'" ;;
|
|
keyonly) echo "description:"; echo " $DESC19_A"; echo " $DESC19_B" ;;
|
|
*) echo "make_form_fixture: unknown form '$form'" >&2; exit 1 ;;
|
|
esac
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$dir/plugins/testplugin/skills/zzzskill/SKILL.md"
|
|
echo "$dir"
|
|
}
|
|
|
|
# Alert text with the `line:col` prefix and ANSI colouring stripped, sorted.
|
|
# The `|| true` matters under this file's `set -o pipefail`: a report with no
|
|
# alerts at all makes grep exit 1, which would abort the whole run inside the
|
|
# command substitutions below — silently, before the empty-baseline guard could
|
|
# print anything. Returning empty output instead is what makes that guard
|
|
# reachable.
|
|
alert_text() {
|
|
echo "$1" \
|
|
| sed -E 's/\x1b\[[0-9;]*m//g' \
|
|
| { grep -oE '(error|warning|suggestion)[[:space:]]+.*' || true; } \
|
|
| sed -E 's/[[:space:]]+/ /g' \
|
|
| sort
|
|
}
|
|
|
|
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
|
|
# compares equal to five empty results, so it would print five vacuous PASSes
|
|
# alongside this one FAIL. The FAIL alone still fails the run at the end.
|
|
fail "the single-line baseline reported nothing — the comparisons below would be vacuous, so they are skipped"
|
|
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 grep -q "VagueWording" <<< "$BARE19"; then
|
|
fail "bare vale already flags the $FORM19 form, so this case can't detect a silently-skipped flattening"
|
|
elif [[ "$GOT19" == "$BASELINE19" ]]; then
|
|
pass "a $FORM19 multi-line description reports the same alerts as its single-line form"
|
|
else
|
|
fail "a $FORM19 multi-line description diverged from its single-line form: got [$GOT19]"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# --- 20. A style token containing an ASCII apostrophe matches inside a
|
|
# flattened description. The flattener used to substitute U+2019 for every `'`
|
|
# before writing the scratch copy, so no rule whose token carried an apostrophe
|
|
# could ever fire on a flattened description — a silent, rule-shaped blind spot.
|
|
# All three branches that can hold an apostrophe are exercised: a value that is
|
|
# safe unquoted; one that must be quoted (it contains `: `) and so lands in a
|
|
# double-quoted scalar, since a single-quoted one would need the `''` escape
|
|
# that kills the scope outright; and one that also holds a double quote, which
|
|
# no inline scalar can spell verbatim and which therefore lands in a `|-`
|
|
# literal block.
|
|
echo ""
|
|
echo "--- a style token containing an apostrophe matches in a flattened description ---"
|
|
APOS_STYLE="$(mktemp -d)"
|
|
new_fixture "$APOS_STYLE"
|
|
mkdir -p "$APOS_STYLE/styles/Apostrophe"
|
|
cat > "$APOS_STYLE/styles/Apostrophe/Token.yml" <<'EOF'
|
|
extends: existence
|
|
message: "apostrophe token: '%s'"
|
|
level: error
|
|
scope: text.frontmatter.description
|
|
ignorecase: true
|
|
tokens:
|
|
- "user's task"
|
|
EOF
|
|
# A body-scoped companion rule, used by case 20b to read back the line number of
|
|
# a line *after* the frontmatter — the only way to catch the blank-line pad
|
|
# being off in either direction.
|
|
cat > "$APOS_STYLE/styles/Apostrophe/Body.yml" <<'EOF'
|
|
extends: existence
|
|
message: "body token: '%s'"
|
|
level: error
|
|
scope: text
|
|
tokens:
|
|
- flattening marker phrase
|
|
EOF
|
|
cat > "$APOS_STYLE/.vale.ini" <<'EOF'
|
|
StylesPath = styles
|
|
|
|
[**/SKILL.md]
|
|
BasedOnStyles = Apostrophe
|
|
EOF
|
|
FIXTURE20_PLAIN="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Use when the user's task needs handling, and a second physical
|
|
line continues the folded scalar.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
new_fixture "$FIXTURE20_PLAIN"
|
|
FIXTURE20_QUOTED="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Triggers on: the user's task needing handling, and a second
|
|
physical line continues the folded scalar.
|
|
---
|
|
|
|
Body.
|
|
EOF
|
|
)"
|
|
new_fixture "$FIXTURE20_QUOTED"
|
|
# Needs quoting (`: `), holds an apostrophe AND a double quote — the one
|
|
# combination no inline scalar can carry, so this is the `|-` literal-block
|
|
# branch. The VagueWording tokens are there for case 20b, which reuses it.
|
|
FIXTURE20_BLOCK="$(make_raw_fixture <<'EOF'
|
|
---
|
|
name: zzzskill
|
|
description: >
|
|
Triggers on: the user's task and "audit this" phrasing, which helps
|
|
with and utilize things across a second physical line.
|
|
---
|
|
|
|
Body carrying a flattening marker phrase for the line-number check.
|
|
EOF
|
|
)"
|
|
new_fixture "$FIXTURE20_BLOCK"
|
|
for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED" \
|
|
"literal-block:$FIXTURE20_BLOCK"; do
|
|
if run_wrap "${CASE20#*:}" --config "$APOS_STYLE/.vale.ini" "$REL_SKILL19" \
|
|
| grep -q "Apostrophe.Token"; then
|
|
pass "an apostrophe-bearing token matches in a flattened ${CASE20%%:*} description"
|
|
else
|
|
fail "an apostrophe-bearing token was rewritten out of a flattened ${CASE20%%:*} description"
|
|
fi
|
|
done
|
|
|
|
# --- 20b. The `|-` literal-block branch that case 20 just proved lossless must
|
|
# also keep the rest of the scope working and keep the line accounting right.
|
|
# The block is 2 physical lines where every inline form is 1, so the blank-line
|
|
# pad that preserves later line numbers has to drop by one. The second
|
|
# assertion pins that arithmetic against the body line's true number: case 3's
|
|
# `<= original line count` bound would not, since a pad that is one line short
|
|
# shifts every later line *up*, staying inside the bound while still lying.
|
|
echo ""
|
|
echo "--- the |- literal-block fallback lints normally and preserves line numbers ---"
|
|
OUT20B=$(run_wrap "$FIXTURE20_BLOCK" --config "$VALE_CONFIG" "$REL_SKILL19")
|
|
if grep -q "VagueWording" <<< "$OUT20B"; then
|
|
pass "a description needing quotes with both an apostrophe and a double quote is still linted"
|
|
else
|
|
fail "a description needing quotes with both an apostrophe and a double quote produced no alerts"
|
|
fi
|
|
WANT20B_LINE="$(grep -n 'flattening marker phrase' "$FIXTURE20_BLOCK/$REL_SKILL19" | cut -d: -f1)"
|
|
# `--output line` prints `file:line:col:Rule:message`, so the line number reads
|
|
# back without any wrapping or colour to strip.
|
|
GOT20B_LINE="$(run_wrap "$FIXTURE20_BLOCK" --config "$APOS_STYLE/.vale.ini" --output line "$REL_SKILL19" \
|
|
| { grep 'Apostrophe.Body' || true; } | head -1 | cut -d: -f2)"
|
|
if [[ "$GOT20B_LINE" == "$WANT20B_LINE" ]]; then
|
|
pass "a body line after a |- flattened description keeps its original line number ($WANT20B_LINE)"
|
|
else
|
|
fail "the |- block's blank-line pad shifted the body: vale reported line $GOT20B_LINE, the file has it at $WANT20B_LINE"
|
|
fi
|
|
|
|
# --- 21. A symlinked file inside a directory argument is mirrored and linted.
|
|
# Vale follows symlinks (both a symlinked file and a file under a symlinked
|
|
# directory), so a `-type f` walk of the tree reported "0 files" where bare vale
|
|
# reports one — and the audit skills read a "0 files" report as NOT RUN.
|
|
echo ""
|
|
echo "--- mirrors a symlinked file reached through a directory argument ---"
|
|
FIXTURE21="$(make_fixture 2)"
|
|
new_fixture "$FIXTURE21"
|
|
mkdir -p "$FIXTURE21/real"
|
|
mv "$FIXTURE21/$REL_SKILL19" "$FIXTURE21/real/SKILL.md"
|
|
ln -s ../../../../real/SKILL.md "$FIXTURE21/$REL_SKILL19"
|
|
BARE21="$(cd "$FIXTURE21" && vale --config "$VALE_CONFIG" plugins 2>&1 || true)"
|
|
WRAPPED21="$(run_wrap "$FIXTURE21" --config "$VALE_CONFIG" plugins)"
|
|
BARE21_FILES="$(echo "$BARE21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
|
|
WRAPPED21_FILES="$(echo "$WRAPPED21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
|
|
if [[ "$BARE21_FILES" != "in 1 file" ]]; then
|
|
fail "bare vale did not lint the symlinked file ($BARE21_FILES), so this case can't detect the walk dropping it"
|
|
elif [[ "$WRAPPED21_FILES" != "$BARE21_FILES" ]]; then
|
|
fail "the directory walk dropped a symlinked file: wrapper saw '$WRAPPED21_FILES', bare vale '$BARE21_FILES'"
|
|
# A here-string, not `echo "$WRAPPED21" | grep -q`: the match sits on line 3 of
|
|
# 8, and under pipefail grep -q exiting early can SIGPIPE echo mid-write and
|
|
# fail this branch on correct output (see docs/spec/gates.md, Tests).
|
|
elif grep -q "VagueWording" <<< "$WRAPPED21"; then
|
|
pass "a symlinked file under a directory argument is mirrored, flattened and flagged"
|
|
else
|
|
fail "a symlinked file was mirrored but not flattened — no alert came back"
|
|
fi
|
|
|
|
# --- 22. The value of a separated two-argv flag is never treated as a lint
|
|
# target, however file-like it looks. `--output tmpl.tmpl` names a real
|
|
# template file: classifying it as input both linted the template and reordered
|
|
# argv, so vale received `--output --no-wrap` and died on `open :`.
|
|
echo ""
|
|
echo "--- a separated flag value that names a real file is not linted as a target ---"
|
|
FIXTURE22="$(make_fixture 1)"
|
|
new_fixture "$FIXTURE22"
|
|
printf 'TMPL{{range .Files}} {{.Path}}{{end}}\n' > "$FIXTURE22/tmpl.tmpl"
|
|
WRAPPED22="$(run_wrap "$FIXTURE22" --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19")"
|
|
BARE22="$(cd "$FIXTURE22" && vale --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19" 2>&1 || true)"
|
|
# The fixture's description is a single physical line, so flattening is a no-op
|
|
# and the two invocations must agree byte for byte.
|
|
if [[ "$WRAPPED22" == "$BARE22" ]]; then
|
|
pass "a separated --output value is passed through to vale, not linted"
|
|
else
|
|
fail "a separated --output value was misrouted: wrapper gave [$WRAPPED22], bare vale [$BARE22]"
|
|
fi
|
|
|
|
# --- 23. A path argument that does not exist is a hard error. Bare vale drops
|
|
# it, falls back to stdin and prints `0 errors ... in stdin` with exit 0, so a
|
|
# typo'd target is indistinguishable from a clean run — and the audit skills'
|
|
# NOT RUN guard string-matches `0 files`, which `in stdin` never produces. This
|
|
# is a deliberate divergence from bare vale, documented in the wrapper header.
|
|
echo ""
|
|
echo "--- a nonexistent path argument fails loudly instead of falling back to stdin ---"
|
|
set +e
|
|
OUT23="$(cd "$FIXTURE22" && bash "$SCRIPT" --config "$VALE_CONFIG" plugins/testplugin/skills/zzzskill/SKILLL.md 2>&1)"
|
|
RC23=$?
|
|
set -e
|
|
if [[ $RC23 -eq 0 ]]; then
|
|
fail "a typo'd path exited 0 — indistinguishable from a clean run, the bug this test guards against"
|
|
elif grep -q "in stdin" <<< "$OUT23"; then
|
|
fail "a typo'd path fell back to reading stdin and reported 'in stdin' instead of erroring"
|
|
elif grep -q "SKILLL.md" <<< "$OUT23"; then
|
|
pass "a typo'd path exits nonzero with a message naming the path"
|
|
else
|
|
fail "a typo'd path exited $RC23 but the message does not name it: $OUT23"
|
|
fi
|
|
|
|
# --- 24. `--output`'s built-in style names must not be path-absolutized. The
|
|
# wrapper rewrites path-valued flag values to absolute form so they still
|
|
# resolve after the `cd` into the scratch mirror, deciding with an `-e`
|
|
# existence test — but `line`, `JSON` and `CLI` are style names, not paths. With
|
|
# a file or directory of that name sitting in the caller's cwd the test hit, the
|
|
# built-in became `$cwd/line`, and vale flipped into template mode and died with
|
|
# `E100 [template] Runtime error` where bare vale prints a normal report.
|
|
echo ""
|
|
echo "--- a built-in --output style name survives a same-named entry in the cwd ---"
|
|
FIXTURE24="$(make_fixture 2)"
|
|
new_fixture "$FIXTURE24"
|
|
mkdir -p "$FIXTURE24/line"
|
|
: > "$FIXTURE24/JSON"
|
|
for FORM24 in "--output line" "--output=line" "--output JSON" "--output=JSON"; do
|
|
# shellcheck disable=SC2086 # deliberate word splitting of the argv fixture
|
|
OUT24="$(run_wrap "$FIXTURE24" --config "$VALE_CONFIG" $FORM24 "$REL_SKILL19")"
|
|
if grep -q "E100" <<< "$OUT24"; then
|
|
fail "'$FORM24' was rewritten to a cwd path and vale flipped into template mode — the bug this test guards against"
|
|
elif grep -q "VagueWording" <<< "$OUT24"; then
|
|
pass "'$FORM24' is passed through as a built-in style name"
|
|
else
|
|
fail "'$FORM24' produced no alert: $OUT24"
|
|
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
|
|
|
|
fi
|
|
# ===== END VALE-DEPENDENT REGION (cases 19-25) ===============================
|
|
|
|
# --- 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 grep -q "{$ARR26\[@\]}" <<< "$FOUND26"; then
|
|
LEAKED26+="$ARR26 "
|
|
fi
|
|
done
|
|
for ARR26 in $FLAGGED26; do
|
|
if ! grep -q "{$ARR26\[@\]}" <<< "$FOUND26"; 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
|
|
|
|
# Case 27 pins the `sourced_files()` exemption against the failure mode that
|
|
# actually happened (issue #97 item 2): a stale source-directive path.
|
|
#
|
|
# The exemption exists because array seeding often lives in the sourced file
|
|
# rather than the sourcing one -- install.sh's DEPLOY_* come from
|
|
# deploy-manifest.sh -- so without it those expansions read as hazards. It works
|
|
# by parsing each file's own source-directive comments. That makes it silently
|
|
# dependent on those paths resolving: a directive naming a file that is not
|
|
# there does not error, it just contributes no seed file, and the exemption
|
|
# quietly stops covering what it was written to cover.
|
|
#
|
|
# tests/run-tests.sh shipped exactly that for the length of PR #95 -- a directive
|
|
# resolving to neither the repo root nor the script's own directory. Nothing
|
|
# caught it: shellcheck's own SC1091 is `info`, and .pre-commit-config.yaml pins
|
|
# `--severity=warning`. The live consequence was nil only because both of
|
|
# run-tests.sh's expansions were independently guarded.
|
|
#
|
|
# Part C is the assertion that would have caught it, and it is the reason this
|
|
# case is not just fixture theatre: it holds every real directive in the scanned
|
|
# corpus to the resolution rule, so the next stale one fails here rather than
|
|
# lying dormant. Parts A and B pin the mechanism Part C depends on -- that
|
|
# resolution is what switches the exemption on, and non-resolution silently
|
|
# switches it off.
|
|
echo ""
|
|
echo "--- every shellcheck source directive resolves, so no seeding exemption is silently off ---"
|
|
FIXTURE27="$(mktemp -d)"
|
|
new_fixture "$FIXTURE27"
|
|
# The seeded array lives ONLY in the sourced file, never in the sourcing one --
|
|
# that separation is the whole point of the exemption.
|
|
{
|
|
echo "#!/usr/bin/env bash"
|
|
echo "SEEDED27=(a b c)"
|
|
} > "$FIXTURE27/seed-lib.sh"
|
|
|
|
# Both fixture bodies are emitted through printf with the closing `]` passed as
|
|
# an argument, never written literally. This file is itself inside the `tests`
|
|
# glob, so a literal bare expansion here would be scanned as a hazard in
|
|
# test-vale-wrap.sh's own source -- the trailing-comment/inside-a-string caveat
|
|
# documented above strip_comments(). Splitting the token means the emitted
|
|
# fixture holds the real text while this file holds no bare spelling, the same
|
|
# trick the `npro[c]` rule uses.
|
|
EXPANSION27="$(printf 'echo "${SEEDED27[@%s}"' ']')"
|
|
|
|
# Part A: a directive that resolves against the script's own directory exempts
|
|
# the expansion, so no hazard is reported.
|
|
{
|
|
echo "#!/usr/bin/env bash"
|
|
echo "# shellcheck source=seed-lib.sh"
|
|
echo 'source "$(dirname "$0")/seed-lib.sh"'
|
|
echo "$EXPANSION27"
|
|
} > "$FIXTURE27/resolves.sh"
|
|
|
|
# Part B: byte-identical except the directive names a file that is not there.
|
|
{
|
|
echo "#!/usr/bin/env bash"
|
|
echo "# shellcheck source=nonexistent/seed-lib.sh"
|
|
echo 'source "$(dirname "$0")/seed-lib.sh"'
|
|
echo "$EXPANSION27"
|
|
} > "$FIXTURE27/stale.sh"
|
|
|
|
RESOLVES27="$(unguarded_expansions "$FIXTURE27/resolves.sh")"
|
|
STALE27="$(unguarded_expansions "$FIXTURE27/stale.sh")"
|
|
|
|
# Part C: no real directive in the derived corpus may fail to resolve. Counted
|
|
# rather than diffed, because sourced_files() reports resolution only by
|
|
# omission -- an unresolvable directive produces no output line at all.
|
|
UNRESOLVED27=""
|
|
for BASH32_SCRIPT in ${BASH32_SCRIPTS[@]+"${BASH32_SCRIPTS[@]}"}; do
|
|
DECLARED27="$(grep -cE '^[[:space:]]*#[[:space:]]*shellcheck[[:space:]]+source=[^[:space:]]+' "$BASH32_SCRIPT" || true)"
|
|
[[ "$DECLARED27" -gt 0 ]] || continue
|
|
RESOLVED27="$(sourced_files "$BASH32_SCRIPT" | grep -c . || true)"
|
|
if [[ "$RESOLVED27" -lt "$DECLARED27" ]]; then
|
|
UNRESOLVED27+="${BASH32_SCRIPT#"$REPO_ROOT"/} ($RESOLVED27/$DECLARED27) "
|
|
fi
|
|
done
|
|
|
|
if [[ -n "$RESOLVES27" ]]; then
|
|
fail "an array seeded only in a resolvable sourced file was reported as a hazard, so the seeding exemption is not reading source directives at all"
|
|
elif [[ -z "$STALE27" ]]; then
|
|
fail "an array seeded only in an UNRESOLVABLE sourced file was still exempted, so this case cannot detect a stale directive and Part C proves nothing"
|
|
elif [[ -n "$UNRESOLVED27" ]]; then
|
|
fail "shellcheck source directive(s) resolve to nothing, silently disarming the seeding exemption for those files: $UNRESOLVED27"
|
|
else
|
|
pass "the seeding exemption switches on only for resolvable directives, and every directive in the scanned corpus resolves"
|
|
fi
|
|
|
|
# --- 28-30. `.vale.ini` glob coverage ---------------------------------------
|
|
#
|
|
# Rehomed from scripts/check-vale-style-sync.sh, deleted by ADR-0025. That
|
|
# script's text-level assertions diffed skill-audit's Vale copy against
|
|
# agent-audit's and went moot when the merge into factory-audit left one copy.
|
|
# Its GLOB-COVERAGE probes did not. Merging the copies does not make a glob typo
|
|
# impossible -- a typo in any one of the three sections of the surviving
|
|
# `.vale.ini` still skips that file shape -- and this file, which already owns
|
|
# the wrapper's behaviour against that same config, is where they belong.
|
|
#
|
|
# The original comment, preserved because it names the failure mode rather than
|
|
# describing the check: nothing in the repo read the `.vale.ini` at all, "and
|
|
# that is what let a one-character glob typo silently disable the prefilter for
|
|
# a whole file type: the hook still MATCHES the file via its `files:` regex, so
|
|
# pre-commit reports neither `Skipped` nor an error; vale lints zero files,
|
|
# prints `0 errors ... in 1 file` and exits 0, and the hook shows `Passed`."
|
|
# Measured again against the merged config while rehoming these: a mutated
|
|
# `[**/SKILL.md]` -> `[**/SKILLS.md]` reports `0 errors ... in 0 files` and
|
|
# exits 0. Every gate in this repo reads that as a pass.
|
|
#
|
|
# ADR-0014's reason for two hook IDs was this same problem, and .pre-commit-
|
|
# config.yaml still carries both IDs after the merge for that reason.
|
|
|
|
# One representative path per file shape the prefilter is supposed to cover,
|
|
# tagged with the `.vale.ini` section that is supposed to cover it and with
|
|
# whether that path is covered by that section ALONE.
|
|
#
|
|
# `isolating` is load-bearing, not decoration. `.apm/agents/demo.agent.md`
|
|
# matches BOTH `[**/agents/*.md]` and `[**/*.agent.md]`, so breaking either one
|
|
# leaves it linted by the other and a probe on it alone would prove nothing
|
|
# about which section is live. `copilot/demo.agent.md` -- a Copilot agent file
|
|
# outside any `agents/` directory -- is what pins `[**/*.agent.md]` on its own,
|
|
# and bare `demo.md` under `agents/` is what pins `[**/agents/*.md]`. Case 29
|
|
# asserts that separation instead of trusting this column.
|
|
#
|
|
# The two `.claude/`-prefixed rows carry the location-independence property
|
|
# (original comment, unchanged in substance): a `SKILL.md` outside `plugins/`
|
|
# still matches `[**/SKILL.md]` and gets linted normally -- the globs constrain
|
|
# filename shape, not location. Every other row starts with `plugins/`, so
|
|
# narrowing a glob to a `plugins/`-shaped path (`[**/SKILL.md]` ->
|
|
# `[**/.apm/skills/*/SKILL.md]`) left all of them matching while the
|
|
# project-scope shape started linting as `0 errors ... in 0 files`.
|
|
#
|
|
# `demo.md` (bare, no `.agent.md` suffix) exercises `[**/agents/*.md]` in
|
|
# isolation, not because any current `.apm/agents/*` file has that shape -- per
|
|
# ADR-0016 they are all `*.agent.md`. factory-audit's agent flow can still be
|
|
# handed that shape in a consuming repo, which is what keeps the row honest.
|
|
#
|
|
# These rows describe what factory-audit's own Vale call can be handed at
|
|
# runtime in any repo, not what this repo's hooks select: most of them sit
|
|
# outside `.pre-commit-config.yaml`'s `^plugins/`-anchored `files:` regexes on
|
|
# purpose. The hook-scope half that once held each row to a hook regex read the
|
|
# published `.pre-commit-hooks.yaml`, which is retired (ADR-0014, 2026-09-16
|
|
# amendment); case 32 owns the local hooks' scope.
|
|
PROBE_TABLE28="$(cat <<'EOF_PROBE28'
|
|
plugins/demo/.apm/skills/demo/SKILL.md|[**/SKILL.md]|isolating
|
|
.claude/skills/demo/SKILL.md|[**/SKILL.md]|isolating
|
|
plugins/demo/.apm/agents/demo.md|[**/agents/*.md]|isolating
|
|
.claude/agents/demo.md|[**/agents/*.md]|isolating
|
|
plugins/demo/.apm/agents/demo.agent.md|[**/*.agent.md]|overlapping
|
|
copilot/demo.agent.md|[**/*.agent.md]|isolating
|
|
EOF_PROBE28
|
|
)"
|
|
|
|
VALE_ASSETS28="$FACTORY_AUDIT/assets/vale"
|
|
|
|
# The probe file carries a description with a token Kyberforge.VagueWording
|
|
# flags, so a config whose glob matches but whose BasedOnStyles lost Kyberforge
|
|
# fails too: it would lint the file and report nothing. `Use proactively` is
|
|
# there for case 30 and is inert for the other two cases -- no Kyberforge rule
|
|
# matches it, only KyberforgeCopilot.ProactivePhrase does.
|
|
PROBE_DESC28="Use when the caller wants a probe that helps with things. Use proactively."
|
|
|
|
# One tree holding every probe path, reused by all three cases. The config is
|
|
# always passed as an absolute path from outside the tree, so one tree serves
|
|
# the real config and the mutated copies alike.
|
|
build_probe_tree28() {
|
|
local dir rel
|
|
dir="$(mktemp -d)"
|
|
while IFS='|' read -r rel _ _; do
|
|
[[ -n "$rel" ]] || continue
|
|
mkdir -p "$dir/$(dirname "$rel")"
|
|
{
|
|
echo "---"
|
|
echo "name: probe"
|
|
echo "description: $PROBE_DESC28"
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$dir/$rel"
|
|
done <<EOF_TREE28
|
|
$PROBE_TABLE28
|
|
EOF_TREE28
|
|
echo "$dir"
|
|
}
|
|
|
|
# vale's report with ANSI colouring stripped. `|| true` on the pipeline because
|
|
# vale exits non-zero merely for HAVING alerts, which is the expected outcome
|
|
# for every probe here, and this file runs under `set -o pipefail`.
|
|
vale_report28() {
|
|
local cfg="$1" dir="$2" rel="$3"
|
|
{ (cd "$dir" && vale --config "$cfg" "$rel" 2>&1) | sed -E 's/\x1b\[[0-9;]*m//g'; } || true
|
|
}
|
|
|
|
# The file count out of vale's own summary line, e.g. `... in 0 files.` Empty
|
|
# output means no summary line at all, which the callers treat as a failure
|
|
# rather than as zero -- vale not running and vale scanning nothing are
|
|
# different defects and must not report the same way.
|
|
files_scanned28() {
|
|
printf '%s\n' "$1" \
|
|
| { grep -oE 'in [0-9]+ files?\.' || true; } \
|
|
| { grep -oE '[0-9]+' || true; } \
|
|
| tail -1
|
|
}
|
|
|
|
# Prints `<id>|<files regex>` for every hook in the given pre-commit config
|
|
# whose entry is factory-audit's vale-wrap.sh. Records are delimited by their
|
|
# `- id:` line, so this does not depend on `entry:` preceding `files:` within a
|
|
# record. Case 32 needs to know which hook each regex belongs to, so the id is
|
|
# carried here.
|
|
hook_records28() {
|
|
local manifest="$1" id raw
|
|
[[ -f "$manifest" ]] || return 0
|
|
awk '
|
|
function flush() {
|
|
if (entry ~ /factory-audit\/scripts\/vale-wrap\.sh/ && files != "") print id "|" files
|
|
id = ""; entry = ""; files = ""
|
|
}
|
|
/^[ \t]*-[ \t]*id:/ { flush(); id = $0; sub(/^[ \t]*-[ \t]*id:[ \t]*/, "", id) }
|
|
/^[ \t]*entry:/ { entry = $0 }
|
|
/^[ \t]*files:/ { files = $0; sub(/^[ \t]*files:[ \t]*/, "", files) }
|
|
END { flush() }
|
|
' "$manifest" | while IFS='|' read -r id raw; do
|
|
# Strip the surrounding YAML quotes; the regex itself never carries them.
|
|
# `read -r id raw` splits on the FIRST `|` only, so a regex containing an
|
|
# alternation survives intact in $raw.
|
|
raw="${raw%\'}"; raw="${raw#\'}"
|
|
raw="${raw%\"}"; raw="${raw#\"}"
|
|
printf '%s|%s\n' "$id" "$raw"
|
|
done
|
|
}
|
|
|
|
TREE28="$(build_probe_tree28)"
|
|
new_fixture "$TREE28"
|
|
|
|
echo ""
|
|
echo "--- every .vale.ini glob section actually scans a real file shape ---"
|
|
|
|
# The floor the original carried as PROBES_CHECKED, moved off the probe count
|
|
# and onto the config itself, which is the stronger question. A gutted table or
|
|
# a table whose section column drifted away from the real headers verified
|
|
# nothing while FAIL stayed 0; so would a fourth section landing in `.vale.ini`
|
|
# with no probe behind it. Both now fail here.
|
|
SECTIONS28=""
|
|
[[ "$VALE_CONFIG_OK" != true ]] || SECTIONS28="$(grep -E '^\[.*\]$' "$VALE_ASSETS28/.vale.ini" || true)"
|
|
TABLE_SECTIONS28="$(printf '%s\n' "$PROBE_TABLE28" | cut -d'|' -f2 | sort -u)"
|
|
UNPROBED28=""
|
|
if [[ "$VALE_CONFIG_OK" != true ]]; then
|
|
# Case 0 already failed and named the cause; reading an unloadable config
|
|
# here would only add a second, vaguer failure for the same defect.
|
|
:
|
|
elif [[ -z "$SECTIONS28" ]]; then
|
|
fail "$VALE_ASSETS28/.vale.ini declares no [glob] section at all — vale would lint nothing, and every probe below would be vacuous"
|
|
else
|
|
while IFS= read -r SEC28; do
|
|
[[ -n "$SEC28" ]] || continue
|
|
if ! grep -qF "|$SEC28|isolating" <<< "$PROBE_TABLE28"; then
|
|
UNPROBED28+="$SEC28 "
|
|
fi
|
|
done <<EOF_SEC28
|
|
$SECTIONS28
|
|
EOF_SEC28
|
|
STALE_SECTIONS28=""
|
|
while IFS= read -r TSEC28; do
|
|
[[ -n "$TSEC28" ]] || continue
|
|
grep -qF -- "$TSEC28" <<< "$SECTIONS28" || STALE_SECTIONS28+="$TSEC28 "
|
|
done <<EOF_TSEC28
|
|
$TABLE_SECTIONS28
|
|
EOF_TSEC28
|
|
if [[ -n "$UNPROBED28" ]]; then
|
|
fail "glob section(s) of .vale.ini have no isolating probe row, so a typo in them would be invisible here: $UNPROBED28"
|
|
elif [[ -n "$STALE_SECTIONS28" ]]; then
|
|
fail "probe row(s) name a glob section that .vale.ini no longer declares, so those rows prove nothing about the live config: $STALE_SECTIONS28"
|
|
else
|
|
pass "every glob section in .vale.ini has an isolating probe row, and every probe row names a live section"
|
|
fi
|
|
fi
|
|
|
|
# One `PASS|<rel>|<message>` or `FAIL|<rel>|<message>` line per probe row, for
|
|
# the config at $1. A function rather than an inline loop so Part B can hold a
|
|
# mutated copy to this exact logic -- a second, "equivalent" loop for the
|
|
# fixture would prove nothing about the live check. With $2 = false it prints
|
|
# nothing: every remaining half of it needs vale.
|
|
probe_coverage28() {
|
|
local cfg="$1" with_vale="$2" rel sec report count
|
|
[[ "$with_vale" == true ]] || return 0
|
|
while IFS='|' read -r rel sec _; do
|
|
[[ -n "$rel" ]] || continue
|
|
report="$(vale_report28 "$cfg" "$TREE28" "$rel")"
|
|
count="$(files_scanned28 "$report")"
|
|
if [[ -z "$count" ]]; then
|
|
echo "FAIL|$rel|vale printed no summary line for $rel, so it is not known whether anything was scanned: ${report:-<empty>}"
|
|
elif [[ "$count" -eq 0 ]]; then
|
|
echo "FAIL|$rel|$sec scanned 0 files for $rel — the section's glob covers no path of that shape, so vale exits 0 and every gate reads it as a pass"
|
|
elif ! grep -qF "Kyberforge.VagueWording" <<< "$report"; then
|
|
echo "FAIL|$rel|$sec scanned $rel but raised no Kyberforge alert — the glob matches but the style is not loaded, which lints the file and reports nothing"
|
|
else
|
|
echo "PASS|$rel|$sec scans $rel ($count file) and raises a Kyberforge alert"
|
|
fi
|
|
done <<EOF_C28
|
|
$PROBE_TABLE28
|
|
EOF_C28
|
|
}
|
|
|
|
# Part A: the live assertion, against the shipped config. Held back entirely
|
|
# when the config cannot load -- case 0 has already named that.
|
|
PART_A_CLEAN28=false
|
|
if [[ "$VALE_CONFIG_OK" == true ]]; then
|
|
PART_A_CLEAN28=true
|
|
while IFS='|' read -r VERDICT28 _ MSG28; do
|
|
[[ -n "$VERDICT28" ]] || continue
|
|
if [[ "$VERDICT28" == PASS ]]; then pass "$MSG28"; else fail "$MSG28"; PART_A_CLEAN28=false; fi
|
|
done <<EOF_A28
|
|
$(probe_coverage28 "$VALE_ASSETS28/.vale.ini" "$VALE_READY")
|
|
EOF_A28
|
|
fi
|
|
|
|
# Part B: proof that Part A's style-not-loaded branch can fail. Case 29 already
|
|
# breaks each GLOB and watches the file count drop; nothing broke the other half
|
|
# of a section, its `BasedOnStyles`, so the branch that reports "the glob
|
|
# matches but the style is not loaded" was never shown to fire. For every
|
|
# section, `Kyberforge` is dropped from that section's BasedOnStyles in a COPY
|
|
# of the shipped assets (never the real file), and the probes that section
|
|
# covers alone must fail on exactly that branch while every other section's
|
|
# isolating probes keep passing. Overlapping rows are not asserted either way:
|
|
# measured against vale 3.15.2, a later matching section's BasedOnStyles
|
|
# replaces an earlier one's, so which verdict an overlapping row gets depends on
|
|
# section order, not on whether the check works.
|
|
#
|
|
# Run only against a config Part A passed. Every copy inherits the base's
|
|
# defects, so on a broken base the "other sections keep passing" half would
|
|
# report Part A's failure a second time.
|
|
if [[ "$VALE_READY" == true && "$PART_A_CLEAN28" == true ]]; then
|
|
echo ""
|
|
echo "--- dropping Kyberforge from any one section's BasedOnStyles fails that section's probes ---"
|
|
STYLE_MUT_FAILS28=""
|
|
while IFS= read -r MSEC28; do
|
|
[[ -n "$MSEC28" ]] || continue
|
|
SDIR28="$(mktemp -d)"
|
|
new_fixture "$SDIR28"
|
|
cp -r "$VALE_ASSETS28/." "$SDIR28/"
|
|
# Removes the exact list element `Kyberforge` from that section's
|
|
# BasedOnStyles only, so `KyberforgeCopilot` and every other section are
|
|
# untouched and the mutation isolates one style load.
|
|
awk -v target="$MSEC28" '
|
|
/^\[.*\]$/ { sec = $0 }
|
|
sec == target && /^[ \t]*BasedOnStyles[ \t]*=/ {
|
|
rhs = $0; sub(/^[^=]*=/, "", rhs)
|
|
n = split(rhs, parts, ","); out = ""
|
|
for (i = 1; i <= n; i++) {
|
|
p = parts[i]; gsub(/^[ \t]+|[ \t]+$/, "", p)
|
|
if (p == "" || p == "Kyberforge") continue
|
|
out = (out == "" ? p : out ", " p)
|
|
}
|
|
print "BasedOnStyles = " out
|
|
next
|
|
}
|
|
{ print }
|
|
' "$VALE_ASSETS28/.vale.ini" > "$SDIR28/.vale.ini"
|
|
if cmp -s "$VALE_ASSETS28/.vale.ini" "$SDIR28/.vale.ini"; then
|
|
STYLE_MUT_FAILS28+="[$MSEC28: the mutation left the config unchanged, so nothing was tested] "
|
|
continue
|
|
fi
|
|
RESULTS28="$(probe_coverage28 "$SDIR28/.vale.ini" true)"
|
|
while IFS='|' read -r REL28 ROWSEC28 ISO28; do
|
|
[[ -n "$REL28" && "$ISO28" == "isolating" ]] || continue
|
|
LINE28="$(printf '%s\n' "$RESULTS28" | { grep -F "|$REL28|" || true; } | head -1)"
|
|
if [[ "$ROWSEC28" == "$MSEC28" ]]; then
|
|
grep -qF "but raised no Kyberforge alert" <<< "$LINE28" \
|
|
|| STYLE_MUT_FAILS28+="[$MSEC28 lost Kyberforge but $REL28 did not fail as style-not-loaded: ${LINE28:-<no result>}] "
|
|
else
|
|
[[ "$LINE28" == PASS\|* ]] \
|
|
|| STYLE_MUT_FAILS28+="[$MSEC28 lost Kyberforge and unrelated probe $REL28 stopped passing, so the mutation was not confined to one section: ${LINE28:-<no result>}] "
|
|
fi
|
|
done <<EOF_B28
|
|
$PROBE_TABLE28
|
|
EOF_B28
|
|
done <<EOF_MSEC28
|
|
$SECTIONS28
|
|
EOF_MSEC28
|
|
if [[ -n "$STYLE_MUT_FAILS28" ]]; then
|
|
fail "Part A's style-not-loaded branch does not catch a section that lost Kyberforge: $STYLE_MUT_FAILS28"
|
|
else
|
|
pass "each section, with Kyberforge dropped from its BasedOnStyles in a copy of the config, fails its isolating probes as style-not-loaded while the others keep passing"
|
|
fi
|
|
fi
|
|
|
|
# --- 29. A broken glob section must FAIL, not pass quietly -------------------
|
|
# The probes above all pass today; what makes them worth having is that they
|
|
# stop passing when a section breaks. Asserted, not assumed: each section is
|
|
# typo'd in a COPY of the shipped assets (never the real file), and its
|
|
# isolating probe paths must go to 0 files scanned while every other section's
|
|
# probes keep scanning. A section whose mutation nothing notices is a section
|
|
# this suite does not really cover, and it fails here.
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
echo ""
|
|
echo "--- a typo in any one glob section is caught by that section's probes ---"
|
|
MUTATION_FAILS29=""
|
|
while IFS= read -r SEC29; do
|
|
[[ -n "$SEC29" ]] || continue
|
|
DIR29="$(mktemp -d)"
|
|
new_fixture "$DIR29"
|
|
cp -r "$VALE_ASSETS28/." "$DIR29/"
|
|
# Insert `ZZ` before the final `.` of the glob: `[**/SKILL.md]` ->
|
|
# `[**/SKILLZZ.md]`, the same one-token shape as the `SKILL.md`/`SKILLS.md`
|
|
# typo the original probes were written for. Applied to the header line only,
|
|
# so BasedOnStyles and StylesPath are untouched and the mutation isolates the
|
|
# glob.
|
|
MUTATED29="$(printf '%s\n' "$SEC29" | sed -E 's/\.([^.]*)\]$/ZZ.\1]/')"
|
|
if [[ "$MUTATED29" == "$SEC29" ]]; then
|
|
MUTATION_FAILS29+="[$SEC29: the mutation rule left the header unchanged, so nothing was tested] "
|
|
continue
|
|
fi
|
|
# Exact-line replacement via awk: the header is a bracket expression, so a
|
|
# sed pattern built from it would be read as a character class.
|
|
awk -v old="$SEC29" -v new="$MUTATED29" '$0 == old { print new; next } { print }' \
|
|
"$VALE_ASSETS28/.vale.ini" > "$DIR29/.vale.ini"
|
|
if ! grep -qF -- "$MUTATED29" "$DIR29/.vale.ini"; then
|
|
MUTATION_FAILS29+="[$SEC29: the mutated header never reached the copied config] "
|
|
continue
|
|
fi
|
|
while IFS='|' read -r REL29 ROWSEC29 ISO29; do
|
|
[[ -n "$REL29" ]] || continue
|
|
COUNT29="$(files_scanned28 "$(vale_report28 "$DIR29/.vale.ini" "$TREE28" "$REL29")")"
|
|
[[ -n "$COUNT29" ]] || { MUTATION_FAILS29+="[$SEC29 broken: no vale summary for $REL29] "; continue; }
|
|
if [[ "$ROWSEC29" == "$SEC29" && "$ISO29" == "isolating" ]]; then
|
|
[[ "$COUNT29" -eq 0 ]] || MUTATION_FAILS29+="[$SEC29 broken but $REL29 still scanned $COUNT29 file(s), so this section's probes cannot detect a typo in it] "
|
|
else
|
|
[[ "$COUNT29" -gt 0 ]] || MUTATION_FAILS29+="[$SEC29 broken and unrelated probe $REL29 stopped scanning, so the mutation was not confined to one section] "
|
|
fi
|
|
done <<EOF_C29
|
|
$PROBE_TABLE28
|
|
EOF_C29
|
|
done <<EOF_MUT29
|
|
$SECTIONS28
|
|
EOF_MUT29
|
|
if [[ -n "$MUTATION_FAILS29" ]]; then
|
|
fail "breaking a glob section did not fail its probes: $MUTATION_FAILS29"
|
|
else
|
|
pass "each glob section, typo'd in a copy of the config, drops its isolating probes to 0 files while the others keep scanning"
|
|
fi
|
|
fi
|
|
|
|
# --- 30. KyberforgeCopilot stays scoped to `.agent.md` -----------------------
|
|
# Per ADR-0013 the Copilot style covers `.agent.md` files ALONE, for the
|
|
# Copilot-only "`Use proactively` has no effect" check, and must not be extended
|
|
# to `.md` files. The merge preserved that scoping and nothing else asserts it.
|
|
#
|
|
# The original comment on the incident, which is why this is checked at all
|
|
# rather than assumed: dropping `, KyberforgeCopilot` from the `[**/*.agent.md]`
|
|
# section "unloaded the whole style silently: no glob broke, the styles/ diff
|
|
# above stayed clean (the style directory is still shipped, just never loaded),
|
|
# the two .vale.ini files are deliberately unequal so no equality check applies,
|
|
# and the probe below still passed because it keys on a Kyberforge alert."
|
|
# One config removes the diff and the equality argument; it does not remove the
|
|
# silence, so the assertion is kept and made behavioural.
|
|
# Prints one failure token per mis-scoped probe for the config at $1; empty
|
|
# output means the Copilot style reaches exactly the `.agent.md` probes. A
|
|
# function so Part B runs this exact logic against mutated copies.
|
|
copilot_scope_failures30() {
|
|
local cfg="$1" rel report has bad=""
|
|
while IFS='|' read -r rel _ _; do
|
|
[[ -n "$rel" ]] || continue
|
|
report="$(vale_report28 "$cfg" "$TREE28" "$rel")"
|
|
has=false
|
|
if grep -qF "KyberforgeCopilot.ProactivePhrase" <<< "$report"; then has=true; fi
|
|
case "$rel" in
|
|
*.agent.md)
|
|
[[ "$has" == true ]] || bad+="[$rel is an agent file but raised no ProactivePhrase alert — the Copilot style is shipped but never loaded for it, so its rules lint nothing] "
|
|
;;
|
|
*)
|
|
# Non-vacuity guard: `absent` only means `scoped out` if the file was
|
|
# scanned at all. Without the Kyberforge half, a glob that stopped
|
|
# matching this path entirely would read as correct scoping.
|
|
if ! grep -qF "Kyberforge.VagueWording" <<< "$report"; then
|
|
bad+="[$rel raised no Kyberforge alert either, so its missing ProactivePhrase proves nothing about scoping] "
|
|
elif [[ "$has" == true ]]; then
|
|
bad+="[$rel is not an .agent.md file but raised a ProactivePhrase alert — the Copilot style has leaked past the scope ADR-0013 fixes it to] "
|
|
fi
|
|
;;
|
|
esac
|
|
done <<EOF_C30
|
|
$PROBE_TABLE28
|
|
EOF_C30
|
|
printf '%s' "$bad"
|
|
}
|
|
|
|
# Rewrites the BasedOnStyles line of one section ($2) of config $1 to $3.
|
|
rewrite_styles30() {
|
|
awk -v target="$2" -v styles="$3" '
|
|
/^\[.*\]$/ { sec = $0 }
|
|
sec == target && /^[ \t]*BasedOnStyles[ \t]*=/ { print "BasedOnStyles = " styles; next }
|
|
{ print }
|
|
' "$1"
|
|
}
|
|
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
echo ""
|
|
echo "--- KyberforgeCopilot reaches .agent.md files and nothing else ---"
|
|
|
|
# Part A: the live assertion, against the shipped config.
|
|
COPILOT_FAILS30="$(copilot_scope_failures30 "$VALE_ASSETS28/.vale.ini")"
|
|
if [[ -n "$COPILOT_FAILS30" ]]; then
|
|
fail "KyberforgeCopilot.ProactivePhrase is mis-scoped: $COPILOT_FAILS30"
|
|
else
|
|
pass "ProactivePhrase fires on every .agent.md probe and on no SKILL.md or agents/*.md probe"
|
|
fi
|
|
|
|
# Part B: proof that Part A can fail, in both directions it guards. Each
|
|
# mutation is written into a COPY of the shipped assets, never the real file.
|
|
#
|
|
# unload -- `, KyberforgeCopilot` dropped from `[**/*.agent.md]`: the exact
|
|
# edit the original comment above records as having passed silently.
|
|
# Part A must report the agent probes as never loading the style.
|
|
# leak -- `KyberforgeCopilot` added to `[**/SKILL.md]`, the extension to
|
|
# `.md` files ADR-0013 forbids. Part A must report the SKILL.md
|
|
# probes as leaked.
|
|
#
|
|
# Run only against a config Part A passed: on a base that already unloads or
|
|
# leaks the style, a mutation is a no-op or a duplicate of Part A's failure.
|
|
if [[ -z "$COPILOT_FAILS30" ]]; then
|
|
echo ""
|
|
echo "--- unloading or leaking KyberforgeCopilot in a copy of the config fails Part A ---"
|
|
UNLOAD30="$(mktemp -d)"
|
|
new_fixture "$UNLOAD30"
|
|
cp -r "$VALE_ASSETS28/." "$UNLOAD30/"
|
|
rewrite_styles30 "$VALE_ASSETS28/.vale.ini" "[**/*.agent.md]" "Kyberforge" > "$UNLOAD30/.vale.ini"
|
|
LEAK30="$(mktemp -d)"
|
|
new_fixture "$LEAK30"
|
|
cp -r "$VALE_ASSETS28/." "$LEAK30/"
|
|
rewrite_styles30 "$VALE_ASSETS28/.vale.ini" "[**/SKILL.md]" "Kyberforge, KyberforgeCopilot" > "$LEAK30/.vale.ini"
|
|
UNLOAD_FAILS30="$(copilot_scope_failures30 "$UNLOAD30/.vale.ini")"
|
|
LEAK_FAILS30="$(copilot_scope_failures30 "$LEAK30/.vale.ini")"
|
|
if cmp -s "$VALE_ASSETS28/.vale.ini" "$UNLOAD30/.vale.ini" || cmp -s "$VALE_ASSETS28/.vale.ini" "$LEAK30/.vale.ini"; then
|
|
fail "a Copilot-scope mutation left the copied config unchanged, so Part B mutated nothing and proves nothing about Part A"
|
|
elif ! grep -qF "[copilot/demo.agent.md is an agent file but raised no ProactivePhrase alert" <<< "$UNLOAD_FAILS30"; then
|
|
fail "dropping KyberforgeCopilot from [**/*.agent.md] did not fail Part A, so an unloaded Copilot style would pass silently again: ${UNLOAD_FAILS30:-<no failure>}"
|
|
elif ! grep -qF "[plugins/demo/.apm/skills/demo/SKILL.md is not an .agent.md file but raised a ProactivePhrase alert" <<< "$LEAK_FAILS30"; then
|
|
fail "adding KyberforgeCopilot to [**/SKILL.md] did not fail Part A, so the style could leak past ADR-0013's scope unnoticed: ${LEAK_FAILS30:-<no failure>}"
|
|
else
|
|
pass "unloading KyberforgeCopilot from .agent.md files and leaking it onto SKILL.md files are each caught by Part A"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# --- 31. No Kyberforge rule is overridden out of its blocking level ----------
|
|
#
|
|
# Also rehomed from the deleted scripts/check-vale-style-sync.sh (ADR-0025).
|
|
# This one is not a glob probe and not a copy diff: it is a third class the
|
|
# merge took out with the script, and cases 28-30 cannot backstop it. They key
|
|
# on `Kyberforge.VagueWording` and `KyberforgeCopilot.ProactivePhrase`, so
|
|
# DescriptionOpener, PaddingPhrase, SentenceOpenerThereIs and CompositionNote
|
|
# can each be retired underneath a passing probe.
|
|
#
|
|
# The original's comments, verbatim:
|
|
#
|
|
# Per-rule overrides are the third way to retire a rule without touching a
|
|
# style file or a glob. Per ADR-0013, every rule is `level: error` and every
|
|
# alert is a FAIL — there is no ignorable tier. Vale's exit
|
|
# code keys on `error` alerts alone, so any override that leaves a rule at
|
|
# anything other than `error` still lints the file, still exits 0, and still
|
|
# shows `Passed` in pre-commit. The glob probe below cannot backstop this: it
|
|
# keys on one `Kyberforge.VagueWording` alert, so DescriptionOpener,
|
|
# PaddingPhrase, SentenceOpenerThereIs and ProactivePhrase can each be retired
|
|
# underneath a passing probe.
|
|
#
|
|
# Asserted as an ALLOWLIST, not a blocklist of `NO|warning|suggestion`, because
|
|
# that is vale 3.15.2's own semantic: only the exact tokens `YES` and `error`
|
|
# keep a rule blocking. `warning`/`suggestion` downgrade it (alert still
|
|
# printed, exit 0 — invisible, since pre-commit swallows a passing hook's
|
|
# output); every other value — `NO`, `false`, `0`, `off`, `n`, empty,
|
|
# `garbage`, and lowercase `yes`, `true`, `1`, `on` — silences the rule
|
|
# outright. Lowercase `yes` is the trap a blocklist cannot cover: it reads as
|
|
# "enabled" to a human and disables the rule. Verified by enumerating the
|
|
# value space against vale 3.15.2.
|
|
#
|
|
# The allowlist demands a BARE `YES`/`error` with nothing after it, which also
|
|
# rejects `error # note` and `error ; note`. Vale itself strips those — a
|
|
# whitespace-preceded `#` or `;` comment is removed and the rule stays live —
|
|
# so rejecting them is deliberately stricter than vale, not a workaround for
|
|
# it. Uniformity is worth more here than the ability to annotate a line that
|
|
# should not exist: no shipped `.vale.ini` has any override line at all, and
|
|
# the failure mode is a loud false positive rather than a silent pass. The
|
|
# genuine hazard is the no-space form — `error# note` and `error; note` are
|
|
# NOT stripped and silence the rule outright — and a rule that demands a bare
|
|
# token catches those without having to reimplement vale's comment parsing.
|
|
#
|
|
# `[A-Za-z0-9_-]` on both halves of the name, not `[A-Za-z]`: a rule named
|
|
# `Kyberforge.Vague2` is genuinely silenced by `= NO` (verified: 1 error ->
|
|
# 0 errors), so an alpha-only class would let a digit-bearing rule name slip
|
|
# past the gate. All five current rule names are pure alpha, so this is
|
|
# forward cover, not a live hole.
|
|
|
|
# The detector, with the original's two regexes unchanged. Part A runs it
|
|
# against the real config and Part B runs it against synthesised lines, so the
|
|
# thing Part B proves is the same thing Part A relies on — a second, "equivalent"
|
|
# copy of the regex for the fixtures would prove nothing about the live check.
|
|
#
|
|
# Original comment on the missing `-q`, kept because it names the trap: No
|
|
# `grep -q` in that pipeline on purpose: `-q` exits on its first match, and
|
|
# under `set -o pipefail` the resulting SIGPIPE on the upstream grep would make
|
|
# the whole pipeline report 141 and read as "no findings".
|
|
bad_overrides31() {
|
|
grep -E '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=' "$1" \
|
|
| grep -Ev '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=[[:space:]]*(YES|error)[[:space:]]*$' \
|
|
|| true
|
|
}
|
|
|
|
echo ""
|
|
echo "--- no .vale.ini override downgrades a Kyberforge rule out of its blocking level ---"
|
|
|
|
# Part A: the live assertion, against the shipped config. A plain grep, so it
|
|
# runs without vale; held back only when case 0 found the config missing or
|
|
# unreadable, where grepping it would read as "no bad override" and pass.
|
|
BAD31=""
|
|
[[ "$VALE_CONFIG_OK" != true ]] || BAD31="$(bad_overrides31 "$VALE_ASSETS28/.vale.ini")"
|
|
if [[ "$VALE_CONFIG_OK" != true ]]; then
|
|
:
|
|
elif [[ -n "$BAD31" ]]; then
|
|
fail ".vale.ini overrides a Kyberforge rule to something other than a bare YES or error (first: '${BAD31%%$'\n'*}') — every rule in this prefilter is level: error and every alert is a FAIL, and any other value downgrades or silences the rule while vale still exits 0. A trailing comment is rejected too: vale strips a spaced '# ...' but not 'error# ...', so this asks for the bare token rather than guessing which form you meant"
|
|
else
|
|
pass "the shipped .vale.ini carries no Kyberforge rule override outside the bare YES/error allowlist"
|
|
fi
|
|
|
|
# Part B: the detector is held to every rule that actually ships, enumerated
|
|
# from the style directories on disk rather than from a list in this file. A
|
|
# list would have to be remembered; the styles directory cannot be forgotten,
|
|
# because adding a rule IS adding a file to it. This is what makes Part A
|
|
# forward-covering: a rule added later under a style whose name the detector's
|
|
# `Kyberforge[A-Za-z0-9_-]*` class does not match fails here the day it lands,
|
|
# rather than being silently un-gated.
|
|
RULES31="$(
|
|
for RULE_FILE31 in "$VALE_ASSETS28"/styles/*/*.yml "$VALE_ASSETS28"/styles/*/*.yaml; do
|
|
[[ -f "$RULE_FILE31" ]] || continue
|
|
STYLE_DIR31="${RULE_FILE31%/*}"
|
|
RULE_BASE31="${RULE_FILE31##*/}"
|
|
RULE_BASE31="${RULE_BASE31%.yml}"
|
|
printf '%s.%s\n' "${STYLE_DIR31##*/}" "${RULE_BASE31%.yaml}"
|
|
done
|
|
)"
|
|
RULE_COUNT31="$(printf '%s\n' "$RULES31" | grep -c . || true)"
|
|
|
|
# Both halves are asserted. A detector that flags everything would pass the
|
|
# reject rows while making Part A a permanent false alarm; one that flags
|
|
# nothing would pass the accept rows while making Part A vacuous. Only holding
|
|
# it to both tells the two apart.
|
|
VARIANTS31="$(cat <<'EOF_VAR31'
|
|
reject|NO
|
|
reject|warning
|
|
reject|suggestion
|
|
reject|yes
|
|
reject|true
|
|
reject|
|
|
reject|error # note
|
|
reject|error# note
|
|
reject|error; note
|
|
accept|YES
|
|
accept|error
|
|
EOF_VAR31
|
|
)"
|
|
|
|
DETECTOR_FAILS31=""
|
|
SYNTH31="$(mktemp -d)"
|
|
new_fixture "$SYNTH31"
|
|
if [[ "$RULE_COUNT31" -eq 0 ]]; then
|
|
fail "no rule file was found under $VALE_ASSETS28/styles/*/ — the enumeration is empty, so Part B verifies nothing and Part A's forward cover is unproven"
|
|
else
|
|
while IFS= read -r RULE31; do
|
|
[[ -n "$RULE31" ]] || continue
|
|
while IFS='|' read -r VERDICT31 VALUE31; do
|
|
[[ -n "$VERDICT31" ]] || continue
|
|
# Written under a real section header so the fixture is a config a human
|
|
# could plausibly ship, not a bare fragment.
|
|
{
|
|
echo "StylesPath = styles"
|
|
echo ""
|
|
echo "[**/SKILL.md]"
|
|
echo "BasedOnStyles = Kyberforge"
|
|
echo "$RULE31 = $VALUE31"
|
|
} > "$SYNTH31/probe.ini"
|
|
GOT31="$(bad_overrides31 "$SYNTH31/probe.ini")"
|
|
if [[ "$VERDICT31" == "reject" && -z "$GOT31" ]]; then
|
|
DETECTOR_FAILS31+="[$RULE31 = '$VALUE31' was NOT flagged, so this value could silence or downgrade the rule with nothing noticing] "
|
|
elif [[ "$VERDICT31" == "accept" && -n "$GOT31" ]]; then
|
|
DETECTOR_FAILS31+="[$RULE31 = '$VALUE31' WAS flagged, so the allowlist rejects a value that keeps the rule blocking] "
|
|
fi
|
|
done <<EOF_VAR_INNER31
|
|
$VARIANTS31
|
|
EOF_VAR_INNER31
|
|
done <<EOF_RULES31
|
|
$RULES31
|
|
EOF_RULES31
|
|
if [[ -n "$DETECTOR_FAILS31" ]]; then
|
|
fail "the override allowlist does not hold for every shipped rule: $DETECTOR_FAILS31"
|
|
else
|
|
pass "all $RULE_COUNT31 rules enumerated from styles/ are covered by the allowlist, which accepts bare YES/error and rejects NO, warning, suggestion, lowercase yes, true, empty and every trailing-comment form"
|
|
fi
|
|
fi
|
|
|
|
# Part C: proof that the thing being gated is a real hazard and a SILENT one.
|
|
# The original recorded this as hand-verified ("1 error -> 0 errors"); asserting
|
|
# it here means the case cannot quietly degrade into guarding a failure mode
|
|
# vale no longer has. One rule is named deliberately rather than looping the
|
|
# enumeration: the mechanism under test is vale's override handling, which is
|
|
# not per-rule, and VagueWording is the rule the shared probe description
|
|
# already trips, so no second fixture shape is needed.
|
|
#
|
|
# `copilot/demo.agent.md` because the override is appended at the end of the
|
|
# file, which places it in the last section — `[**/*.agent.md]`.
|
|
if [[ "$VALE_READY" == true ]]; then
|
|
OVERRIDE_DIR31="$(mktemp -d)"
|
|
new_fixture "$OVERRIDE_DIR31"
|
|
cp -r "$VALE_ASSETS28/." "$OVERRIDE_DIR31/"
|
|
printf 'Kyberforge.VagueWording = NO\n' >> "$OVERRIDE_DIR31/.vale.ini"
|
|
SILENCED31="$(vale_report28 "$OVERRIDE_DIR31/.vale.ini" "$TREE28" "copilot/demo.agent.md")"
|
|
SILENCED_COUNT31="$(files_scanned28 "$SILENCED31")"
|
|
if [[ -z "$SILENCED_COUNT31" || "$SILENCED_COUNT31" -eq 0 ]]; then
|
|
fail "the override fixture scanned no file at all, so the disappearance of the VagueWording alert proves nothing about overrides"
|
|
elif grep -qF "Kyberforge.VagueWording" <<< "$SILENCED31"; then
|
|
# Reported as a FAIL, not a pass. It is not a defect in the config, but it
|
|
# means Parts A and B are guarding a failure mode this vale build no longer
|
|
# has — and a guard that guards nothing while reporting PASS is the same
|
|
# vacuous pass the whole case exists to close. Someone has to look.
|
|
fail "an '= NO' override no longer silences the rule in this vale build, so the allowlist in Parts A and B is guarding a failure mode that no longer exists — re-verify against this vale version before trusting or removing it"
|
|
else
|
|
pass "an '= NO' override silences a rule while vale still scans the file and exits 0, which is the silent downgrade the allowlist above exists to catch"
|
|
fi
|
|
fi
|
|
|
|
# --- 32. Every vale prefilter hook in .pre-commit-config.yaml still selects a
|
|
# live, correctly-classed corpus ----------------------------------------------
|
|
#
|
|
# The one assertion of the deleted scripts/check-vale-style-sync.sh (ADR-0025)
|
|
# that cases 28-31 did not rehome, re-scoped onto what is actually at risk. The
|
|
# original compared the `files:` regexes across the two manifests, selecting each
|
|
# hook by its `entry:` (`entry ~ skill "/scripts/vale-wrap.sh"`, old line 220) --
|
|
# which worked only because the two skills gave the two hooks two distinct entry
|
|
# paths. After the merge both hooks share one `entry:`, so that selector can no
|
|
# longer tell them apart and a faithful port would have to key on hook `id:`
|
|
# instead. That port was case 33, deleted with the published
|
|
# `.pre-commit-hooks.yaml` it compared against (ADR-0014, 2026-09-16
|
|
# amendment); its one guard that did not depend on the second manifest -- a
|
|
# local regex narrowed to a single plugin -- is property 3 below. Nothing else
|
|
# in the repo asserts this repo's OWN prefilter regexes --
|
|
# `.pre-commit-config.yaml`'s vale-audit-prefilter-skill and
|
|
# vale-audit-prefilter-agent. Narrow either one to match zero files and every gate still passes: pre-commit does not
|
|
# error on a hook that matches nothing, it simply never runs it. That is the same
|
|
# silent-zero failure mode case 28 guards on the vale side of this pipeline, one
|
|
# layer up -- there the glob scans 0 files and exits 0, here the hook is handed 0
|
|
# files and never starts.
|
|
#
|
|
# Three properties, because matching SOMETHING is not the same as matching the
|
|
# right thing: a regex loosened to `^plugins/` would match hundreds of files and
|
|
# clear a bare non-emptiness check while handing vale a corpus it has no glob
|
|
# for. So each hook must also select only its own artifact class -- ADR-0014's
|
|
# reason for two hook IDs, carried across the merge by ADR-0025's comment in the
|
|
# config, is precisely that the two scopes stay independently addressable.
|
|
# And each hook must select ALL of its class's authoring source: a regex
|
|
# narrowed from `^plugins/[^/]+/...` to `^plugins/kyberforge/...` still matches
|
|
# tracked files, all of the right class, while silently dropping every other
|
|
# plugin out of the prefilter. Measured before any case caught it: that exact
|
|
# narrowing left 6 of 38 skills prefiltered and the whole suite green.
|
|
|
|
echo ""
|
|
echo "--- each .pre-commit-config.yaml vale prefilter hook matches a real, correctly-classed file ---"
|
|
|
|
PC_CONFIG32="$REPO_ROOT/.pre-commit-config.yaml"
|
|
# The corpus pre-commit itself draws from. A `files:` regex is matched against
|
|
# repository paths, so a regex matching no tracked path matches nothing the gate
|
|
# will ever hand the hook.
|
|
REPO_FILES32="$(cd "$REPO_ROOT" && git ls-files)"
|
|
# Each class's full authoring-source corpus, per the layout AGENTS.md fixes
|
|
# (`plugins/<name>/.apm/` is the only authoring source). Globals, not locals,
|
|
# so Part D can point them at a layout that no longer exists.
|
|
CORPUS_RE_SKILL32='^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
|
|
CORPUS_RE_AGENT32='^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$'
|
|
|
|
# Prints one failure token per defect; empty output means every prefilter hook in
|
|
# $1 is scoped to a live corpus of its own artifact class. The config path is an
|
|
# argument so Part B can run this exact function against a mutated copy -- a
|
|
# second, "equivalent" implementation for the fixture would prove nothing about
|
|
# the live check.
|
|
prefilter_scope_failures32() {
|
|
local config="$1" files="$2"
|
|
local records id re class matched count offenders offending m corpus missing nmissing corpus_re
|
|
local seen_skill=false seen_agent=false bad=""
|
|
records="$(hook_records28 "$config")"
|
|
if [[ -z "$records" ]]; then
|
|
printf '%s' "[no hook whose entry is factory-audit's vale-wrap.sh was parsed out of ${config##*/} at all, so nothing below was checked] "
|
|
return 0
|
|
fi
|
|
while IFS='|' read -r id re; do
|
|
[[ -n "$id" ]] || continue
|
|
case "$id" in
|
|
*-skill) class="skill" ;;
|
|
*-agent) class="agent" ;;
|
|
*)
|
|
bad+="[$id is a vale prefilter hook whose id names neither artifact class, so this case cannot tell which corpus it is meant to select] "
|
|
continue
|
|
;;
|
|
esac
|
|
# Recorded as soon as the class is known, not at the end of the iteration:
|
|
# a hook that fails a check below is still a hook that EXISTS, and reporting
|
|
# it as missing as well would bury the real defect under a second message.
|
|
[[ "$class" != skill ]] || seen_skill=true
|
|
[[ "$class" != agent ]] || seen_agent=true
|
|
matched="$(printf '%s\n' "$files" | { grep -E "$re" || true; })"
|
|
count="$(printf '%s\n' "$matched" | grep -c . || true)"
|
|
if [[ "$count" -eq 0 ]]; then
|
|
bad+="[$id: 'files: $re' matches no tracked file in this repo, so pre-commit never runs it and the prefilter is off for the whole $class corpus while every gate still reports a pass] "
|
|
continue
|
|
fi
|
|
offenders=""
|
|
offending=0
|
|
while IFS= read -r m; do
|
|
[[ -n "$m" ]] || continue
|
|
# Per ADR-0016 every agent file is `<name>.agent.md` under an `agents/`
|
|
# directory; the skill corpus is SKILL.md files. Asserting the shape of
|
|
# every matched path is what rejects a regex loosened to a wider scope,
|
|
# and it makes the two corpora disjoint by construction.
|
|
if [[ "$class" == skill ]]; then
|
|
[[ "$m" != */SKILL.md ]] || continue
|
|
else
|
|
[[ "$m" != */agents/*.agent.md ]] || continue
|
|
fi
|
|
offending=$((offending + 1))
|
|
# A loosened regex can select hundreds of paths; three name the shape of
|
|
# the leak, and the count carries the scale.
|
|
[[ "$offending" -gt 3 ]] || offenders+="$m "
|
|
done <<EOF_MATCHED32
|
|
$matched
|
|
EOF_MATCHED32
|
|
if [[ "$offending" -gt 0 ]]; then
|
|
bad+="[$id: 'files: $re' selects $count file(s), $offending of them outside the $class artifact class, so the two prefilter scopes are no longer independently addressable and vale is handed files no glob in its config covers — first: $offenders] "
|
|
fi
|
|
# Every path in the class's corpus (CORPUS_RE_*32 above) must be
|
|
# selected, or part of the corpus is silently unprefiltered.
|
|
if [[ "$class" == skill ]]; then
|
|
corpus_re="$CORPUS_RE_SKILL32"
|
|
else
|
|
corpus_re="$CORPUS_RE_AGENT32"
|
|
fi
|
|
corpus="$(printf '%s\n' "$files" | { grep -E "$corpus_re" || true; })"
|
|
# An empty corpus makes the comparison below vacuous: nothing can be
|
|
# missing from nothing. The corpus regex is hard-coded to today's layout,
|
|
# so a layout move would silently turn this property into a pass. Fail
|
|
# instead, the same way property 1 fails on zero matches.
|
|
if [[ -z "$(printf '%s\n' "$corpus" | grep . || true)" ]]; then
|
|
bad+="[$id: the $class corpus regex '$corpus_re' matches no tracked file, so this case cannot check that 'files: $re' selects the whole $class corpus; update the regex to the current layout] "
|
|
continue
|
|
fi
|
|
missing="$(comm -23 <(printf '%s\n' "$corpus" | grep . | sort) <(printf '%s\n' "$matched" | grep . | sort) || true)"
|
|
nmissing="$(printf '%s\n' "$missing" | grep -c . || true)"
|
|
if [[ "$nmissing" -gt 0 ]]; then
|
|
bad+="[$id: 'files: $re' misses $nmissing tracked $class file(s) under plugins/*/.apm/, so part of the corpus is never prefiltered while every gate still reports a pass — first: $(printf '%s\n' "$missing" | head -3 | tr '\n' ' ')] "
|
|
fi
|
|
done <<EOF_RECORDS32
|
|
$records
|
|
EOF_RECORDS32
|
|
# A config that simply dropped one of the two hooks would otherwise pass every
|
|
# check above by having nothing left to fail.
|
|
[[ "$seen_skill" == true ]] || bad+="[no vale prefilter hook for the skill corpus was found in ${config##*/}, so SKILL.md files are not prefiltered at all] "
|
|
[[ "$seen_agent" == true ]] || bad+="[no vale prefilter hook for the agent corpus was found in ${config##*/}, so agent files are not prefiltered at all] "
|
|
printf '%s' "$bad"
|
|
}
|
|
|
|
# Part A: the live assertion, against this repo's own config.
|
|
if [[ -z "$REPO_FILES32" ]]; then
|
|
fail "git ls-files reported no tracked file, so the corpus this case matches the hook regexes against is empty and every check below would be vacuous"
|
|
else
|
|
SCOPE_FAILS32="$(prefilter_scope_failures32 "$PC_CONFIG32" "$REPO_FILES32")"
|
|
if [[ -n "$SCOPE_FAILS32" ]]; then
|
|
fail ".pre-commit-config.yaml's vale prefilter hooks are mis-scoped: $SCOPE_FAILS32"
|
|
else
|
|
pass "both vale prefilter hooks in .pre-commit-config.yaml match at least one tracked file and select only their own artifact class"
|
|
fi
|
|
fi
|
|
|
|
# Part B: proof that Part A can fail. Its whole point is that a narrowed regex is
|
|
# SILENT -- pre-commit skips a hook that matches nothing without a word -- so a
|
|
# Part A that could not detect the narrowing would be exactly the vacuous pass it
|
|
# exists to close. Every `files:` regex in a COPY of the config (never the real
|
|
# file) is prefixed with a path segment no tracked file starts with, which is the
|
|
# minimal form of the narrowing: still a valid regex, still a hook pre-commit
|
|
# accepts, matching zero files.
|
|
MUT32="$(mktemp -d)"
|
|
new_fixture "$MUT32"
|
|
awk '/^[ \t]*files:/ { sub(/\^/, "^zzz-no-such-path/") } { print }' \
|
|
"$PC_CONFIG32" > "$MUT32/.pre-commit-config.yaml"
|
|
MUT_RECORDS32="$(hook_records28 "$MUT32/.pre-commit-config.yaml")"
|
|
MUT_FAILS32="$(prefilter_scope_failures32 "$MUT32/.pre-commit-config.yaml" "$REPO_FILES32")"
|
|
if ! grep -q 'zzz-no-such-path' <<< "$MUT_RECORDS32"; then
|
|
fail "the narrowed regexes never reached the copied config, so Part B narrowed nothing and proves nothing about Part A"
|
|
elif [[ "$(printf '%s\n' "$MUT_RECORDS32" | grep -c .)" -ne 2 ]]; then
|
|
fail "the mutated config did not parse back as two prefilter hooks, so any failure below would come from the parser, not from the narrowing"
|
|
elif ! grep -qF "vale-audit-prefilter-skill: 'files: ^zzz-no-such-path/" <<< "$MUT_FAILS32"; then
|
|
fail "narrowing the skill hook's regex to match zero files did not fail this check, so Part A cannot detect a prefilter that has been silently switched off for SKILL.md files"
|
|
elif ! grep -qF "vale-audit-prefilter-agent: 'files: ^zzz-no-such-path/" <<< "$MUT_FAILS32"; then
|
|
fail "narrowing the agent hook's regex to match zero files did not fail this check, so Part A cannot detect a prefilter that has been silently switched off for agent files"
|
|
else
|
|
pass "narrowing either hook's 'files:' regex to match zero files is caught by Part A, which is what makes its pass mean something"
|
|
fi
|
|
|
|
# Part C: proof that property 3 can fail. Each hook's regex is narrowed to one
|
|
# plugin in a COPY of the config -- the exact narrowing that once slipped
|
|
# through. Replacement is fixed-string, via ENVIRON, so the regex's backslashes
|
|
# reach awk literally; `awk -v` would process them as escape sequences.
|
|
narrow_config32() {
|
|
OLD32="$2" NEW32="$3" awk '
|
|
/^[ \t]*files:/ {
|
|
i = index($0, ENVIRON["OLD32"])
|
|
if (i) $0 = substr($0, 1, i - 1) ENVIRON["NEW32"] substr($0, i + length(ENVIRON["OLD32"]))
|
|
}
|
|
{ print }
|
|
' "$1"
|
|
}
|
|
narrow_config32 "$PC_CONFIG32" \
|
|
'^plugins/[^/]+/\.apm/skills/' '^plugins/kyberforge/\.apm/skills/' > "$MUT32/skill.yaml"
|
|
narrow_config32 "$PC_CONFIG32" \
|
|
'^plugins/[^/]+/\.apm/agents/' '^plugins/kyberforge/\.apm/agents/' > "$MUT32/agent.yaml"
|
|
NARROW_SKILL32="$(prefilter_scope_failures32 "$MUT32/skill.yaml" "$REPO_FILES32")"
|
|
NARROW_AGENT32="$(prefilter_scope_failures32 "$MUT32/agent.yaml" "$REPO_FILES32")"
|
|
if cmp -s "$PC_CONFIG32" "$MUT32/skill.yaml" || cmp -s "$PC_CONFIG32" "$MUT32/agent.yaml"; then
|
|
fail "a one-plugin narrowing left the copied config unchanged, so Part C narrowed nothing and proves nothing about property 3"
|
|
elif ! grep -qF "vale-audit-prefilter-skill: 'files: ^plugins/kyberforge/" <<< "$NARROW_SKILL32" \
|
|
|| ! grep -qF "tracked skill file(s)" <<< "$NARROW_SKILL32"; then
|
|
fail "narrowing the skill hook to ^plugins/kyberforge/ did not fail Part A as an incomplete corpus: ${NARROW_SKILL32:-<no failure>}"
|
|
elif ! grep -qF "vale-audit-prefilter-agent: 'files: ^plugins/kyberforge/" <<< "$NARROW_AGENT32" \
|
|
|| ! grep -qF "tracked agent file(s)" <<< "$NARROW_AGENT32"; then
|
|
fail "narrowing the agent hook to ^plugins/kyberforge/ did not fail Part A as an incomplete corpus: ${NARROW_AGENT32:-<no failure>}"
|
|
else
|
|
pass "narrowing either hook's 'files:' regex to one plugin is caught by Part A as an incomplete corpus"
|
|
fi
|
|
|
|
# Part D: proof that property 3 cannot pass vacuously. Each corpus regex is
|
|
# pointed at a layout no tracked file has -- what a future move of
|
|
# plugins/*/.apm/ would do to the hard-coded regexes -- and the live config must
|
|
# then fail as an empty corpus rather than pass with nothing to compare.
|
|
SAVED_SKILL_RE32="$CORPUS_RE_SKILL32"
|
|
SAVED_AGENT_RE32="$CORPUS_RE_AGENT32"
|
|
CORPUS_RE_SKILL32='^zzz-no-such-path/SKILL\.md$'
|
|
CORPUS_RE_AGENT32='^zzz-no-such-path/[^/]+\.agent\.md$'
|
|
EMPTY_FAILS32="$(prefilter_scope_failures32 "$PC_CONFIG32" "$REPO_FILES32")"
|
|
CORPUS_RE_SKILL32="$SAVED_SKILL_RE32"
|
|
CORPUS_RE_AGENT32="$SAVED_AGENT_RE32"
|
|
if ! grep -qF "vale-audit-prefilter-skill: the skill corpus regex" <<< "$EMPTY_FAILS32"; then
|
|
fail "a skill corpus regex matching no tracked file did not fail Part A, so property 3 would pass vacuously after a layout move: ${EMPTY_FAILS32:-<no failure>}"
|
|
elif ! grep -qF "vale-audit-prefilter-agent: the agent corpus regex" <<< "$EMPTY_FAILS32"; then
|
|
fail "an agent corpus regex matching no tracked file did not fail Part A, so property 3 would pass vacuously after a layout move: ${EMPTY_FAILS32:-<no failure>}"
|
|
else
|
|
pass "a corpus regex matching no tracked file fails Part A instead of passing vacuously"
|
|
fi
|
|
|
|
# --- 34. Every glob section loads a real style, asserted without vale --------
|
|
#
|
|
# Case 28 asks this behaviourally -- it lints a probe through the real config
|
|
# and watches for a Kyberforge alert -- but every probe of it that can answer
|
|
# the question is behind `VALE_READY`. On a machine with no vale those probes
|
|
# do not run, the config is then read by case 0 alone, and case 0 deliberately
|
|
# exempts an EMPTY `BasedOnStyles` so that 28's style-not-loaded branch keeps
|
|
# something left to detect. Between the two, an emptied `BasedOnStyles` is
|
|
# caught by nothing in a non-strict run: vale loads that config, lints every
|
|
# file the section matches with NO rule, prints `0 errors ... in 1 file` and
|
|
# exits 0, and every gate in this repo reads that as a pass. That is the
|
|
# silent-pass class ADR-0013 exists to prevent, and `--strict` -- which a
|
|
# consuming repo's install does not run -- is the only thing standing in front of it.
|
|
#
|
|
# So this case asks the same question of the config TEXT, with no dependency on
|
|
# vale being installed. It is a separate case rather than an addition to either
|
|
# neighbour: case 0's exemption and case 28's behavioural branch both stay
|
|
# exactly as documented, and no property is owned twice.
|
|
#
|
|
# The StylesPath half is the deleted scripts/check-vale-style-sync.sh's
|
|
# requirement (ADR-0025), restored. `vale_config_defects0` absolutizes with
|
|
# `[[ "$sp" == /* ]] || sp="$dir/$sp"`, which ACCEPTS an absolute StylesPath --
|
|
# a path that resolves on the machine that wrote it and on no other. Relative
|
|
# resolution against the config's own directory is the only reason the bundled
|
|
# styles are found under a CONSUMING repo's install of factory-audit, so an
|
|
# absolute one passes every check here and hard-fails every repo that installs
|
|
# the plugin. It is asserted here, not in case 0, to keep case 0's
|
|
# scope the one its comment describes.
|
|
|
|
VALE_ASSETS34="$FACTORY_AUDIT/assets/vale"
|
|
|
|
# `<section header>|<BasedOnStyles value>` for every `[glob]` section of $1, in
|
|
# file order. A section carrying no BasedOnStyles at all prints an empty value
|
|
# rather than no row: an absent key and an emptied one are the same defect and
|
|
# must report as one. Keys above the first header are vale's global scope, not
|
|
# a section, and are skipped -- a global BasedOnStyles does not make a section
|
|
# that overrides it with an empty one load anything.
|
|
section_styles34() {
|
|
awk '
|
|
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
|
|
/^[ \t]*\[.*\][ \t]*$/ {
|
|
if (sec != "") print sec "|" val
|
|
sec = trim($0); val = ""; next
|
|
}
|
|
sec != "" && /^[ \t]*BasedOnStyles[ \t]*=/ {
|
|
val = $0; sub(/^[^=]*=/, "", val); val = trim(val)
|
|
}
|
|
END { if (sec != "") print sec "|" val }
|
|
' "$1"
|
|
}
|
|
|
|
# One failure token per defect in the config at $1; empty output means every
|
|
# section would load at least one real style for a consumer. The section floor
|
|
# is here for the same reason case 28 Part A carries its own: a config
|
|
# whose sections were all deleted lints nothing at all, and without a floor this
|
|
# function would report it clean.
|
|
style_load_defects34() {
|
|
local cfg="$1" dir sp sec names name bad="" sections=0
|
|
if [[ ! -f "$cfg" ]]; then
|
|
printf '%s' "[$cfg does not exist] "
|
|
return 0
|
|
fi
|
|
dir="$(dirname "$cfg")"
|
|
sp="$({ grep -E '^[[:space:]]*StylesPath[[:space:]]*=' "$cfg" || true; } | tail -1)"
|
|
sp="${sp#*=}"
|
|
sp="${sp#"${sp%%[![:space:]]*}"}"
|
|
sp="${sp%"${sp##*[![:space:]]}"}"
|
|
if [[ -z "$sp" ]]; then
|
|
printf '%s' "[${cfg##*/} sets no StylesPath, so no section can name a style that loads] "
|
|
return 0
|
|
fi
|
|
if [[ "$sp" == /* ]]; then
|
|
printf '%s' "[${cfg##*/} sets the ABSOLUTE StylesPath '$sp'; it resolves only on the machine that wrote it, and a consuming repo's install of factory-audit -- which is the only reason these styles ship -- gets 'path does not exist'] "
|
|
return 0
|
|
fi
|
|
if [[ ! -d "$dir/$sp" ]]; then
|
|
printf '%s' "[StylesPath resolves to $dir/$sp, which is not a directory] "
|
|
return 0
|
|
fi
|
|
while IFS='|' read -r sec names; do
|
|
[[ -n "$sec" ]] || continue
|
|
sections=$((sections + 1))
|
|
if [[ -z "$names" ]]; then
|
|
bad+="[$sec declares no non-empty BasedOnStyles, so vale lints every file it matches with no rule loaded, prints '0 errors' and exits 0] "
|
|
continue
|
|
fi
|
|
while IFS= read -r name; do
|
|
name="${name#"${name%%[![:space:]]*}"}"
|
|
name="${name%"${name##*[![:space:]]}"}"
|
|
# `Vale` is vale's built-in style and has no directory, exactly as in
|
|
# vale_config_defects0.
|
|
[[ -n "$name" && "$name" != "Vale" ]] || continue
|
|
[[ -d "$dir/$sp/$name" ]] || bad+="[$sec names style '$name', but $dir/$sp/$name does not exist] "
|
|
done <<EOF_NAMES34
|
|
$(printf '%s\n' "$names" | tr ',' '\n')
|
|
EOF_NAMES34
|
|
done <<EOF_SEC34
|
|
$(section_styles34 "$cfg")
|
|
EOF_SEC34
|
|
if (( sections == 0 )); then
|
|
bad+="[${cfg##*/} declares no [glob] section at all, so vale matches no file and this check would otherwise pass vacuously] "
|
|
fi
|
|
printf '%s' "$bad"
|
|
}
|
|
|
|
echo ""
|
|
echo "--- every glob section of the shipped .vale.ini loads a real style (no vale needed) ---"
|
|
|
|
# Part A: the live assertion, against the shipped config.
|
|
STYLE_LOAD_DEFECTS34="$(style_load_defects34 "$VALE_ASSETS34/.vale.ini")"
|
|
if [[ -n "$STYLE_LOAD_DEFECTS34" ]]; then
|
|
fail "the shipped .vale.ini has a section vale would lint with no style loaded, or a StylesPath no consumer's clone can resolve: $STYLE_LOAD_DEFECTS34"
|
|
else
|
|
pass "every [glob] section of the shipped .vale.ini names at least one style, every name it lists resolves to a directory under a RELATIVE StylesPath, and at least one section exists"
|
|
fi
|
|
|
|
# Part B: proof that Part A can fail, against the same function Part A calls --
|
|
# a second, "equivalent" reader written for the fixture would prove nothing
|
|
# about the live check. Every mutation lands in a COPY of the shipped assets,
|
|
# never the real file, and each copy is guarded for having actually changed
|
|
# before its verdict is trusted.
|
|
#
|
|
# empty BasedOnStyles -- per section, the silent case described above.
|
|
# absolute StylesPath -- pointed at the copy's OWN styles directory, which
|
|
# exists, so absoluteness is the only property left
|
|
# failing and the branch cannot pass for the wrong
|
|
# reason.
|
|
#
|
|
# Run only against a config Part A passed: every copy inherits the base's
|
|
# defects, so on a broken base these would re-report Part A's failure.
|
|
if [[ -z "$STYLE_LOAD_DEFECTS34" ]]; then
|
|
echo ""
|
|
echo "--- emptying any one section's BasedOnStyles, or absolutizing StylesPath, fails that check ---"
|
|
MUT_FAILS34=""
|
|
while IFS='|' read -r MSEC34 _; do
|
|
[[ -n "$MSEC34" ]] || continue
|
|
MDIR34="$(mktemp -d)"
|
|
new_fixture "$MDIR34"
|
|
cp -r "$VALE_ASSETS34/." "$MDIR34/"
|
|
# The section header line and every other section are untouched, so the
|
|
# mutation isolates one section's style load.
|
|
awk -v target="$MSEC34" '
|
|
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
|
|
/^[ \t]*\[.*\][ \t]*$/ { sec = trim($0) }
|
|
sec == target && /^[ \t]*BasedOnStyles[ \t]*=/ { print "BasedOnStyles ="; next }
|
|
{ print }
|
|
' "$VALE_ASSETS34/.vale.ini" > "$MDIR34/.vale.ini"
|
|
if cmp -s "$VALE_ASSETS34/.vale.ini" "$MDIR34/.vale.ini"; then
|
|
MUT_FAILS34+="[$MSEC34: the mutation left the config unchanged, so nothing was tested] "
|
|
continue
|
|
fi
|
|
MDEFECTS34="$(style_load_defects34 "$MDIR34/.vale.ini")"
|
|
grep -qF "[$MSEC34 declares no non-empty BasedOnStyles" <<< "$MDEFECTS34" \
|
|
|| MUT_FAILS34+="[$MSEC34 lost its BasedOnStyles and Part A did not fail on it: ${MDEFECTS34:-<no failure>}] "
|
|
done <<EOF_MUT34
|
|
$(section_styles34 "$VALE_ASSETS34/.vale.ini")
|
|
EOF_MUT34
|
|
ABS34="$(mktemp -d)"
|
|
new_fixture "$ABS34"
|
|
cp -r "$VALE_ASSETS34/." "$ABS34/"
|
|
# Written through ENVIRON, as in narrow_config32, so the path reaches awk
|
|
# literally rather than through `-v`'s escape processing.
|
|
ABS_SP34="$ABS34/styles" awk '
|
|
/^[ \t]*StylesPath[ \t]*=/ { print "StylesPath = " ENVIRON["ABS_SP34"]; next }
|
|
{ print }
|
|
' "$VALE_ASSETS34/.vale.ini" > "$ABS34/.vale.ini"
|
|
ABS_DEFECTS34="$(style_load_defects34 "$ABS34/.vale.ini")"
|
|
if ! grep -qF "StylesPath = $ABS34/styles" "$ABS34/.vale.ini"; then
|
|
fail "the absolute-StylesPath mutation never reached its copied config, so Part B proves nothing about that branch"
|
|
elif [[ ! -d "$ABS34/styles" ]]; then
|
|
fail "the absolute StylesPath mutation points at a directory that does not exist, so a failure below would not be about absoluteness at all"
|
|
elif ! grep -qF "sets the ABSOLUTE StylesPath" <<< "$ABS_DEFECTS34"; then
|
|
fail "an absolute StylesPath naming a REAL styles directory passed Part A, so the config can ship a path that resolves on this machine alone: ${ABS_DEFECTS34:-<no failure>}"
|
|
elif [[ -n "$MUT_FAILS34" ]]; then
|
|
fail "Part A does not catch a section whose BasedOnStyles was emptied, which is the silent case this whole case exists for: $MUT_FAILS34"
|
|
else
|
|
pass "each section, with its BasedOnStyles emptied in a copy of the config, fails Part A by name, and so does an absolute StylesPath pointing at a real styles directory"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "Results: $PASS passed, $FAIL failed"
|
|
[[ $FAIL -eq 0 ]] || exit 1
|
|
if [[ "$VALE_AVAILABLE" != true ]]; then
|
|
# run-tests.sh records the first `SKIP` line printed above as the reason.
|
|
echo "Every static case passed, but vale is not installed and the Vale-dependent cases did not run — exiting 77 so run-tests.sh reports this suite as skipped, and --strict fails it."
|
|
exit 77
|
|
fi
|