Why: two branches that both bump a skill 1.0.0 -> 1.0.1 with different content merge without a conflict, and each passed the gate against its own merge-base, so main could ship two changes under one version. Implementation Notes: - check-skill-version-bump requires the pushed version to exceed both the merge-base and the main tip; failures name the baseline they missed. - Presence is read from the tree, so a blob missing from a partial clone is a read failure instead of a silently exempt "new" skill. - A leading UTF-8 BOM no longer reads as a missing version. - Version parts reject leading zeros in all three validators (check-skill-version-bump, skill-size-check, factory-audit). - New tests cover equal bumps, moved files, major/minor ordering, bad refs, unreadable blobs, mode-only changes, symlinks and tag peeling. Impact: ADR-0022 amended (reverses "not main's current tip"); gates.md updated to match, including pre-commit 4.6.1's exact ref selection. ADR: 0022 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
944 lines
42 KiB
Bash
Executable File
944 lines
42 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression test for scripts/skill-size-check.sh, which enforces two
|
|
# independent gate families that must not be conflated:
|
|
#
|
|
# * agentskills.io spec conformance — 500 lines and a 5,000-token ceiling
|
|
# enforced via a word-count proxy (MAX_WORDS, currently 2770) over the
|
|
# WHOLE FILE, frontmatter included.
|
|
# * ADR-0020 context budget — description 250 chars SUGGESTION / 400 FAIL,
|
|
# body-ONLY 600 words SUGGESTION / 900 FAIL, and resolvable boundary-clause
|
|
# routing targets.
|
|
#
|
|
# The constant-agreement block below is the load-bearing part: all three copies
|
|
# (this hook, the auditor's skill flow, the auditor's agent flow) are
|
|
# hand-duplicated because a cache-installed plugin cannot read outside its own
|
|
# directory, and nothing but these assertions stops them drifting.
|
|
#
|
|
# ADR-0025 merged skill-audit and agent-audit into factory-audit, which moved two
|
|
# of those copies but did not reduce them to one: the constants live in the two
|
|
# mode libraries validate.sh sources, and the two libraries still declare them
|
|
# separately. So the comparisons below read lib-checks-skill.sh and
|
|
# lib-checks-agent.sh directly rather than the entry point, which declares none
|
|
# of them — grepping validate.sh would find nothing and report every constant as
|
|
# <unset>, or worse, silently agree that two empty values match.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
SCRIPT="$REPO_ROOT/scripts/skill-size-check.sh"
|
|
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh"
|
|
AGENT_VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-agent.sh"
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
|
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
|
|
|
TMPDIR="$(mktemp -d)"
|
|
trap 'rm -rf "$TMPDIR"' EXIT
|
|
|
|
make_fixture() {
|
|
local name="$1" lines="$2" words_per_line="$3" file
|
|
file="$TMPDIR/$name.md"
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: Test fixture."
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
for ((i = 1; i <= lines; i++)); do
|
|
w=""
|
|
for ((j = 1; j <= words_per_line; j++)); do
|
|
w="$w word"
|
|
done
|
|
echo "$w"
|
|
done
|
|
} > "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
echo ""
|
|
echo "--- passes a file under both limits ---"
|
|
SMALL="$(make_fixture small 10 5)"
|
|
if "$SCRIPT" "$SMALL"; then
|
|
pass "file under both limits exits 0"
|
|
else
|
|
fail "file under both limits should have exited 0"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- fails a file over the line limit ---"
|
|
MANY_LINES="$(make_fixture many-lines 600 1)"
|
|
if "$SCRIPT" "$MANY_LINES" 2>/dev/null; then
|
|
fail "file over the 500-line ceiling should have exited non-zero"
|
|
else
|
|
pass "file over the 500-line ceiling exits non-zero"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- fails a file over the word-count limit ---"
|
|
MANY_WORDS="$(make_fixture many-words 10 600)"
|
|
if "$SCRIPT" "$MANY_WORDS" 2>/dev/null; then
|
|
fail "file over the word ceiling should have exited non-zero"
|
|
else
|
|
pass "file over the word ceiling exits non-zero"
|
|
fi
|
|
|
|
# Boundary-pair tests below read the script's current MAX_WORDS rather than
|
|
# hardcoding it, so they don't silently drift if the threshold changes again.
|
|
MAX_WORDS="$(grep -oE '^MAX_WORDS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
MAX_LINES="$(grep -oE '^MAX_LINES=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
|
|
# The audit (factory-audit's lib-checks-skill.sh) duplicates both ceilings, because
|
|
# a cache-installed plugin's scripts cannot read files outside the plugin
|
|
# directory. Nothing but this assertion stops the copies drifting, and drift
|
|
# means a SKILL.md passes its own audit and is then rejected by the commit hook.
|
|
echo ""
|
|
echo "--- the hook and factory-audit's skill checks agree on both ceilings ---"
|
|
if [[ ! -f "$VALIDATE" ]]; then
|
|
fail "factory-audit lib-checks-skill.sh not found at $VALIDATE"
|
|
else
|
|
V_MAX_WORDS="$(grep -oE '^MAX_WORDS = [0-9]+' "$VALIDATE" | grep -oE '[0-9]+')"
|
|
V_MAX_LINES="$(grep -oE '^MAX_LINES = [0-9]+' "$VALIDATE" | grep -oE '[0-9]+')"
|
|
if [[ "$V_MAX_WORDS" == "$MAX_WORDS" ]]; then
|
|
pass "both enforce MAX_WORDS=$MAX_WORDS"
|
|
else
|
|
fail "MAX_WORDS drift: hook says $MAX_WORDS, lib-checks-skill.sh says ${V_MAX_WORDS:-<unset>}"
|
|
fi
|
|
if [[ "$V_MAX_LINES" == "$MAX_LINES" ]]; then
|
|
pass "both enforce MAX_LINES=$MAX_LINES"
|
|
else
|
|
fail "MAX_LINES drift: hook says $MAX_LINES, lib-checks-skill.sh says ${V_MAX_LINES:-<unset>}"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ADR-0020 constants
|
|
# ---------------------------------------------------------------------------
|
|
# Three hand-maintained copies, for the same cache-isolation reason as
|
|
# MAX_WORDS/MAX_LINES above. The skill flow carries all four; the agent flow carries
|
|
# only the two description constants, because ADR-0020 deliberately gives
|
|
# agents NO body word gate (a skill body competes with the caller's live
|
|
# conversation; an agent body becomes the system prompt of a fresh context).
|
|
# The absence of BODY_* in the agent flow is asserted below so a well-meaning
|
|
# "consistency" edit that adds them fails here rather than contradicting the
|
|
# ADR silently.
|
|
DESC_SUGGEST_CHARS="$(grep -oE '^DESC_SUGGEST_CHARS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
DESC_MAX_CHARS="$(grep -oE '^DESC_MAX_CHARS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
BODY_SUGGEST_WORDS="$(grep -oE '^BODY_SUGGEST_WORDS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
BODY_MAX_WORDS="$(grep -oE '^BODY_MAX_WORDS=[0-9]+' "$SCRIPT" | cut -d= -f2)"
|
|
|
|
echo ""
|
|
echo "--- the hook declares all four ADR-0020 constants ---"
|
|
for pair in "DESC_SUGGEST_CHARS:$DESC_SUGGEST_CHARS" "DESC_MAX_CHARS:$DESC_MAX_CHARS" \
|
|
"BODY_SUGGEST_WORDS:$BODY_SUGGEST_WORDS" "BODY_MAX_WORDS:$BODY_MAX_WORDS"; do
|
|
if [[ -n "${pair#*:}" ]]; then
|
|
pass "${pair%%:*}=${pair#*:}"
|
|
else
|
|
fail "${pair%%:*} is not declared in $SCRIPT"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "--- the hook and factory-audit's skill checks agree on all four ADR-0020 constants ---"
|
|
for const in DESC_SUGGEST_CHARS DESC_MAX_CHARS BODY_SUGGEST_WORDS BODY_MAX_WORDS; do
|
|
hook_value="$(grep -oE "^${const}=[0-9]+" "$SCRIPT" | cut -d= -f2)"
|
|
audit_value="$(grep -oE "^${const} = [0-9]+" "$VALIDATE" | grep -oE '[0-9]+' || true)"
|
|
if [[ -n "$hook_value" && "$hook_value" == "$audit_value" ]]; then
|
|
pass "both enforce $const=$hook_value"
|
|
else
|
|
fail "$const drift: hook says ${hook_value:-<unset>}, lib-checks-skill.sh says ${audit_value:-<unset>}"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "--- the hook and factory-audit's agent checks agree on the description constants ---"
|
|
if [[ ! -f "$AGENT_VALIDATE" ]]; then
|
|
fail "factory-audit lib-checks-agent.sh not found at $AGENT_VALIDATE"
|
|
else
|
|
for const in DESC_SUGGEST_CHARS DESC_MAX_CHARS; do
|
|
hook_value="$(grep -oE "^${const}=[0-9]+" "$SCRIPT" | cut -d= -f2)"
|
|
agent_value="$(grep -oE "^${const} = [0-9]+" "$AGENT_VALIDATE" | grep -oE '[0-9]+' || true)"
|
|
if [[ -n "$hook_value" && "$hook_value" == "$agent_value" ]]; then
|
|
pass "both enforce $const=$hook_value"
|
|
else
|
|
fail "$const drift: hook says ${hook_value:-<unset>}, lib-checks-agent.sh says ${agent_value:-<unset>}"
|
|
fi
|
|
done
|
|
echo ""
|
|
echo "--- the agent flow declares NO body word gate (ADR-0020 is explicit about this) ---"
|
|
if grep -qE '^BODY_(SUGGEST|MAX)_WORDS = ' "$AGENT_VALIDATE"; then
|
|
fail "lib-checks-agent.sh declares a body word gate — ADR-0020 gives agents the description gates and NO body word gate"
|
|
else
|
|
pass "lib-checks-agent.sh declares no BODY_*_WORDS constant"
|
|
fi
|
|
fi
|
|
|
|
# make_line_fixture builds a file with an exact total line count (frontmatter
|
|
# included), independent of word count, for the line-boundary tests.
|
|
make_line_fixture() {
|
|
local name="$1" total_lines="$2" file body_lines
|
|
file="$TMPDIR/$name.md"
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: Test fixture."
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
} > "$file"
|
|
body_lines=$((total_lines - 6))
|
|
for ((i = 1; i <= body_lines; i++)); do
|
|
echo "word"
|
|
done >> "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
# The line ceiling is inclusive of the limit itself, enforced via `>` — so
|
|
# exactly $MAX_LINES must pass and $((MAX_LINES + 1)) must fail. This matches
|
|
# factory-audit's lib-checks-skill.sh `line_count <= 500` pass condition; the two
|
|
# previously disagreed at exactly $MAX_LINES lines, so a SKILL.md could pass its
|
|
# own audit and still be blocked by the commit hook.
|
|
echo ""
|
|
echo "--- passes a file at exactly the $MAX_LINES-line boundary ---"
|
|
AT_LINES="$(make_line_fixture at-line-limit "$MAX_LINES")"
|
|
ACTUAL_LINES=$(awk 'END{print NR}' "$AT_LINES")
|
|
if [[ "$ACTUAL_LINES" -ne "$MAX_LINES" ]]; then
|
|
fail "fixture has $ACTUAL_LINES lines, expected exactly $MAX_LINES"
|
|
elif "$SCRIPT" "$AT_LINES"; then
|
|
pass "file at exactly $MAX_LINES lines exits 0"
|
|
else
|
|
fail "file at exactly $MAX_LINES lines should have exited 0 (the off-by-one this test guards against)"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- fails a file one line over the $MAX_LINES-line boundary ---"
|
|
OVER_LINES="$(make_line_fixture over-line-limit "$((MAX_LINES + 1))")"
|
|
ACTUAL_OVER_LINES=$(awk 'END{print NR}' "$OVER_LINES")
|
|
if [[ "$ACTUAL_OVER_LINES" -ne "$((MAX_LINES + 1))" ]]; then
|
|
fail "fixture has $ACTUAL_OVER_LINES lines, expected exactly $((MAX_LINES + 1))"
|
|
elif "$SCRIPT" "$OVER_LINES" 2>/dev/null; then
|
|
fail "file at $((MAX_LINES + 1)) lines should have exited non-zero"
|
|
else
|
|
pass "file at $((MAX_LINES + 1)) lines exits non-zero"
|
|
fi
|
|
|
|
# make_word_fixture builds a file with an exact total word count (frontmatter
|
|
# words included, since the script's `wc -w` counts the whole file).
|
|
#
|
|
# The padding goes in a frontmatter `notes:` field, NOT in the body, and that
|
|
# placement is the point: MAX_WORDS is a whole-file measurement while ADR-0020's
|
|
# BODY_MAX_WORDS is a body-only one. Padding the body would make a 2,770-word
|
|
# fixture trip the 900-word body ceiling too, and the MAX_WORDS boundary test
|
|
# would stop isolating MAX_WORDS. `notes:` is an unused key — the description
|
|
# stays short, so the description gate stays quiet as well.
|
|
make_word_fixture() {
|
|
local name="$1" target="$2" file frame_words padding
|
|
file="$TMPDIR/$name.md"
|
|
padding=""
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: Test fixture."
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "notes:$padding"
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$file"
|
|
frame_words=$(wc -w < "$file")
|
|
for ((i = 1; i <= target - frame_words; i++)); do
|
|
padding="$padding word"
|
|
done
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: Test fixture."
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "notes:$padding"
|
|
echo "---"
|
|
echo ""
|
|
echo "Body."
|
|
} > "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
echo ""
|
|
echo "--- passes a file at exactly the $MAX_WORDS-word boundary ---"
|
|
AT_WORDS="$(make_word_fixture at-word-limit "$MAX_WORDS")"
|
|
ACTUAL_WORDS=$(wc -w < "$AT_WORDS")
|
|
if [[ "$ACTUAL_WORDS" -ne "$MAX_WORDS" ]]; then
|
|
fail "fixture has $ACTUAL_WORDS words, expected exactly $MAX_WORDS"
|
|
elif "$SCRIPT" "$AT_WORDS"; then
|
|
pass "file at exactly $MAX_WORDS words exits 0"
|
|
else
|
|
fail "file at exactly $MAX_WORDS words should have exited 0"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- fails a file one word over the $MAX_WORDS-word boundary ---"
|
|
OVER_WORDS="$(make_word_fixture over-word-limit "$((MAX_WORDS + 1))")"
|
|
ACTUAL_OVER_WORDS=$(wc -w < "$OVER_WORDS")
|
|
if [[ "$ACTUAL_OVER_WORDS" -ne "$((MAX_WORDS + 1))" ]]; then
|
|
fail "fixture has $ACTUAL_OVER_WORDS words, expected exactly $((MAX_WORDS + 1))"
|
|
elif "$SCRIPT" "$OVER_WORDS" 2>/dev/null; then
|
|
fail "file at $((MAX_WORDS + 1)) words should have exited non-zero"
|
|
else
|
|
pass "file at $((MAX_WORDS + 1)) words exits non-zero"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ADR-0020 behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# make_budget_fixture builds a SKILL.md with a verbatim description and an
|
|
# exact BODY word count (frontmatter words excluded — the ADR-0020 body gate
|
|
# counts the body only).
|
|
make_budget_fixture() {
|
|
local name="$1" desc="$2" body_words="$3" file
|
|
file="$TMPDIR/$name.md"
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: $desc"
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
echo ""
|
|
python3 -c "print(' '.join(['word'] * $body_words))"
|
|
} > "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
# desc_of_length <n> — a description of EXACTLY n characters that carries a
|
|
# boundary clause and names no routing target.
|
|
#
|
|
# ADR-0020's missing-boundary-clause SUGGESTION fires on every description
|
|
# without one, so a fixture that omits it is never "otherwise clean": a test
|
|
# asserting silence would be asserting the boundary check's ABSENCE rather than
|
|
# the length boundary it names. The clause is paid for out of the same budget
|
|
# being measured (padding arithmetic, not a fixed suffix) so the character count
|
|
# stays exact. "anything else" is unhyphenated, so no routing target rides along.
|
|
desc_of_length() {
|
|
python3 - "$1" <<'PY'
|
|
import sys
|
|
n = int(sys.argv[1])
|
|
prefix = 'Use when doing the thing. Do not use for anything else. '
|
|
assert n >= len(prefix), 'requested description shorter than the boundary clause'
|
|
print(prefix + 'x' * (n - len(prefix)))
|
|
PY
|
|
}
|
|
|
|
# make_tree_fixture <label> <desc> <body_words> — a SKILL.md inside a synthetic
|
|
# apm plugin monorepo, so the boundary-target resolver has a universe.
|
|
#
|
|
# Resolution walks up FROM THE TARGET FILE to an authoring root (the nearest
|
|
# ancestor holding plugins/*/.apm/{skills,agents}, falling back to .git); it is
|
|
# never derived from the checker's own location, because deriving it from
|
|
# ${BASH_SOURCE} leaked this repo's 39-skill universe into every consumer repo
|
|
# running the hook. A fixture in a bare mktemp -d therefore has NO universe and
|
|
# correctly reports "DID NOT RUN" — that is not a bug to paper over with a
|
|
# looser assertion, it is why the fixture has to be a real tree:
|
|
#
|
|
# <root>/plugins/subject-plugin/.apm/skills/<label>/SKILL.md <- the subject
|
|
# <root>/plugins/subject-plugin/.apm/skills/sibling-skill/ <- same package
|
|
# <root>/plugins/subject-plugin/.apm/agents/sibling-agent.agent.md
|
|
# <root>/plugins/other-plugin/.apm/skills/cross-plugin-skill/ <- sibling plugin
|
|
#
|
|
# The sibling plugin is what makes "every plugin in the monorepo contributes its
|
|
# names" testable; without it a cross-plugin target and a typo are the same.
|
|
#
|
|
# Both sibling skill directories get a real SKILL.md, and that is load-bearing
|
|
# rather than tidiness: a skill directory is a resolvable name only if it HOLDS
|
|
# a SKILL.md. An empty leftover directory is untracked by git, so counting one
|
|
# made a target resolve on the machine that made it and dangle in a fresh clone
|
|
# — the same install-dependence the deployed-tree rule exists to remove. This
|
|
# fixture used to `mkdir` the two siblings and write nothing into them, so it
|
|
# was itself relying on the behaviour the resolver no longer has.
|
|
make_tree_fixture() {
|
|
local label="$1" desc="$2" body_words="$3" root apm sib
|
|
root="$TMPDIR/tree-$label"
|
|
apm="$root/plugins/subject-plugin/.apm"
|
|
mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \
|
|
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"
|
|
: > "$apm/agents/sibling-agent.agent.md"
|
|
for sib in "$apm/skills/sibling-skill" \
|
|
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"; do
|
|
{
|
|
echo "---"
|
|
echo "name: $(basename "$sib")"
|
|
echo "description: Use when doing the other thing. Do not use for anything else."
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
echo ""
|
|
echo "Do the thing."
|
|
} > "$sib/SKILL.md"
|
|
done
|
|
{
|
|
echo "---"
|
|
echo "name: $label"
|
|
echo "description: $desc"
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
echo ""
|
|
python3 -c "print(' '.join(['word'] * $body_words))"
|
|
} > "$apm/skills/$label/SKILL.md"
|
|
echo "$apm/skills/$label/SKILL.md"
|
|
}
|
|
|
|
# expect_gate <label> <expected: pass|suggest|fail> <file> [needle]
|
|
expect_gate() {
|
|
local label="$1" expected="$2" file="$3" needle="${4:-}" out status
|
|
set +e
|
|
out="$("$SCRIPT" "$file" 2>&1)"
|
|
status=$?
|
|
set -e
|
|
case "$expected" in
|
|
pass)
|
|
if [[ $status -eq 0 && -z "$out" ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (exit $status, output: ${out:-<empty>})"
|
|
fi
|
|
;;
|
|
# The tier and the needle are matched ADJACENTLY — `*"SUGGESTION"*"$needle"*`
|
|
# — not as two independent substring tests. Independently, any output
|
|
# carrying a SUGGESTION anywhere and the needle anywhere satisfied the
|
|
# assertion, so a needle emitted at the WRONG TIER still passed: a finding
|
|
# that moved from SUGGESTION to a blocking ERROR line would be caught only
|
|
# by the exit-status test, and one that moved from SUGGESTION to INFO would
|
|
# not be caught at all. grammar_case's `suggests` branch in
|
|
# tests/test-adr0020-targets.sh has always matched them adjacently; this is
|
|
# the same rule.
|
|
suggest)
|
|
if [[ $status -eq 0 && "$out" == *"SUGGESTION"*"$needle"* ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (exit $status, output: ${out:-<empty>})"
|
|
fi
|
|
;;
|
|
fail)
|
|
if [[ $status -ne 0 && "$out" == *"$needle"* ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (exit $status, output: ${out:-<empty>})"
|
|
fi
|
|
;;
|
|
# A check that DECLINED to run must say so and must not fail the file. The
|
|
# ERROR guard is the point: a declined check that also errored would satisfy
|
|
# a bare "output contains INFO" assertion.
|
|
info)
|
|
if [[ $status -eq 0 && "$out" == *"INFO"* && "$out" == *"$needle"* \
|
|
&& "$out" != *"ERROR"* ]]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (exit $status, output: ${out:-<empty>})"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
echo ""
|
|
echo "--- description budget: $DESC_SUGGEST_CHARS SUGGESTION / $DESC_MAX_CHARS FAIL, both inclusive ---"
|
|
D_AT_SUGGEST="$(desc_of_length "$DESC_SUGGEST_CHARS")"
|
|
D_OVER_SUGGEST="$(desc_of_length "$((DESC_SUGGEST_CHARS + 1))")"
|
|
D_AT_MAX="$(desc_of_length "$DESC_MAX_CHARS")"
|
|
D_OVER_MAX="$(desc_of_length "$((DESC_MAX_CHARS + 1))")"
|
|
expect_gate "description at exactly $DESC_SUGGEST_CHARS chars is silent" \
|
|
pass "$(make_budget_fixture desc-at-suggest "$D_AT_SUGGEST" 10)"
|
|
expect_gate "description at $((DESC_SUGGEST_CHARS + 1)) chars suggests and exits 0" \
|
|
suggest "$(make_budget_fixture desc-over-suggest "$D_OVER_SUGGEST" 10)" \
|
|
"description is $((DESC_SUGGEST_CHARS + 1)) characters"
|
|
expect_gate "description at exactly $DESC_MAX_CHARS chars suggests, does not fail" \
|
|
suggest "$(make_budget_fixture desc-at-max "$D_AT_MAX" 10)" \
|
|
"description is $DESC_MAX_CHARS characters"
|
|
expect_gate "description at $((DESC_MAX_CHARS + 1)) chars fails" \
|
|
fail "$(make_budget_fixture desc-over-max "$D_OVER_MAX" 10)" \
|
|
"$DESC_MAX_CHARS-character ceiling"
|
|
|
|
echo ""
|
|
echo "--- description length is measured after YAML folding is resolved ---"
|
|
FOLDED="$TMPDIR/folded.md"
|
|
{
|
|
echo "---"
|
|
echo "name: folded"
|
|
echo "description: >"
|
|
python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))"
|
|
echo "---"
|
|
echo ""
|
|
echo "Do the thing."
|
|
} > "$FOLDED"
|
|
expect_gate "a >-folded 450-char description fails (raw first line would read as 1 char)" \
|
|
fail "$FOLDED" "description is 450 characters"
|
|
|
|
# Every body fixture below carries a boundary clause for the same reason
|
|
# desc_of_length() does: without one the missing-boundary-clause SUGGESTION
|
|
# fires and a body-budget test that asserts silence stops isolating the body
|
|
# budget. It is short, so the description gate stays quiet too.
|
|
CLEAN_DESC="Short valid description. Do not use for anything else."
|
|
echo ""
|
|
echo "--- body budget: $BODY_SUGGEST_WORDS SUGGESTION / $BODY_MAX_WORDS FAIL, body only, both inclusive ---"
|
|
expect_gate "body at exactly $BODY_SUGGEST_WORDS words is silent" \
|
|
pass "$(make_budget_fixture body-at-suggest "$CLEAN_DESC" "$BODY_SUGGEST_WORDS")"
|
|
expect_gate "body at $((BODY_SUGGEST_WORDS + 1)) words suggests and exits 0" \
|
|
suggest "$(make_budget_fixture body-over-suggest "$CLEAN_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \
|
|
"body is $((BODY_SUGGEST_WORDS + 1)) words"
|
|
expect_gate "body at exactly $BODY_MAX_WORDS words suggests, does not fail" \
|
|
suggest "$(make_budget_fixture body-at-max "$CLEAN_DESC" "$BODY_MAX_WORDS")" \
|
|
"body is $BODY_MAX_WORDS words"
|
|
expect_gate "body at $((BODY_MAX_WORDS + 1)) words fails" \
|
|
fail "$(make_budget_fixture body-over-max "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")" \
|
|
"$BODY_MAX_WORDS-word ceiling"
|
|
|
|
# The two word gates measure different things and must stay separable: a file
|
|
# whose FRONTMATTER pushes the whole-file count past the body ceiling must not
|
|
# trip the body gate, and a file under MAX_WORDS can still fail the body gate.
|
|
echo ""
|
|
echo "--- the body gate and the whole-file gate are independent measurements ---"
|
|
BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")"
|
|
# The needle pins the COUNT, not the bare word "words". "words" appears in the
|
|
# whole-file ceiling message, in the body ceiling message and in the body target
|
|
# message alike, so it was satisfied by any of the three — including the one
|
|
# this case exists to prove does NOT fire. Naming the number is what makes the
|
|
# assertion about the body-only measurement.
|
|
expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \
|
|
suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \
|
|
"body is $((BODY_MAX_WORDS - 5)) words"
|
|
BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")"
|
|
BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")"
|
|
if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then
|
|
pass "the body-gate fixture is $BIG_BODY_WORDS whole-file words, well under MAX_WORDS=$MAX_WORDS — it fails on the body gate alone"
|
|
else
|
|
fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ADR-0020's hand-invocation carve-out (issue #108)
|
|
# ---------------------------------------------------------------------------
|
|
# A skill carrying `disable-model-invocation: true` is removed from the
|
|
# model-visible listing entirely — it is not preloaded, and the Skill tool
|
|
# refuses to call it — so its description is never matched against user intent.
|
|
# ADR-0020, skill-author Step 2 and factory-audit's own Gotchas all give it ONE
|
|
# plain human-facing sentence: no trigger list, no boundary clause. No validator
|
|
# knew the field existed, so the boundary-clause SUGGESTION fired on exactly the
|
|
# shape the contract mandates, and its remedy — "so the router knows where NOT
|
|
# to send this skill" — named a router that cannot see the skill at all.
|
|
#
|
|
# The carve-out is NARROW and the half it does not cover is the half worth
|
|
# testing: the body is still loaded on invocation, so the body budget stands,
|
|
# and the 400-character ceiling stands because it is an outlier stop rather than
|
|
# a routing-quality target. Every case below asserts one of those two halves.
|
|
make_hand_invoked_fixture() {
|
|
local name="$1" desc="$2" body_words="$3" file
|
|
file="$TMPDIR/$name.md"
|
|
{
|
|
echo "---"
|
|
echo "name: $name"
|
|
echo "description: $desc"
|
|
echo "disable-model-invocation: true"
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
echo ""
|
|
python3 -c "print(' '.join(['word'] * $body_words))"
|
|
} > "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
# A description over the 250-character target, carrying no boundary clause and
|
|
# no routing target — the exact shape `zoom-out` and `caveman` ship. Built with
|
|
# no hyphens so nothing in it reads as a target.
|
|
HAND_DESC="$(python3 -c "
|
|
prefix = 'Tell the agent to zoom out and give broader context. '
|
|
print(prefix + 'x' * (300 - len(prefix)))")"
|
|
|
|
echo ""
|
|
echo "--- a hand-invoked skill is exempt from the routing rules, and only those ---"
|
|
expect_gate "a hand-invoked skill with a 300-char description and no boundary clause is silent" \
|
|
pass "$(make_hand_invoked_fixture hand-quiet "$HAND_DESC" 10)"
|
|
# The control that makes the case above mean something. Same description, same
|
|
# body, only the frontmatter flag removed: both findings must appear, or the
|
|
# exemption is being credited for silence it did not cause.
|
|
expect_gate "control: the SAME description without the flag is over the 250-char target" \
|
|
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
|
|
"description is 300 characters"
|
|
expect_gate "control: the SAME description without the flag has no boundary clause" \
|
|
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
|
|
"has no boundary clause"
|
|
|
|
echo ""
|
|
echo "--- the carve-out lifts the routing rules ONLY: both size gates still bite ---"
|
|
# The description ceiling is not a routing budget: a hand-invoked description is
|
|
# still the one line a human reads in the `/` menu, and 400 characters is the
|
|
# outlier stop either way.
|
|
HAND_OVER_MAX="$(python3 -c "
|
|
prefix = 'Tell the agent to zoom out and give broader context. '
|
|
print(prefix + 'x' * (401 - len(prefix)))")"
|
|
expect_gate "a hand-invoked description over $DESC_MAX_CHARS chars still FAILS" \
|
|
fail "$(make_hand_invoked_fixture hand-over-max "$HAND_OVER_MAX" 10)" \
|
|
"$DESC_MAX_CHARS-character ceiling"
|
|
# The body is loaded on invocation like any other body and competes with the
|
|
# caller's live conversation exactly the same way, so neither body tier moves.
|
|
expect_gate "a hand-invoked body over $BODY_MAX_WORDS words still FAILS" \
|
|
fail "$(make_hand_invoked_fixture hand-over-body "$HAND_DESC" "$((BODY_MAX_WORDS + 1))")" \
|
|
"$BODY_MAX_WORDS-word ceiling"
|
|
expect_gate "a hand-invoked body over $BODY_SUGGEST_WORDS words is still suggested" \
|
|
suggest "$(make_hand_invoked_fixture hand-over-body-suggest "$HAND_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \
|
|
"body is $((BODY_SUGGEST_WORDS + 1)) words"
|
|
|
|
echo ""
|
|
echo "--- the flag is read as a BOOLEAN, not as any mention of the key ---"
|
|
# `disable-model-invocation: false` is the model-invoked case written out
|
|
# longhand. Reading the key's presence instead of its value would hand every
|
|
# routing exemption to anyone who typed the field at all.
|
|
HAND_FALSE="$TMPDIR/hand-false.md"
|
|
{
|
|
echo "---"
|
|
echo "name: hand-false"
|
|
echo "description: $HAND_DESC"
|
|
echo "disable-model-invocation: false"
|
|
echo "metadata:"
|
|
echo " version: \"1.0.0\""
|
|
echo "---"
|
|
echo ""
|
|
echo "Do the thing."
|
|
} > "$HAND_FALSE"
|
|
expect_gate "disable-model-invocation: false is NOT the carve-out" \
|
|
suggest "$HAND_FALSE" "has no boundary clause"
|
|
|
|
echo ""
|
|
echo "--- resolvable boundary targets ---"
|
|
# Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and
|
|
# plugins/*/.apm/agents/), reached by walking up FROM THE SKILL FILE. These
|
|
# fixtures therefore build their own synthetic monorepo (make_tree_fixture) and
|
|
# name only fixture-local targets: they must not depend on this repo's live
|
|
# skills, or renaming git-commits would break a test about extraction grammar.
|
|
expect_gate "a boundary target naming a sibling skill in the same package resolves" \
|
|
pass "$(make_tree_fixture target-ok \
|
|
"Use when doing the thing. Do not use for commits — use sibling-skill instead." 10)"
|
|
expect_gate "a boundary target naming a skill in a SIBLING PLUGIN resolves (that is what a monorepo means)" \
|
|
pass "$(make_tree_fixture target-cross-plugin \
|
|
"Use when doing the thing. Do not use for the other thing — use cross-plugin-skill instead." 10)"
|
|
expect_gate "a boundary target naming an AGENT resolves (agents are valid targets)" \
|
|
pass "$(make_tree_fixture target-agent-ok \
|
|
"Use when doing the thing. Do not use when the caller is an agent — invoke sibling-agent instead." 10)"
|
|
# CORROBORATED: `sibling-skill` resolves in the same sentence, which is what
|
|
# promotes a prose-form target from "reported" to "blocking". A lone prose-form
|
|
# target is deliberately not fatal — see the case below and the shared resolver's
|
|
# CORROBORATION note.
|
|
expect_gate "a boundary target that resolves to nothing fails when its sentence names one that does" \
|
|
fail "$(make_tree_fixture target-missing \
|
|
"Use when doing the thing. Do not use for improvements — use sibling-skill or no-such-skill-anywhere instead." 10)" \
|
|
"routes to 'no-such-skill-anywhere'"
|
|
# UNCORROBORATED: identical grammar to the case above, and identical grammar to
|
|
# "run `pre-commit` instead". Reported at SUGGESTION tier, exit 0 — a gate that
|
|
# ships hot with no baseline and no suppression mechanism must not block a commit
|
|
# on a token it cannot tell from a tool name.
|
|
expect_gate "a lone boundary target that resolves to nothing is reported, not fatal" \
|
|
suggest "$(make_tree_fixture target-missing-lone \
|
|
"Use when doing the thing. Do not use for improvements — use no-such-lone-skill instead." 10)" \
|
|
"routes to 'no-such-lone-skill'"
|
|
expect_gate "a /slash-command boundary target that resolves to nothing fails" \
|
|
fail "$(make_tree_fixture target-missing-slash \
|
|
"Use when doing the thing. Do not use for improvements — use /no-such-slash-skill instead." 10)" \
|
|
"routes to 'no-such-slash-skill'"
|
|
# False-positive guards. These phrasings are lifted from real descriptions:
|
|
# pc-run says "run pre-commit hooks", diagnose chains "fix -> regression-test",
|
|
# gitea-files says "(use Read/Write/Edit)", gitea-labels-milestones says
|
|
# "through `issue_write`/`pull_request_write`". None of them is a routing
|
|
# target, and reading any of them as one makes the gate untrustworthy.
|
|
#
|
|
# Each carries a boundary clause in a SEPARATE sentence. That is not decoration:
|
|
# target extraction is decided per sentence, so the clause satisfies the
|
|
# missing-boundary-clause SUGGESTION (keeping the expected output empty) while
|
|
# leaving the sentence under test outside a boundary context, which is the exact
|
|
# condition each of these is about. They are built as trees so a universe exists
|
|
# — in a bare temp dir the resolver would decline and the guard would pass
|
|
# vacuously, proving nothing about extraction.
|
|
expect_gate "'run pre-commit hooks' outside a boundary sentence is not a routing target" \
|
|
pass "$(make_tree_fixture fp-precommit \
|
|
"Use when the user wants to run pre-commit hooks or install git hooks. Do not use for anything else." 10)"
|
|
expect_gate "an arrow chain outside a boundary clause is not a routing target" \
|
|
pass "$(make_tree_fixture fp-arrow \
|
|
"Reproduce → minimise → instrument → fix → regression-test. Use when a bug is reported. Do not use for anything else." 10)"
|
|
expect_gate "tool names and MCP tool names are not routing targets" \
|
|
pass "$(make_tree_fixture fp-tools \
|
|
"Use when writing issues. Do not use for local files (use Read/Write/Edit) — that write goes through \`issue_write\`/\`pull_request_write\` instead." 10)"
|
|
|
|
echo ""
|
|
echo "--- with NO authoring root the resolver declines OUT LOUD and does not fail the file ---"
|
|
# The consumer/draft case, and a real one: a SKILL.md in a bare directory with no
|
|
# plugins/*/.apm/ above it and no .git has no universe to resolve against. The
|
|
# required behaviour is neither a false FAIL nor silence — silence is how a whole
|
|
# gate family goes missing unnoticed — so the INFO and the named unchecked target
|
|
# are both asserted, along with exit 0. This is the same path make_tree_fixture
|
|
# exists to escape, kept pinned so a future "just use the repo root" shortcut
|
|
# (the ${BASH_SOURCE} universe leak ADR-0020 removed) fails here.
|
|
expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \
|
|
info "$(make_budget_fixture no-universe \
|
|
"Use when doing the thing. Do not use for improvements — use some-other-skill instead." 10)" \
|
|
"Unchecked target(s): some-other-skill"
|
|
|
|
# NOTE: this section prints no header and runs no assertions any more — see why
|
|
# below. The commentary is kept because it records why probes are removed rather
|
|
# than skipped, which is the rule the next person to touch this file needs.
|
|
#
|
|
# ADR-0020 records the broken routing targets and splits fixing them into its own
|
|
# issue. This asserted the gate actually sees them rather than the check being
|
|
# vacuous in the corpus it was written against.
|
|
#
|
|
# There used to be a third probe here, for `skill-improve` in skill-audit's
|
|
# description. It was already stale: that target was fixed, so the iteration
|
|
# permanently took a `pass "SKIP: ..."` branch — an assertion-free result counted
|
|
# in the totals, which is worse than no probe at all because it makes the suite
|
|
# look one test stronger than it is. It also contradicted
|
|
# tests/test-adr0020-targets.sh, which pins the live dangling set as EXACTLY
|
|
# {neuledge-context}; that file is the authority on the set, this one only
|
|
# checks each member is individually detected.
|
|
#
|
|
# Both SKIP branches are gone with it, for the same reason. A probe whose fixture
|
|
# has been retrofitted is not "still passing" — it is a pin that needs updating,
|
|
# here and in the exact-set assertion in test-adr0020-targets.sh, and it should
|
|
# say so out loud rather than quietly agreeing with whatever it finds.
|
|
# The gitea-labels probe was dropped when the issue #99 retrofit cut the
|
|
# composition sentence whose YAML fold produced that target. Per the rule above
|
|
# it is removed, not skipped.
|
|
#
|
|
# The `neuledge-context` probe — the last one — went the same way in wave 3 of
|
|
# that retrofit, which deleted the boundary clause naming it. **The corpus now
|
|
# has zero dangling targets**, so this loop is removed entirely rather than left
|
|
# to iterate over an empty list.
|
|
#
|
|
# That is deliberate and follows the rule stated above. A loop over no probes
|
|
# produces no assertion while still returning success, which is the vacuous-pass
|
|
# shape this comment block exists to reject — it would make the suite look one
|
|
# test stronger than it is, exactly the complaint levelled at the old
|
|
# `skill-improve` SKIP branch.
|
|
#
|
|
# Nothing is lost. This file only ever checked that each member of the live
|
|
# dangling set is individually detected; tests/test-adr0020-targets.sh remains
|
|
# the authority on the set itself, and now pins it as EMPTY, which is what
|
|
# catches a newly-authored clause naming a target that does not resolve. That
|
|
# file also carries synthetic fixtures built inside a real plugin tree, which
|
|
# exercise the detection path without depending on the corpus staying broken.
|
|
#
|
|
# If a real dangling target ever reappears, add its probe back here.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage tier: zero arguments is exit 2, not a clean run
|
|
# ---------------------------------------------------------------------------
|
|
# The script used to print nothing and exit 0 when handed no paths, which made
|
|
# a mis-scoped `files:` pattern indistinguishable from a corpus with no
|
|
# findings — the whole ADR-0020 gate family silently disabled while every hook
|
|
# reported green. Exit 2 (not 1) is the same split a8cd5e8 made in
|
|
# provider-adapter-author's validate-adapter.sh and the one vale-wrap.sh already
|
|
# used: {0,1} are verdicts, 2 is "you invoked this wrong".
|
|
#
|
|
# SAFE FOR THE HOOK. Both manifests declare pass_filenames: true and neither
|
|
# sets always_run, and pre-commit skips a filename-passing hook outright when
|
|
# its `files:` pattern matches nothing, so pre-commit never invokes this script
|
|
# with an empty argument list. That claim is asserted below rather than left in
|
|
# prose, so a config edit that turns it false fails here.
|
|
echo ""
|
|
echo "--- zero arguments is a usage error (exit 2), not a silent clean run ---"
|
|
set +e
|
|
USAGE_OUT="$("$SCRIPT" 2>&1)"
|
|
USAGE_RC=$?
|
|
set -e
|
|
if [[ $USAGE_RC -eq 2 ]]; then
|
|
pass "no arguments exits 2"
|
|
else
|
|
fail "no arguments exited $USAGE_RC, expected 2 (output: ${USAGE_OUT:-<empty>})"
|
|
fi
|
|
if [[ "$USAGE_OUT" == *usage* ]]; then
|
|
pass "no arguments prints a usage message"
|
|
else
|
|
fail "no arguments produced no usage message (output: ${USAGE_OUT:-<empty>})"
|
|
fi
|
|
# The exit code must be DISTINCT from both verdicts, or the split buys nothing.
|
|
# $SMALL is the clean fixture built at the top of this file; $MANY_LINES is over
|
|
# the line ceiling.
|
|
set +e
|
|
"$SCRIPT" "$SMALL" > /dev/null 2>&1
|
|
CLEAN_RC=$?
|
|
"$SCRIPT" "$MANY_LINES" > /dev/null 2>&1
|
|
FINDING_RC=$?
|
|
set -e
|
|
if [[ $CLEAN_RC -eq 0 && $FINDING_RC -eq 1 && $USAGE_RC -eq 2 ]]; then
|
|
pass "the three exit codes are distinct: clean=0, findings=1, usage=2"
|
|
else
|
|
fail "exit codes collide — clean=$CLEAN_RC findings=$FINDING_RC usage=$USAGE_RC"
|
|
fi
|
|
# The hook contract the usage exit depends on. If either manifest ever stops
|
|
# passing filenames, or starts always_run, pre-commit could invoke the script
|
|
# with no paths and exit 2 would break the hook rather than diagnose a caller.
|
|
HOOK_CONTRACT="$(python3 - "$REPO_ROOT" <<'PYHOOK'
|
|
import os
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
root = sys.argv[1]
|
|
problems = []
|
|
|
|
|
|
def check(label, hook):
|
|
if hook is None:
|
|
problems.append('%s declares no such hook' % label)
|
|
return
|
|
if hook.get('pass_filenames') is False:
|
|
problems.append('%s sets pass_filenames: false' % label)
|
|
if hook.get('always_run'):
|
|
problems.append('%s sets always_run: true' % label)
|
|
|
|
|
|
with open(os.path.join(root, '.pre-commit-config.yaml'), encoding='utf-8') as fh:
|
|
cfg = yaml.safe_load(fh) or {}
|
|
found = None
|
|
for repo in cfg.get('repos') or []:
|
|
for hook in (repo.get('hooks') or []):
|
|
if hook.get('id') == 'skill-size-check':
|
|
found = hook
|
|
check('.pre-commit-config.yaml skill-size-check', found)
|
|
|
|
with open(os.path.join(root, '.pre-commit-hooks.yaml'), encoding='utf-8') as fh:
|
|
hooks = yaml.safe_load(fh) or []
|
|
found = None
|
|
for hook in hooks:
|
|
if isinstance(hook, dict) and hook.get('id') == 'kyberforge-skill-size-check':
|
|
found = hook
|
|
check('.pre-commit-hooks.yaml kyberforge-skill-size-check', found)
|
|
|
|
print('; '.join(problems))
|
|
PYHOOK
|
|
)"
|
|
if [[ -z "$HOOK_CONTRACT" ]]; then
|
|
pass "both manifests pass filenames and neither is always_run, so pre-commit never invokes the script with no paths"
|
|
else
|
|
fail "the usage exit would break the hook: $HOOK_CONTRACT"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# An unreadable path is diagnosed ONCE
|
|
# ---------------------------------------------------------------------------
|
|
# The stat dance lived twice — a bash pre-loop and the Python per-file loop —
|
|
# and both printed the same sentence, so one broken file produced two ERROR
|
|
# lines with two different "so ... could not be measured" clauses. Duplicated
|
|
# output on a blocking gate reads as two problems and sends the author hunting
|
|
# for a second one. The check must still FIRE (silence is the failure this
|
|
# script forbids itself); it must fire exactly once.
|
|
echo ""
|
|
echo "--- an unreadable path produces exactly one ERROR line, not two ---"
|
|
UNREADABLE_DIR="$TMPDIR/unreadable"
|
|
mkdir -p "$UNREADABLE_DIR/a-directory.md"
|
|
ln -sf "$TMPDIR/definitely-not-here.md" "$UNREADABLE_DIR/broken-link.md"
|
|
|
|
# unreadable_case <label> <path>
|
|
unreadable_case() {
|
|
local label="$1" path="$2" out status=0 count
|
|
set +e
|
|
out="$("$SCRIPT" "$path" 2>&1)"
|
|
status=$?
|
|
set -e
|
|
count="$(printf '%s\n' "$out" | grep -cF "ERROR: $path" || true)"
|
|
if [[ $status -eq 0 ]]; then
|
|
fail "$label: exited 0 — an unmeasurable path passed in silence (output: ${out:-<empty>})"
|
|
elif [[ "$count" != "1" ]]; then
|
|
fail "$label: $count ERROR lines name the path, expected exactly 1 (output: $out)"
|
|
else
|
|
pass "$label"
|
|
fi
|
|
}
|
|
unreadable_case "a path that does not exist is reported once" \
|
|
"$TMPDIR/no-such-file.md"
|
|
unreadable_case "a DIRECTORY named *.md is reported once" \
|
|
"$UNREADABLE_DIR/a-directory.md"
|
|
unreadable_case "a broken symlink is reported once" \
|
|
"$UNREADABLE_DIR/broken-link.md"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encoding, write side: under LC_ALL=C the report must still print
|
|
# ---------------------------------------------------------------------------
|
|
# read_text() in the shared ADR-0020 resolver block pins the READS to UTF-8.
|
|
# That moved the crash to the WRITE: this script's own message text carries em
|
|
# dashes (the boundary SUGGESTION is one), so under LC_ALL=C the streams' ASCII
|
|
# default raised UnicodeEncodeError while PRINTING -- after every check had
|
|
# already run, losing the whole report at the last step and turning a
|
|
# SUGGESTION-only exit 0 into a traceback and an exit 1.
|
|
echo ""
|
|
echo "--- under LC_ALL=C the SUGGESTION is printed, not lost to a UnicodeEncodeError ---"
|
|
LOCALE_SKILL="$TMPDIR/locale-skill"
|
|
mkdir -p "$LOCALE_SKILL"
|
|
cat > "$LOCALE_SKILL/SKILL.md" <<'LOCALEEOF'
|
|
---
|
|
name: locale-skill
|
|
description: A valid skill description that is well within the limit.
|
|
metadata:
|
|
version: "1.0.0"
|
|
---
|
|
|
|
## Step 1
|
|
|
|
Do the thing.
|
|
LOCALEEOF
|
|
set +e
|
|
LOCALE_OUT="$(env LC_ALL=C PYTHONUTF8=0 "$SCRIPT" "$LOCALE_SKILL/SKILL.md" 2>&1)"
|
|
LOCALE_STATUS=$?
|
|
set -e
|
|
if [[ $LOCALE_STATUS -ne 0 ]]; then
|
|
fail "a SUGGESTION-only subject exited $LOCALE_STATUS under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})"
|
|
elif [[ "$LOCALE_OUT" == *UnicodeEncodeError* || "$LOCALE_OUT" == *Traceback* ]]; then
|
|
fail "the report died encoding its own message text under LC_ALL=C (output: $LOCALE_OUT)"
|
|
elif [[ "$LOCALE_OUT" != *"description has no boundary clause"* ]]; then
|
|
fail "the SUGGESTION never reached stdout under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})"
|
|
else
|
|
pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# metadata.version shape: no leading zeros, matching check-skill-version-bump
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- metadata.version with a leading zero is malformed ---"
|
|
for version_case in "1.0.08:malformed" "01.0.1:malformed" "1.0.10:valid" "0.1.0:valid"; do
|
|
version="${version_case%%:*}"
|
|
expected="${version_case##*:}"
|
|
VERSION_SKILL="$TMPDIR/version-$version"
|
|
mkdir -p "$VERSION_SKILL"
|
|
cat > "$VERSION_SKILL/SKILL.md" <<VERSIONEOF
|
|
---
|
|
name: version-skill
|
|
description: A valid skill description that is well within the limit.
|
|
metadata:
|
|
version: "$version"
|
|
---
|
|
|
|
## Step 1
|
|
|
|
Do the thing.
|
|
VERSIONEOF
|
|
set +e
|
|
VERSION_OUT="$("$SCRIPT" "$VERSION_SKILL/SKILL.md" 2>&1)"
|
|
VERSION_STATUS=$?
|
|
set -e
|
|
if [[ "$expected" == malformed ]]; then
|
|
if [[ $VERSION_STATUS -ne 0 && "$VERSION_OUT" == *"metadata.version is malformed ('$version')"* ]]; then
|
|
pass "'$version' is rejected as malformed"
|
|
else
|
|
fail "'$version' was not rejected as malformed (exit $VERSION_STATUS): ${VERSION_OUT:-<empty>}"
|
|
fi
|
|
elif [[ "$VERSION_OUT" == *"metadata.version is malformed"* ]]; then
|
|
fail "'$version' was wrongly rejected as malformed: $VERSION_OUT"
|
|
else
|
|
pass "'$version' is accepted"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "Results: $PASS passed, $FAIL failed"
|
|
[[ $FAIL -eq 0 ]]
|