Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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+)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -153,15 +153,15 @@ 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

- All tests green for the **right reason**
- `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)
Expand Down
27 changes: 24 additions & 3 deletions .claude/rules/bash-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions .claude/rules/perf-fork-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Bash 3.0 Compatibility
name: Bash 3.2 Compatibility

on:
pull_request:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions bashunit
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
}
Expand Down
4 changes: 2 additions & 2 deletions tests/acceptance/install_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions tests/functional/doubles_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
36 changes: 34 additions & 2 deletions tests/unit/bash_version_test.sh
Original file line number Diff line number Diff line change
@@ -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"
}
Loading