From 7cbe02ad15304e932087ccd2a29dbfe3ad502ee2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 01:56:56 -0700 Subject: [PATCH] Keep the checksum lint's regression corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checksum lint took nineteen rounds of review. Every round was a definition shape that slipped past it, and every fix was verified once, by hand, against a scratch file that was then deleted — so the corpus survives only in dfcd595's commit message. Nothing stops the next edit reintroducing any of them. test/lint is that corpus, kept. Each case writes a fixture definition, calls lint_checksums, and asserts on the `failures` array — the same array bin/ci's summary reads, so a test can't pass on output that wouldn't fail the run. Both directions are covered, because they aren't symmetric. A false negative ships an unverified download silently, which is what the lint exists to prevent; a false positive blocks CI over a URL nothing fetches, which is what gets checks deleted. Twenty undigested forms must fail — every quoting style, continuations, split mid-scheme, after if/&&/;/ANSI-C quoting/a multi-line string/a heredoc/a quoted <]*}" } -echo "${BANNER}šŸš€ Local CI for ruby-dev${RESET}" - heading() { printf '\n%s%s%s\n' "$TITLE" "$1" "$RESET"; [ $# -gt 1 ] && printf '%s%s%s\n' "$SUBTITLE" "$2" "$RESET"; return 0; } pass() { printf '%s āœ“ %s%s\n' "$SUCCESS" "$1" "$RESET"; } fail() { printf '%s āœ— %s%s\n' "$ERROR" "$1" "$RESET"; failures+=("$1"); } @@ -77,7 +68,7 @@ require_definitions() { lint_syntax() { heading "Syntax" "bash -n over scripts and definitions" local f - for f in bin/ci test/build $(definitions); do + for f in bin/ci test/build test/lint $(definitions); do if bash -n "$f" 2>/dev/null; then pass "$f"; else fail "$f has a syntax error"; bash -n "$f" || true; fi done } @@ -176,6 +167,25 @@ lint_checksums() { done } +# --- Lint tests ------------------------------------------------------------- +# The checksum lint's own regression corpus. Run here, not just syntax-checked, because a +# regression suite CI never executes is one that rots unnoticed — which is the same +# inspected-nothing failure the lint it covers kept producing. +# +# test/lint sources this file, which is why everything above is definitions and only a +# direct run does anything. Output is swallowed unless it fails; its per-case lines would +# drown this summary. +lint_tests() { + heading "Lint tests" "test/lint over the checksum lint" + local out + if out=$(test/lint 2>&1); then + pass "$(printf '%s' "$out" | tail -1)" + else + fail "test/lint" + printf '%s\n' "$out" + fi +} + # --- Shellcheck ------------------------------------------------------------- # Optional: not everywhere, and not worth blocking a build matrix over. Report # the skip out loud rather than passing silently, so nobody reads a green run as @@ -194,7 +204,7 @@ lint_shellcheck() { # find on version-numbered filenames, deliberate word splitting). Gating on info # would mean either noisy failures or a scattering of disable comments, and both # train people to ignore the step. - if shellcheck -s bash --severity=warning bin/ci test/build; then + if shellcheck -s bash --severity=warning bin/ci test/build test/lint; then pass "scripts" else fail "shellcheck" @@ -222,33 +232,54 @@ signoff() { gh signoff } -started=$SECONDS +main() { + cd "$(dirname "$0")/.." -require_definitions -lint_syntax -lint_checksums -lint_shellcheck + # Before anything else, so --help doesn't sit through a lint pass first. + case "${1:-}" in + -h|--help) sed -n '2,15p' "$0" | sed 's/^#\{1,\} \{0,1\}//'; exit 0 ;; + esac -partial=false -case "${1:-}" in - --lint) partial=true ;; - "") build_matrix ;; - *) partial=true; build_matrix "$@" ;; -esac + echo "${BANNER}šŸš€ Local CI for ruby-dev${RESET}" -elapsed=$(( SECONDS - started )) + local started=$SECONDS -if [ ${#failures[@]} -eq 0 ]; then - printf '\n%sāœ… CI passed in %ds%s\n' "$SUCCESS" "$elapsed" "$RESET" - if $partial; then - printf '%sšŸ“‹ Partial run — not signing off. Run bin/ci with no arguments to sign off.%s\n' \ - "$SUBTITLE" "$RESET" + require_definitions + lint_syntax + lint_checksums + lint_tests + lint_shellcheck + + local partial=false + case "${1:-}" in + --lint) partial=true ;; + "") build_matrix ;; + *) partial=true; build_matrix "$@" ;; + esac + + local elapsed=$(( SECONDS - started )) + + if [ ${#failures[@]} -eq 0 ]; then + printf '\n%sāœ… CI passed in %ds%s\n' "$SUCCESS" "$elapsed" "$RESET" + if $partial; then + printf '%sšŸ“‹ Partial run — not signing off. Run bin/ci with no arguments to sign off.%s\n' \ + "$SUBTITLE" "$RESET" + else + signoff + fi else - signoff + printf '\n%sāŒ CI failed in %ds%s\n' "$ERROR" "$elapsed" "$RESET" + local f + for f in "${failures[@]}"; do printf '%s • %s%s\n' "$ERROR" "$f" "$RESET"; done + printf '%sšŸ“‹ No sign-off. Fix the issues and try again.%s\n' "$SUBTITLE" "$RESET" + exit 1 fi -else - printf '\n%sāŒ CI failed in %ds%s\n' "$ERROR" "$elapsed" "$RESET" - for f in "${failures[@]}"; do printf '%s • %s%s\n' "$ERROR" "$f" "$RESET"; done - printf '%sšŸ“‹ No sign-off. Fix the issues and try again.%s\n' "$SUBTITLE" "$RESET" - exit 1 +} + +# Everything above is definitions, so test/lint can source this file and call the lints +# directly. Only a direct run does anything: sourced, this would cd elsewhere, print a +# banner, run all three lints over the sourcing script's arguments, and exit 1 out of the +# harness that sourced it. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" fi diff --git a/test/lint b/test/lint new file mode 100755 index 0000000..58ba1cc --- /dev/null +++ b/test/lint @@ -0,0 +1,316 @@ +#!/bin/bash +# Regression tests for bin/ci's checksum lint. +# +# That lint took nineteen rounds of review to get right, and every round was a definition +# shape that slipped past it — quoting, continuations, `;#`, ANSI-C strings, heredocs, +# attached commands, query strings. Each was verified once, by hand, against a scratch file +# that was then deleted. This file is that corpus, kept. +# +# Two directions matter and they are not symmetric. A false negative means an unverified +# download ships silently, which is what the lint exists to prevent. A false positive +# blocks CI over a URL nothing fetches, which is what makes people delete the check. Both +# are tested. +# +# test/lint # run every case +# +# Cases call lint_checksums directly and assert on its `failures` array — the same array +# bin/ci's summary reads, so a test can't pass on output that wouldn't fail the run. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +# bin/ci only runs its dispatch when executed, so sourcing it just defines the lints. +# shellcheck source=bin/ci +. bin/ci + +# Fixtures live under test/, never in the repo root: a file there matching [0-9]* is a +# definition to bin/ci's definitions(), to test/build's VERSIONS, and to both dockerfiles' +# `COPY [0-9]* ./`. A stray fixture would be built for real. +FIXTURES=$(mktemp -d "${TMPDIR:-/tmp}/ruby-dev-lint.XXXXXX") +trap 'rm -rf "$FIXTURES"' EXIT + +DIGEST=c2dab63cbc8f2a05526108ad419efa63a67ed4074dbbcf9fc2b1ca664cb45ba0 + +tests=0 +failed=0 + +ok() { + tests=$(( tests + 1 )) + printf ' āœ“ %s\n' "$1" +} + +bad() { + tests=$(( tests + 1 )) + failed=$(( failed + 1 )) + printf ' āœ— %s\n' "$1" + [ $# -gt 1 ] && [ -n "$2" ] && printf ' %s\n' "$2" + return 0 +} + +# Joining with a plain expansion of an empty array is an error under `set -u` on macOS's +# Bash 3.2, and this repo supports it. +failure_text() { + if [ ${#failures[@]} -eq 0 ]; then printf ''; else printf '%s; ' "${failures[@]}"; fi +} + +# Write the fixture on stdin into its own directory and lint it there. Its own directory +# because definitions() globs $PWD, so cases would otherwise see each other's files. +lint_fixture() { + # Two `local` statements, not one: in `local name=$1 dir="$FIXTURES/$name"`, the second + # assignment doesn't see the first — it resolves $name from the caller's scope, which is + # the trap bin/ci's own comment_index hit before it was deleted. + local name=$1 + local dir="$FIXTURES/$name" + mkdir -p "$dir" + cat > "$dir/9.9.9" + failures=() + cd "$dir" + # Redirected, not piped: `failures` is a global the caller inspects, and a pipeline would + # append to it in a subshell that then exits. + lint_checksums >/dev/null 2>&1 + cd "$ROOT" +} + +# An archive URL with no digest must be reported, and reported as a missing digest — +# not as some other failure that happens to be nonzero. +must_fail() { + local name=$1 + lint_fixture "$name" + if [ ${#failures[@]} -eq 0 ]; then + bad "$name (not flagged)" + else + case "$(failure_text)" in + *"no sha256 on"*) ok "$name" ;; + *) bad "$name (flagged, but not for a missing digest)" "$(failure_text)" ;; + esac + fi +} + +must_pass() { + local name=$1 + lint_fixture "$name" + if [ ${#failures[@]} -eq 0 ]; then ok "$name"; else bad "$name" "$(failure_text)"; fi +} + +# --- Must fail: undigested archives ---------------------------------------- +# Every shell spelling that reached review as a silent pass. Position is no longer what +# decides — these are here so that stays true. + +printf '\nUndigested archives (must be caught)\n' + +must_fail 'double-quoted' < "$FIXTURES/continuations" + +expected=$(printf '%s\n' 'one two' 'three' 'scheme="https://host/pkg.tar.gz"' 'trailing ') +actual=$(join_continuations "$FIXTURES/continuations") +if [ "$actual" = "$expected" ]; then + ok 'joins with nothing, as shell does, and flushes a trailing continuation' +else + bad 'joins with nothing, as shell does, and flushes a trailing continuation' \ + "got: $(printf '%s' "$actual" | tr '\n' '|')" +fi + +# --- The real definitions -------------------------------------------------- + +printf "\nThis repo's definitions\n" + +failures=() +lint_checksums >/dev/null 2>&1 +if [ ${#failures[@]} -eq 0 ]; then + ok "all $(definitions | wc -l | tr -d ' ') definitions pass" +else + bad 'the real definitions pass' "$(failure_text)" +fi + +printf '\n' +[ $failed -eq 0 ] && printf 'All %d tests passed.\n' "$tests" \ + || printf '%d of %d test(s) failed.\n' "$failed" "$tests" +exit $failed