B1: check-skill-version-bump.sh resolves every merge-base with `git merge-base
--all` instead of the single base git happens to pick. A criss-cross history has
two, so the verdict turned on that choice: a skill byte-identical to main's tip
could still be reported "not above merge-base" / "not above main tip" and fail a
push that should pass. A skill now counts as changed only when it differs from
EVERY base, and its version must exceed the version at every base it exists at
as well as at the main tip; with more than one base the failure names which one.
Case 40 in tests/test-skill-version-bump.sh builds the criss-cross fixture and
pins both directions.
B2: check-apm-current.sh no longer assumes the remote default branch is `main`
when origin/HEAD is unset. A checkout whose default is `master` was standing on
its default branch and being told "this is a feature branch, so discard it" --
to throw away a real lock update. With origin/HEAD unset nothing is asserted and
the neutral advice stands. tests/test-apm-current-hook.sh covers the unset case
on both `main` and `master`.
#4: the required-frontmatter checks folded into skill-size-check.sh by c8a7c9e
were untested apart from the leading-zero shape -- mutating the missing-version
ERROR into a no-op left every suite green. tests/test-adr0020-frontmatter.sh now
pins name presence and non-emptiness, metadata.version presence and semver
shape, and the four grep defects the deleted test-skill-frontmatter.sh named.
#5: nothing asked whether a Vale rule still MATCHES anything -- rewriting
CompositionNote.yml's tokens to match nothing left test-vale-wrap.sh at 63/63.
Case 35 enumerates the rule files under the Kyberforge* style directories at run
time, requires an alert from each on its own fixture, and fails when a
discovered rule has no fixture row. The stale comment at case 31 is corrected.
#6: tests/run-tests.sh --strict exited 0 when discovery found no test-*.sh at
all; strictness only ever acted on skips, and with no suites there were none. It
now cross-checks the git index the way run-bats.sh does and fails
unconditionally on an empty set, naming the search root.
N9: the skill-size-check hook description in .pre-commit-config.yaml covered
only the size, context-budget and boundary-target gates. It now also names the
required frontmatter fields, matching docs/spec/gates.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
639 lines
27 KiB
Bash
Executable File
639 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
SCRIPT="$REPO_ROOT/scripts/check-skill-version-bump.sh"
|
||
PASS=0
|
||
FAIL=0
|
||
|
||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||
|
||
# The gate reads versions with python3 + PyYAML, the same hard requirement
|
||
# skill-size-check carries. Without them this suite cannot run: exit 77 so
|
||
# run-tests reports SKIPPED (and --strict fails the push).
|
||
if ! python3 -c 'import yaml' 2>/dev/null; then
|
||
echo "SKIP: python3 with PyYAML is required" >&2
|
||
exit 77
|
||
fi
|
||
|
||
# Output assertions use here-strings, never `echo | grep -q` (pipefail race;
|
||
# see docs/spec/gates.md, Tests).
|
||
|
||
CLEANUP_DIRS=()
|
||
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
|
||
|
||
# write_skill <repo> <plugin> <skill> <version-line|""> [body]
|
||
# An empty version line writes a SKILL.md with no metadata block at all.
|
||
write_skill() {
|
||
local repo="$1" plugin="$2" skill="$3" vline="$4" body="${5:-body}"
|
||
local d="$repo/plugins/$plugin/.apm/skills/$skill"
|
||
mkdir -p "$d"
|
||
{
|
||
echo "---"
|
||
echo "name: $skill"
|
||
echo "description: Use when testing."
|
||
if [[ -n "$vline" ]]; then
|
||
echo "metadata:"
|
||
echo " $vline"
|
||
fi
|
||
echo "---"
|
||
echo "$body"
|
||
} > "$d/SKILL.md"
|
||
}
|
||
|
||
commit() { (cd "$1" && git add -A && git commit -q -m "${2:-change}"); }
|
||
|
||
# A fixture with skills alpha (1.0.0) and beta (1.0.9) on main, then checked out
|
||
# onto a feature branch.
|
||
make_fixture() {
|
||
local dir
|
||
dir="$(mktemp -d)"
|
||
CLEANUP_DIRS+=("$dir")
|
||
(cd "$dir" && git init -q -b main && git config user.email t@t.t && git config user.name t)
|
||
write_skill "$dir" demo alpha 'version: "1.0.0"'
|
||
write_skill "$dir" demo beta "version: 1.0.9"
|
||
commit "$dir" initial
|
||
(cd "$dir" && git checkout -q -b feature)
|
||
echo "$dir"
|
||
}
|
||
|
||
# Every PRE_COMMIT_* input comes from here: a value inherited from the pre-push
|
||
# hook running this suite names a sha of the real repo, not the fixture.
|
||
run_check() {
|
||
local dir="$1"
|
||
if [[ $# -ge 2 ]]; then
|
||
(cd "$dir" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_REMOTE_BRANCH \
|
||
&& PRE_COMMIT_TO_REF="$2" bash "$SCRIPT" 2>&1)
|
||
else
|
||
(cd "$dir" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
|
||
&& bash "$SCRIPT" 2>&1)
|
||
fi
|
||
}
|
||
|
||
# expect_pass <desc> <dir> [to-ref]
|
||
expect_pass() {
|
||
local desc="$1"; shift
|
||
local out
|
||
if out="$(run_check "$@")" && [[ -z "$out" ]]; then
|
||
pass "$desc"
|
||
else
|
||
fail "$desc — expected silent exit 0, got: $out"
|
||
fi
|
||
}
|
||
|
||
# expect_fail <desc> <pattern> <dir> [to-ref]
|
||
expect_fail() {
|
||
local desc="$1" pattern="$2"; shift 2
|
||
local out
|
||
if out="$(run_check "$@")"; then
|
||
fail "$desc — expected non-zero exit, got 0"
|
||
elif grep -qE "$pattern" <<< "$out"; then
|
||
pass "$desc"
|
||
else
|
||
fail "$desc — output did not match /$pattern/: $out"
|
||
fi
|
||
}
|
||
|
||
echo ""
|
||
echo "--- 1. unchanged skill passes ---"
|
||
F="$(make_fixture)"
|
||
echo "unrelated" > "$F/README.md"; commit "$F"
|
||
expect_pass "no skill change passes silently" "$F"
|
||
|
||
echo ""
|
||
echo "--- 2. changed without bump fails ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "new body"; commit "$F"
|
||
expect_fail "unbumped change fails and names baseline and current" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
|
||
echo ""
|
||
echo "--- 3. changed with patch / minor / major bump passes ---"
|
||
for v in 1.0.1 1.1.0 2.0.0; do
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha "version: \"$v\"" "new body"; commit "$F"
|
||
expect_pass "bump to $v passes" "$F"
|
||
done
|
||
|
||
echo ""
|
||
echo "--- 4. version decreased fails ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo beta "version: 1.0.8" "new body"; commit "$F"
|
||
expect_fail "decrease fails" "beta: 1\.0\.9 -> 1\.0\.8" "$F"
|
||
|
||
echo ""
|
||
echo "--- 5. change to a non-SKILL.md file still counts ---"
|
||
F="$(make_fixture)"
|
||
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
|
||
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/x.md"; commit "$F"
|
||
expect_fail "references/ change without bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
|
||
echo ""
|
||
echo "--- 6. tests/-only change passes without bump ---"
|
||
F="$(make_fixture)"
|
||
mkdir -p "$F/plugins/demo/.apm/skills/alpha/tests"
|
||
echo "t" > "$F/plugins/demo/.apm/skills/alpha/tests/test-x.sh"; commit "$F"
|
||
expect_pass "tests/-only change is exempt" "$F"
|
||
|
||
echo ""
|
||
echo "--- 7. new skill exempt ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo gamma 'version: "0.1.0"'; commit "$F"
|
||
expect_pass "new skill passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 8. deleted skill exempt ---"
|
||
F="$(make_fixture)"
|
||
rm -rf "$F/plugins/demo/.apm/skills/alpha"; commit "$F"
|
||
expect_pass "deleted skill passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 9. renamed skill exempt ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git mv plugins/demo/.apm/skills/alpha plugins/demo/.apm/skills/alpha2)
|
||
commit "$F"
|
||
expect_pass "renamed skill passes (old absent at pushed, new absent at baseline)" "$F"
|
||
|
||
echo ""
|
||
echo "--- 10. missing version on changed skill fails ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha "" "new body"; commit "$F"
|
||
expect_fail "missing version fails" "alpha: metadata\.version missing" "$F"
|
||
|
||
echo ""
|
||
echo "--- 11. malformed / prerelease version fails ---"
|
||
for v in 'version: 1.1' 'version: "1.0.1-rc1"'; do
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha "$v" "new body"; commit "$F"
|
||
expect_fail "'$v' is rejected like skill-size-check rejects it" "alpha: metadata\.version missing or not" "$F"
|
||
done
|
||
|
||
echo ""
|
||
echo "--- 12. multiple offenders all reported ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "x"
|
||
write_skill "$F" demo beta "version: 1.0.9" "x"
|
||
commit "$F"
|
||
OUT="$(run_check "$F" || true)"
|
||
if grep -q "alpha: 1.0.0 -> 1.0.0" <<< "$OUT" && grep -q "beta: 1.0.9 -> 1.0.9" <<< "$OUT" \
|
||
&& grep -q "bump PATCH at minimum" <<< "$OUT"; then
|
||
pass "both offenders and the fix are reported"
|
||
else
|
||
fail "not every offender reported: $OUT"
|
||
fi
|
||
|
||
echo ""
|
||
echo "--- 13. PRE_COMMIT_TO_REF respected over HEAD ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
|
||
BAD="$(cd "$F" && git rev-parse HEAD)"
|
||
(cd "$F" && git checkout -q -b clean main)
|
||
echo "unrelated" > "$F/README.md"; commit "$F"
|
||
CLEAN="$(cd "$F" && git rev-parse HEAD)"
|
||
expect_fail "unbumped TO_REF fails while HEAD is clean" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$BAD"
|
||
(cd "$F" && git checkout -q "$BAD")
|
||
expect_pass "clean TO_REF passes while HEAD is unbumped" "$F" "$CLEAN"
|
||
|
||
echo ""
|
||
echo "--- 14. all-zeros delete sha no-ops ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
|
||
expect_pass "40-zero sha exits 0" "$F" "0000000000000000000000000000000000000000"
|
||
expect_pass "64-zero sha exits 0" "$F" "$(printf '0%.0s' {1..64})"
|
||
|
||
echo ""
|
||
echo "--- 15. bin plugin covered ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" bin tool 'version: "1.0.0"'
|
||
(cd "$F" && git checkout -q main); commit "$F" "add bin skill"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" bin tool 'version: "1.0.0"' "changed"; commit "$F"
|
||
expect_fail "bin skill change without bump fails" "plugins/bin/\.apm/skills/tool: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
|
||
echo ""
|
||
echo "--- 16. multi-digit semver compare ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo beta "version: 1.0.10" "x"; commit "$F"
|
||
expect_pass "1.0.10 > 1.0.9 passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 17. pushed version must also exceed main's tip ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
|
||
(cd "$F" && git checkout -q main)
|
||
write_skill "$F" demo alpha 'version: "1.0.5"' "main moved on"; commit "$F"
|
||
(cd "$F" && git checkout -q feature)
|
||
expect_fail "bump over the merge-base fails when main's tip is higher" \
|
||
"alpha: 1\.0\.5 -> 1\.0\.1 \(not above main tip\)" "$F"
|
||
OUT="$(run_check "$F" || true)"
|
||
if grep -q "not above merge-base" <<< "$OUT"; then
|
||
fail "merge-base reported as failed although 1.0.1 > 1.0.0: $OUT"
|
||
else
|
||
pass "only the baseline actually failed is named"
|
||
fi
|
||
write_skill "$F" demo alpha 'version: "1.0.6"' "branch change 2"; commit "$F"
|
||
expect_pass "bump above both the merge-base and main's tip passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 18. origin/main preferred over local main ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "x"; commit "$F"
|
||
# A stale local main pointing at the feature tip would make the diff empty;
|
||
# origin/main at the original commit must win and still see the change.
|
||
(cd "$F" && git update-ref refs/remotes/origin/main main && git branch -f main feature)
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "y"; commit "$F"
|
||
if OUT="$(run_check "$F")" && [[ -z "$OUT" ]]; then
|
||
pass "origin/main used as baseline (1.0.0 -> 1.0.1 counted as a bump)"
|
||
else
|
||
fail "unexpected output: $OUT"
|
||
fi
|
||
(cd "$F" && git update-ref refs/remotes/origin/main feature~1)
|
||
expect_fail "origin/main at the bumped commit flags the further unbumped change" \
|
||
"merge-base with origin/main" "$F"
|
||
expect_fail "the report names alpha and the versions read from origin/main's merge-base" \
|
||
"demo/\.apm/skills/alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base\)" "$F"
|
||
|
||
echo ""
|
||
echo "--- 19. no main ref fails closed ---"
|
||
F="$(mktemp -d)"; CLEANUP_DIRS+=("$F")
|
||
(cd "$F" && git init -q -b trunk && git config user.email t@t.t && git config user.name t)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"'; commit "$F"
|
||
expect_fail "missing main fails with a clear message" "neither origin/main nor main resolves" "$F"
|
||
|
||
echo ""
|
||
echo "--- 20. non-ASCII paths are not hidden by core.quotePath ---"
|
||
F="$(make_fixture)"
|
||
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
|
||
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/résumé.md"; commit "$F"
|
||
expect_fail "non-ASCII file under references/ without bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
write_skill "$F" demo "café" 'version: "2.0.0"'; commit "$F" "add café"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" demo "café" 'version: "2.0.0"' "changed"; commit "$F"
|
||
expect_fail "non-ASCII skill dir without bump fails" "skills/café: 2\.0\.0 -> 2\.0\.0" "$F"
|
||
write_skill "$F" demo "café" 'version: "2.0.1"' "changed again"; commit "$F"
|
||
expect_pass "non-ASCII skill dir with bump passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 21. local main fallback: pushed commit already in main fails closed ---"
|
||
F="$(make_fixture)"
|
||
MAIN_SHA="$(cd "$F" && git rev-parse main)"
|
||
expect_fail "pushing main's sha without origin/main fails" \
|
||
"origin/main does not resolve.*already contained in local main" "$F" "$MAIN_SHA"
|
||
(cd "$F" && git checkout -q main)
|
||
expect_fail "HEAD on main without origin/main fails" \
|
||
"origin/main does not resolve and HEAD is already contained in local main" "$F"
|
||
|
||
echo ""
|
||
echo "--- 22. pushing main itself with origin/main present ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main && git update-ref refs/remotes/origin/main main)
|
||
expect_pass "main equal to origin/main passes (nothing changed vs main)" "$F"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "x"; commit "$F"
|
||
expect_pass "main ahead of origin/main with a bump passes" "$F"
|
||
write_skill "$F" demo beta "version: 1.0.9" "x"; commit "$F"
|
||
expect_fail "main ahead of origin/main without a bump fails" "beta: 1\.0\.9 -> 1\.0\.9" "$F"
|
||
(cd "$F" && git update-ref refs/remotes/origin/main main)
|
||
expect_pass "already-merged content (merge-base == pushed) passes against origin/main" "$F" \
|
||
"$(cd "$F" && git rev-parse main~1)"
|
||
|
||
echo ""
|
||
echo "--- 23. version shape is ASCII-only, bounded, and has no leading zeros ---"
|
||
for v in 'version: "1.0.1"' 'version: "1.0.1"' 'version: "1.0.9999999999"' \
|
||
'version: "99999999999999999999.0.0"' 'version: "1.0.08"' 'version: "01.0.1"' \
|
||
'version: "1.00.1"'; do
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha "$v" "new body"; commit "$F"
|
||
OUT="$(run_check "$F" || true)"
|
||
if grep -q "alpha: metadata\.version missing or not" <<< "$OUT" \
|
||
&& ! grep -qiE "integer|syntax error|value too great" <<< "$OUT"; then
|
||
pass "'$v' is rejected as invalid without a bash arithmetic error"
|
||
else
|
||
fail "'$v' not cleanly rejected: $OUT"
|
||
fi
|
||
done
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.999999999"' "new body"; commit "$F"
|
||
expect_pass "nine-digit part is accepted and compared" "$F"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.10"' "new body"; commit "$F"
|
||
expect_pass "a zero inside a part (1.0.10) is not a leading zero" "$F"
|
||
|
||
echo ""
|
||
echo "--- 24. python3 / PyYAML failures are never reported as a missing version ---"
|
||
REAL_PYTHON="$(command -v python3)"
|
||
SHIM_ROOT="$(mktemp -d)"; CLEANUP_DIRS+=("$SHIM_ROOT")
|
||
mkdir -p "$SHIM_ROOT/shadow" "$SHIM_ROOT/noyaml" "$SHIM_ROOT/crash"
|
||
printf 'raise ImportError("PyYAML deliberately unavailable in this fixture")\n' \
|
||
> "$SHIM_ROOT/shadow/yaml.py"
|
||
cat > "$SHIM_ROOT/noyaml/python3" <<EOF
|
||
#!/bin/sh
|
||
PYTHONPATH="$SHIM_ROOT/shadow\${PYTHONPATH:+:\$PYTHONPATH}" exec "$REAL_PYTHON" "\$@"
|
||
EOF
|
||
# Passes the up-front import probe, crashes on the real read.
|
||
cat > "$SHIM_ROOT/crash/python3" <<EOF
|
||
#!/bin/sh
|
||
[ "\$1" = "-c" ] && [ "\$2" = "import yaml" ] && exec "$REAL_PYTHON" "\$@"
|
||
echo "Traceback: simulated interpreter failure" >&2
|
||
exit 1
|
||
EOF
|
||
chmod +x "$SHIM_ROOT/noyaml/python3" "$SHIM_ROOT/crash/python3"
|
||
if PATH="$SHIM_ROOT/noyaml:$PATH" python3 -c 'import yaml' 2>/dev/null; then
|
||
fail "fixture check: the no-PyYAML shim still imports yaml — the next assertion would be vacuous"
|
||
else
|
||
pass "fixture check: the no-PyYAML shim makes 'import yaml' fail"
|
||
fi
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
|
||
OUT="$(cd "$F" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
|
||
&& PATH="$SHIM_ROOT/noyaml:$PATH" bash "$SCRIPT" 2>&1)" && RC=0 || RC=$?
|
||
if [[ $RC -ne 0 ]] && grep -q "PyYAML is required" <<< "$OUT" && grep -q "Fix: python3 -m pip install PyYAML" <<< "$OUT" \
|
||
&& ! grep -q "Traceback" <<< "$OUT"; then
|
||
pass "missing PyYAML fails with FAIL + Fix, no traceback"
|
||
else
|
||
fail "missing PyYAML not reported cleanly (rc=$RC): $OUT"
|
||
fi
|
||
OUT="$(cd "$F" && unset PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF PRE_COMMIT_REMOTE_BRANCH \
|
||
&& PATH="$SHIM_ROOT/crash:$PATH" bash "$SCRIPT" 2>&1)" && RC=0 || RC=$?
|
||
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version" <<< "$OUT" \
|
||
&& ! grep -q "missing or not" <<< "$OUT"; then
|
||
pass "python3 crash during the read is a read failure, not a missing version"
|
||
else
|
||
fail "python3 crash misreported (rc=$RC): $OUT"
|
||
fi
|
||
|
||
echo ""
|
||
echo "--- 25. SKILL.md deleted but skill dir kept ---"
|
||
F="$(make_fixture)"
|
||
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
|
||
echo "ref" > "$F/plugins/demo/.apm/skills/alpha/references/x.md"
|
||
rm "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; commit "$F"
|
||
expect_fail "missing SKILL.md is reported as such" "alpha: SKILL\.md missing at HEAD \(baseline: 1\.0\.0\)" "$F"
|
||
|
||
echo ""
|
||
echo "--- 26. baseline without a valid version accepts any valid version ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
write_skill "$F" demo legacy ""; commit "$F" "add legacy skill"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" demo legacy 'version: "0.0.1"' "changed"; commit "$F"
|
||
expect_pass "invalid baseline + valid current passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 27. no merge-base (unrelated histories) fails closed ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q --orphan unrelated && git rm -rq --cached . && rm -rf plugins)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "orphan"; commit "$F" "orphan root"
|
||
expect_fail "unrelated history fails at the merge-base check" "no merge-base between main and HEAD" "$F"
|
||
|
||
echo ""
|
||
echo "--- 28. annotated tag objects as PRE_COMMIT_TO_REF ---"
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
|
||
(cd "$F" && git tag -a v9 -m "tag" && git checkout -q main)
|
||
TAG_OBJ="$(cd "$F" && git rev-parse v9)"
|
||
if [[ "$(cd "$F" && git cat-file -t "$TAG_OBJ")" == "tag" ]]; then
|
||
pass "fixture check: TO_REF is a tag object, not a commit"
|
||
else
|
||
fail "fixture check: v9 is not an annotated tag object"
|
||
fi
|
||
expect_fail "unbumped change behind an annotated tag fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F" "$TAG_OBJ"
|
||
# Peeling is what makes the local-main fallback's "pushed commit is the
|
||
# merge-base" test see through a tag: compared unpeeled, the tag's own sha
|
||
# never equals the merge-base and the empty diff would pass.
|
||
(cd "$F" && git tag -a on-main -m "tag" main)
|
||
expect_fail "a tag on local main's commit fails closed like the commit itself" \
|
||
"origin/main does not resolve and [0-9a-f]+ is already contained in local main" \
|
||
"$F" "$(cd "$F" && git rev-parse on-main)"
|
||
(cd "$F" && git tag -a tree-tag -m "tag" "main^{tree}")
|
||
expect_fail "a tag on a tree fails closed" "pushed ref [0-9a-f]+ does not resolve to a commit" \
|
||
"$F" "$(cd "$F" && git rev-parse tree-tag)"
|
||
|
||
echo ""
|
||
echo "--- 29. CRLF frontmatter is parsed ---"
|
||
# crlf <repo> <skill>: rewrite that skill's SKILL.md with CRLF line endings.
|
||
crlf() {
|
||
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
|
||
sed 's/$/\r/' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
||
}
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git config core.autocrlf false && git checkout -q main)
|
||
crlf "$F" alpha; commit "$F" "alpha to CRLF"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "crlf body"; crlf "$F" alpha; commit "$F"
|
||
if grep -q $'\r' "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; then
|
||
pass "fixture check: SKILL.md carries CRLF"
|
||
else
|
||
fail "fixture check: SKILL.md has no CRLF"
|
||
fi
|
||
expect_fail "unbumped CRLF skill reports both parsed versions" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "crlf body 2"; crlf "$F" alpha; commit "$F"
|
||
expect_pass "bumped CRLF skill passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 30. identical bump already merged to main fails ---"
|
||
# Branches A and B both bump alpha 1.0.0 -> 1.0.1 with different content. The
|
||
# bumps do not conflict at merge, so without the tip rule main would ship two
|
||
# changes under one version.
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q -b branch-a main)
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "change A"; commit "$F"
|
||
(cd "$F" && git checkout -q main && git merge -q --no-ff -m "merge A" branch-a \
|
||
&& git update-ref refs/remotes/origin/main main && git checkout -q feature)
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "change B"; commit "$F"
|
||
expect_fail "B's 1.0.1 fails against A's 1.0.1 on origin/main" \
|
||
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
|
||
write_skill "$F" demo alpha 'version: "1.0.2"' "change B 2"; commit "$F"
|
||
expect_pass "B at 1.0.2 passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 31. skill deleted on main's tip: only the merge-base rule applies ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
rm -rf "$F/plugins/demo/.apm/skills/alpha"; commit "$F" "drop alpha on main"
|
||
(cd "$F" && git checkout -q feature)
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "branch change"; commit "$F"
|
||
expect_pass "bump over the merge-base passes when main's tip lacks the skill" "$F"
|
||
(cd "$F" && git checkout -q -b unbumped main~1)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "unbumped"; commit "$F"
|
||
expect_fail "unbumped change still fails against the merge-base" \
|
||
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
|
||
|
||
echo ""
|
||
echo "--- 32. UTF-8 BOM before the frontmatter is parsed ---"
|
||
# bom <repo> <skill>: prefix that skill's SKILL.md with a UTF-8 byte-order mark.
|
||
bom() {
|
||
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
|
||
{ printf '\xef\xbb\xbf'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
|
||
}
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "bom body"; bom "$F" alpha; commit "$F"
|
||
if [[ "$(head -c 3 "$F/plugins/demo/.apm/skills/alpha/SKILL.md" | od -An -tx1 | tr -d ' ')" == "efbbbf" ]]; then
|
||
pass "fixture check: SKILL.md starts with a BOM"
|
||
else
|
||
fail "fixture check: SKILL.md has no BOM"
|
||
fi
|
||
expect_fail "unbumped BOM skill reports its parsed version, not a missing one" \
|
||
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "bom body 2"; bom "$F" alpha; commit "$F"
|
||
expect_pass "bumped BOM skill passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 33. a file moved from one skill to another flags both ---"
|
||
# --no-renames: with rename detection, --name-only lists only the new path and
|
||
# alpha would lose a file without anyone noticing.
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
mkdir -p "$F/plugins/demo/.apm/skills/alpha/references"
|
||
printf 'ref line %s\n' 1 2 3 4 5 > "$F/plugins/demo/.apm/skills/alpha/references/x.md"
|
||
commit "$F" "alpha reference"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
mkdir -p "$F/plugins/demo/.apm/skills/beta/references"
|
||
(cd "$F" && git mv plugins/demo/.apm/skills/alpha/references/x.md plugins/demo/.apm/skills/beta/references/x.md)
|
||
write_skill "$F" demo beta "version: 1.0.10"; commit "$F"
|
||
expect_fail "alpha is flagged although only beta was bumped" \
|
||
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
|
||
|
||
echo ""
|
||
echo "--- 34. comparison is ordered major first ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
write_skill "$F" demo alpha 'version: "2.0.0"'; commit "$F" "alpha 2.0.0"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" demo alpha 'version: "1.9.0"' "new body"; commit "$F"
|
||
expect_fail "2.0.0 -> 1.9.0 fails although minor rose" "alpha: 2\.0\.0 -> 1\.9\.0" "$F"
|
||
|
||
echo ""
|
||
echo "--- 35. unresolvable PRE_COMMIT_TO_REF fails closed ---"
|
||
F="$(make_fixture)"
|
||
expect_fail "a sha absent from the repo fails" \
|
||
"pushed ref 1234567890abcdef1234567890abcdef12345678 does not resolve to a commit" \
|
||
"$F" "1234567890abcdef1234567890abcdef12345678"
|
||
|
||
echo ""
|
||
echo "--- 36. a SKILL.md git cannot read fails closed ---"
|
||
# drop_blob <repo> <rev:path>: delete that blob's loose object, as a corrupt or
|
||
# partial clone would lack it. The tree still names the file.
|
||
drop_blob() {
|
||
local sha
|
||
sha="$(cd "$1" && git rev-parse "$2")"
|
||
rm -f "$1/.git/objects/${sha:0:2}/${sha:2}"
|
||
}
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
|
||
drop_blob "$F" "HEAD:plugins/demo/.apm/skills/alpha/SKILL.md"
|
||
OUT="$(run_check "$F")" && RC=0 || RC=$?
|
||
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version from [0-9a-f]*:plugins/demo/.apm/skills/alpha/SKILL.md" <<< "$OUT" \
|
||
&& grep -q "fatal: bad object" <<< "$OUT" && ! grep -qE "missing or not|SKILL\.md missing" <<< "$OUT"; then
|
||
pass "an unreadable pushed SKILL.md is a read failure carrying git's error"
|
||
else
|
||
fail "unreadable pushed SKILL.md misreported (rc=$RC): $OUT"
|
||
fi
|
||
F="$(make_fixture)"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F"
|
||
drop_blob "$F" "main:plugins/demo/.apm/skills/alpha/SKILL.md"
|
||
OUT="$(run_check "$F")" && RC=0 || RC=$?
|
||
if [[ $RC -ne 0 ]] && grep -q "could not read metadata.version" <<< "$OUT"; then
|
||
pass "an unreadable merge-base SKILL.md fails closed instead of exempting the skill"
|
||
else
|
||
fail "unreadable merge-base SKILL.md not caught (rc=$RC): $OUT"
|
||
fi
|
||
|
||
echo ""
|
||
echo "--- 37. a mode-only change counts as a change ---"
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git config core.fileMode true)
|
||
chmod +x "$F/plugins/demo/.apm/skills/alpha/SKILL.md"; commit "$F"
|
||
if [[ "$(cd "$F" && git diff --summary main HEAD)" == *"mode change 100644 => 100755"* ]]; then
|
||
pass "fixture check: the commit changes only the file mode"
|
||
else
|
||
fail "fixture check: no mode change recorded"
|
||
fi
|
||
expect_fail "chmod +x without a bump fails" "alpha: 1\.0\.0 -> 1\.0\.0" "$F"
|
||
|
||
echo ""
|
||
echo "--- 38. skill directory replaced by a symlink ---"
|
||
# Current behaviour, pinned: the path is no longer a tree at the pushed commit,
|
||
# so the skill is exempt as deleted. apm drops symlinks under .apm/ (ADR-0017),
|
||
# so readers do lose the skill.
|
||
F="$(make_fixture)"
|
||
rm -rf "$F/plugins/demo/.apm/skills/alpha"
|
||
ln -s beta "$F/plugins/demo/.apm/skills/alpha"; commit "$F"
|
||
if [[ "$(cd "$F" && git ls-tree HEAD plugins/demo/.apm/skills/alpha)" == 120000* ]]; then
|
||
pass "fixture check: alpha is committed as a symlink"
|
||
else
|
||
fail "fixture check: alpha is not a symlink in the commit"
|
||
fi
|
||
expect_pass "a skill replaced by a symlink is exempt as deleted" "$F"
|
||
|
||
echo ""
|
||
echo "--- 39. leading blank lines before the frontmatter are parsed ---"
|
||
# skill-size-check's FRONTMATTER_RE accepts whitespace before the opening
|
||
# `---`; this gate must read the same shape. Otherwise the pushed side reports
|
||
# "missing" for a file the commit hook accepted, and a baseline shaped this way
|
||
# reads as "no valid version", which lets any version pass.
|
||
# blank_lead <repo> <skill>: prefix that skill's SKILL.md with blank lines.
|
||
blank_lead() {
|
||
local f="$1/plugins/demo/.apm/skills/$2/SKILL.md"
|
||
{ printf '\n \t\n'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
|
||
}
|
||
F="$(make_fixture)"
|
||
(cd "$F" && git checkout -q main)
|
||
blank_lead "$F" alpha; commit "$F" "alpha leading blank"
|
||
(cd "$F" && git checkout -q feature && git merge -q main)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"' "lead body"; blank_lead "$F" alpha; commit "$F"
|
||
expect_fail "unbumped skill with leading blank lines is held to its baseline version" \
|
||
"alpha: 1\.0\.0 -> 1\.0\.0 \(not above merge-base\)" "$F"
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "lead body 2"; blank_lead "$F" alpha; commit "$F"
|
||
expect_pass "bumped skill with leading blank lines passes" "$F"
|
||
|
||
echo ""
|
||
echo "--- 40. a criss-cross history is judged against EVERY merge-base ---"
|
||
# Two merge bases, and which one plain `git merge-base` prints is git's choice,
|
||
# not a property of the history. The gate used to take that single answer, so
|
||
# the verdict turned on it: here `git diff main feature -- plugins` is EMPTY
|
||
# (main already carries the bump, via its merge of the feature branch) and the
|
||
# push still failed with "not above main tip", because the base git picked was
|
||
# the one that predates the bump. `--all` plus the intersection rule makes the
|
||
# answer the same whichever base git would have named.
|
||
#
|
||
# C0 alpha 1.0.0
|
||
# +-- feature: F1 bumps alpha to 1.0.1
|
||
# +-- main: M1 unrelated, then M2 merges F1
|
||
# feature: F2 merges M1 -> merge bases {M1, F1}
|
||
F="$(mktemp -d)"; CLEANUP_DIRS+=("$F")
|
||
(cd "$F" && git init -q -b main && git config user.email t@t.t && git config user.name t)
|
||
write_skill "$F" demo alpha 'version: "1.0.0"'; commit "$F" C0
|
||
(cd "$F" && git checkout -q -b feature)
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "new body"; commit "$F" F1
|
||
F1_SHA="$(cd "$F" && git rev-parse HEAD)"
|
||
(cd "$F" && git checkout -q main)
|
||
echo unrelated > "$F/m1.txt"; commit "$F" M1
|
||
M1_SHA="$(cd "$F" && git rev-parse HEAD)"
|
||
(cd "$F" && git merge -q --no-edit "$F1_SHA" -m M2 > /dev/null)
|
||
(cd "$F" && git checkout -q feature && git merge -q --no-edit "$M1_SHA" -m F2 > /dev/null)
|
||
|
||
BASES40="$(cd "$F" && git merge-base --all main feature | sort)"
|
||
if [[ "$(printf '%s\n' "$BASES40" | wc -l)" -eq 2 ]]; then
|
||
pass "fixture check: the history really does have two merge bases"
|
||
else
|
||
fail "fixture check: expected two merge bases, got: $BASES40"
|
||
fi
|
||
if [[ -z "$(cd "$F" && git diff main feature -- plugins)" ]]; then
|
||
pass "fixture check: nothing under plugins/ differs between main and the branch"
|
||
else
|
||
fail "fixture check: plugins/ differs between main and the branch, so this is not the case under test"
|
||
fi
|
||
expect_pass "a skill identical to main's tip passes whichever merge-base git would pick" "$F"
|
||
|
||
# And the ratchet still holds on the same shape: a further edit with no bump
|
||
# differs from BOTH bases, so it is not excused by the criss-cross.
|
||
write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F" F3
|
||
expect_fail "an unbumped edit on a criss-cross branch still fails, naming the baseline sha" \
|
||
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base [0-9a-f]{40}\)" "$F"
|
||
|
||
echo ""
|
||
echo "Results: $PASS passed, $FAIL failed"
|
||
[[ $FAIL -eq 0 ]]
|