fix(lint): derive the release gate from the pushed ref, reject multi-token entries
The gate hardcoded HEAD as its diff tip, but pre-commit exports PRE_COMMIT_TO_REF for exactly this. Pushing "somebranch:main" from another checkout diffed the wrong tip — a false negative when HEAD is older, a false positive when newer. Fixing only the diff tip leaves a second bug: git describe took the tag baseline from HEAD too, so a tag reachable only from HEAD becomes a baseline the pushed ref never saw. Both now resolve from the pushed ref, and an all-zeros ref (branch deletion) short-circuits before any rev resolution rather than surfacing as "could not diff". PRE_COMMIT_FROM_REF is deliberately not used: it is the remote's current tip, so diffing from it would let an untagged release-relevant commit already on main excuse the next push from cutting a tag — the drift this gate exists to catch. The baseline must stay the last release tag. collect_release_paths took tokens[0] as a path unconditionally. ADR-0014 makes bare single-path entries a binding constraint, but nothing enforced it, and the sibling .pre-commit-config.yaml already ships "entry: bash <script>". Under that shape add_release_path takes "bash", git diff accepts the non-matching pathspec silently, bundle_root becomes "." and is skipped — the hook's whole surface leaves the gate with no error, the same shape as the --config regression in LESSONS.md. Multi-token entries now fail loudly naming the hook and the ADR, and tokens[0] must resolve at HEAD or at the tag (the union is load-bearing: a per-scope check would reject the deletion cases). Six mutations verified, each restored. One correction worth recording: the first multi-token test passed with its guard removed, because the existence guard caught "bash" and printed a similar message. It now requires the verbatim entry text that only the multi-token diagnostic emits. Refs: #85 ADR: 0014
This commit is contained in:
@@ -52,9 +52,37 @@ make_tagged_fixture() {
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# Helper: a fixture whose manifest carries one malformed entry: at the tag *and*
|
||||
# at HEAD, plus a post-tag change to the file that entry was meant to cover.
|
||||
# Committing the bad entry before the tag is what makes the assertion sharp — an
|
||||
# edited manifest is itself release-relevant, so the gate would fail for the
|
||||
# wrong reason and hide a parser that degrades silently.
|
||||
make_malformed_fixture() {
|
||||
local entry="$1" dir
|
||||
dir="$(mktemp -d)"
|
||||
(cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
write_release_paths "$dir"
|
||||
cat > "$dir/.pre-commit-hooks.yaml" <<EOF
|
||||
- id: fake-size-check
|
||||
entry: $entry
|
||||
language: script
|
||||
EOF
|
||||
(cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
|
||||
echo "v2" > "$dir/scripts/skill-size-check.sh"
|
||||
(cd "$dir" && git add -A && git commit -q -m "change the file the malformed entry should cover")
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# $3 is optional: pre-commit's PRE_COMMIT_TO_REF, the local sha being pushed.
|
||||
# Left off entirely, the variable stays unset and the script falls back to HEAD,
|
||||
# exactly as a plain `git push <remote> <current-branch>` behaves.
|
||||
run_check() {
|
||||
local dir="$1" branch="$2"
|
||||
(cd "$dir" && PRE_COMMIT_REMOTE_BRANCH="$branch" bash "$SCRIPT" 2>&1)
|
||||
if [[ $# -ge 3 ]]; then
|
||||
(cd "$dir" && PRE_COMMIT_REMOTE_BRANCH="$branch" PRE_COMMIT_TO_REF="$3" bash "$SCRIPT" 2>&1)
|
||||
else
|
||||
(cd "$dir" && PRE_COMMIT_REMOTE_BRANCH="$branch" bash "$SCRIPT" 2>&1)
|
||||
fi
|
||||
}
|
||||
|
||||
CLEANUP_DIRS=()
|
||||
@@ -268,6 +296,120 @@ else
|
||||
fail "reported only the manifest change and hid which shipped paths the retirement removed"
|
||||
fi
|
||||
|
||||
# --- 15. A multi-token entry: is rejected loudly, not silently mis-parsed ---
|
||||
# ADR-0014 binds entries to a bare script path, but nothing enforced it, and the
|
||||
# sibling .pre-commit-config.yaml already ships `entry: bash <script>`. Under the
|
||||
# old parser tokens[0] became "bash": a pathspec matching nothing (which git diff
|
||||
# accepts in silence) and a bundle root of "." (skipped), so the hook's whole
|
||||
# surface dropped out of the gate and the post-tag change below diffed clean.
|
||||
echo ""
|
||||
echo "--- exits 1 naming the hook when an entry: carries more than one token ---"
|
||||
# The entry is quoted back verbatim, not just its first token: that is what makes
|
||||
# the diagnostic point at the argument the author has to remove, and what
|
||||
# distinguishes this from the unresolvable-path rejection test 16 covers.
|
||||
FIXTURE15="$(make_malformed_fixture "bash scripts/skill-size-check.sh")"; track "$FIXTURE15"
|
||||
OUT15=$(run_check "$FIXTURE15" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE15" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on a multi-token entry, dropping that hook's paths from the gate"
|
||||
elif echo "$OUT15" | grep -q "fake-size-check" \
|
||||
&& echo "$OUT15" | grep -q "bash scripts/skill-size-check.sh" \
|
||||
&& echo "$OUT15" | grep -q "ADR-0014"; then
|
||||
pass "rejects a multi-token entry, quoting it back and naming the hook and ADR-0014"
|
||||
else
|
||||
fail "rejected the multi-token entry without naming the hook, the entry, and ADR-0014"
|
||||
fi
|
||||
|
||||
# --- 16. An entry naming no file this repo ships is rejected loudly ---
|
||||
# The token-count guard alone still lets a single bare command name (`entry:
|
||||
# vale`, valid for language: system) through as a pathspec matching nothing.
|
||||
# Existence is checked against the union of the worktree and $LAST_TAG, so this
|
||||
# cannot misfire on the deletion cases tests 12-14 pin.
|
||||
echo ""
|
||||
echo "--- exits 1 naming the hook when an entry: names no file in the worktree or at the tag ---"
|
||||
FIXTURE16="$(make_malformed_fixture "vale")"; track "$FIXTURE16"
|
||||
OUT16=$(run_check "$FIXTURE16" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE16" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on an entry that names no shipped file"
|
||||
elif echo "$OUT16" | grep -q "fake-size-check" && echo "$OUT16" | grep -q "ADR-0014"; then
|
||||
pass "rejects an entry that resolves to no file, naming the hook and the ADR-0014 constraint"
|
||||
else
|
||||
fail "rejected the unresolvable entry without naming the hook and the ADR-0014 constraint"
|
||||
fi
|
||||
|
||||
# --- 17. The pushed ref, not HEAD, is what gets gated ---
|
||||
# pre-commit exports the local sha of each pushed ref as PRE_COMMIT_TO_REF.
|
||||
# `git push <remote> pushed-tip:main` from a checkout sitting on an older commit
|
||||
# is the false-negative direction: HEAD is still at the tag and diffs clean while
|
||||
# the branch actually landing on main carries an untagged, release-relevant
|
||||
# change. HEAD is reset back to the tag so the two genuinely differ.
|
||||
echo ""
|
||||
echo "--- exits 1 on a release-relevant change reachable only from PRE_COMMIT_TO_REF ---"
|
||||
FIXTURE17="$(make_tagged_fixture)"; track "$FIXTURE17"
|
||||
echo "v2" > "$FIXTURE17/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE17" && git add -A && git commit -q -m "release-relevant change" \
|
||||
&& git branch pushed-tip && git reset -q --hard v1.0.0)
|
||||
OUT17=$(run_check "$FIXTURE17" "refs/heads/main" "pushed-tip" || true)
|
||||
if echo "$OUT17" | grep -q "skill-size-check.sh"; then
|
||||
pass "gates the pushed ref's tip, not HEAD, when HEAD is behind it"
|
||||
else
|
||||
fail "diffed HEAD instead of PRE_COMMIT_TO_REF and missed a release-relevant change"
|
||||
fi
|
||||
|
||||
# --- 18. Neither the diff tip nor the tag baseline may come from a newer HEAD ---
|
||||
# The false-positive direction: HEAD has moved past a v2.0.0 that the pushed ref
|
||||
# never saw. Reading either end of the diff off HEAD fails a push that is clean
|
||||
# since its own baseline — diffing v2.0.0..HEAD flags HEAD's untagged commit, and
|
||||
# resolving the tag from HEAD while diffing pushed-tip flags v2.0.0's change.
|
||||
echo ""
|
||||
echo "--- exits 0 when the pushed ref is clean since its own tag but HEAD has moved on ---"
|
||||
FIXTURE18="$(make_tagged_fixture)"; track "$FIXTURE18"
|
||||
(cd "$FIXTURE18" && git branch pushed-tip)
|
||||
echo "v2" > "$FIXTURE18/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE18" && git add -A && git commit -q -m "released change" && git tag v2.0.0)
|
||||
echo "v3" > "$FIXTURE18/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE18" && git add -A && git commit -q -m "unreleased change on HEAD's line")
|
||||
if run_check "$FIXTURE18" "refs/heads/main" "pushed-tip" > /dev/null; then
|
||||
pass "exits 0 for a pushed ref clean since the tag reachable from it, ignoring HEAD's line"
|
||||
else
|
||||
fail "gated HEAD's tag or tip and falsely demanded a release for a clean pushed ref"
|
||||
fi
|
||||
|
||||
# --- 19. A branch deletion is a no-op, not a confusing git failure ---
|
||||
# pre-commit sets PRE_COMMIT_TO_REF to an all-zeros sha when the push deletes a
|
||||
# branch. Nothing is being shipped, and the sha resolves to nothing, so without
|
||||
# an explicit guard the gate reports "could not diff" on an unrelated operation.
|
||||
echo ""
|
||||
echo "--- exits 0 when PRE_COMMIT_TO_REF is the all-zeros branch-deletion sha ---"
|
||||
FIXTURE19="$(make_tagged_fixture)"; track "$FIXTURE19"
|
||||
echo "v2" > "$FIXTURE19/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE19" && git add -A && git commit -q -m "release-relevant change")
|
||||
if run_check "$FIXTURE19" "refs/heads/main" "0000000000000000000000000000000000000000" > /dev/null; then
|
||||
pass "treats an all-zeros PRE_COMMIT_TO_REF as a branch deletion and exits 0"
|
||||
else
|
||||
fail "turned a branch deletion into a failure instead of a no-op"
|
||||
fi
|
||||
|
||||
# --- 20. The repo's own .pre-commit-hooks.yaml satisfies the entry constraints ---
|
||||
# The parser guards above are only safe to ship if the manifest actually in tree
|
||||
# passes them. It is replayed into a fixture (with the paths its entries name
|
||||
# created) rather than run against the real repo, which has no release tag yet.
|
||||
echo ""
|
||||
echo "--- accepts the real .pre-commit-hooks.yaml this repo ships ---"
|
||||
FIXTURE20="$(mktemp -d)"; track "$FIXTURE20"
|
||||
(cd "$FIXTURE20" && git init -q && git config user.email t@t.t && git config user.name t)
|
||||
cp "$REPO_ROOT/.pre-commit-hooks.yaml" "$FIXTURE20/.pre-commit-hooks.yaml"
|
||||
while IFS= read -r real_entry; do
|
||||
mkdir -p "$FIXTURE20/$(dirname "$real_entry")"
|
||||
echo "v1" > "$FIXTURE20/$real_entry"
|
||||
done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$REPO_ROOT/.pre-commit-hooks.yaml")
|
||||
(cd "$FIXTURE20" && git add -A && git commit -q -m "initial" && git tag v1.0.0)
|
||||
OUT20=$(run_check "$FIXTURE20" "refs/heads/main" || true)
|
||||
if [[ -z "$OUT20" ]]; then
|
||||
pass "parses every entry in the repo's real .pre-commit-hooks.yaml without complaint"
|
||||
else
|
||||
fail "the repo's own .pre-commit-hooks.yaml no longer satisfies the entry constraints: $OUT20"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user