From 07c4fe39617b62799e53b08774116061a666b2bf Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Jul 2026 15:52:01 +0200 Subject: [PATCH 1/2] feat!: raise the minimum bash from 3.0 to 3.2 3.2 is the bash macOS ships, which is the reason for supporting an old bash at all. 3.0 (2004) and 3.1 (2005) have no practical install base, and supporting them has a real cost: they lack printf -v and +=, and predate the 3.2 change to [[ =~ ]] quoting, so a regex match behaves DIFFERENTLY across 'supported' versions. The gate compared only the major version, so every 3.x passed regardless of the declared 3.0 minimum. It now checks the minor too. Also corrects two claims in the rules that this work disproved: - perf-fork-budget.md said Bash 3.0 has no [[ =~ ]] and the grep -E fork in assert_matches was therefore mandatory. [[ =~ ]] works on a real 3.0 build; the fork follows from the house style rule preferring [ ], not from the version floor. - bash-style.md listed [[ ]] alongside genuinely 4.0+ constructs. The two are now separated, so a style preference is not mistaken for a compatibility constraint. CI builds and tests a real bash 3.2 instead of 3.0. Verified by running the unit suite under both builds in Docker, invoking the script as an argument to the old bash rather than through its shebang. --- .claude/CLAUDE.md | 14 ++++---- .claude/rules/bash-style.md | 27 ++++++++++++-- .claude/rules/perf-fork-budget.md | 7 ++-- ...{tests-bash-3.0.yml => tests-bash-3.2.yml} | 28 +++++++-------- CHANGELOG.md | 3 ++ README.md | 2 +- bashunit | 15 +++++--- docs/index.md | 2 +- docs/installation.md | 4 +-- install.sh | 2 +- tests/unit/bash_version_test.sh | 36 +++++++++++++++++-- 11 files changed, 103 insertions(+), 37 deletions(-) rename .github/workflows/{tests-bash-3.0.yml => tests-bash-3.2.yml} (80%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 26912109..517ed90f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -**bashunit** is a lightweight Bash testing framework (Bash 3.0+) focused on developer experience. Provides assertions, test doubles (spies/mocks), data providers, snapshots, and more. +**bashunit** is a lightweight Bash testing framework (Bash 3.2+) focused on developer experience. Provides assertions, test doubles (spies/mocks), data providers, snapshots, and more. **Documentation:** https://bashunit.com @@ -11,7 +11,7 @@ ### TDD by Default **RED → GREEN → REFACTOR** — every change starts from a failing test. No exceptions. -### Bash 3.0+ Compatible +### Bash 3.2+ Compatible Works on macOS default bash. **Prohibited features:** - `declare -A` (associative arrays - Bash 4.0+) @@ -39,7 +39,7 @@ breaks `make lint`. See "Formatting" below. ``` bashunit/ -├── src/ # Core framework code (Bash 3.0+ compatible) +├── src/ # Core framework code (Bash 3.2+ compatible) │ ├── bashunit.sh # Main entry point │ ├── assertions.sh # Assertion functions │ ├── assert_*.sh # Specialized assertions @@ -120,7 +120,7 @@ Rules auto-load based on file paths being edited (via `paths:` frontmatter in ea ### `src/**/*.sh` - Small, portable functions -- Bash 3.0+ compatibility (no associative arrays, no `[[`, no `${var,,}`) +- Bash 3.2+ compatibility (no associative arrays, no `[[`, no `${var,,}`) - Proper namespacing (`bashunit::*`) - No external dependencies in core @@ -139,7 +139,7 @@ Rules auto-load based on file paths being edited (via `paths:` frontmatter in ea ### Never: - Invent commands/features not in the codebase -- Break Bash 3.0+ compatibility +- Break Bash 3.2+ compatibility - Skip tests or quality checks - Change public API without docs/CHANGELOG - Commit without tests passing @@ -153,7 +153,7 @@ Rules auto-load based on file paths being edited (via `paths:` frontmatter in ea - Keep tests passing during REFACTOR - Update CHANGELOG.md for user-visible changes - Run quality checks before committing -- Maintain Bash 3.0+ compatibility +- Maintain Bash 3.2+ compatibility ## Definition of Done @@ -161,7 +161,7 @@ Rules auto-load based on file paths being edited (via `paths:` frontmatter in ea - `make sa` passes (ShellCheck) - `make lint` passes (EditorConfig) - Code formatted to 2-space indent by hand (verified by `make lint`, never `shfmt -w`) -- Bash 3.0+ compatible +- Bash 3.2+ compatible - Parallel tests passing (`./bashunit --parallel tests/`) - CHANGELOG.md updated (if user-facing changes) - ADR created/updated (if architectural decision) diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index fdd59c76..3b592996 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -6,17 +6,38 @@ paths: # Bash Style & Compatibility Rules -## Bash 3.0+ Compatibility (Critical) +## Bash 3.2+ Compatibility (Critical) -bashunit must work on **Bash 3.0+** (macOS default). These features are **prohibited**: +bashunit must work on **Bash 3.2+** — the bash macOS ships, and the reason for +supporting an old bash at all. Bash 3.0/3.1 are not supported: they predate +`printf -v` and `+=`, and predate the 3.2 change to `[[ =~ ]]` quoting, so a +regex match would behave differently across "supported" versions. + +These are **version-breaking** above the floor and are prohibited. They are +enforced mechanically by `tests/unit/bash_compatibility_test.sh`, which greps +`src/` regardless of test coverage: | Feature | Bash ver | Alternative | |---------|----------|-------------| | `declare -A` (associative arrays) | 4.0+ | Parallel indexed arrays | -| `[[ ]]` (test operator) | — | `[ ]` with `=` not `==` | | `${var,,}` / `${var^^}` (case) | 4.0+ | `tr '[:upper:]' '[:lower:]'` | | `${array[-1]}` (negative index) | 4.3+ | `${array[${#array[@]}-1]}` | | `&>>` (append both) | 4.0+ | `>> file 2>&1` | +| `BASHPID` | 4.0+ | fork (`mktemp`) or an assigned ordinal | +| `mapfile` / `readarray` | 4.0+ | `while IFS= read -r` | +| `declare -n` / `local -n` (nameref) | 4.3+ | Return-slot pattern (below) | +| `coproc` | 4.0+ | — | +| `${var@Q}` (transformations) | 4.4+ | — | + +### Style, not compatibility + +These are **house style**, not version constraints. Do not justify them as +Bash 3 requirements, and do not add them to the compatibility gate: + +| Convention | Reality | +|------------|---------| +| Prefer `[ ]` over `[[ ]]` | `[[ ]]` works on every supported bash, including 3.0 (verified against a real build) | +| Avoid `printf -v` | Works on 3.1+; the real objection is that it resolves against the *dynamic* scope, like `eval "$name=..."` | ## Coding Conventions diff --git a/.claude/rules/perf-fork-budget.md b/.claude/rules/perf-fork-budget.md index ecf57e2f..0231c413 100644 --- a/.claude/rules/perf-fork-budget.md +++ b/.claude/rules/perf-fork-budget.md @@ -68,8 +68,11 @@ only** — toggling it in the caller's shell clobbers caller state (#808). duplicate check) stay awk. - **`${var//pattern/}` on large strings**: quadratic on Bash 3.2 — 2.7 s where awk takes 3.5ms. Never string-replace over big captures. -- **Regex assertions** (`assert_matches`): Bash 3.0 has no `[[ =~ ]]`; the - `grep -E` fork is mandatory. +- **Regex assertions** (`assert_matches`): the `grep -E` fork follows from the + house rule preferring `[ ]` over `[[ ]]`, **not** from the version floor — + `[[ =~ ]]` was verified working on a real Bash 3.0 build. Since the floor is + now 3.2, where `=~` quoting semantics are stable, this fork is removable if + the style rule is revisited. Measure before assuming it is a win. - **Single-file build artifact**: sourcing `bin/bashunit` is *not* faster than sourcing `src/*.sh` (parse time dominates, file opens don't). - **`tput cols` at startup**: returns 80 on non-tty; snapshots depend on that diff --git a/.github/workflows/tests-bash-3.0.yml b/.github/workflows/tests-bash-3.2.yml similarity index 80% rename from .github/workflows/tests-bash-3.0.yml rename to .github/workflows/tests-bash-3.2.yml index e8097764..7c939aba 100644 --- a/.github/workflows/tests-bash-3.0.yml +++ b/.github/workflows/tests-bash-3.2.yml @@ -1,4 +1,4 @@ -name: Bash 3.0 Compatibility +name: Bash 3.2 Compatibility on: pull_request: @@ -18,7 +18,7 @@ concurrency: jobs: build-image: - name: "Build Bash 3.0 Image" + name: "Build Bash 3.2 Image" runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -27,7 +27,7 @@ jobs: with: fetch-depth: 1 - - name: Build Bash 3.0 Docker image + - name: Build Bash 3.2 Docker image run: | docker build -t bashunit-bash3 -f - . <<'EOF' FROM debian:bookworm-slim @@ -42,19 +42,19 @@ jobs: && rm -rf /var/lib/apt/lists/* WORKDIR /tmp - RUN curl -LO https://ftp.gnu.org/gnu/bash/bash-3.0.tar.gz \ - && tar xzf bash-3.0.tar.gz \ - && cd bash-3.0 \ + RUN curl -LO https://ftp.gnu.org/gnu/bash/bash-3.2.tar.gz \ + && tar xzf bash-3.2.tar.gz \ + && cd bash-3.2 \ && curl -fsSL -o support/config.guess 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.guess' \ && curl -fsSL -o support/config.sub 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.sub' \ && chmod +x support/config.guess support/config.sub \ - && ./configure --prefix=/opt/bash-3.0 \ + && ./configure --prefix=/opt/bash-3.2 \ && make \ && make install \ - && rm -rf /tmp/bash-3.0* + && rm -rf /tmp/bash-3.2* WORKDIR /bashunit - CMD ["/opt/bash-3.0/bin/bash", "--version"] + CMD ["/opt/bash-3.2/bin/bash", "--version"] EOF - name: Save Docker image @@ -68,7 +68,7 @@ jobs: retention-days: 1 test: - name: "Bash 3.0 - ${{ matrix.name }}" + name: "Bash 3.2 - ${{ matrix.name }}" runs-on: ubuntu-latest needs: build-image timeout-minutes: 15 @@ -104,13 +104,13 @@ jobs: - name: Load Docker image run: docker load --input /tmp/bashunit-bash3.tar - - name: Verify Bash 3.0 version - run: docker run --rm bashunit-bash3 /opt/bash-3.0/bin/bash --version + - name: Verify Bash 3.2 version + run: docker run --rm bashunit-bash3 /opt/bash-3.2/bin/bash --version - - name: Run tests with Bash 3.0 (${{ matrix.name }}) + - name: Run tests with Bash 3.2 (${{ matrix.name }}) run: | docker run --rm \ -v "$(pwd)":/bashunit \ -w /bashunit \ bashunit-bash3 \ - /opt/bash-3.0/bin/bash ./bashunit ${{ matrix.flags }} tests/ + /opt/bash-3.2/bin/bash ./bashunit ${{ matrix.flags }} tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb36578..171d7222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Breaking +- The minimum supported bash is now **3.2**, up from 3.0. 3.2 is the version macOS ships, which is the reason for supporting an old bash at all; 3.0 (2004) and 3.1 (2005) have no practical install base. They also lack `printf -v` and `+=`, and predate the 3.2 change to `[[ =~ ]]` quoting — so at a 3.0 floor a regex match behaves *differently across supported versions*, which is worse than not supporting them. The runtime gate previously compared only the major version, so any 3.x was accepted regardless of the declared minimum; it now checks the minor and refuses 3.0/3.1 with a clear message. CI builds and tests against a real bash 3.2 instead of 3.0 + ### Added - Docs: an [Agentic coding](https://bashunit.com/ai-agents) page covering machine-readable results (`--report-json`, `--output tap`), the flags that keep an AI agent's edit-run loop tight (`--filter`, `--rerun-failed`, `--test-timeout`), and the API traps that make generated tests pass for the wrong reason. It also surfaces `llms.txt` / `llms-full.txt`, which were already published but linked from nowhere - A drop-in agent skill at [bashunit.com/bashunit-skill.md](https://bashunit.com/bashunit-skill.md) for Claude Code and other tools that load a `SKILL.md` diff --git a/README.md b/README.md index a7c255e3..a79a6fa5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ## Why bashunit -A lightweight, fast testing framework for **Bash 3.0+**, focused on developer experience. +A lightweight, fast testing framework for **Bash 3.2+**, focused on developer experience. It ships 71 assertions plus spies, mocks, data providers, snapshots and more. ## Quick start diff --git a/bashunit b/bashunit index a1339826..b6f3fb19 100755 --- a/bashunit +++ b/bashunit @@ -1,7 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -declare -r BASHUNIT_MIN_BASH_VERSION="3.0" +# 3.2 is the bash macOS ships, which is the reason for supporting an old bash at +# all. 3.0 and 3.1 lack `printf -v` and `+=`, and predate the 3.2 change to +# `[[ =~ ]]` quoting -- so at a 3.0 floor a regex match behaves differently +# across supported versions, which is worse than not supporting them. +declare -r BASHUNIT_MIN_BASH_VERSION="3.2" function _check_bash_version() { local current_version @@ -16,10 +20,13 @@ function _check_bash_version() { current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" fi - local major - IFS=. read -r major _ <<<"$current_version" + local major minor + IFS=. read -r major minor <<<"$current_version" + minor=${minor:-0} - if ((major < 3)); then + # The minor matters now: the previous gate compared only the major, so every + # 3.x passed regardless of the declared minimum. + if ((major < 3)) || { ((major == 3)) && ((minor < 2)); }; then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi diff --git a/docs/index.md b/docs/index.md index 802a4e03..809dedd7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- # https://vitepress.dev/reference/default-theme-home-page layout: home -description: "bashunit is a fast, simple bash testing framework: assertions, mocks, spies, data providers, snapshots and coverage. Runs on Bash 3.0+ (Linux, macOS, WSL)." +description: "bashunit is a fast, simple bash testing framework: assertions, mocks, spies, data providers, snapshots and coverage. Runs on Bash 3.2+ (Linux, macOS, WSL)." hero: name: bashunit diff --git a/docs/installation.md b/docs/installation.md index 6e9cf478..1dc1cf76 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,5 +1,5 @@ --- -description: "Install bashunit via install.sh, npm, Brew, MacPorts or bashdep: a single-file bash testing framework running on Bash 3.0+ (Linux, macOS, WSL)." +description: "Install bashunit via install.sh, npm, Brew, MacPorts or bashdep: a single-file bash testing framework running on Bash 3.2+ (Linux, macOS, WSL)." --- # Installation @@ -8,7 +8,7 @@ description: "Install bashunit via install.sh, npm, Brew, MacPorts or bashdep: a ## Requirements -bashunit requires **Bash 3.0** or newer. On Windows use [WSL](https://learn.microsoft.com/windows/wsl/install). +bashunit requires **Bash 3.2** or newer. On Windows use [WSL](https://learn.microsoft.com/windows/wsl/install). ## install.sh diff --git a/install.sh b/install.sh index 2e8f582b..6574098b 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ # success message must be unreachable on any failure path (#840). set -euo pipefail -# Helper function for regex matching (Bash 3.0+ compatible) +# Helper function for regex matching (Bash 3.2+ compatible) function regex_match() { [[ $1 =~ $2 ]] } diff --git a/tests/unit/bash_version_test.sh b/tests/unit/bash_version_test.sh index 22180ec0..bde3c80b 100755 --- a/tests/unit/bash_version_test.sh +++ b/tests/unit/bash_version_test.sh @@ -1,9 +1,41 @@ #!/usr/bin/env bash -function test_fail_with_old_bash_version() { +# The floor is 3.2, not 3.0: that is the bash macOS ships, and bash 3.0/3.1 lack +# `printf -v` and `+=`, and predate the 3.2 change to `[[ =~ ]]` quoting that +# makes regex matching behave consistently across supported versions. +# The gate used to compare only the major, so any 3.x was accepted. + +function test_fail_with_bash_2() { local output local exit_code=0 output=$(BASHUNIT_TEST_BASH_VERSION=2.05 ./bashunit --version 2>&1) || exit_code=$? - assert_contains "Bashunit requires Bash >= 3.0. Current version: 2.05" "$output" + assert_contains "Bashunit requires Bash >= 3.2. Current version: 2.05" "$output" assert_general_error "$output" "" "$exit_code" } + +function test_fail_with_bash_3_0() { + local output + local exit_code=0 + output=$(BASHUNIT_TEST_BASH_VERSION=3.0 ./bashunit --version 2>&1) || exit_code=$? + assert_contains "Bashunit requires Bash >= 3.2. Current version: 3.0" "$output" + assert_general_error "$output" "" "$exit_code" +} + +function test_fail_with_bash_3_1() { + local output + local exit_code=0 + output=$(BASHUNIT_TEST_BASH_VERSION=3.1 ./bashunit --version 2>&1) || exit_code=$? + assert_general_error "$output" "" "$exit_code" +} + +function test_accepts_bash_3_2() { + local exit_code=0 + BASHUNIT_TEST_BASH_VERSION=3.2 ./bashunit --version >/dev/null 2>&1 || exit_code=$? + assert_successful_code "" "" "$exit_code" +} + +function test_accepts_bash_4_and_newer() { + local exit_code=0 + BASHUNIT_TEST_BASH_VERSION=5.2 ./bashunit --version >/dev/null 2>&1 || exit_code=$? + assert_successful_code "" "" "$exit_code" +} From db11c3dc300920f8d6d5e992240af8185797b3d0 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Jul 2026 16:04:26 +0200 Subject: [PATCH 2/2] test: widen the external-script mock/spy skips to all of bash 3.x Testing a real bash 3.2 exposed a pre-existing gap rather than a regression: mocks and spies are shell functions and do not cross the process boundary into an external script on bash 3, but the skip guards only covered 3.0 (major == 3 && minor < 1). CI had only ever run 3.0, so these tests were always skipped and never ran on 3.2. Confirmed pre-existing by running tests/functional/doubles_test.sh against main under the same bash 3.2 image: it fails there identically. All four CI modes (sequential, parallel, simple parallel, strict) now pass on a real bash 3.2 build. --- tests/acceptance/install_test.sh | 4 ++-- tests/functional/doubles_test.sh | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/acceptance/install_test.sh b/tests/acceptance/install_test.sh index b6c36c0b..70fbbe52 100644 --- a/tests/acceptance/install_test.sh +++ b/tests/acceptance/install_test.sh @@ -242,8 +242,8 @@ function test_install_downloads_the_given_version_without_dir() { function test_install_downloads_the_non_stable_beta_version() { # Skip on Bash 3.0 - mocks don't work for external scripts - if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then - bashunit::skip "Mocks don't work for external scripts in Bash 3.0" + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]]; then + bashunit::skip "Mocks do not reach external scripts on Bash 3.x" return fi if [[ "$ACTIVE_INTERNET" -eq 1 ]]; then diff --git a/tests/functional/doubles_test.sh b/tests/functional/doubles_test.sh index 798c7bb0..cc0a01d1 100644 --- a/tests/functional/doubles_test.sh +++ b/tests/functional/doubles_test.sh @@ -17,8 +17,8 @@ function test_mock_ps_when_executing_a_sourced_function() { function test_spy_commands_called_when_executing_a_script() { # Skip on Bash 3.0 - shell function exports don't work for external scripts - if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then - bashunit::skip "Spies don't work for external scripts in Bash 3.0" + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]]; then + bashunit::skip "Spies do not reach external scripts on Bash 3.x" return fi @@ -48,8 +48,8 @@ function test_spy_commands_called_when_executing_a_sourced_function() { function test_spy_commands_called_once_when_executing_a_script() { # Skip on Bash 3.0 - shell function exports don't work for external scripts - if [[ "${BASH_VERSINFO[0]}" -eq 3 ]] && [[ "${BASH_VERSINFO[1]}" -lt 1 ]]; then - bashunit::skip "Spies don't work for external scripts in Bash 3.0" + if [[ "${BASH_VERSINFO[0]}" -eq 3 ]]; then + bashunit::skip "Spies do not reach external scripts on Bash 3.x" return fi # Skip when coverage is enabled because coverage uses head internally,