From 9b898e6e66e0fb646ad51958dabc0e956c922990 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Jul 2026 14:44:57 +0200 Subject: [PATCH] test(compat): gate the too-new bash constructs at source level The Bash 3.0 CI job only catches a too-new construct when a test happens to execute the line, so a ${var,,} inside a rarely-taken branch parses fine and can ship. These greps reject the construct at source level regardless of coverage. Covers associative arrays, case-conversion expansion, negative array subscripts, &>>, BASHPID, mapfile/readarray, namerefs, coproc and parameter transformations. Each pattern was verified against a planted violation, and comment lines are skipped so a construct quoted in the learn tutorial text is not an error. Only version-breaking syntax is gated. Style preferences are not compatibility rules and are deliberately excluded -- notably [[ ]], which works on every bash we support. --- tests/unit/bash_compatibility_test.sh | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/unit/bash_compatibility_test.sh b/tests/unit/bash_compatibility_test.sh index d5d05998..c666e1ad 100644 --- a/tests/unit/bash_compatibility_test.sh +++ b/tests/unit/bash_compatibility_test.sh @@ -1,5 +1,22 @@ #!/usr/bin/env bash +# Guards the minimum-bash contract mechanically. +# +# The Bash 3.0 CI job only catches a too-new construct when a test happens to +# EXECUTE the line: `${var,,}` inside a rarely-taken branch parses fine and fails +# at runtime, so it can ship. These greps close that gap by rejecting the +# construct at source level regardless of coverage. +# +# Only genuinely version-breaking syntax belongs here. Style preferences (for +# example preferring `[ ]` over `[[ ]]`) are not compatibility rules and are not +# enforced by these tests -- `[[ ]]` works on every bash we support. + +# Returns offending "file:line: text" for a pattern, skipping comment lines so a +# rule quoted in documentation or in the `learn` tutorial text is not an error. +function bashunit::compat::offenders() { + grep -rnE "$1" src/ 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true +} + # Bash 3.0 does not expand a compound array assignment attached to `local`: # `local arr=(a b)` stores the literal string "(a b)" as a single element # instead of building the array. Every bash >= 3.2 does the right thing, so @@ -15,3 +32,50 @@ function test_src_has_no_compound_array_assignment_attached_to_local() { assert_empty "$offenders" } + +# Associative arrays are Bash 4.0+. Use parallel indexed arrays instead. +function test_src_has_no_associative_arrays() { + assert_empty "$(bashunit::compat::offenders '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*A)')" +} + +# ${var,,} / ${var^^} case conversion is Bash 4.0+. Use tr instead. +function test_src_has_no_parameter_expansion_case_conversion() { + assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?(,,|\^\^|,|\^)\}')" +} + +# ${array[-1]} is Bash 4.3+. Use ${array[${#array[@]}-1]} instead. +function test_src_has_no_negative_array_subscripts() { + assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*\[-[0-9]')" +} + +# &>> is Bash 4.0+. Use `>> file 2>&1` instead. +function test_src_has_no_append_both_streams_redirect() { + assert_empty "$(bashunit::compat::offenders '&>>')" +} + +# BASHPID is Bash 4.0+. Subshells inherit $$, so a per-worker unique token needs +# a fork (mktemp) or an externally assigned ordinal -- see #851. +function test_src_has_no_bashpid() { + assert_empty "$(bashunit::compat::offenders 'BASHPID')" +} + +# mapfile/readarray are Bash 4.0+. Use a `while IFS= read -r` loop instead. +function test_src_has_no_mapfile_or_readarray() { + assert_empty "$(bashunit::compat::offenders '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)')" +} + +# declare -n / local -n (namerefs) are Bash 4.3+. Use the return-slot pattern +# documented in .claude/rules/bash-style.md instead. +function test_src_has_no_namerefs() { + assert_empty "$(bashunit::compat::offenders '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*n)[[:space:]]')" +} + +# coproc is Bash 4.0+. +function test_src_has_no_coproc() { + assert_empty "$(bashunit::compat::offenders '(^|[^[:alnum:]_])coproc([^[:alnum:]_]|$)')" +} + +# ${var@Q} and friends are Bash 4.4+. +function test_src_has_no_parameter_transformations() { + assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*@[QEPAKa]\}')" +}