Summary
bashunit supports Bash 3.0, so it forks where a newer shell has a builtin. Today there is no way to ship a faster body for a newer shell, because the compatibility test rejects a too-new construct even inside a branch that Bash 3.0 never takes.
This asks for the mechanism: the runner uses the fastest construct the running shell offers, and falls back per capability down to the 3.0 floor. The floor does not move.
Why the current gates are not it
Five version checks already exist, all runtime predicates re-evaluated on every call:
| Site |
Version |
Runs |
src/runner/context.sh:31-36 _supports_reliable_pipefail |
3.1 |
per test (src/runner/exec.sh:326) |
src/assert/core.sh:441-450 _supports_nocasematch |
3.1 |
per assertion |
src/runner/parallel.sh:4-14 _supports_wait_n |
4.3 |
per poll |
src/coverage/config.sh:128-133 xtrace_is_supported |
4.1 |
once |
src/util/clock.sh:5-17 clock impl |
5.0 |
memoized, dispatched per read |
Measured per-call overhead above a single ungated definition, best of 5 over 100k calls:
| Shape |
3.0 |
4.0 |
5.2 |
5.3 |
Top-level if/else picks the definition once |
+0.09 |
-0.05 |
0.00 |
-0.07 |
case on a memoized impl, per call |
+1.7 |
+1.2 |
+0.8 |
+1.2 |
| Predicate function per call (today's pattern) |
+17.9 |
+8.9 |
+5.0 |
+4.3 |
Load-time selection is free. The pattern in use is the slow one. On its own that is 0.1% of a test, so converting the five is not the point; the point is that a load-time gate lets a fast body remove a fork, which is worth 1 to 3 ms.
What is safe to gate, measured
On a real Bash 3.00.22 and 3.2.57, with each construct placed inside if false; then ... fi and inside an uncalled function:
Parse-time. Can never sit behind a gate: &>>, |&, ;;&, ;&, arr+=(x). These kill the file even where they are never reached. arr+=(x) is the trap, since it is a parse error on 3.0 only and parses fine on 3.2.
Runtime-only. Safe inside an untaken branch: ${v,,}, ${v^^}, ${v@Q}, mapfile, declare -A, declare -n, local -n, exec {fd}>, printf -v, x+=y, coproc, wait -n, BASHPID, SRANDOM, EPOCHREALTIME, fractional read -t, ${arr[-1]}, ${ cmd; }.
Dead-code safety did not depend on shape: if false and an uncalled function behaved identically in every case.
One useful corollary. The entrypoint sources every module under set -e, so the Bash 3.0 job already catches every parse-time construct anywhere in src/, whatever the test coverage. The gap the compatibility test closes is exactly the runtime class in untaken branches, and that is the class a gate would contain.
Proposed shape
A flags file declaring one flag per boundary in use, then a column-0 if/else in a leaf module:
# src/system/bash.sh
_BASHUNIT_BASH_GE_40=0
_bashunit_bash_tier=$((${BASH_VERSINFO[0]:-0} * 10 + ${BASH_VERSINFO[1]:-0}))
if [ "$_bashunit_bash_tier" -ge 40 ]; then _BASHUNIT_BASH_GE_40=1; fi
unset _bashunit_bash_tier
# in a leaf module, column 0
if [ "$_BASHUNIT_BASH_GE_40" = 1 ]; then
function bashunit::str::to_lower_to_slot() { _BASHUNIT_STR_LOWER_OUT=${1,,}; }
else
function bashunit::str::to_lower_to_slot() { ...tr...; }
fi
The tier lives in the flag name, so the compatibility test can compare a construct's minimum version against the gate's declared tier by text alone.
src/console/colors.sh:32-89 is the existing precedent for a top-level if/else that assigns at load time and survives the build.
Verified against the build
A prototype gate (${input,,} in src/util/str.sh, flags in a new src/system/bash.sh) was taken end to end in a scratch copy of the tree:
./build.sh emitted the block byte-for-byte minus comments.
bash -n on the artifact passed on 3.0, 3.2, 4.0, 5.2 and 5.3.
- The gated helper's tests passed on all five, with
tr selected on 3.x and ${input,,} on 4.0+.
- Full suite against the artifact: 2492 passed, 2 failed, both expected (the compatibility rule this issue is about, and
adr_module_table_test.sh wanting its file count bumped for the new file).
The wrong placement was confirmed too. The same block in an index.sh works in dev mode but exits 127 on 5.2 from the artifact while passing on 3.2, because build::process_file (build.sh:94-98) emits an aggregator's body before the files it sources. Invisible on the reference platform; tests/unit/project/build_test.sh:161-171 is what catches it. So the ADR-011 aggregator rule has to be part of the contract.
The compatibility test amendment
Rejected: a trailing pragma such as # bashunit: bash4. It is the author's claim rather than a structural fact, so honouring it is a smuggling path by construction.
Rejected: a per-file allow-list. An ungated construct elsewhere in the same file then ships, which is the gap the test exists to close.
Proposed: recognise the gate block. A construct is allowed only when the offending line sits in the then-branch of a column-0 if [ "$_BASHUNIT_BASH_GE_NN" = 1 ]; then whose NN is at least the construct's minimum, closed by a column-0 else or fi. An indented header is a runtime check and gates nothing. Each rule moves from offenders 'P' to ungated_offenders 'P' TIER, and the parse-time five get tier 0, meaning no gate is ever enough.
A prototype of this is green at 19 tests, covering: allowed inside a matching or higher gate; rejected when ungated, under a lower tier, in the else-branch, or after the gate closes; an indented or non-canonical header gating nothing; the pragma being unable to smuggle; each parse-time construct rejected even inside a _GE_50 gate; malformed and unclosed gates; and a new test_every_src_file_parses_with_the_running_bash, which is trivial on 5.x and meaningful on the macOS 3.2 and real-3.0 jobs.
The rule a gate must obey
A gate may change speed. It may never change observable behavior.
That is not a slogan, and the textbook example breaks it. ${v,,} and tr '[:upper:]' '[:lower:]' disagree on non-ASCII: BSD tr folds ÑÜ, GNU and busybox do not, and ${v,,} folds under C.UTF-8 but not under C. So every gated helper needs an equivalence test over its real input domain, or a documented ASCII-only contract. It also needs a "branch selected matches tier" test, so a gate that silently always falls back to the compat body cannot pass.
CI already exercises both sides: ubuntu, alpine and Windows run 5.x, macOS runs 3.2, and tests-bash-3.0.yml runs a real 3.0.
Scope
Smallest useful slice: the flags file, the compatibility test amendment, the parse test, and an ADR (adrs/adr-013-bash-version-gated-fast-paths.md, shaped like adr-008). Pure safety net, no behavior change.
Then exactly one gated helper, chosen because it removes a fork rather than because a builtin exists.
Two notes for whoever takes this. Real Bash 3.0 rejects :: names in declare -f and declare -F, so gate tests must introspect with type. And build_test.sh:404-417 greps ^function bashunit:: at column 0, so an indented gated definition is invisible to it; it stays green but stops guarding gated functions against a double embed.
Summary
bashunit supports Bash 3.0, so it forks where a newer shell has a builtin. Today there is no way to ship a faster body for a newer shell, because the compatibility test rejects a too-new construct even inside a branch that Bash 3.0 never takes.
This asks for the mechanism: the runner uses the fastest construct the running shell offers, and falls back per capability down to the 3.0 floor. The floor does not move.
Why the current gates are not it
Five version checks already exist, all runtime predicates re-evaluated on every call:
src/runner/context.sh:31-36_supports_reliable_pipefailsrc/runner/exec.sh:326)src/assert/core.sh:441-450_supports_nocasematchsrc/runner/parallel.sh:4-14_supports_wait_nsrc/coverage/config.sh:128-133xtrace_is_supportedsrc/util/clock.sh:5-17clock implMeasured per-call overhead above a single ungated definition, best of 5 over 100k calls:
if/elsepicks the definition oncecaseon a memoized impl, per callLoad-time selection is free. The pattern in use is the slow one. On its own that is 0.1% of a test, so converting the five is not the point; the point is that a load-time gate lets a fast body remove a fork, which is worth 1 to 3 ms.
What is safe to gate, measured
On a real Bash 3.00.22 and 3.2.57, with each construct placed inside
if false; then ... fiand inside an uncalled function:Parse-time. Can never sit behind a gate:
&>>,|&,;;&,;&,arr+=(x). These kill the file even where they are never reached.arr+=(x)is the trap, since it is a parse error on 3.0 only and parses fine on 3.2.Runtime-only. Safe inside an untaken branch:
${v,,},${v^^},${v@Q},mapfile,declare -A,declare -n,local -n,exec {fd}>,printf -v,x+=y,coproc,wait -n,BASHPID,SRANDOM,EPOCHREALTIME, fractionalread -t,${arr[-1]},${ cmd; }.Dead-code safety did not depend on shape:
if falseand an uncalled function behaved identically in every case.One useful corollary. The entrypoint sources every module under
set -e, so the Bash 3.0 job already catches every parse-time construct anywhere insrc/, whatever the test coverage. The gap the compatibility test closes is exactly the runtime class in untaken branches, and that is the class a gate would contain.Proposed shape
A flags file declaring one flag per boundary in use, then a column-0
if/elsein a leaf module:The tier lives in the flag name, so the compatibility test can compare a construct's minimum version against the gate's declared tier by text alone.
src/console/colors.sh:32-89is the existing precedent for a top-levelif/elsethat assigns at load time and survives the build.Verified against the build
A prototype gate (
${input,,}insrc/util/str.sh, flags in a newsrc/system/bash.sh) was taken end to end in a scratch copy of the tree:./build.shemitted the block byte-for-byte minus comments.bash -non the artifact passed on 3.0, 3.2, 4.0, 5.2 and 5.3.trselected on 3.x and${input,,}on 4.0+.adr_module_table_test.shwanting its file count bumped for the new file).The wrong placement was confirmed too. The same block in an
index.shworks in dev mode but exits 127 on 5.2 from the artifact while passing on 3.2, becausebuild::process_file(build.sh:94-98) emits an aggregator's body before the files it sources. Invisible on the reference platform;tests/unit/project/build_test.sh:161-171is what catches it. So the ADR-011 aggregator rule has to be part of the contract.The compatibility test amendment
Rejected: a trailing pragma such as
# bashunit: bash4. It is the author's claim rather than a structural fact, so honouring it is a smuggling path by construction.Rejected: a per-file allow-list. An ungated construct elsewhere in the same file then ships, which is the gap the test exists to close.
Proposed: recognise the gate block. A construct is allowed only when the offending line sits in the then-branch of a column-0
if [ "$_BASHUNIT_BASH_GE_NN" = 1 ]; thenwhoseNNis at least the construct's minimum, closed by a column-0elseorfi. An indented header is a runtime check and gates nothing. Each rule moves fromoffenders 'P'toungated_offenders 'P' TIER, and the parse-time five get tier 0, meaning no gate is ever enough.A prototype of this is green at 19 tests, covering: allowed inside a matching or higher gate; rejected when ungated, under a lower tier, in the else-branch, or after the gate closes; an indented or non-canonical header gating nothing; the pragma being unable to smuggle; each parse-time construct rejected even inside a
_GE_50gate; malformed and unclosed gates; and a newtest_every_src_file_parses_with_the_running_bash, which is trivial on 5.x and meaningful on the macOS 3.2 and real-3.0 jobs.The rule a gate must obey
A gate may change speed. It may never change observable behavior.
That is not a slogan, and the textbook example breaks it.
${v,,}andtr '[:upper:]' '[:lower:]'disagree on non-ASCII: BSDtrfoldsÑÜ, GNU and busybox do not, and${v,,}folds underC.UTF-8but not underC. So every gated helper needs an equivalence test over its real input domain, or a documented ASCII-only contract. It also needs a "branch selected matches tier" test, so a gate that silently always falls back to the compat body cannot pass.CI already exercises both sides: ubuntu, alpine and Windows run 5.x, macOS runs 3.2, and
tests-bash-3.0.ymlruns a real 3.0.Scope
Smallest useful slice: the flags file, the compatibility test amendment, the parse test, and an ADR (
adrs/adr-013-bash-version-gated-fast-paths.md, shaped like adr-008). Pure safety net, no behavior change.Then exactly one gated helper, chosen because it removes a fork rather than because a builtin exists.
Two notes for whoever takes this. Real Bash 3.0 rejects
::names indeclare -fanddeclare -F, so gate tests must introspect withtype. Andbuild_test.sh:404-417greps^function bashunit::at column 0, so an indented gated definition is invisible to it; it stays green but stops guarding gated functions against a double embed.