From d6530ff509f243f427d3ad9c570301da1f5fced0 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 12 May 2026 11:05:21 +0200 Subject: [PATCH 1/4] ref(bash): codify dynamic-scope safety for outvar helpers Bash 'local' is dynamically scoped, so helpers that take a caller-named variable as an outvar must defend against the callee's locals shadowing the caller's same-named variable; otherwise an eval-based assignment silently writes to the local and the caller's variable keeps its old value. This regression caused 12 parallel test failures in #662 before the prefix was added. - Document the convention in .claude/rules/bash-style.md (Outvar helpers section) with the failing example, the required pattern, and a pointer to the intentional dynamic-scope use in src/coverage.sh. - Defensive rename in src/test_doubles.sh: all internal locals on the indirect-read paths (${!file_var}) use the __bu_ prefix so a caller local cannot shadow the resolved global file path. - Add regression tests in tests/unit/runner_test.sh that pass each helper's documented internal variable name as the caller's outvar to catch any future drop of the prefix. Closes #674 --- .claude/rules/bash-style.md | 71 ++++++++++++++++ CHANGELOG.md | 3 + src/test_doubles.sh | 162 ++++++++++++++++++------------------ tests/unit/runner_test.sh | 39 +++++++++ 4 files changed, 194 insertions(+), 81 deletions(-) diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index baccfc84..6fd3019e 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -49,6 +49,77 @@ Constants -> Globals -> Private functions -> Public functions Source deps relative to script: `"$(dirname "${BASH_SOURCE[0]}")/dep.sh"` +## Outvar helpers (dynamic-scope safety) + +Bash `local` is **dynamically scoped**, not lexically scoped. A `local` inside a callee +shadows the caller's same-named variable for the duration of the call. Any helper that +writes back to a caller-named variable (the **outvar pattern**) must defend against this +or it will silently corrupt callers that pick a "natural" name. + +### Rule + +Helpers that take a caller-named variable as an outvar (typically the first argument) +**MUST prefix every internal local with `__bu_`** so no caller-passed name can collide. + +### Why + +Without the prefix, this fails silently: + +```bash +without_prefix() { + local _out=$1 + local subshell_output=$2 # LOCAL shadows caller's same-named var + local line="formatted" + eval "$_out=\$line" # assigns to the LOCAL, not the caller +} + +main() { + local subshell_output="raw" + without_prefix subshell_output "$subshell_output" + echo "$subshell_output" # prints "raw" — the helper appeared to succeed +} +``` + +The caller's variable is untouched, no error is raised, and tests downstream silently +get stale data. This regression bit us in #662 (12 parallel test failures, fixed in +PR #672) before the prefix was applied. + +### Pattern + +```bash +# Writes into the named outvar. +# Arguments: $1 outvar name, $2 input +function bashunit::pkg::do_thing() { + local __bu_out=$1 + local __bu_in=$2 + local __bu_val="${__bu_in%%]*}" + __bu_val="${__bu_val#[}" + eval "$__bu_out=\$__bu_val" +} +``` + +- Use `eval "$__bu_out=\$__bu_val"` for the assignment. Quote the `$` of the value with + a backslash so only the outvar name is expanded at eval time. +- Do **not** use `printf -v` (Bash 3.0 lacks it) or `declare -n` (Bash 4.3+). +- A regression test should pass the helper's own local names as outvar to catch any + contributor who later drops the prefix. + +### Where the convention applies in this repo + +- All four hot-path helpers in `src/runner.sh`: `extract_encoded_field`, + `extract_subshell_type`, `format_subshell_output`, `compute_total_assertions`. +- Indirect-read sites in `src/test_doubles.sh` (`${!file_var}`) follow the same rule + defensively — internal locals are `__bu_`-prefixed even though the read direction is + less likely to collide in practice. + +### Intentional dynamic-scope mutation is a separate pattern + +The coverage branch helpers in `src/coverage.sh` (`_branch_push_if` and friends) deliberately +mutate caller locals (`if_decision_line`, `if_arms`, `if_depth`, `if_arm_start`). That is +documented inline at `src/coverage.sh:818-821` and is **not** the outvar pattern — the +helper has no `$1`-named outvar argument; the caller agrees to share state by convention. +Don't introduce new instances of this pattern without an inline justification comment. + ## ShellCheck All code must pass `make sa`. Use directives sparingly with reason: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7e5332..ee90b1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Internal +- Codify dynamic-scope safety rule for outvar helpers (`__bu_` prefix on internal locals) and apply it defensively across `src/test_doubles.sh`, with regression tests (#674) + ### Performance - Faster runtime-error detection: single `case` glob instead of 23-iteration loop in `detect_runtime_error` (#668) - Hot-path coverage flag now cached in `_BASHUNIT_COVERAGE_ON`, removing a function dispatch per call (#664) diff --git a/src/test_doubles.sh b/src/test_doubles.sh index c1d6d253..aca25f70 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -3,25 +3,25 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=() function bashunit::unmock() { - local command=$1 + local __bu_command=$1 if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then return fi - local i - for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do - if [ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" = "$command" ]; then - unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" - unset -f "$command" - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - local times_file_var="${variable}_times_file" - local params_file_var="${variable}_params_file" - [ -f "${!times_file_var-}" ] && rm -f "${!times_file_var}" - [ -f "${!params_file_var-}" ] && rm -f "${!params_file_var}" - unset "$times_file_var" - unset "$params_file_var" + local __bu_i + for __bu_i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do + if [ "${_BASHUNIT_MOCKED_FUNCTIONS[$__bu_i]:-}" = "$__bu_command" ]; then + unset "_BASHUNIT_MOCKED_FUNCTIONS[$__bu_i]" + unset -f "$__bu_command" + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + local __bu_times_file_var="${__bu_variable}_times_file" + local __bu_params_file_var="${__bu_variable}_params_file" + [ -f "${!__bu_times_file_var-}" ] && rm -f "${!__bu_times_file_var}" + [ -f "${!__bu_params_file_var-}" ] && rm -f "${!__bu_params_file_var}" + unset "$__bu_times_file_var" + unset "$__bu_params_file_var" break fi done @@ -86,19 +86,19 @@ function bashunit::spy() { } function assert_have_been_called() { - local command=$1 - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - local file_var="${variable}_times_file" - local times=0 - if [ -f "${!file_var-}" ]; then - times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) + local __bu_command=$1 + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + local __bu_file_var="${__bu_variable}_times_file" + local __bu_times=0 + if [ -f "${!__bu_file_var-}" ]; then + __bu_times=$(cat "${!__bu_file_var}" 2>/dev/null || builtin echo 0) fi - local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + local __bu_label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - if [ "$times" -eq 0 ]; then + if [ "$__bu_times" -eq 0 ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once" + bashunit::console_results::print_failed_test "${__bu_label}" "${__bu_command}" "to have been called" "once" return fi @@ -106,36 +106,36 @@ function assert_have_been_called() { } function assert_have_been_called_with() { - local command=$1 + local __bu_command=$1 shift - local index="" + local __bu_index="" if [ "$(echo "${!#}" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then - index=${!#} + __bu_index=${!#} set -- "${@:1:$#-1}" fi - local expected="$*" + local __bu_expected="$*" - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - local file_var="${variable}_params_file" - local line="" - if [ -f "${!file_var-}" ]; then - if [ -n "$index" ]; then - line=$(sed -n "${index}p" "${!file_var}" 2>/dev/null || true) + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + local __bu_file_var="${__bu_variable}_params_file" + local __bu_line="" + if [ -f "${!__bu_file_var-}" ]; then + if [ -n "$__bu_index" ]; then + __bu_line=$(sed -n "${__bu_index}p" "${!__bu_file_var}" 2>/dev/null || true) else - line=$(tail -n 1 "${!file_var}" 2>/dev/null || true) + __bu_line=$(tail -n 1 "${!__bu_file_var}" 2>/dev/null || true) fi fi - local raw - IFS=$'\x1e' read -r raw _ <<<"$line" || true + local __bu_raw + IFS=$'\x1e' read -r __bu_raw _ <<<"$__bu_line" || true - if [ "$expected" != "$raw" ]; then + if [ "$__bu_expected" != "$__bu_raw" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \ - "${FUNCNAME[1]}")" "$expected" "but got " "$raw" + "${FUNCNAME[1]}")" "$__bu_expected" "but got " "$__bu_raw" return fi @@ -143,21 +143,21 @@ function assert_have_been_called_with() { } function assert_have_been_called_times() { - local expected_count=$1 - local command=$2 - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - local file_var="${variable}_times_file" - local times=0 - if [ -f "${!file_var-}" ]; then - times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) + local __bu_expected_count=$1 + local __bu_command=$2 + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + local __bu_file_var="${__bu_variable}_times_file" + local __bu_times=0 + if [ -f "${!__bu_file_var-}" ]; then + __bu_times=$(cat "${!__bu_file_var}" 2>/dev/null || builtin echo 0) fi - local label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - if [ "$times" -ne "$expected_count" ]; then + local __bu_label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + if [ "$__bu_times" -ne "$__bu_expected_count" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${label}" "${command}" \ - "to have been called" "${expected_count} times" \ - "actual" "${times} times" + bashunit::console_results::print_failed_test "${__bu_label}" "${__bu_command}" \ + "to have been called" "${__bu_expected_count} times" \ + "actual" "${__bu_times} times" return fi @@ -165,42 +165,42 @@ function assert_have_been_called_times() { } function assert_have_been_called_nth_with() { - local nth=$1 - local command=$2 + local __bu_nth=$1 + local __bu_command=$2 shift 2 - local expected="$*" - - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - local times_file_var="${variable}_times_file" - local file_var="${variable}_params_file" - local label - label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" - - local times=0 - if [ -f "${!times_file_var-}" ]; then - times=$(cat "${!times_file_var}" 2>/dev/null || builtin echo 0) + local __bu_expected="$*" + + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + local __bu_times_file_var="${__bu_variable}_times_file" + local __bu_file_var="${__bu_variable}_params_file" + local __bu_label + __bu_label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" + + local __bu_times=0 + if [ -f "${!__bu_times_file_var-}" ]; then + __bu_times=$(cat "${!__bu_times_file_var}" 2>/dev/null || builtin echo 0) fi - if [ "$nth" -gt "$times" ]; then + if [ "$__bu_nth" -gt "$__bu_times" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${label}" \ - "expected call" "at index ${nth} but" "only called ${times} times" + bashunit::console_results::print_failed_test "${__bu_label}" \ + "expected call" "at index ${__bu_nth} but" "only called ${__bu_times} times" return fi - local line="" - if [ -f "${!file_var-}" ]; then - line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true) + local __bu_line="" + if [ -f "${!__bu_file_var-}" ]; then + __bu_line=$(sed -n "${__bu_nth}p" "${!__bu_file_var}" 2>/dev/null || true) fi - local raw - IFS=$'\x1e' read -r raw _ <<<"$line" || true + local __bu_raw + IFS=$'\x1e' read -r __bu_raw _ <<<"$__bu_line" || true - if [ "$expected" != "$raw" ]; then + if [ "$__bu_expected" != "$__bu_raw" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${label}" \ - "$expected" "but got " "$raw" + bashunit::console_results::print_failed_test "${__bu_label}" \ + "$__bu_expected" "but got " "$__bu_raw" return fi @@ -208,7 +208,7 @@ function assert_have_been_called_nth_with() { } function assert_not_called() { - local command=$1 - local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - assert_have_been_called_times 0 "$command" "$label" + local __bu_command=$1 + local __bu_label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + assert_have_been_called_times 0 "$__bu_command" "$__bu_label" } diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index 5c1e3af0..d01d8d95 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -173,3 +173,42 @@ function test_format_subshell_output_strips_type_and_expands_markers() { expected=$' line1\nline2\nline3' assert_same "$expected" "$out" } + +# Regression for #674: helpers that take a caller-named outvar MUST not be +# corrupted when the caller's variable shares a name with one of the helper's +# internal locals. Bash uses dynamic scoping for `local`, so without the +# `__bu_` prefix on internal names the eval assignment would hit the local and +# the caller's variable would silently keep its old value. +function test_format_subshell_output_does_not_shadow_caller_named_subshell_output() { + local subshell_output="raw" + bashunit::runner::format_subshell_output subshell_output "[failed] formatted" + + assert_same " formatted" "$subshell_output" +} + +function test_extract_subshell_type_does_not_shadow_caller_named_subshell_output() { + local subshell_output="[failed] payload" + local type + bashunit::runner::extract_subshell_type type "$subshell_output" + + assert_same "failed" "$type" + assert_same "[failed] payload" "$subshell_output" +} + +function test_extract_encoded_field_does_not_shadow_caller_named_test_execution_result() { + local test_execution_result="##TEST_TITLE=hi##ASSERTIONS_PASSED=1" + local out + bashunit::runner::extract_encoded_field out "$test_execution_result" "TEST_TITLE" + + assert_same "hi" "$out" + assert_same "##TEST_TITLE=hi##ASSERTIONS_PASSED=1" "$test_execution_result" +} + +function test_compute_total_assertions_does_not_shadow_caller_named_test_execution_result() { + local test_execution_result="##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" + local total + bashunit::runner::compute_total_assertions total "$test_execution_result" + + assert_same "5" "$total" + assert_same "##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" "$test_execution_result" +} From 60a31d135c799c724c924026e15235f9dbb270f0 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 12 May 2026 11:35:32 +0200 Subject: [PATCH 2/4] ref(doubles): extend __bu_ prefix to mock/spy locals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After review feedback on PR #674: mock/spy were left unprotected in the first pass. Both helpers take a caller-named command and write derived globals via 'export "${variable}_..._file"=...' — if a caller has a local with the same constructed name in scope, the export updates the local instead of creating a clean env binding (dynamic scoping). Same hazard class as the indirect-read sites, applied consistently now. Updates the rule in .claude/rules/bash-style.md to call out the whole test_doubles.sh surface, not just the indirect-read sites. --- .claude/rules/bash-style.md | 10 +++--- src/test_doubles.sh | 62 ++++++++++++++++++------------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index 6fd3019e..97b8a22c 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -106,11 +106,13 @@ function bashunit::pkg::do_thing() { ### Where the convention applies in this repo -- All four hot-path helpers in `src/runner.sh`: `extract_encoded_field`, +- All four hot-path outvar helpers in `src/runner.sh`: `extract_encoded_field`, `extract_subshell_type`, `format_subshell_output`, `compute_total_assertions`. -- Indirect-read sites in `src/test_doubles.sh` (`${!file_var}`) follow the same rule - defensively — internal locals are `__bu_`-prefixed even though the read direction is - less likely to collide in practice. +- All helpers in `src/test_doubles.sh` that touch caller-named or caller-constructed + variables: `bashunit::mock`, `bashunit::spy`, `bashunit::unmock`, plus the + `assert_have_been_called*` family. Each takes a command name and either `export`s + derived globals or reads them via `${!file_var}`; both directions can collide with a + caller local of the same constructed name, so internal locals are `__bu_`-prefixed. ### Intentional dynamic-scope mutation is a separate pattern diff --git a/src/test_doubles.sh b/src/test_doubles.sh index aca25f70..44f4e11f 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -28,43 +28,43 @@ function bashunit::unmock() { } function bashunit::mock() { - local command=$1 + local __bu_command=$1 shift if [ $# -gt 0 ]; then - eval "function $command() { $* \"\$@\"; }" + eval "function $__bu_command() { $* \"\$@\"; }" else - eval "function $command() { builtin echo \"$($CAT)\" ; }" + eval "function $__bu_command() { builtin echo \"$($CAT)\" ; }" fi - export -f "${command?}" + export -f "${__bu_command?}" - _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$__bu_command" } function bashunit::spy() { - local command=$1 - local exit_code_or_impl="${2:-}" - local variable - variable="$(bashunit::helper::normalize_variable_name "$command")" - - local times_file params_file - local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" - times_file=$(bashunit::temp_file "${test_id}_${variable}_times") - params_file=$(bashunit::temp_file "${test_id}_${variable}_params") - echo 0 >"$times_file" - : >"$params_file" - export "${variable}_times_file"="$times_file" - export "${variable}_params_file"="$params_file" - - local body_suffix="" - if [[ "$exit_code_or_impl" =~ ^[0-9]+$ ]]; then - body_suffix="return $exit_code_or_impl" - elif [ -n "$exit_code_or_impl" ]; then - body_suffix="$exit_code_or_impl \"\$@\"" + local __bu_command=$1 + local __bu_exit_code_or_impl="${2:-}" + local __bu_variable + __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" + + local __bu_times_file __bu_params_file + local __bu_test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" + __bu_times_file=$(bashunit::temp_file "${__bu_test_id}_${__bu_variable}_times") + __bu_params_file=$(bashunit::temp_file "${__bu_test_id}_${__bu_variable}_params") + echo 0 >"$__bu_times_file" + : >"$__bu_params_file" + export "${__bu_variable}_times_file"="$__bu_times_file" + export "${__bu_variable}_params_file"="$__bu_params_file" + + local __bu_body_suffix="" + if [[ "$__bu_exit_code_or_impl" =~ ^[0-9]+$ ]]; then + __bu_body_suffix="return $__bu_exit_code_or_impl" + elif [ -n "$__bu_exit_code_or_impl" ]; then + __bu_body_suffix="$__bu_exit_code_or_impl \"\$@\"" fi - eval "function $command() { + eval "function $__bu_command() { local raw=\"\$*\" local serialized=\"\" local arg @@ -72,17 +72,17 @@ function bashunit::spy() { serialized=\"\$serialized\$(builtin printf '%q' \"\$arg\")$'\\x1f'\" done serialized=\${serialized%$'\\x1f'} - builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' + builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$__bu_params_file' local _c - _c=\$(cat '$times_file' 2>/dev/null || builtin echo 0) + _c=\$(cat '$__bu_times_file' 2>/dev/null || builtin echo 0) _c=\$((_c+1)) - builtin echo \"\$_c\" > '$times_file' - $body_suffix + builtin echo \"\$_c\" > '$__bu_times_file' + $__bu_body_suffix }" - export -f "${command?}" + export -f "${__bu_command?}" - _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$__bu_command" } function assert_have_been_called() { From e139f252c19687e39fa80032712aec640e23ce08 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 12 May 2026 12:07:37 +0200 Subject: [PATCH 3/4] docs(changelog): simplify Unreleased entries --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee90b1a6..ef11db91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,14 @@ ## Unreleased ### Internal -- Codify dynamic-scope safety rule for outvar helpers (`__bu_` prefix on internal locals) and apply it defensively across `src/test_doubles.sh`, with regression tests (#674) +- Codify outvar `__bu_` prefix rule; apply across `test_doubles.sh` (#674) ### Performance -- Faster runtime-error detection: single `case` glob instead of 23-iteration loop in `detect_runtime_error` (#668) -- Hot-path coverage flag now cached in `_BASHUNIT_COVERAGE_ON`, removing a function dispatch per call (#664) -- Parallel runner blocks on `wait -n` on Bash 4.3+ instead of polling `jobs -r`, removing sleep-induced slot-release latency (#667) -- Hot-path result helpers (`extract_encoded_field`, `extract_subshell_type`, `format_subshell_output`, `compute_total_assertions`) use outvar pattern, dropping a fork per call per test (#662) -- `generate_id` and `normalize_variable_name` drop `grep` and `random_str` forks via pure-bash globbing/inlining (#663) +- Faster runtime-error detection: single `case` glob (#668) +- Cache coverage-enabled flag in hot path (#664) +- Parallel runner uses `wait -n` on Bash 4.3+ instead of polling (#667) +- Outvar pattern in hot-path result helpers, dropping a fork per call (#662) +- Drop `grep`/`random_str` forks in `generate_id` and `normalize_variable_name` (#663) ## [0.36.0](https://github.com/TypedDevs/bashunit/compare/0.35.0...0.36.0) - 2026-05-07 From 241263d9bf77c87d93480515bbc4adb1227fdffd Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 12 May 2026 12:39:28 +0200 Subject: [PATCH 4/4] ref(runner): switch hot-path helpers to global-slot return pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the eval-based outvar pattern (and its mandatory __bu_ prefix on internal locals) in favor of dedicated global return slots, one per helper: _BASHUNIT_RUNNER_FIELD_OUT (extract_encoded_field) _BASHUNIT_RUNNER_TOTAL_OUT (compute_total_assertions) _BASHUNIT_RUNNER_TYPE_OUT (extract_subshell_type) _BASHUNIT_RUNNER_OUTPUT_OUT (format_subshell_output) Same perf as outvar (no $(...) fork per call) but eliminates the ergonomic cost of __bu_-prefixed internal locals and removes the dynamic scoping pitfall entirely — the helper never touches caller-named variables. For test_doubles.sh the real fix wasn't on the helper's locals at all: spy state is keyed by command name and stored via 'export NAME=value' where NAME is constructed from the command. The fix is to namespace the constructed name itself under _BASHUNIT_SPY_*, so a caller doing 'local foo_times_file=...' cannot collide with the helper's _BASHUNIT_SPY_foo_TIMES_FILE. All test_doubles.sh internal locals are restored to natural names. The bash-style.md rule is rewritten around three patterns: 1. Preferred: dedicated global return slot 2. For helpers that build dynamic names: namespace the constructed name 3. Last-resort outvar-by-name (requires __bu_ prefix + regression test) Bash 3.0+ compatibility preserved. --- .claude/rules/bash-style.md | 99 +++++++++------- CHANGELOG.md | 2 +- src/runner.sh | 130 ++++++++++---------- src/test_doubles.sh | 229 ++++++++++++++++++------------------ tests/unit/runner_test.sh | 74 ++++++------ 5 files changed, 277 insertions(+), 257 deletions(-) diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index 97b8a22c..2976e4e3 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -49,70 +49,85 @@ Constants -> Globals -> Private functions -> Public functions Source deps relative to script: `"$(dirname "${BASH_SOURCE[0]}")/dep.sh"` -## Outvar helpers (dynamic-scope safety) +## Returning values from a helper (dynamic-scope safety) Bash `local` is **dynamically scoped**, not lexically scoped. A `local` inside a callee -shadows the caller's same-named variable for the duration of the call. Any helper that -writes back to a caller-named variable (the **outvar pattern**) must defend against this -or it will silently corrupt callers that pick a "natural" name. +shadows the caller's same-named variable for the duration of the call. Same trap fires +with `${!name}` reads and `eval "$name=..."`/`printf -v "$name"`/`export "$name"=...` +writes — they all resolve against the dynamic scope. -### Rule +We need **all three** properties on the hot path: -Helpers that take a caller-named variable as an outvar (typically the first argument) -**MUST prefix every internal local with `__bu_`** so no caller-passed name can collide. +1. **No subshell fork.** Returning via stdout and capturing with `$(...)` costs a fork + per call, which dominates per-test cost. +2. **No collision with caller locals.** A helper that silently overwrites or reads + the wrong variable is the worst kind of bug — no error, just stale data. +3. **Bash 3.0+ portable.** Rules out `declare -n` (4.3+) and `printf -v` (3.1+, and + it has the same dynamic-scope shadowing as `eval "$name=..."` anyway). -### Why - -Without the prefix, this fails silently: +### Preferred: dedicated global return slot ```bash -without_prefix() { - local _out=$1 - local subshell_output=$2 # LOCAL shadows caller's same-named var - local line="formatted" - eval "$_out=\$line" # assigns to the LOCAL, not the caller -} +_BASHUNIT_PKG_THING_OUT="" -main() { - local subshell_output="raw" - without_prefix subshell_output "$subshell_output" - echo "$subshell_output" # prints "raw" — the helper appeared to succeed +# Writes the result into _BASHUNIT_PKG_THING_OUT. +# Arguments: $1 input +function bashunit::pkg::do_thing() { + local input=$1 # natural name, no prefix needed + local val="${input%%]*}" + val="${val#[}" + _BASHUNIT_PKG_THING_OUT=$val } + +# Caller +bashunit::pkg::do_thing "$payload" +local thing=$_BASHUNIT_PKG_THING_OUT +``` + +- Zero forks per call. +- Helper has natural local names; no `__bu_` noise. +- Caller cannot accidentally shadow the slot because the `_BASHUNIT_*` namespace + is reserved for the framework. +- A dedicated slot per helper (rather than one shared `_BASHUNIT_OUT`) means + adjacent or nested calls can't clobber each other. Cheap: globals are free. + +Examples in tree: `src/runner.sh` (`_BASHUNIT_RUNNER_FIELD_OUT`, +`_BASHUNIT_RUNNER_TOTAL_OUT`, `_BASHUNIT_RUNNER_TYPE_OUT`, `_BASHUNIT_RUNNER_OUTPUT_OUT`), +`src/coverage.sh` (`_BASHUNIT_BRANCH_ARMS_OUT`). + +### When the helper builds dynamic variable names (mock/spy state) + +Use a clear namespace prefix on the **constructed** name, not on the helper's locals: + +```bash +# Spy state lives in _BASHUNIT_SPY_${variable}_TIMES_FILE, not ${variable}_times_file. +# A caller doing `local foo_times_file=...` is therefore harmless: the helper +# resolves a different global. +export "_BASHUNIT_SPY_${variable}_TIMES_FILE"="$times_file" ``` -The caller's variable is untouched, no error is raised, and tests downstream silently -get stale data. This regression bit us in #662 (12 parallel test failures, fixed in -PR #672) before the prefix was applied. +Example in tree: `src/test_doubles.sh` (`_BASHUNIT_SPY_*`). + +### Last-resort fallback: outvar by name + `__bu_` prefix on locals -### Pattern +Only use this when neither a fixed return slot nor a namespaced constructed name is +practical (e.g. a generic helper called from many call sites with different output +variables and no shared global is appropriate): ```bash -# Writes into the named outvar. -# Arguments: $1 outvar name, $2 input function bashunit::pkg::do_thing() { local __bu_out=$1 local __bu_in=$2 local __bu_val="${__bu_in%%]*}" - __bu_val="${__bu_val#[}" eval "$__bu_out=\$__bu_val" } ``` -- Use `eval "$__bu_out=\$__bu_val"` for the assignment. Quote the `$` of the value with - a backslash so only the outvar name is expanded at eval time. -- Do **not** use `printf -v` (Bash 3.0 lacks it) or `declare -n` (Bash 4.3+). -- A regression test should pass the helper's own local names as outvar to catch any - contributor who later drops the prefix. - -### Where the convention applies in this repo - -- All four hot-path outvar helpers in `src/runner.sh`: `extract_encoded_field`, - `extract_subshell_type`, `format_subshell_output`, `compute_total_assertions`. -- All helpers in `src/test_doubles.sh` that touch caller-named or caller-constructed - variables: `bashunit::mock`, `bashunit::spy`, `bashunit::unmock`, plus the - `assert_have_been_called*` family. Each takes a command name and either `export`s - derived globals or reads them via `${!file_var}`; both directions can collide with a - caller local of the same constructed name, so internal locals are `__bu_`-prefixed. +- All internal locals MUST be `__bu_`-prefixed; otherwise dynamic-scope shadowing + silently breaks the caller's outvar (see PR #672 — that exact bug caused 12 parallel + test failures in #662). +- Include a regression test that calls the helper with one of its own documented + internal names as the outvar. ### Intentional dynamic-scope mutation is a separate pattern diff --git a/CHANGELOG.md b/CHANGELOG.md index ef11db91..9588fac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Internal -- Codify outvar `__bu_` prefix rule; apply across `test_doubles.sh` (#674) +- Codify global-slot return pattern for hot-path helpers; namespace mock/spy state under `_BASHUNIT_SPY_*` (#674) ### Performance - Faster runtime-error detection: single `case` glob (#668) diff --git a/src/runner.sh b/src/runner.sh index dc071561..b65eff41 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -57,71 +57,76 @@ function bashunit::runner::apply_interpolated_title() { printf '%s' "$interpolated" } -# All four helpers below use the outvar pattern (first argument is the name of -# the variable to assign into) so callers can avoid the per-test $(...) subshell -# capture in the hot path. Internal locals use a `__bu_` prefix to avoid name -# collisions with caller variables passed by name. - -# Writes the value of an encoded field (##KEY=value##) into the named outvar. -# Arguments: $1 outvar name, $2 test_execution_result, $3 key +# Hot-path result helpers below return their value via a dedicated global slot +# (`_BASHUNIT_RUNNER_*_OUT`) instead of stdout. This avoids the per-test +# `$(...)` subshell capture that dominated the result-parsing hot path. Callers +# invoke the helper and immediately read the slot: +# +# bashunit::runner::extract_subshell_type "$subshell_output" +# type=$_BASHUNIT_RUNNER_TYPE_OUT +# +# A dedicated slot per helper (rather than one shared slot) means nested or +# adjacent calls cannot clobber each other and callers don't need to copy out +# before every other helper runs. +_BASHUNIT_RUNNER_FIELD_OUT="" +_BASHUNIT_RUNNER_TOTAL_OUT="" +_BASHUNIT_RUNNER_TYPE_OUT="" +_BASHUNIT_RUNNER_OUTPUT_OUT="" + +# Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT. +# Arguments: $1 test_execution_result, $2 key function bashunit::runner::extract_encoded_field() { - local __bu_out=$1 - local __bu_in=$2 - local __bu_key=$3 - local __bu_marker="##${__bu_key}=" - local __bu_val="" - case "$__bu_in" in - *"$__bu_marker"*) - local __bu_rest="${__bu_in#*"$__bu_marker"}" - __bu_val="${__bu_rest%%##*}" + local test_execution_result=$1 + local key=$2 + local marker="##${key}=" + case "$test_execution_result" in + *"$marker"*) + local rest="${test_execution_result#*"$marker"}" + _BASHUNIT_RUNNER_FIELD_OUT="${rest%%##*}" ;; + *) _BASHUNIT_RUNNER_FIELD_OUT="" ;; esac - eval "$__bu_out=\$__bu_val" } -# Writes the sum of all ASSERTIONS_* counters into the named outvar. -# Arguments: $1 outvar name, $2 test_execution_result +# Writes the sum of all ASSERTIONS_* counters into _BASHUNIT_RUNNER_TOTAL_OUT. +# Arguments: $1 test_execution_result function bashunit::runner::compute_total_assertions() { - local __bu_out=$1 - local __bu_in=$2 - local __bu_failed __bu_passed __bu_skipped __bu_incomplete __bu_snapshot - __bu_failed="${__bu_in##*##ASSERTIONS_FAILED=}" - __bu_failed="${__bu_failed%%##*}" - __bu_passed="${__bu_in##*##ASSERTIONS_PASSED=}" - __bu_passed="${__bu_passed%%##*}" - __bu_skipped="${__bu_in##*##ASSERTIONS_SKIPPED=}" - __bu_skipped="${__bu_skipped%%##*}" - __bu_incomplete="${__bu_in##*##ASSERTIONS_INCOMPLETE=}" - __bu_incomplete="${__bu_incomplete%%##*}" - __bu_snapshot="${__bu_in##*##ASSERTIONS_SNAPSHOT=}" - __bu_snapshot="${__bu_snapshot%%##*}" - local __bu_val - __bu_val=$((${__bu_failed:-0} + ${__bu_passed:-0} + ${__bu_skipped:-0})) - __bu_val=$((__bu_val + ${__bu_incomplete:-0} + ${__bu_snapshot:-0})) - eval "$__bu_out=\$__bu_val" + local test_execution_result=$1 + local failed passed skipped incomplete snapshot + failed="${test_execution_result##*##ASSERTIONS_FAILED=}" + failed="${failed%%##*}" + passed="${test_execution_result##*##ASSERTIONS_PASSED=}" + passed="${passed%%##*}" + skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}" + skipped="${skipped%%##*}" + incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}" + incomplete="${incomplete%%##*}" + snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}" + snapshot="${snapshot%%##*}" + local total + total=$((${failed:-0} + ${passed:-0} + ${skipped:-0})) + total=$((total + ${incomplete:-0} + ${snapshot:-0})) + _BASHUNIT_RUNNER_TOTAL_OUT=$total } -# Writes the subshell type marker (text inside leading [...]) into the named outvar. -# Arguments: $1 outvar name, $2 subshell_output +# Writes the subshell type marker (text inside leading [...]) into _BASHUNIT_RUNNER_TYPE_OUT. +# Arguments: $1 subshell_output function bashunit::runner::extract_subshell_type() { - local __bu_out=$1 - local __bu_in=$2 - local __bu_val="${__bu_in%%]*}" - __bu_val="${__bu_val#[}" - eval "$__bu_out=\$__bu_val" + local subshell_output=$1 + local type="${subshell_output%%]*}" + _BASHUNIT_RUNNER_TYPE_OUT="${type#[}" } # Writes the subshell output (minus the leading [type] marker, with embedded -# status markers replaced by newlines) into the named outvar. -# Arguments: $1 outvar name, $2 subshell_output +# status markers replaced by newlines) into _BASHUNIT_RUNNER_OUTPUT_OUT. +# Arguments: $1 subshell_output function bashunit::runner::format_subshell_output() { - local __bu_out=$1 - local __bu_in=$2 - local __bu_val="${__bu_in#*]}" - __bu_val=${__bu_val//\[failed\]/$'\n'} - __bu_val=${__bu_val//\[skipped\]/$'\n'} - __bu_val=${__bu_val//\[incomplete\]/$'\n'} - eval "$__bu_out=\$__bu_val" + local subshell_output=$1 + local line="${subshell_output#*]}" + line=${line//\[failed\]/$'\n'} + line=${line//\[skipped\]/$'\n'} + line=${line//\[incomplete\]/$'\n'} + _BASHUNIT_RUNNER_OUTPUT_OUT=$line } function bashunit::runner::detect_runtime_error() { @@ -837,9 +842,10 @@ function bashunit::runner::run_test() { local subshell_output=$(bashunit::runner::decode_subshell_output "$test_execution_result") if [ -n "$subshell_output" ]; then - local type - bashunit::runner::extract_subshell_type type "$subshell_output" - bashunit::runner::format_subshell_output subshell_output "$subshell_output" + bashunit::runner::extract_subshell_type "$subshell_output" + local type=$_BASHUNIT_RUNNER_TYPE_OUT + bashunit::runner::format_subshell_output "$subshell_output" + subshell_output=$_BASHUNIT_RUNNER_OUTPUT_OUT if ! bashunit::env::is_failures_only_enabled; then bashunit::state::print_line "$type" "$subshell_output" fi @@ -854,13 +860,15 @@ function bashunit::runner::run_test() { local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE" - local total_assertions - bashunit::runner::compute_total_assertions total_assertions "$test_execution_result" + bashunit::runner::compute_total_assertions "$test_execution_result" + local total_assertions=$_BASHUNIT_RUNNER_TOTAL_OUT - local encoded_test_title hook_failure encoded_hook_message - bashunit::runner::extract_encoded_field encoded_test_title "$test_execution_result" "TEST_TITLE" - bashunit::runner::extract_encoded_field hook_failure "$test_execution_result" "TEST_HOOK_FAILURE" - bashunit::runner::extract_encoded_field encoded_hook_message "$test_execution_result" "TEST_HOOK_MESSAGE" + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" + local encoded_test_title=$_BASHUNIT_RUNNER_FIELD_OUT + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_FAILURE" + local hook_failure=$_BASHUNIT_RUNNER_FIELD_OUT + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_MESSAGE" + local encoded_hook_message=$_BASHUNIT_RUNNER_FIELD_OUT local test_title="" [ -n "$encoded_test_title" ] && test_title="$(bashunit::helper::decode_base64 "$encoded_test_title")" diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 44f4e11f..e9f82b7b 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -2,69 +2,74 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=() +# Spy/mock state is keyed by the sanitized command name and stored in globals +# prefixed with `_BASHUNIT_SPY_` so they cannot collide with caller locals. +# A test that does `local foo_times_file=...` is harmless because the helper +# resolves `_BASHUNIT_SPY_foo_TIMES_FILE` instead. + function bashunit::unmock() { - local __bu_command=$1 + local command=$1 if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then return fi - local __bu_i - for __bu_i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do - if [ "${_BASHUNIT_MOCKED_FUNCTIONS[$__bu_i]:-}" = "$__bu_command" ]; then - unset "_BASHUNIT_MOCKED_FUNCTIONS[$__bu_i]" - unset -f "$__bu_command" - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - local __bu_times_file_var="${__bu_variable}_times_file" - local __bu_params_file_var="${__bu_variable}_params_file" - [ -f "${!__bu_times_file_var-}" ] && rm -f "${!__bu_times_file_var}" - [ -f "${!__bu_params_file_var-}" ] && rm -f "${!__bu_params_file_var}" - unset "$__bu_times_file_var" - unset "$__bu_params_file_var" + local i + for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do + if [ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" = "$command" ]; then + unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" + unset -f "$command" + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local times_file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" + local params_file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" + [ -f "${!times_file_var-}" ] && rm -f "${!times_file_var}" + [ -f "${!params_file_var-}" ] && rm -f "${!params_file_var}" + unset "$times_file_var" + unset "$params_file_var" break fi done } function bashunit::mock() { - local __bu_command=$1 + local command=$1 shift if [ $# -gt 0 ]; then - eval "function $__bu_command() { $* \"\$@\"; }" + eval "function $command() { $* \"\$@\"; }" else - eval "function $__bu_command() { builtin echo \"$($CAT)\" ; }" + eval "function $command() { builtin echo \"$($CAT)\" ; }" fi - export -f "${__bu_command?}" + export -f "${command?}" - _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$__bu_command" + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function bashunit::spy() { - local __bu_command=$1 - local __bu_exit_code_or_impl="${2:-}" - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - - local __bu_times_file __bu_params_file - local __bu_test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" - __bu_times_file=$(bashunit::temp_file "${__bu_test_id}_${__bu_variable}_times") - __bu_params_file=$(bashunit::temp_file "${__bu_test_id}_${__bu_variable}_params") - echo 0 >"$__bu_times_file" - : >"$__bu_params_file" - export "${__bu_variable}_times_file"="$__bu_times_file" - export "${__bu_variable}_params_file"="$__bu_params_file" - - local __bu_body_suffix="" - if [[ "$__bu_exit_code_or_impl" =~ ^[0-9]+$ ]]; then - __bu_body_suffix="return $__bu_exit_code_or_impl" - elif [ -n "$__bu_exit_code_or_impl" ]; then - __bu_body_suffix="$__bu_exit_code_or_impl \"\$@\"" + local command=$1 + local exit_code_or_impl="${2:-}" + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + + local times_file params_file + local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" + times_file=$(bashunit::temp_file "${test_id}_${variable}_times") + params_file=$(bashunit::temp_file "${test_id}_${variable}_params") + echo 0 >"$times_file" + : >"$params_file" + export "_BASHUNIT_SPY_${variable}_TIMES_FILE"="$times_file" + export "_BASHUNIT_SPY_${variable}_PARAMS_FILE"="$params_file" + + local body_suffix="" + if [[ "$exit_code_or_impl" =~ ^[0-9]+$ ]]; then + body_suffix="return $exit_code_or_impl" + elif [ -n "$exit_code_or_impl" ]; then + body_suffix="$exit_code_or_impl \"\$@\"" fi - eval "function $__bu_command() { + eval "function $command() { local raw=\"\$*\" local serialized=\"\" local arg @@ -72,33 +77,33 @@ function bashunit::spy() { serialized=\"\$serialized\$(builtin printf '%q' \"\$arg\")$'\\x1f'\" done serialized=\${serialized%$'\\x1f'} - builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$__bu_params_file' + builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' local _c - _c=\$(cat '$__bu_times_file' 2>/dev/null || builtin echo 0) + _c=\$(cat '$times_file' 2>/dev/null || builtin echo 0) _c=\$((_c+1)) - builtin echo \"\$_c\" > '$__bu_times_file' - $__bu_body_suffix + builtin echo \"\$_c\" > '$times_file' + $body_suffix }" - export -f "${__bu_command?}" + export -f "${command?}" - _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$__bu_command" + _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function assert_have_been_called() { - local __bu_command=$1 - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - local __bu_file_var="${__bu_variable}_times_file" - local __bu_times=0 - if [ -f "${!__bu_file_var-}" ]; then - __bu_times=$(cat "${!__bu_file_var}" 2>/dev/null || builtin echo 0) + local command=$1 + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" + local times=0 + if [ -f "${!file_var-}" ]; then + times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) fi - local __bu_label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - if [ "$__bu_times" -eq 0 ]; then + if [ "$times" -eq 0 ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${__bu_label}" "${__bu_command}" "to have been called" "once" + bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once" return fi @@ -106,36 +111,36 @@ function assert_have_been_called() { } function assert_have_been_called_with() { - local __bu_command=$1 + local command=$1 shift - local __bu_index="" + local index="" if [ "$(echo "${!#}" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then - __bu_index=${!#} + index=${!#} set -- "${@:1:$#-1}" fi - local __bu_expected="$*" + local expected="$*" - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - local __bu_file_var="${__bu_variable}_params_file" - local __bu_line="" - if [ -f "${!__bu_file_var-}" ]; then - if [ -n "$__bu_index" ]; then - __bu_line=$(sed -n "${__bu_index}p" "${!__bu_file_var}" 2>/dev/null || true) + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" + local line="" + if [ -f "${!file_var-}" ]; then + if [ -n "$index" ]; then + line=$(sed -n "${index}p" "${!file_var}" 2>/dev/null || true) else - __bu_line=$(tail -n 1 "${!__bu_file_var}" 2>/dev/null || true) + line=$(tail -n 1 "${!file_var}" 2>/dev/null || true) fi fi - local __bu_raw - IFS=$'\x1e' read -r __bu_raw _ <<<"$__bu_line" || true + local raw + IFS=$'\x1e' read -r raw _ <<<"$line" || true - if [ "$__bu_expected" != "$__bu_raw" ]; then + if [ "$expected" != "$raw" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \ - "${FUNCNAME[1]}")" "$__bu_expected" "but got " "$__bu_raw" + "${FUNCNAME[1]}")" "$expected" "but got " "$raw" return fi @@ -143,21 +148,21 @@ function assert_have_been_called_with() { } function assert_have_been_called_times() { - local __bu_expected_count=$1 - local __bu_command=$2 - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - local __bu_file_var="${__bu_variable}_times_file" - local __bu_times=0 - if [ -f "${!__bu_file_var-}" ]; then - __bu_times=$(cat "${!__bu_file_var}" 2>/dev/null || builtin echo 0) + local expected_count=$1 + local command=$2 + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" + local times=0 + if [ -f "${!file_var-}" ]; then + times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) fi - local __bu_label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - if [ "$__bu_times" -ne "$__bu_expected_count" ]; then + local label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + if [ "$times" -ne "$expected_count" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${__bu_label}" "${__bu_command}" \ - "to have been called" "${__bu_expected_count} times" \ - "actual" "${__bu_times} times" + bashunit::console_results::print_failed_test "${label}" "${command}" \ + "to have been called" "${expected_count} times" \ + "actual" "${times} times" return fi @@ -165,42 +170,42 @@ function assert_have_been_called_times() { } function assert_have_been_called_nth_with() { - local __bu_nth=$1 - local __bu_command=$2 + local nth=$1 + local command=$2 shift 2 - local __bu_expected="$*" - - local __bu_variable - __bu_variable="$(bashunit::helper::normalize_variable_name "$__bu_command")" - local __bu_times_file_var="${__bu_variable}_times_file" - local __bu_file_var="${__bu_variable}_params_file" - local __bu_label - __bu_label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" - - local __bu_times=0 - if [ -f "${!__bu_times_file_var-}" ]; then - __bu_times=$(cat "${!__bu_times_file_var}" 2>/dev/null || builtin echo 0) + local expected="$*" + + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local times_file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" + local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" + local label + label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" + + local times=0 + if [ -f "${!times_file_var-}" ]; then + times=$(cat "${!times_file_var}" 2>/dev/null || builtin echo 0) fi - if [ "$__bu_nth" -gt "$__bu_times" ]; then + if [ "$nth" -gt "$times" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${__bu_label}" \ - "expected call" "at index ${__bu_nth} but" "only called ${__bu_times} times" + bashunit::console_results::print_failed_test "${label}" \ + "expected call" "at index ${nth} but" "only called ${times} times" return fi - local __bu_line="" - if [ -f "${!__bu_file_var-}" ]; then - __bu_line=$(sed -n "${__bu_nth}p" "${!__bu_file_var}" 2>/dev/null || true) + local line="" + if [ -f "${!file_var-}" ]; then + line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true) fi - local __bu_raw - IFS=$'\x1e' read -r __bu_raw _ <<<"$__bu_line" || true + local raw + IFS=$'\x1e' read -r raw _ <<<"$line" || true - if [ "$__bu_expected" != "$__bu_raw" ]; then + if [ "$expected" != "$raw" ]; then bashunit::state::add_assertions_failed - bashunit::console_results::print_failed_test "${__bu_label}" \ - "$__bu_expected" "but got " "$__bu_raw" + bashunit::console_results::print_failed_test "${label}" \ + "$expected" "but got " "$raw" return fi @@ -208,7 +213,7 @@ function assert_have_been_called_nth_with() { } function assert_not_called() { - local __bu_command=$1 - local __bu_label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" - assert_have_been_called_times 0 "$__bu_command" "$__bu_label" + local command=$1 + local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" + assert_have_been_called_times 0 "$command" "$label" } diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index d01d8d95..e75f8ed9 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -128,87 +128,79 @@ function test_detect_runtime_error_matches_unexpected_eof() { assert_same "line 5: unexpected EOF while looking for matching" "$actual" } -function test_extract_encoded_field_writes_value_to_outvar() { - local out="" - bashunit::runner::extract_encoded_field out \ +function test_extract_encoded_field_writes_value_to_slot() { + bashunit::runner::extract_encoded_field \ "preamble##TEST_TITLE=hello world##ASSERTIONS_PASSED=1" "TEST_TITLE" - assert_same "hello world" "$out" + assert_same "hello world" "$_BASHUNIT_RUNNER_FIELD_OUT" } function test_extract_encoded_field_writes_empty_when_key_missing() { - local out="prior" - bashunit::runner::extract_encoded_field out "##ASSERTIONS_PASSED=1" "TEST_TITLE" + _BASHUNIT_RUNNER_FIELD_OUT="prior" + bashunit::runner::extract_encoded_field "##ASSERTIONS_PASSED=1" "TEST_TITLE" - assert_empty "$out" + assert_empty "$_BASHUNIT_RUNNER_FIELD_OUT" } -function test_compute_total_assertions_sums_into_outvar() { - local out="" - bashunit::runner::compute_total_assertions out \ +function test_compute_total_assertions_sums_into_slot() { + bashunit::runner::compute_total_assertions \ "##ASSERTIONS_FAILED=1##ASSERTIONS_PASSED=2##ASSERTIONS_SKIPPED=3##ASSERTIONS_INCOMPLETE=4##ASSERTIONS_SNAPSHOT=5" - assert_same "15" "$out" + assert_same "15" "$_BASHUNIT_RUNNER_TOTAL_OUT" } function test_compute_total_assertions_treats_missing_counters_as_zero() { - local out="" - bashunit::runner::compute_total_assertions out "##ASSERTIONS_PASSED=2" + bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2" - assert_same "2" "$out" + assert_same "2" "$_BASHUNIT_RUNNER_TOTAL_OUT" } -function test_extract_subshell_type_strips_brackets_into_outvar() { - local out="" - bashunit::runner::extract_subshell_type out "[failed] something happened" +function test_extract_subshell_type_strips_brackets_into_slot() { + bashunit::runner::extract_subshell_type "[failed] something happened" - assert_same "failed" "$out" + assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" } function test_format_subshell_output_strips_type_and_expands_markers() { - local out="" - bashunit::runner::format_subshell_output out "[failed] line1[skipped]line2[incomplete]line3" + bashunit::runner::format_subshell_output "[failed] line1[skipped]line2[incomplete]line3" local expected expected=$' line1\nline2\nline3' - assert_same "$expected" "$out" + assert_same "$expected" "$_BASHUNIT_RUNNER_OUTPUT_OUT" } -# Regression for #674: helpers that take a caller-named outvar MUST not be -# corrupted when the caller's variable shares a name with one of the helper's -# internal locals. Bash uses dynamic scoping for `local`, so without the -# `__bu_` prefix on internal names the eval assignment would hit the local and -# the caller's variable would silently keep its old value. -function test_format_subshell_output_does_not_shadow_caller_named_subshell_output() { +# Regression for #674: caller-named locals must not be silently corrupted by +# the helpers. With the global-slot return pattern the helper never touches +# caller-named variables, so a caller can freely use natural names (e.g. +# `subshell_output`, `test_execution_result`) without any shadowing risk. +function test_format_subshell_output_does_not_touch_caller_locals() { local subshell_output="raw" - bashunit::runner::format_subshell_output subshell_output "[failed] formatted" + bashunit::runner::format_subshell_output "[failed] formatted" - assert_same " formatted" "$subshell_output" + assert_same " formatted" "$_BASHUNIT_RUNNER_OUTPUT_OUT" + assert_same "raw" "$subshell_output" } -function test_extract_subshell_type_does_not_shadow_caller_named_subshell_output() { +function test_extract_subshell_type_does_not_touch_caller_locals() { local subshell_output="[failed] payload" - local type - bashunit::runner::extract_subshell_type type "$subshell_output" + bashunit::runner::extract_subshell_type "$subshell_output" - assert_same "failed" "$type" + assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" assert_same "[failed] payload" "$subshell_output" } -function test_extract_encoded_field_does_not_shadow_caller_named_test_execution_result() { +function test_extract_encoded_field_does_not_touch_caller_locals() { local test_execution_result="##TEST_TITLE=hi##ASSERTIONS_PASSED=1" - local out - bashunit::runner::extract_encoded_field out "$test_execution_result" "TEST_TITLE" + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" - assert_same "hi" "$out" + assert_same "hi" "$_BASHUNIT_RUNNER_FIELD_OUT" assert_same "##TEST_TITLE=hi##ASSERTIONS_PASSED=1" "$test_execution_result" } -function test_compute_total_assertions_does_not_shadow_caller_named_test_execution_result() { +function test_compute_total_assertions_does_not_touch_caller_locals() { local test_execution_result="##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" - local total - bashunit::runner::compute_total_assertions total "$test_execution_result" + bashunit::runner::compute_total_assertions "$test_execution_result" - assert_same "5" "$total" + assert_same "5" "$_BASHUNIT_RUNNER_TOTAL_OUT" assert_same "##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" "$test_execution_result" }