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/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, 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" +}