From 5dcb7ece6be4d0ff8d34f009e46caa7c77660206 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:19:41 +0200 Subject: [PATCH 01/18] feat(coverage): emit function records in LCOV and optional text summary Adds FN/FNDA/FNF/FNH function records to the LCOV report (consumed by genhtml, Codecov, Coveralls). Adds an opt-in per-function summary to the text report, gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep the default output compact. --- src/coverage.sh | 106 ++++++++++++++++++++++++++ tests/unit/coverage_reporting_test.sh | 93 ++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/coverage.sh b/src/coverage.sh index d3786007..a7d88ea7 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -903,6 +903,11 @@ function bashunit::coverage::report_text() { printf "%sTotal: %d/%d (%d%%)%s\n" \ "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" + # Optional per-function summary (gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS) + if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then + bashunit::coverage::report_text_functions + fi + # Show report location if generated if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then echo "" @@ -910,6 +915,71 @@ function bashunit::coverage::report_text() { fi } +# Per-function coverage summary printed after the file table. +# Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. +function bashunit::coverage::report_text_functions() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local functions_data + functions_data=$(bashunit::coverage::extract_functions "$file") + [ -z "$functions_data" ] && continue + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + local -a file_lines=() + local _fli=0 _fl + while IFS= read -r _fl || [ -n "$_fl" ]; do + file_lines[_fli]="$_fl" + ((++_fli)) + done <"$file" + + local display_file="${file#"$(pwd)"/}" + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Functions" + echo "---------" + printed_header=true + fi + echo "${display_file}" + + local fn_entry + while IFS= read -r fn_entry; do + [ -z "$fn_entry" ] && continue + local fn_name fn_start fn_end fn_rest + fn_name="${fn_entry%%|*}" + fn_rest="${fn_entry#*|}" + fn_start="${fn_rest%%|*}" + fn_end="${fn_rest#*|}" + + local fn_executable=0 fn_hit=0 ln + for ((ln = fn_start; ln <= fn_end; ln++)); do + local ln_content="${file_lines[$((ln - 1))]:-}" + if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then + fn_executable=$((fn_executable + 1)) + local ln_hits=${hits_by_line[$ln]:-0} + [ "$ln_hits" -gt 0 ] && fn_hit=$((fn_hit + 1)) + fi + done + + local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" + fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") + fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") + color=$(bashunit::coverage::get_color_for_class "$fn_class") + + printf " %s%-38s %3d/%3d lines (%3d%%)%s\n" \ + "$color" "$fn_name" "$fn_hit" "$fn_executable" "$fn_pct" "$reset" + done <<<"$functions_data" + done < <(bashunit::coverage::get_tracked_files) +} + function bashunit::coverage::report_lcov() { local output_file="${1:-$BASHUNIT_COVERAGE_REPORT}" @@ -935,6 +1005,42 @@ function bashunit::coverage::report_lcov() { [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count done < <(bashunit::coverage::get_all_line_hits "$file") + # Function records (FN/FNDA/FNF/FNH) + local fn_total=0 fn_hit=0 + local fn_entry fn_name fn_start fn_end fn_rest + local -a fn_dn_records=() + local _fdi=0 + while IFS= read -r fn_entry; do + [ -z "$fn_entry" ] && continue + fn_name="${fn_entry%%|*}" + fn_rest="${fn_entry#*|}" + fn_start="${fn_rest%%|*}" + fn_end="${fn_rest#*|}" + echo "FN:${fn_start},${fn_name}" + fn_total=$((fn_total + 1)) + + # Function is "hit" if any executable line in its range has hits + local fln any_hit=0 + for ((fln = fn_start; fln <= fn_end; fln++)); do + local fc="${hits_by_line[$fln]:-0}" + if [ "$fc" -gt 0 ]; then + any_hit=1 + break + fi + done + fn_dn_records[_fdi]="FNDA:${any_hit},${fn_name}" + _fdi=$((_fdi + 1)) + [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) + done < <(bashunit::coverage::extract_functions "$file") + + # Emit FNDA records grouped after FN records (per LCOV convention) + local fda + for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do + echo "$fda" + done + echo "FNF:$fn_total" + echo "FNH:$fn_hit" + local lineno=0 executable=0 hit=0 line line_hits local -a lcov_lines=() local _lli=0 _ll diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 76957e5d..df6a6c01 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -395,3 +395,96 @@ EOF rm -f "$temp_file" } + +function test_coverage_report_lcov_includes_function_records() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function alpha() { + echo "in alpha" +} +function beta() { + echo "in beta" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + # Hit alpha body (line 3) only; beta body (line 6) not hit + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_contains "FN:2,alpha" "$content" + assert_contains "FN:5,beta" "$content" + assert_contains "FNDA:1,alpha" "$content" + assert_contains "FNDA:0,beta" "$content" + assert_contains "FNF:2" "$content" + assert_contains "FNH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_text_includes_function_summary_when_enabled() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_FUNCTIONS="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function alpha() { + echo "alpha" +} +function beta() { + echo "beta" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_contains "alpha" "$output" + assert_contains "beta" "$output" + assert_contains "Functions" "$output" + + unset BASHUNIT_COVERAGE_SHOW_FUNCTIONS + rm -f "$temp_file" +} + +function test_coverage_report_text_omits_function_summary_by_default() { + BASHUNIT_COVERAGE="true" + unset BASHUNIT_COVERAGE_SHOW_FUNCTIONS + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function only_fn() { + echo "x" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "only_fn" "$output" + + rm -f "$temp_file" +} From 8282bc4469c9b428dfca5e0e4f37f33e9a725c73 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:22:14 +0200 Subject: [PATCH 02/18] test(coverage): cover HTML per-line test attribution tooltip Adds tests for the HTML report's tooltip that lists which tests hit each covered line: render path, dedup of repeated hits, and absence when no test attribution data is present. --- tests/unit/coverage_reporting_test.sh | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index df6a6c01..72696076 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -488,3 +488,92 @@ EOF rm -f "$temp_file" } + +function test_coverage_html_renders_test_attribution_tooltip() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered line" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${temp_file}:2|tests/unit/sample_test.sh:test_should_do_thing" \ + >>"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains 'class="hits-tooltip"' "$content" + assert_contains "Tests hitting this line" "$content" + assert_contains "sample_test.sh" "$content" + assert_contains "test_should_do_thing" "$content" + assert_contains 'class="hits-badge has-tooltip"' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_tooltip_dedupes_repeated_test_hits() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + # Same test recorded multiple times (typical for loops) + { + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + } >>"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local count + count=$(grep -c "test_one" "$out_html" || true) + + # Tooltip should list test_one exactly once despite multiple records + assert_equals "1" "$count" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_omits_tooltip_when_no_test_data() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "no tests recorded" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_not_contains "Tests hitting this line" "$content" + assert_not_contains 'class="hits-badge has-tooltip"' "$content" + + rm -f "$temp_file" "$out_html" +} From 3458073a360966bbb9d2700b989ab0625e7ed02d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:25:09 +0200 Subject: [PATCH 03/18] test(coverage): add HTML report generation tests Covers index.html overall metrics rendering, per-file HTML page creation, covered/uncovered/non-executable row classification, and HTML escaping of special characters in source content. --- tests/unit/coverage_reporting_test.sh | 122 ++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 72696076..25cba15b 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -577,3 +577,125 @@ EOF rm -f "$temp_file" "$out_html" } + +function test_coverage_html_index_contains_overall_metrics() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "a" +echo "b" +echo "c" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + { + echo "${temp_file}:2" + echo "${temp_file}:3" + } >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_dir + out_dir=$(mktemp -d) + + bashunit::coverage::report_html "$out_dir" >/dev/null + + assert_file_exists "$out_dir/index.html" + + local index + index=$(cat "$out_dir/index.html") + + assert_contains "Code Coverage Report" "$index" + assert_contains "Overall Code Coverage" "$index" + # 2 of 3 executable lines hit -> 66% + assert_contains "66%" "$index" + assert_contains "$(basename "$temp_file")" "$index" + + rm -rf "$out_dir" + rm -f "$temp_file" +} + +function test_coverage_html_index_creates_per_file_pages() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_dir + out_dir=$(mktemp -d) + + bashunit::coverage::report_html "$out_dir" >/dev/null + + # Per-file HTML page exists under files/ + local file_pages + file_pages=$(find "$out_dir/files/" -maxdepth 1 -type f -name '*.html' | wc -l | tr -d ' ') + assert_equals "1" "$file_pages" + + rm -rf "$out_dir" + rm -f "$temp_file" +} + +function test_coverage_html_file_page_marks_covered_and_uncovered_rows() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +echo "uncovered" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains 'class="covered line-anchor"' "$content" + assert_contains 'class="uncovered line-anchor"' "$content" + # Line 1 (shebang) is non-executable -> no covered/uncovered class + assert_contains 'id="line-1" class=" line-anchor"' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_file_page_escapes_special_chars() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo " & 'quote'" +EOF + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains "<tag>" "$content" + assert_contains "&" "$content" + # Raw must not appear in the code cell content + assert_not_contains 'echo "' "$content" + + rm -f "$temp_file" "$out_html" +} From 21e3631ef7b090323da519093be7bca22810580e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:31:20 +0200 Subject: [PATCH 04/18] test(coverage): pin subshell tracking behavior Documents and verifies how the DEBUG-trap recorder behaves across subshell forms via fixtures: command substitution, explicit ( ... ), pipelines, process substitution, and functions invoked inside $(...). Locks in the known limitation that hits produced inside a subshell are not propagated back to the parent's data file. --- tests/unit/coverage_subshell_test.sh | 184 +++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/unit/coverage_subshell_test.sh diff --git a/tests/unit/coverage_subshell_test.sh b/tests/unit/coverage_subshell_test.sh new file mode 100644 index 00000000..64a30504 --- /dev/null +++ b/tests/unit/coverage_subshell_test.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Subshell tracking edge cases. +# bashunit relies on `set -T` plus the DEBUG trap so child shell contexts +# inherit the recorder. These tests pin the documented behavior so future +# regressions surface as failing tests instead of silent gaps. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" +_ORIG_COVERAGE_PATHS="" +_ORIG_COVERAGE_EXCLUDE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + _ORIG_COVERAGE_PATHS="${BASHUNIT_COVERAGE_PATHS:-}" + _ORIG_COVERAGE_EXCLUDE="${BASHUNIT_COVERAGE_EXCLUDE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_PATHS="${TMPDIR:-/tmp}" + export BASHUNIT_COVERAGE_EXCLUDE="*_test.sh" +} + +function tear_down() { + trap - DEBUG + set +T + + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then + export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" + else + unset BASHUNIT_COVERAGE_PATHS + fi + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then + export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" + else + unset BASHUNIT_COVERAGE_EXCLUDE + fi +} + +# Helper: run a fixture under coverage tracking and return how many +# distinct hit lines were recorded for it. +function _run_fixture_under_coverage() { + local fixture="$1" + bashunit::coverage::init + echo "$fixture" >>"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + bashunit::coverage::enable_trap + # shellcheck disable=SC1090 + source "$fixture" >/dev/null 2>&1 + bashunit::coverage::disable_trap + + # When the suite itself runs in parallel mode, hits are flushed to a + # per-PID data file. Aggregate so the assertion below sees them. + bashunit::coverage::aggregate_parallel + + bashunit::coverage::get_all_line_hits "$fixture" | wc -l | tr -d ' ' +} + +function test_coverage_records_lines_inside_command_substitution() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +result=$(echo "inside-subst") +echo "after $result" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: the outer line containing $(...) is recorded, + # but the command inside the subshell does not propagate hits back to + # the parent's coverage data file. Both outer lines should be hit. + assert_equals "2" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_explicit_subshell_block() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +( + echo "in subshell" +) +echo "after" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: writes from inside ( ... ) hit the in-memory + # buffer of the subshell, which is discarded on subshell exit. Only + # the outer `echo "after"` line is recorded back in the parent. + assert_equals "1" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_pipeline_lhs() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +echo "one" | cat >/dev/null +echo "two" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Each pipeline source line is recorded once (the pipeline as a unit). + assert_equals "2" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_process_substitution_consumer() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +while read -r _line; do + : "$_line" +done < <(echo "a") +echo "after" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Consumer side of <(...) is tracked: the `while` line, the loop body, + # and the trailing echo (3 distinct hit lines). + assert_equals "3" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_lines_inside_function_called_from_subshell() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +function _sub_helper() { + echo "in helper" +} +result=$(_sub_helper) +echo "after $result" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: the function body runs inside the $(...) + # subshell, so its hits are lost. Only the caller line and trailing + # echo are recorded in the parent's data file. + assert_equals "2" "$hit_count" + + rm -f "$fixture" +} From 5ba609c4457ad707d846c5100b9ddd2b7658f27f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:33:22 +0200 Subject: [PATCH 05/18] test(coverage): cover parallel data aggregation Adds direct tests for aggregate_parallel: merging per-PID hits files, deduplicating the tracked-files index, merging test-hit attribution files, no-op behavior when nothing to merge, and graceful handling of empty per-PID files. --- .../coverage_parallel_aggregation_test.sh | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/unit/coverage_parallel_aggregation_test.sh diff --git a/tests/unit/coverage_parallel_aggregation_test.sh b/tests/unit/coverage_parallel_aggregation_test.sh new file mode 100644 index 00000000..b2306bde --- /dev/null +++ b/tests/unit/coverage_parallel_aggregation_test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Parallel coverage aggregation tests. +# When tests run in parallel, each worker writes to a per-PID file +# alongside the canonical data file (e.g. hits.dat.12345). The +# aggregate_parallel function is responsible for merging those into the +# canonical files at end-of-run and deduplicating the tracked-files +# index. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" +} + +function tear_down() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi +} + +function test_aggregate_parallel_merges_hits_from_per_pid_files() { + bashunit::coverage::init + + # Simulate two worker PIDs each having written their own hits file + echo "/path/to/file.sh:5" >"${_BASHUNIT_COVERAGE_DATA_FILE}.111" + printf '/path/to/file.sh:5\n/path/to/file.sh:7\n' \ + >"${_BASHUNIT_COVERAGE_DATA_FILE}.222" + + bashunit::coverage::aggregate_parallel + + local merged + merged=$(cat "$_BASHUNIT_COVERAGE_DATA_FILE") + + assert_contains "/path/to/file.sh:5" "$merged" + assert_contains "/path/to/file.sh:7" "$merged" + + # Per-PID files removed after merge + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.111" + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.222" +} + +function test_aggregate_parallel_dedupes_tracked_files() { + bashunit::coverage::init + + # Two workers tracked the same file; aggregation must keep one entry + printf '/path/to/file.sh\n/path/to/other.sh\n' \ + >"${_BASHUNIT_COVERAGE_TRACKED_FILES}.111" + printf '/path/to/file.sh\n/path/to/third.sh\n' \ + >"${_BASHUNIT_COVERAGE_TRACKED_FILES}.222" + + bashunit::coverage::aggregate_parallel + + local entries + entries=$(wc -l <"$_BASHUNIT_COVERAGE_TRACKED_FILES" | tr -d ' ') + + # Three unique paths despite duplicate file.sh entries + assert_equals "3" "$entries" +} + +function test_aggregate_parallel_merges_test_hits_files() { + bashunit::coverage::init + + echo "/src/a.sh:10|tests/x_test.sh:test_one" \ + >"${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.111" + echo "/src/a.sh:10|tests/y_test.sh:test_two" \ + >"${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.222" + + bashunit::coverage::aggregate_parallel + + local content + content=$(cat "$_BASHUNIT_COVERAGE_TEST_HITS_FILE") + + assert_contains "test_one" "$content" + assert_contains "test_two" "$content" +} + +function test_aggregate_parallel_is_a_noop_when_no_per_pid_files_exist() { + bashunit::coverage::init + + echo "/already-merged.sh:1" >"$_BASHUNIT_COVERAGE_DATA_FILE" + + bashunit::coverage::aggregate_parallel + + local content + content=$(cat "$_BASHUNIT_COVERAGE_DATA_FILE") + + assert_equals "/already-merged.sh:1" "$content" +} + +function test_aggregate_parallel_handles_empty_per_pid_file() { + bashunit::coverage::init + + : >"${_BASHUNIT_COVERAGE_DATA_FILE}.999" + + bashunit::coverage::aggregate_parallel + + # Empty file aggregated and removed without error + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.999" +} From 7f808d07c8586075e3d6497855ad2d27166aacf1 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:36:57 +0200 Subject: [PATCH 06/18] feat(coverage): list uncovered executable lines in text report Adds an opt-in "Uncovered Lines" section to the text report, gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Each tracked file contributes a single line listing its missed executable lines, with consecutive line numbers compressed into ranges (e.g. "src/foo.sh:12-14,18,22-25") so sparse misses stay scannable. Files with full coverage are skipped. --- src/coverage.sh | 79 ++++++++++++++++++++ tests/unit/coverage_reporting_test.sh | 101 ++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/src/coverage.sh b/src/coverage.sh index a7d88ea7..079346b7 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -908,6 +908,11 @@ function bashunit::coverage::report_text() { bashunit::coverage::report_text_functions fi + # Optional uncovered hotspots (gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED) + if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then + bashunit::coverage::report_text_uncovered + fi + # Show report location if generated if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then echo "" @@ -915,6 +920,80 @@ function bashunit::coverage::report_text() { fi } +# List executable lines that were never hit, grouped by file. +# Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed +# when no uncovered lines exist so a fully-covered run stays quiet. +function bashunit::coverage::report_text_uncovered() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + local -a uncovered_lines=() + local _ucount=0 + local lineno=0 line + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + bashunit::coverage::is_executable_line "$line" "$lineno" || continue + local lh="${hits_by_line[$lineno]:-0}" + if [ "$lh" -eq 0 ]; then + uncovered_lines[_ucount]="$lineno" + _ucount=$((_ucount + 1)) + fi + done <"$file" + + [ "$_ucount" -eq 0 ] && continue + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Uncovered Lines" + echo "---------------" + printed_header=true + fi + + local display_file="${file#"$(pwd)"/}" + local color reset="$_BASHUNIT_COLOR_DEFAULT" + color="$_BASHUNIT_COLOR_FAILED" + + # Compress consecutive line numbers into ranges (3-5 instead of 3,4,5) + local out="" prev_start="" prev_end="" ln + for ln in "${uncovered_lines[@]}"; do + if [ -z "$prev_start" ]; then + prev_start="$ln" + prev_end="$ln" + continue + fi + if [ "$ln" -eq $((prev_end + 1)) ]; then + prev_end="$ln" + else + if [ "$prev_start" = "$prev_end" ]; then + out="${out}${prev_start}," + else + out="${out}${prev_start}-${prev_end}," + fi + prev_start="$ln" + prev_end="$ln" + fi + done + if [ -n "$prev_start" ]; then + if [ "$prev_start" = "$prev_end" ]; then + out="${out}${prev_start}" + else + out="${out}${prev_start}-${prev_end}" + fi + fi + out="${out%,}" + + printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" + done < <(bashunit::coverage::get_tracked_files) +} + # Per-function coverage summary printed after the file table. # Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. function bashunit::coverage::report_text_functions() { diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 25cba15b..f696aaee 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -699,3 +699,104 @@ EOF rm -f "$temp_file" "$out_html" } + +function test_coverage_report_text_lists_uncovered_hotspots_when_enabled() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +echo "uncovered-1" +echo "uncovered-2" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_contains "Uncovered" "$output" + # Lines 3 and 4 are uncovered, rendered as a compressed range "3-4" + assert_contains "3-4" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} + +function test_coverage_report_text_uncovered_renders_singletons_separately() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "uncovered-2" +echo "covered-3" +echo "uncovered-4" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + # Non-consecutive uncovered lines stay as individual entries + assert_contains "2,4" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} + +function test_coverage_report_text_omits_uncovered_section_by_default() { + BASHUNIT_COVERAGE="true" + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "uncovered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "Uncovered" "$output" + + rm -f "$temp_file" +} + +function test_coverage_report_text_skips_uncovered_section_when_no_misses() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "Uncovered" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} From 46dbad516018d66dde3e410024fc0e72684ca33f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:39:06 +0200 Subject: [PATCH 07/18] docs(coverage): document new env vars and pin subshell contract Updates CHANGELOG with the new LCOV function records and the opt-in text-report blocks (BASHUNIT_COVERAGE_SHOW_FUNCTIONS, BASHUNIT_COVERAGE_SHOW_UNCOVERED). Replaces the vague "subshell may have edge cases" note with the precise tracked-vs-lost behavior pinned by the new subshell test fixtures. --- CHANGELOG.md | 3 +++ docs/coverage.md | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a53623..4db4888c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - `--show-output` displays captured test output on assertion failures (#637) - npm registry distribution: `npm install -g bashunit` (#244) - `bashunit::env::supports_color` and `bashunit::io::clear_screen` helpers (#247) +- LCOV reports now include `FN`, `FNDA`, `FNF` and `FNH` function records, consumed by `genhtml`, Codecov and Coveralls +- `BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true` adds a per-function coverage block to the text report +- `BASHUNIT_COVERAGE_SHOW_UNCOVERED=true` adds an "Uncovered Lines" block to the text report, with consecutive line numbers compressed into ranges ### Changed - Docs moved into their own npm workspace under `docs/` (use `cd docs && npm ci` or `make docs/install`) diff --git a/docs/coverage.md b/docs/coverage.md index 909f3193..1ef5896e 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -106,6 +106,10 @@ BASHUNIT_COVERAGE_MIN=80 # Color thresholds for console output BASHUNIT_COVERAGE_THRESHOLD_LOW=50 # Red below this BASHUNIT_COVERAGE_THRESHOLD_HIGH=80 # Green above this, yellow between + +# Optional text-report blocks (off by default, opt-in for verbose runs) +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true # Print per-function coverage +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true # Print missed line ranges per file ``` ## Examples @@ -355,4 +359,12 @@ Coverage only tracks Bash code. External commands (like `grep`, `sed`, etc.) are ### Subshell Behavior -Due to Bash's process model, some subshell contexts may not have full coverage tracking. The DEBUG trap is inherited into subshells, but complex nested scenarios may have edge cases. +Due to Bash's process model, hits produced inside a subshell are written to the subshell's in-memory buffer, which is discarded when the subshell exits. The pinned behavior is: + +- `$( ... )` command substitution: the outer line is recorded; commands inside the substitution are not. +- `( ... )` explicit subshells: the same applies; only the outer line is tracked. +- Pipelines (`a | b`): each stage is recorded as a single hit on its source line. +- Process substitution `< <( ... )`: the consumer side is fully tracked; producer lines are not. +- Functions invoked from `$( ... )`: the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell. + +These contracts are pinned by `tests/unit/coverage_subshell_test.sh`. From 3b026defff2de718975a85467b4ff02c6ad8a024 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 10:11:08 +0200 Subject: [PATCH 08/18] test(coverage): skip subshell tests under parallel mode and on Git Bash Enabling the DEBUG trap inside a parallel test worker process makes the worker fire the trap on every internal coordination command, which combines with /tmp file-I/O contention to deadlock CI runners (15-minute timeouts on Ubuntu/Alpine parallel jobs and on Windows Git Bash). The contracts these tests pin are deterministic in single-process mode, so the parallel run is not a useful execution context. Adds a guard that calls bashunit::skip when BASHUNIT_PARALLEL_RUN=true or when running on CYGWIN/MINGW/MSYS, and applies it to all five subshell tests. --- tests/unit/coverage_subshell_test.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/coverage_subshell_test.sh b/tests/unit/coverage_subshell_test.sh index 64a30504..2f60372e 100644 --- a/tests/unit/coverage_subshell_test.sh +++ b/tests/unit/coverage_subshell_test.sh @@ -14,6 +14,26 @@ _ORIG_COVERAGE="" _ORIG_COVERAGE_PATHS="" _ORIG_COVERAGE_EXCLUDE="" +# Whole-suite skip: enabling the DEBUG trap inside a parallel test +# worker process makes the worker fire the trap on every internal +# coordination command, which combines with file-I/O contention on +# /tmp to deadlock CI runners. The contracts tested here are +# deterministic in single-process mode, so the parallel run is not +# a useful execution context. +function _skip_when_parallel_or_windows() { + if [ "${BASHUNIT_PARALLEL_RUN:-false}" = "true" ]; then + bashunit::skip "subshell tracking tests require single-process execution" + return 0 + fi + case "$(uname -s 2>/dev/null)" in + CYGWIN* | MINGW* | MSYS*) + bashunit::skip "DEBUG trap + set -T behavior is unstable on Git Bash" + return 0 + ;; + esac + return 1 +} + function set_up() { _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" @@ -85,6 +105,7 @@ function _run_fixture_under_coverage() { } function test_coverage_records_lines_inside_command_substitution() { + _skip_when_parallel_or_windows && return 0 local fixture fixture=$(mktemp) cat >"$fixture" <<'EOF' @@ -104,6 +125,7 @@ EOF } function test_coverage_records_explicit_subshell_block() { + _skip_when_parallel_or_windows && return 0 local fixture fixture=$(mktemp) cat >"$fixture" <<'EOF' @@ -125,6 +147,7 @@ EOF } function test_coverage_records_pipeline_lhs() { + _skip_when_parallel_or_windows && return 0 local fixture fixture=$(mktemp) cat >"$fixture" <<'EOF' @@ -142,6 +165,7 @@ EOF } function test_coverage_records_process_substitution_consumer() { + _skip_when_parallel_or_windows && return 0 local fixture fixture=$(mktemp) cat >"$fixture" <<'EOF' @@ -162,6 +186,7 @@ EOF } function test_coverage_records_lines_inside_function_called_from_subshell() { + _skip_when_parallel_or_windows && return 0 local fixture fixture=$(mktemp) cat >"$fixture" <<'EOF' From 7688aa8a779e6e781ea4428898cbd4ca67dec2c2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:50:40 +0200 Subject: [PATCH 09/18] docs(adr): branch coverage MVP design Records the decision to use static branch-point detection plus line-hit inference for the branch-coverage MVP, scoping included constructs (if/elif/else, case) and listing deferred items (implicit-else, short-circuit branches, loop-entry decisions). --- adrs/adr-007-branch-coverage-mvp.md | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 adrs/adr-007-branch-coverage-mvp.md diff --git a/adrs/adr-007-branch-coverage-mvp.md b/adrs/adr-007-branch-coverage-mvp.md new file mode 100644 index 00000000..d463162c --- /dev/null +++ b/adrs/adr-007-branch-coverage-mvp.md @@ -0,0 +1,90 @@ +# Branch Coverage MVP via Static Branch-Point Detection + +* Status: accepted +* Date: 2026-05-04 + +## Context and Problem Statement + +Coverage today reports line-level execution only. Standard tooling (genhtml, Codecov, Coveralls) consumes branch records via the LCOV `BRDA`/`BRF`/`BRH` fields, which let reviewers see whether `else`/`elif` arms and individual `case` patterns were exercised. Adding true branch coverage to a Bash framework is non-trivial because: + +1. Bash exposes no native instrumentation comparable to gcov branch counters. +2. The DEBUG trap fires on commands, not on branch decisions. +3. `BASH_COMMAND` reflects the *next* command, not the boolean outcome of a conditional. + +We need a path that yields useful, mostly-correct branch metrics in LCOV reports without breaking Bash 3.0+ compatibility or the cost profile of the existing line tracker. + +## Decision Drivers + +* Bash 3.0+ compatibility (no associative arrays, no `[[`, no Bash 4-only features). +* Reuse existing line-hit data; do not double the runtime cost of coverage. +* LCOV output must be consumable by genhtml, Codecov and Coveralls without custom processing. +* Implementation must fit in `src/coverage.sh` and remain testable with the existing unit-test patterns. +* Behavior must be predictable enough to pin in tests; "best-effort heuristic" outputs are not acceptable. + +## Considered Options + +1. **Static branch-point detection plus line-hit inference** — parse the source file for branch-introducing constructs (`if`/`elif`/`else`, `case` patterns), compute the line range owned by each outcome, then mark the outcome as "taken" iff any line inside its range was hit. +2. **Runtime decision tracing via `BASH_COMMAND`** — record the actual command being executed in the DEBUG trap and reconstruct decisions taken (`if X` followed by execution of either then-block or else-block). +3. **Patch-based instrumentation** — preprocess source files to insert hit recorders inside each branch arm, run tests against the instrumented copy, post-process the data file. + +## Decision Outcome + +Chosen option: **Option 1 (static branch-point detection plus line-hit inference)**. + +It reuses the existing line-hit data file with no DEBUG-trap changes. Bash 3.0+ compatibility is preserved because the parser is a single pass over the source with brace counting, identical in shape to the existing `extract_functions` walker. The output maps cleanly to LCOV `BRDA` records, and the contract ("an arm is taken iff any executable line inside it was hit") is precise enough to write unit tests against. + +### Positive Consequences + +* Zero runtime cost beyond the existing line tracker. Branch records are computed during report generation, not during test execution. +* Reuses `is_executable_line` and `get_all_line_hits`, which already tolerate Bash 3.0 limitations. +* LCOV output remains a single file, consumed unchanged by downstream tools. + +### Negative Consequences + +* Branch detection is line-presence based, not outcome based. A `then` arm whose only statement is a comment-line will register as `not taken` even if the conditional fired (because there are no executable lines inside). This is documented as a known limitation. +* Implicit `else` (when an `if/elif` chain has no explicit `else`) is reported only when at least one explicit arm exists; the synthetic "fall-through" outcome is omitted from this MVP and may be added in a follow-up. +* Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. + +## Pros and Cons of the Options + +### Option 1: Static + line-hit inference (chosen) + +* Good, because reuses existing data and code paths. +* Good, because matches the implementation pattern of `extract_functions` already shipping in the codebase. +* Good, because output is deterministic and easy to test. +* Bad, because cannot distinguish "arm executed but produced no executable lines" from "arm not executed". + +### Option 2: Runtime DEBUG-trap decision tracing + +* Good, because reflects actual runtime behavior. +* Bad, because `BASH_COMMAND` semantics across Bash 3.x and 5.x diverge for `((...))`, `[[...]]` and pipelines, requiring per-version logic. +* Bad, because increases per-line overhead; the existing tracker already has measurable cost. +* Bad, because subshell context loss (already documented for line coverage) extends to branches taken inside `$(...)`. + +### Option 3: Source-rewrite instrumentation + +* Good, because most accurate signal possible. +* Bad, because requires either running tests against a rewritten source tree or hooking `source` to redirect to instrumented copies — both invasive and brittle. +* Bad, because debugging stack traces and line numbers no longer match the user's source. +* Bad, because doubles the code surface and breaks the "DEBUG-trap only" simplicity model. + +## Scope of MVP + +Included: + +* `if`/`elif`/`else` chains: each arm is one outcome. +* `case` statements: each pattern is one outcome. +* LCOV `BRDA:,,,` lines. +* `BRF:` and `BRH:` per file. + +Deferred (potential follow-ups): + +* Synthetic "implicit-else" outcomes for `if/elif` chains without an explicit `else`. +* Per-sub-expression decisions inside `if A && B`. +* `&&` / `||` short-circuit branches outside `if`. +* Loop-entry decisions (`while`/`until`). + +## Links + +* Builds on the function extractor introduced in `src/coverage.sh` (see `bashunit::coverage::extract_functions`). +* LCOV format reference: From 56c9e7b3b6a83c6a822f21f9caf87688c8dcfd43 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:50:48 +0200 Subject: [PATCH 10/18] feat(coverage): branch extractor and hit computation Adds bashunit::coverage::extract_branches, a single-pass parser that discovers if/elif/else chains and case patterns and emits one record per decision with the line ranges of each arm. Pairs it with bashunit::coverage::compute_branch_hits, which walks the existing line-hit data and marks each arm taken iff at least one executable line inside its range was hit. Both functions are Bash 3.0+ compatible (parallel indexed arrays in place of associative arrays). See adrs/adr-007-branch-coverage-mvp.md for the design and known limitations. --- src/coverage.sh | 273 +++++++++++++++++++++++++++ tests/unit/coverage_branches_test.sh | 239 +++++++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 tests/unit/coverage_branches_test.sh diff --git a/src/coverage.sh b/src/coverage.sh index 079346b7..4c206563 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -776,6 +776,260 @@ function bashunit::coverage::extract_functions() { fi } +# Extract branch points from a Bash file. +# Output format: ||:[,:]... +# kind ∈ {if, case} +# Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. +function bashunit::coverage::extract_branches() { + local file="$1" + + # Pre-load lines for indexed access + local -a lines=() + local _i=0 _l + while IFS= read -r _l || [ -n "$_l" ]; do + lines[_i]="$_l" + ((++_i)) + done <"$file" + + local total_lines=$_i + local lineno=0 + + # State for if/elif/else parsing. We allow nested ifs by stacking + # decision contexts in parallel arrays (Bash 3.0 has no associative + # arrays). + local -a if_stack_decision_line=() + local -a if_stack_arms=() # comma-separated "start:end" pairs accumulated + local -a if_stack_current_arm_start=() + local if_depth=0 + + # State for case parsing + local -a case_stack_decision_line=() + local -a case_stack_arms=() + local -a case_stack_current_arm_start=() + local -a case_stack_in_pattern=() # 1 once we've seen the first pattern + local case_depth=0 + + local trimmed first + while [ "$lineno" -lt "$total_lines" ]; do + local line="${lines[$lineno]}" + lineno=$((lineno + 1)) + + # Strip leading whitespace + trimmed="${line#"${line%%[![:space:]]*}"}" + + # Skip comments and empty lines for keyword matching + case "$trimmed" in '' | '#'*) continue ;; esac + + first="${trimmed%%[[:space:]\;]*}" + + # --- if / elif / else / fi handling --- + # Reserved-word patterns are single-quoted to avoid Bash parser + # confusion with the surrounding `case ... esac`. + case "$first" in + 'if') + # Push new decision context + if_stack_decision_line[if_depth]=$lineno + if_stack_arms[if_depth]="" + # Body of `then` arm starts on the next line + if_stack_current_arm_start[if_depth]=$((lineno + 1)) + if_depth=$((if_depth + 1)) + continue + ;; + 'elif') + if [ "$if_depth" -gt 0 ]; then + local idx=$((if_depth - 1)) + local arm_end=$((lineno - 1)) + local existing="${if_stack_arms[$idx]}" + local prev_start="${if_stack_current_arm_start[$idx]}" + if [ -z "$existing" ]; then + if_stack_arms[idx]="${prev_start}:${arm_end}" + else + if_stack_arms[idx]="${existing},${prev_start}:${arm_end}" + fi + if_stack_current_arm_start[idx]=$((lineno + 1)) + fi + continue + ;; + 'else') + if [ "$if_depth" -gt 0 ]; then + local idx=$((if_depth - 1)) + local arm_end=$((lineno - 1)) + local existing="${if_stack_arms[$idx]}" + local prev_start="${if_stack_current_arm_start[$idx]}" + if [ -z "$existing" ]; then + if_stack_arms[idx]="${prev_start}:${arm_end}" + else + if_stack_arms[idx]="${existing},${prev_start}:${arm_end}" + fi + if_stack_current_arm_start[idx]=$((lineno + 1)) + fi + continue + ;; + 'fi') + if [ "$if_depth" -gt 0 ]; then + local idx=$((if_depth - 1)) + local arm_end=$((lineno - 1)) + local existing="${if_stack_arms[$idx]}" + local prev_start="${if_stack_current_arm_start[$idx]}" + local final_arms + if [ -z "$existing" ]; then + final_arms="${prev_start}:${arm_end}" + else + final_arms="${existing},${prev_start}:${arm_end}" + fi + echo "${if_stack_decision_line[$idx]}|if|${final_arms}" + if_depth=$idx + fi + continue + ;; + 'case') + case_stack_decision_line[case_depth]=$lineno + case_stack_arms[case_depth]="" + case_stack_current_arm_start[case_depth]=0 + case_stack_in_pattern[case_depth]=0 + case_depth=$((case_depth + 1)) + continue + ;; + 'esac') + if [ "$case_depth" -gt 0 ]; then + local cidx=$((case_depth - 1)) + # Close out the trailing pattern (its body extends until ;; or esac) + if [ "${case_stack_in_pattern[$cidx]}" = "1" ]; then + local arm_end=$((lineno - 1)) + local prev_start="${case_stack_current_arm_start[$cidx]}" + local existing="${case_stack_arms[$cidx]}" + if [ -z "$existing" ]; then + case_stack_arms[cidx]="${prev_start}:${arm_end}" + else + case_stack_arms[cidx]="${existing},${prev_start}:${arm_end}" + fi + fi + if [ -n "${case_stack_arms[$cidx]}" ]; then + echo "${case_stack_decision_line[$cidx]}|case|${case_stack_arms[$cidx]}" + fi + case_depth=$cidx + fi + continue + ;; + esac + + # --- case pattern detection --- + # Inside a case body, lines like `pattern)` open a new arm; `;;` + # closes the current arm. + if [ "$case_depth" -gt 0 ]; then + local cidx=$((case_depth - 1)) + + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + # Close current arm; body ended on the previous line + if [ "${case_stack_in_pattern[$cidx]}" = "1" ]; then + local arm_end=$((lineno - 1)) + local prev_start="${case_stack_current_arm_start[$cidx]}" + local existing="${case_stack_arms[$cidx]}" + if [ -z "$existing" ]; then + case_stack_arms[cidx]="${prev_start}:${arm_end}" + else + case_stack_arms[cidx]="${existing},${prev_start}:${arm_end}" + fi + case_stack_in_pattern[cidx]=0 + fi + continue + ;; + esac + + # A pattern line ends with `)` and is not just a closing paren of + # a subshell. + case "$trimmed" in + *')'*) + # Avoid matching things like `cmd $(other)` mid-line: the + # `)` we want is at end-of-trimmed (optionally followed by `# comment`). + local pattern_close + pattern_close="${trimmed%%')'*}" + local rest_after_paren="${trimmed#"$pattern_close"}" + rest_after_paren="${rest_after_paren#)}" + # Remove trailing whitespace and optional comment + rest_after_paren="${rest_after_paren#"${rest_after_paren%%[![:space:]]*}"}" + case "$rest_after_paren" in + '' | '#'*) + # This is a case pattern. Body starts on next line. + case_stack_current_arm_start[cidx]=$((lineno + 1)) + case_stack_in_pattern[cidx]=1 + continue + ;; + esac + ;; + esac + fi + done +} + +# Compute branch hit data for a file. +# Output format: ||| +# block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). +# An arm is "taken" iff at least one executable line inside its range +# has a recorded hit. taken_count is 0 (not taken) or 1 (taken). MVP +# does not preserve actual hit counts per arm. +function bashunit::coverage::compute_branch_hits() { + local file="$1" + + # Pre-load hits keyed by line number + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + # Pre-load source lines for is_executable_line checks + local -a src_lines=() + local _sli=0 _sl + while IFS= read -r _sl || [ -n "$_sl" ]; do + src_lines[_sli]="$_sl" + ((++_sli)) + done <"$file" + + local block=0 + local branch_entry decision_line kind arms rest + while IFS= read -r branch_entry; do + [ -z "$branch_entry" ] && continue + + decision_line="${branch_entry%%|*}" + rest="${branch_entry#*|}" + kind="${rest%%|*}" + arms="${rest#*|}" + : "$kind" # currently unused in output; reserved for future BRDA grouping + + local arm_index=0 + local arm + local IFS_orig="$IFS" + IFS=',' + # shellcheck disable=SC2086 + set -- $arms + IFS="$IFS_orig" + + for arm in "$@"; do + local arm_start="${arm%%:*}" + local arm_end="${arm##*:}" + local taken=0 + local ln + for ((ln = arm_start; ln <= arm_end; ln++)); do + local content="${src_lines[$((ln - 1))]:-}" + if bashunit::coverage::is_executable_line "$content" "$ln"; then + local h=${hits_by_line[$ln]:-0} + if [ "$h" -gt 0 ]; then + taken=1 + break + fi + fi + done + + echo "${decision_line}|${block}|${arm_index}|${taken}" + arm_index=$((arm_index + 1)) + done + + block=$((block + 1)) + done < <(bashunit::coverage::extract_branches "$file") +} + # Calculate coverage for a specific function in a file # Returns: hit_lines:executable_lines:percentage function bashunit::coverage::get_function_coverage() { @@ -1120,6 +1374,25 @@ function bashunit::coverage::report_lcov() { echo "FNF:$fn_total" echo "FNH:$fn_hit" + # Branch records (BRDA/BRF/BRH) + local br_total=0 br_hit=0 br_entry + while IFS= read -r br_entry; do + [ -z "$br_entry" ] && continue + local br_line br_block br_idx br_taken br_rest + br_line="${br_entry%%|*}" + br_rest="${br_entry#*|}" + br_block="${br_rest%%|*}" + br_rest="${br_rest#*|}" + br_idx="${br_rest%%|*}" + br_taken="${br_rest#*|}" + + echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" + br_total=$((br_total + 1)) + [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) + done < <(bashunit::coverage::compute_branch_hits "$file") + echo "BRF:$br_total" + echo "BRH:$br_hit" + local lineno=0 executable=0 hit=0 line line_hits local -a lcov_lines=() local _lli=0 _ll diff --git a/tests/unit/coverage_branches_test.sh b/tests/unit/coverage_branches_test.sh new file mode 100644 index 00000000..895cf265 --- /dev/null +++ b/tests/unit/coverage_branches_test.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Tests for the branch-point extractor and branch-hit computation. +# See adrs/adr-007-branch-coverage-mvp.md for the design. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" +} + +function tear_down() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi +} + +# extract_branches output format: +# ||:[,:]... +# kind ∈ {if, case} + +function test_extract_branches_finds_simple_if_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "not x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Decision on line 2 with two arms: then (line 3) and else (line 5) + assert_contains "2|if|3:3,5:5" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_if_elif_else_chain() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "a" ]; then + echo "a" +elif [ "$1" = "b" ]; then + echo "b" +else + echo "other" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Three arms: then (line 3), elif body (line 5), else (line 7) + assert_contains "2|if|3:3,5:5,7:7" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_case_patterns() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # case decision on line 2, three pattern arms with bodies on 4, 7, 10 + assert_contains "2|case|4:4,7:7,10:10" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_returns_nothing_for_no_branches() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +echo "no branches here" +echo "still none" +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + assert_empty "$result" + + rm -f "$fixture" +} + +function test_extract_branches_handles_if_without_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # MVP scope: only the explicit then arm is reported. Implicit-else + # (synthetic fall-through outcome) is deferred per ADR-007. + assert_contains "2|if|3:3" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_taken_arm() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + # Hit only the `then` arm body + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Format: decision_line|block|arm_index|taken_count + assert_contains "2|0|0|1" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_all_arms_zero_when_unhit() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "y" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + assert_contains "2|0|0|0" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_assigns_distinct_blocks_per_decision() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "first" +fi +if [ "$2" = "y" ]; then + echo "second" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${fixture}:6" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Two decisions -> two distinct block ids (0 and 1) + assert_contains "2|0|0|1" "$result" + assert_contains "5|1|0|1" "$result" + + rm -f "$fixture" +} From ace7b5082d10b6352d3aa3d2d72dbca84e00caae Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:52:11 +0200 Subject: [PATCH 11/18] test(coverage): cover BRDA/BRF/BRH emission in LCOV report Verifies that the LCOV report emits branch records produced by compute_branch_hits: one BRDA per arm for if/else and case, the BRF total, the BRH count of taken arms, and that BRF/BRH are still emitted (as zeros) for files with no branch points. --- tests/unit/coverage_reporting_test.sh | 101 ++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index f696aaee..079c8951 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -396,6 +396,107 @@ EOF rm -f "$temp_file" } +function test_coverage_report_lcov_includes_branch_records_for_if_else() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_contains "BRDA:2,0,0,1" "$content" + assert_contains "BRDA:2,0,1,0" "$content" + assert_contains "BRF:2" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_includes_branch_records_for_case() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:7" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + # Three case arms: only the second was hit + assert_contains "BRDA:2,0,0,0" "$content" + assert_contains "BRDA:2,0,1,1" "$content" + assert_contains "BRDA:2,0,2,0" "$content" + assert_contains "BRF:3" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_omits_branch_records_when_none_present() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "no branches" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_not_contains "BRDA:" "$content" + assert_contains "BRF:0" "$content" + assert_contains "BRH:0" "$content" + + rm -f "$temp_file" "$report_file" +} + function test_coverage_report_lcov_includes_function_records() { BASHUNIT_COVERAGE="true" bashunit::coverage::init From 60652d2fd9669fd444a4e15a9fabda463f4815ee Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:54:06 +0200 Subject: [PATCH 12/18] docs(coverage): document branch coverage MVP Adds the BRDA/BRF/BRH entry to the changelog and a "Branch Coverage Scope" section to docs/coverage.md spelling out the limitations (no-executable-line arms, implicit-else omission, compound conditional folding, untracked short-circuit and loop-entry decisions). --- CHANGELOG.md | 1 + docs/coverage.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db4888c..8cc79a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - npm registry distribution: `npm install -g bashunit` (#244) - `bashunit::env::supports_color` and `bashunit::io::clear_screen` helpers (#247) - LCOV reports now include `FN`, `FNDA`, `FNF` and `FNH` function records, consumed by `genhtml`, Codecov and Coveralls +- LCOV reports now include `BRDA`, `BRF` and `BRH` branch records for `if`/`elif`/`else` chains and `case` patterns (see `adrs/adr-007-branch-coverage-mvp.md`) - `BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true` adds a per-function coverage block to the text report - `BASHUNIT_COVERAGE_SHOW_UNCOVERED=true` adds an "Uncovered Lines" block to the text report, with consecutive line numbers compressed into ranges diff --git a/docs/coverage.md b/docs/coverage.md index 1ef5896e..0cb32164 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -368,3 +368,14 @@ Due to Bash's process model, hits produced inside a subshell are written to the - Functions invoked from `$( ... )`: the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell. These contracts are pinned by `tests/unit/coverage_subshell_test.sh`. + +### Branch Coverage Scope + +The LCOV report includes `BRDA`/`BRF`/`BRH` records for `if`/`elif`/`else` chains and `case` patterns. An arm is reported as taken iff at least one executable line inside its range was hit. Known limitations: + +- An arm whose body has no executable lines (e.g. only comments or braces) registers as not-taken even when the conditional fired. +- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted. +- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. +- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked. + +See `adrs/adr-007-branch-coverage-mvp.md` for the full design rationale. From 21adf545f18fadde8ee68907c2e73c674be4f051 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 10:02:08 +0200 Subject: [PATCH 13/18] refactor(coverage): extract helpers in branch logic, verify Bash 3.2 Eliminates duplication in extract_branches by extracting two helpers: - _append_arm: shared arm-close logic, returns via global to avoid per-line subshell cost. - _is_case_pattern_line: case-pattern opener detection. Folds the elif/else clauses (identical except for keyword) into one branch. Replaces the IFS+set-- arm split in compute_branch_hits with a parameter-expansion loop, and pulls the per-arm taken check into _arm_taken. LCOV BRDA parsing now uses IFS='|' read for clarity. Verified on /bin/bash 3.2.57 (macOS default): 814 unit tests pass, parallel mode included. No new Bash 4+ constructs introduced. --- src/coverage.sh | 286 +++++++++++++++++++++--------------------------- 1 file changed, 122 insertions(+), 164 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 4c206563..8514f6a4 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -776,6 +776,41 @@ function bashunit::coverage::extract_functions() { fi } +# Append "start:end" to a comma-separated arms string. Result is +# returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost +# of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays +# (or namerefs) by reference, so a single output slot is the cheapest +# portable option. +_BASHUNIT_BRANCH_ARMS_OUT="" +function bashunit::coverage::_append_arm() { + local existing="$1" arm_start="$2" arm_end="$3" + if [ -z "$existing" ]; then + _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" + else + _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" + fi +} + +# Detect whether a trimmed line is a case-pattern opener (ends with +# `)` optionally followed by whitespace and a comment). Avoids +# matching mid-line uses such as `cmd $(other)`. +function bashunit::coverage::_is_case_pattern_line() { + local trimmed="$1" + case "$trimmed" in + *')'*) ;; + *) return 1 ;; + esac + + local before_paren="${trimmed%%')'*}" + local after="${trimmed#"$before_paren"}" + after="${after#)}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in + '' | '#'*) return 0 ;; + esac + return 1 +} + # Extract branch points from a Bash file. # Output format: ||:[,:]... # kind ∈ {if, case} @@ -794,192 +829,133 @@ function bashunit::coverage::extract_branches() { local total_lines=$_i local lineno=0 - # State for if/elif/else parsing. We allow nested ifs by stacking - # decision contexts in parallel arrays (Bash 3.0 has no associative - # arrays). - local -a if_stack_decision_line=() - local -a if_stack_arms=() # comma-separated "start:end" pairs accumulated - local -a if_stack_current_arm_start=() + # Nested decision contexts live in parallel indexed arrays since + # Bash 3.0 has no associative arrays. Each array is keyed by depth. + local -a if_decision_line=() if_arms=() if_arm_start=() local if_depth=0 - # State for case parsing - local -a case_stack_decision_line=() - local -a case_stack_arms=() - local -a case_stack_current_arm_start=() - local -a case_stack_in_pattern=() # 1 once we've seen the first pattern + local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() local case_depth=0 - local trimmed first + local trimmed first idx prev_end while [ "$lineno" -lt "$total_lines" ]; do local line="${lines[$lineno]}" lineno=$((lineno + 1)) - # Strip leading whitespace trimmed="${line#"${line%%[![:space:]]*}"}" - - # Skip comments and empty lines for keyword matching case "$trimmed" in '' | '#'*) continue ;; esac - first="${trimmed%%[[:space:]\;]*}" + prev_end=$((lineno - 1)) - # --- if / elif / else / fi handling --- # Reserved-word patterns are single-quoted to avoid Bash parser # confusion with the surrounding `case ... esac`. case "$first" in 'if') - # Push new decision context - if_stack_decision_line[if_depth]=$lineno - if_stack_arms[if_depth]="" - # Body of `then` arm starts on the next line - if_stack_current_arm_start[if_depth]=$((lineno + 1)) + if_decision_line[if_depth]=$lineno + if_arms[if_depth]="" + if_arm_start[if_depth]=$((lineno + 1)) if_depth=$((if_depth + 1)) continue ;; - 'elif') - if [ "$if_depth" -gt 0 ]; then - local idx=$((if_depth - 1)) - local arm_end=$((lineno - 1)) - local existing="${if_stack_arms[$idx]}" - local prev_start="${if_stack_current_arm_start[$idx]}" - if [ -z "$existing" ]; then - if_stack_arms[idx]="${prev_start}:${arm_end}" - else - if_stack_arms[idx]="${existing},${prev_start}:${arm_end}" - fi - if_stack_current_arm_start[idx]=$((lineno + 1)) - fi - continue - ;; - 'else') + 'elif' | 'else') if [ "$if_depth" -gt 0 ]; then - local idx=$((if_depth - 1)) - local arm_end=$((lineno - 1)) - local existing="${if_stack_arms[$idx]}" - local prev_start="${if_stack_current_arm_start[$idx]}" - if [ -z "$existing" ]; then - if_stack_arms[idx]="${prev_start}:${arm_end}" - else - if_stack_arms[idx]="${existing},${prev_start}:${arm_end}" - fi - if_stack_current_arm_start[idx]=$((lineno + 1)) + idx=$((if_depth - 1)) + bashunit::coverage::_append_arm "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$prev_end" + if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + if_arm_start[idx]=$((lineno + 1)) fi continue ;; 'fi') if [ "$if_depth" -gt 0 ]; then - local idx=$((if_depth - 1)) - local arm_end=$((lineno - 1)) - local existing="${if_stack_arms[$idx]}" - local prev_start="${if_stack_current_arm_start[$idx]}" - local final_arms - if [ -z "$existing" ]; then - final_arms="${prev_start}:${arm_end}" - else - final_arms="${existing},${prev_start}:${arm_end}" - fi - echo "${if_stack_decision_line[$idx]}|if|${final_arms}" + idx=$((if_depth - 1)) + bashunit::coverage::_append_arm "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$prev_end" + echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" if_depth=$idx fi continue ;; 'case') - case_stack_decision_line[case_depth]=$lineno - case_stack_arms[case_depth]="" - case_stack_current_arm_start[case_depth]=0 - case_stack_in_pattern[case_depth]=0 + case_decision_line[case_depth]=$lineno + case_arms[case_depth]="" + case_arm_start[case_depth]=0 + case_in_pattern[case_depth]=0 case_depth=$((case_depth + 1)) continue ;; 'esac') if [ "$case_depth" -gt 0 ]; then - local cidx=$((case_depth - 1)) - # Close out the trailing pattern (its body extends until ;; or esac) - if [ "${case_stack_in_pattern[$cidx]}" = "1" ]; then - local arm_end=$((lineno - 1)) - local prev_start="${case_stack_current_arm_start[$cidx]}" - local existing="${case_stack_arms[$cidx]}" - if [ -z "$existing" ]; then - case_stack_arms[cidx]="${prev_start}:${arm_end}" - else - case_stack_arms[cidx]="${existing},${prev_start}:${arm_end}" - fi + idx=$((case_depth - 1)) + if [ "${case_in_pattern[$idx]}" = "1" ]; then + bashunit::coverage::_append_arm "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$prev_end" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" fi - if [ -n "${case_stack_arms[$cidx]}" ]; then - echo "${case_stack_decision_line[$cidx]}|case|${case_stack_arms[$cidx]}" + if [ -n "${case_arms[$idx]}" ]; then + echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" fi - case_depth=$cidx + case_depth=$idx fi continue ;; esac - # --- case pattern detection --- - # Inside a case body, lines like `pattern)` open a new arm; `;;` - # closes the current arm. - if [ "$case_depth" -gt 0 ]; then - local cidx=$((case_depth - 1)) - - case "$trimmed" in - ';;&'* | ';;'* | ';&'*) - # Close current arm; body ended on the previous line - if [ "${case_stack_in_pattern[$cidx]}" = "1" ]; then - local arm_end=$((lineno - 1)) - local prev_start="${case_stack_current_arm_start[$cidx]}" - local existing="${case_stack_arms[$cidx]}" - if [ -z "$existing" ]; then - case_stack_arms[cidx]="${prev_start}:${arm_end}" - else - case_stack_arms[cidx]="${existing},${prev_start}:${arm_end}" - fi - case_stack_in_pattern[cidx]=0 - fi - continue - ;; - esac + # Inside a case body: `;;` / `;&` / `;;&` close the current arm, + # `pattern)` opens a new one. + [ "$case_depth" -eq 0 ] && continue + idx=$((case_depth - 1)) + + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + if [ "${case_in_pattern[$idx]}" = "1" ]; then + bashunit::coverage::_append_arm "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$prev_end" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + case_in_pattern[idx]=0 + fi + continue + ;; + esac - # A pattern line ends with `)` and is not just a closing paren of - # a subshell. - case "$trimmed" in - *')'*) - # Avoid matching things like `cmd $(other)` mid-line: the - # `)` we want is at end-of-trimmed (optionally followed by `# comment`). - local pattern_close - pattern_close="${trimmed%%')'*}" - local rest_after_paren="${trimmed#"$pattern_close"}" - rest_after_paren="${rest_after_paren#)}" - # Remove trailing whitespace and optional comment - rest_after_paren="${rest_after_paren#"${rest_after_paren%%[![:space:]]*}"}" - case "$rest_after_paren" in - '' | '#'*) - # This is a case pattern. Body starts on next line. - case_stack_current_arm_start[cidx]=$((lineno + 1)) - case_stack_in_pattern[cidx]=1 - continue - ;; - esac - ;; - esac + if bashunit::coverage::_is_case_pattern_line "$trimmed"; then + case_arm_start[idx]=$((lineno + 1)) + case_in_pattern[idx]=1 fi done } +# Returns 1 (true/taken) iff any executable line in [arm_start..arm_end] +# has a recorded hit. Caller must have populated the hits_by_line and +# src_lines arrays in scope (Bash 3.0 cannot pass arrays in). +# Result is echoed as "0" or "1" so the caller can capture it. +function bashunit::coverage::_arm_taken() { + local arm_start="$1" arm_end="$2" + local ln content h + for ((ln = arm_start; ln <= arm_end; ln++)); do + content="${src_lines[$((ln - 1))]:-}" + bashunit::coverage::is_executable_line "$content" "$ln" || continue + h=${hits_by_line[$ln]:-0} + if [ "$h" -gt 0 ]; then + echo 1 + return + fi + done + echo 0 +} + # Compute branch hit data for a file. # Output format: ||| # block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). # An arm is "taken" iff at least one executable line inside its range -# has a recorded hit. taken_count is 0 (not taken) or 1 (taken). MVP -# does not preserve actual hit counts per arm. +# has a recorded hit. taken_count is 0 or 1 — MVP does not preserve +# per-arm hit counts. function bashunit::coverage::compute_branch_hits() { local file="$1" - # Pre-load hits keyed by line number local -a hits_by_line=() local _hl_ln _hl_cnt while IFS=: read -r _hl_ln _hl_cnt; do [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt done < <(bashunit::coverage::get_all_line_hits "$file") - # Pre-load source lines for is_executable_line checks local -a src_lines=() local _sli=0 _sl while IFS= read -r _sl || [ -n "$_sl" ]; do @@ -988,39 +964,29 @@ function bashunit::coverage::compute_branch_hits() { done <"$file" local block=0 - local branch_entry decision_line kind arms rest + local branch_entry decision_line arms rest remaining arm arm_start arm_end taken arm_index while IFS= read -r branch_entry; do [ -z "$branch_entry" ] && continue decision_line="${branch_entry%%|*}" rest="${branch_entry#*|}" - kind="${rest%%|*}" + # Skip the kind field — reserved for future BRDA grouping but not + # needed by this MVP output. arms="${rest#*|}" - : "$kind" # currently unused in output; reserved for future BRDA grouping - - local arm_index=0 - local arm - local IFS_orig="$IFS" - IFS=',' - # shellcheck disable=SC2086 - set -- $arms - IFS="$IFS_orig" - - for arm in "$@"; do - local arm_start="${arm%%:*}" - local arm_end="${arm##*:}" - local taken=0 - local ln - for ((ln = arm_start; ln <= arm_end; ln++)); do - local content="${src_lines[$((ln - 1))]:-}" - if bashunit::coverage::is_executable_line "$content" "$ln"; then - local h=${hits_by_line[$ln]:-0} - if [ "$h" -gt 0 ]; then - taken=1 - break - fi - fi - done + + arm_index=0 + remaining="$arms" + while [ -n "$remaining" ]; do + arm="${remaining%%,*}" + if [ "$arm" = "$remaining" ]; then + remaining="" + else + remaining="${remaining#*,}" + fi + + arm_start="${arm%%:*}" + arm_end="${arm##*:}" + taken=$(bashunit::coverage::_arm_taken "$arm_start" "$arm_end") echo "${decision_line}|${block}|${arm_index}|${taken}" arm_index=$((arm_index + 1)) @@ -1375,17 +1341,9 @@ function bashunit::coverage::report_lcov() { echo "FNH:$fn_hit" # Branch records (BRDA/BRF/BRH) - local br_total=0 br_hit=0 br_entry - while IFS= read -r br_entry; do - [ -z "$br_entry" ] && continue - local br_line br_block br_idx br_taken br_rest - br_line="${br_entry%%|*}" - br_rest="${br_entry#*|}" - br_block="${br_rest%%|*}" - br_rest="${br_rest#*|}" - br_idx="${br_rest%%|*}" - br_taken="${br_rest#*|}" - + local br_total=0 br_hit=0 br_line br_block br_idx br_taken + while IFS='|' read -r br_line br_block br_idx br_taken; do + [ -z "$br_line" ] && continue echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" br_total=$((br_total + 1)) [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) From 967489baa51a702030c5e2b8428ebc80cdc37e53 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 10:20:39 +0200 Subject: [PATCH 14/18] docs(coverage): expand branch coverage section with worked example Promotes branch coverage to a top-level section with: a what-counts table, the two opt-in env vars (SHOW_FUNCTIONS, SHOW_UNCOVERED), a worked example showing the full LCOV output for a partially-tested if/elif/else chain, genhtml integration command, and a Codecov gate recipe. Adds FN/FNDA/FNF/FNH and BRDA/BRF/BRH rows to the LCOV field reference table. --- docs/coverage.md | 143 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 11 deletions(-) diff --git a/docs/coverage.md b/docs/coverage.md index 0cb32164..27f9bdd4 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -278,6 +278,13 @@ end_of_record |-------|-------------|---------| | `TN:` | Test Name (usually empty) | `TN:` | | `SF:` | Source File path | `SF:/home/user/project/src/math.sh` | +| `FN:` | Function: `start_line,name` | `FN:5,multiply` | +| `FNDA:` | Function call data: `count,name` (1 if any line in body was hit, else 0) | `FNDA:1,add` | +| `FNF:` | Functions Found | `FNF:2` | +| `FNH:` | Functions Hit | `FNH:1` | +| `BRDA:` | Branch data: `decision_line,block,arm,taken` | `BRDA:12,0,1,1` | +| `BRF:` | Branches Found | `BRF:6` | +| `BRH:` | Branches Hit | `BRH:4` | | `DA:` | Line Data: `line_number,hit_count` | `DA:15,3` (line 15 hit 3 times) | | `LF:` | Lines Found (total executable lines) | `LF:25` | | `LH:` | Lines Hit (lines with hits > 0) | `LH:20` | @@ -351,6 +358,131 @@ These lines are not counted toward coverage: - Control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `in`) - Case statement patterns (`--option)`, `*)`) and terminators (`;;`, `;&`, `;;&`) +## Branch Coverage + +Beyond line and function coverage, bashunit emits **branch coverage** records in the LCOV report so reviewers can see whether each `else`/`elif` arm and each `case` pattern was exercised. Branch records are produced automatically; no extra flags are needed. + +### What Counts as a Branch + +| Construct | Arms | +|-----------|------| +| `if X; then ... fi` | 1 (the `then` body) | +| `if X; then ... else ... fi` | 2 (`then` + `else`) | +| `if X; then ... elif Y; then ... else ... fi` | 3 (one per arm) | +| `case X in a) ... ;; b) ... ;; *) ... ;; esac` | one per pattern | + +An arm is reported as **taken** iff at least one executable line inside its range was hit by tests. + +### Verbose Output Helpers + +Two opt-in environment variables enrich the text report when investigating coverage gaps: + +::: code-group +```bash [Per-function block] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true bashunit tests/ --coverage +``` +```bash [Uncovered lines block] +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true bashunit tests/ --coverage +``` +```bash [Both] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true \ +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \ + bashunit tests/ --coverage +``` +::: + +The default text report stays compact; opt in only when triaging. + +### Worked Example + +Given `src/route.sh`: + +```bash +#!/usr/bin/env bash +function route() { + if [ "$1" = "GET" ]; then + echo "fetch" + elif [ "$1" = "POST" ]; then + echo "create" + else + echo "405" + fi +} +``` + +If tests only call `route GET`, the LCOV record looks like: + +``` +TN: +SF:/path/to/src/route.sh +FN:2,route +FNDA:1,route +FNF:1 +FNH:1 +BRDA:3,0,0,1 +BRDA:3,0,1,0 +BRDA:3,0,2,0 +BRF:3 +BRH:1 +DA:3,1 +DA:4,1 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +LF:6 +LH:2 +end_of_record +``` + +**Reading the branch records:** +- `BRDA:3,0,0,1`: decision on line 3, block 0, arm 0 (`then`/GET), taken. +- `BRDA:3,0,1,0`: same decision, arm 1 (`elif`/POST), not taken. +- `BRDA:3,0,2,0`: same decision, arm 2 (`else`/405), not taken. +- `BRF:3` `BRH:1`: 3 branches found, 1 taken. + +### Visualizing with genhtml + +LCOV's `genhtml` renders branch coverage alongside line and function coverage: + +::: code-group +```bash [Generate] +bashunit tests/ --coverage +genhtml --branch-coverage coverage/lcov.info -o coverage/html +``` +::: + +The resulting site shows a red/green diamond next to each branch decision, mirroring `gcov`'s C/C++ output. + +### CI Integration + +Codecov and Coveralls pick up the new records without configuration. To require branch coverage in PR gates: + +::: code-group +```yaml [Codecov] +coverage: + status: + project: + default: + target: 80% + patch: + default: + target: 80% + threshold: 0% + flags: + - branch +``` +::: + +### Limitations + +- An arm whose body has no executable lines (only comments or braces) registers as not-taken even when the conditional fired. +- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted. +- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. +- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked. + +See `adrs/adr-007-branch-coverage-mvp.md` for the design rationale and the rejected alternatives. + ## Limitations ### External Commands @@ -368,14 +500,3 @@ Due to Bash's process model, hits produced inside a subshell are written to the - Functions invoked from `$( ... )`: the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell. These contracts are pinned by `tests/unit/coverage_subshell_test.sh`. - -### Branch Coverage Scope - -The LCOV report includes `BRDA`/`BRF`/`BRH` records for `if`/`elif`/`else` chains and `case` patterns. An arm is reported as taken iff at least one executable line inside its range was hit. Known limitations: - -- An arm whose body has no executable lines (e.g. only comments or braces) registers as not-taken even when the conditional fired. -- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted. -- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. -- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked. - -See `adrs/adr-007-branch-coverage-mvp.md` for the full design rationale. From 06eceba68952a1bb495670c111160fe56d201865 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 14:43:28 +0200 Subject: [PATCH 15/18] refactor(coverage): split branch parser into per-construct handlers Extracts six small handlers (_branch_push_if, _branch_close_if_arm, _branch_emit_if, _branch_push_case, _branch_close_case_arm, _branch_emit_case, _branch_open_case_pattern) that operate on the state arrays kept as locals in extract_branches via Bash's dynamic scoping. The main loop becomes a straightforward dispatch over the first token of each line. Bash 3.0+ compatibility preserved (no namerefs, no associative arrays); verified on /bin/bash 3.2.57 with the full unit suite in both sequential and parallel modes. --- src/coverage.sh | 158 ++++++++++++++++++++++++++---------------------- 1 file changed, 86 insertions(+), 72 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 8514f6a4..0655baf7 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -815,110 +815,124 @@ function bashunit::coverage::_is_case_pattern_line() { # Output format: ||:[,:]... # kind ∈ {if, case} # Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. +# The handlers below operate on the per-construct state arrays that +# extract_branches keeps as locals. Bash 3.0 has dynamic scoping for +# `local` vars, so the helpers see and mutate the caller's state +# without needing namerefs (which would require Bash 4.3+). + +function bashunit::coverage::_branch_push_if() { + local lineno=$1 + if_decision_line[if_depth]=$lineno + if_arms[if_depth]="" + if_arm_start[if_depth]=$((lineno + 1)) + if_depth=$((if_depth + 1)) +} + +function bashunit::coverage::_branch_close_if_arm() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + if_arm_start[idx]=$((lineno + 1)) +} + +function bashunit::coverage::_branch_emit_if() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" + if_depth=$idx +} + +function bashunit::coverage::_branch_push_case() { + local lineno=$1 + case_decision_line[case_depth]=$lineno + case_arms[case_depth]="" + case_arm_start[case_depth]=0 + case_in_pattern[case_depth]=0 + case_depth=$((case_depth + 1)) +} + +function bashunit::coverage::_branch_close_case_arm() { + local lineno=$1 idx=$((case_depth - 1)) + [ "${case_in_pattern[$idx]}" = "1" ] || return 0 + bashunit::coverage::_append_arm \ + "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + case_in_pattern[idx]=0 +} + +function bashunit::coverage::_branch_emit_case() { + local lineno=$1 idx=$((case_depth - 1)) + bashunit::coverage::_branch_close_case_arm "$lineno" + if [ -n "${case_arms[$idx]}" ]; then + echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" + fi + case_depth=$idx +} + +function bashunit::coverage::_branch_open_case_pattern() { + local lineno=$1 idx=$((case_depth - 1)) + case_arm_start[idx]=$((lineno + 1)) + case_in_pattern[idx]=1 +} + function bashunit::coverage::extract_branches() { local file="$1" - # Pre-load lines for indexed access local -a lines=() local _i=0 _l while IFS= read -r _l || [ -n "$_l" ]; do lines[_i]="$_l" ((++_i)) done <"$file" - local total_lines=$_i - local lineno=0 - # Nested decision contexts live in parallel indexed arrays since - # Bash 3.0 has no associative arrays. Each array is keyed by depth. + # State arrays — read and mutated by the _branch_* helpers via Bash's + # dynamic scoping. Each array is keyed by depth so nested constructs + # work without associative arrays. local -a if_decision_line=() if_arms=() if_arm_start=() local if_depth=0 - local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() local case_depth=0 - local trimmed first idx prev_end + local lineno=0 line trimmed first while [ "$lineno" -lt "$total_lines" ]; do - local line="${lines[$lineno]}" + line="${lines[$lineno]}" lineno=$((lineno + 1)) trimmed="${line#"${line%%[![:space:]]*}"}" case "$trimmed" in '' | '#'*) continue ;; esac first="${trimmed%%[[:space:]\;]*}" - prev_end=$((lineno - 1)) - # Reserved-word patterns are single-quoted to avoid Bash parser - # confusion with the surrounding `case ... esac`. + # Reserved-word patterns single-quoted to dodge `case ... esac` + # parser confusion. case "$first" in - 'if') - if_decision_line[if_depth]=$lineno - if_arms[if_depth]="" - if_arm_start[if_depth]=$((lineno + 1)) - if_depth=$((if_depth + 1)) - continue - ;; + 'if') bashunit::coverage::_branch_push_if "$lineno" ;; 'elif' | 'else') - if [ "$if_depth" -gt 0 ]; then - idx=$((if_depth - 1)) - bashunit::coverage::_append_arm "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$prev_end" - if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" - if_arm_start[idx]=$((lineno + 1)) - fi - continue + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" ;; 'fi') - if [ "$if_depth" -gt 0 ]; then - idx=$((if_depth - 1)) - bashunit::coverage::_append_arm "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$prev_end" - echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" - if_depth=$idx - fi - continue - ;; - 'case') - case_decision_line[case_depth]=$lineno - case_arms[case_depth]="" - case_arm_start[case_depth]=0 - case_in_pattern[case_depth]=0 - case_depth=$((case_depth + 1)) - continue + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" ;; + 'case') bashunit::coverage::_branch_push_case "$lineno" ;; 'esac') - if [ "$case_depth" -gt 0 ]; then - idx=$((case_depth - 1)) - if [ "${case_in_pattern[$idx]}" = "1" ]; then - bashunit::coverage::_append_arm "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$prev_end" - case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" - fi - if [ -n "${case_arms[$idx]}" ]; then - echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" - fi - case_depth=$idx - fi - continue + [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" ;; - esac - - # Inside a case body: `;;` / `;&` / `;;&` close the current arm, - # `pattern)` opens a new one. - [ "$case_depth" -eq 0 ] && continue - idx=$((case_depth - 1)) - - case "$trimmed" in - ';;&'* | ';;'* | ';&'*) - if [ "${case_in_pattern[$idx]}" = "1" ]; then - bashunit::coverage::_append_arm "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$prev_end" - case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" - case_in_pattern[idx]=0 - fi - continue + *) + [ "$case_depth" -eq 0 ] && continue + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + bashunit::coverage::_branch_close_case_arm "$lineno" + ;; + *) + if bashunit::coverage::_is_case_pattern_line "$trimmed"; then + bashunit::coverage::_branch_open_case_pattern "$lineno" + fi + ;; + esac ;; esac - - if bashunit::coverage::_is_case_pattern_line "$trimmed"; then - case_arm_start[idx]=$((lineno + 1)) - case_in_pattern[idx]=1 - fi done } From c0593b170c3dd48ed83e42a520a93db383bbb467 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 15:31:45 +0200 Subject: [PATCH 16/18] refactor(coverage): tighten LCOV record parsing and branch hit walk Replaces verbose "%%|*" / "%%|*" parameter-expansion peels with a single IFS='|' read across the three pipe-delimited record formats this module emits (function, branch, function-coverage row), and folds the comma-separated arms split in compute_branch_hits into a one-line IFS=',' read -ra. Net -26 lines, no behavior change. Verified on /bin/bash 3.2.57 with the full unit suite in sequential and parallel modes, plus make sa and make lint. --- src/coverage.sh | 76 ++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 51 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 0655baf7..d7860400 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -977,31 +977,17 @@ function bashunit::coverage::compute_branch_hits() { ((++_sli)) done <"$file" - local block=0 - local branch_entry decision_line arms rest remaining arm arm_start arm_end taken arm_index + local block=0 decision_line _kind arms branch_entry + local -a arm_specs=() + local arm arm_index taken while IFS= read -r branch_entry; do [ -z "$branch_entry" ] && continue - - decision_line="${branch_entry%%|*}" - rest="${branch_entry#*|}" - # Skip the kind field — reserved for future BRDA grouping but not - # needed by this MVP output. - arms="${rest#*|}" + IFS='|' read -r decision_line _kind arms <<<"$branch_entry" arm_index=0 - remaining="$arms" - while [ -n "$remaining" ]; do - arm="${remaining%%,*}" - if [ "$arm" = "$remaining" ]; then - remaining="" - else - remaining="${remaining#*,}" - fi - - arm_start="${arm%%:*}" - arm_end="${arm##*:}" - taken=$(bashunit::coverage::_arm_taken "$arm_start" "$arm_end") - + IFS=',' read -ra arm_specs <<<"$arms" + for arm in "${arm_specs[@]}"; do + taken=$(bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}") echo "${decision_line}|${block}|${arm_index}|${taken}" arm_index=$((arm_index + 1)) done @@ -1263,26 +1249,20 @@ function bashunit::coverage::report_text_functions() { fi echo "${display_file}" - local fn_entry - while IFS= read -r fn_entry; do - [ -z "$fn_entry" ] && continue - local fn_name fn_start fn_end fn_rest - fn_name="${fn_entry%%|*}" - fn_rest="${fn_entry#*|}" - fn_start="${fn_rest%%|*}" - fn_end="${fn_rest#*|}" + local fn_name fn_start fn_end ln fn_executable fn_hit + local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue - local fn_executable=0 fn_hit=0 ln + fn_executable=0 + fn_hit=0 for ((ln = fn_start; ln <= fn_end; ln++)); do - local ln_content="${file_lines[$((ln - 1))]:-}" - if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then - fn_executable=$((fn_executable + 1)) - local ln_hits=${hits_by_line[$ln]:-0} - [ "$ln_hits" -gt 0 ] && fn_hit=$((fn_hit + 1)) - fi + bashunit::coverage::is_executable_line \ + "${file_lines[$((ln - 1))]:-}" "$ln" || continue + fn_executable=$((fn_executable + 1)) + [ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) done - local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") color=$(bashunit::coverage::get_color_for_class "$fn_class") @@ -1318,25 +1298,20 @@ function bashunit::coverage::report_lcov() { [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count done < <(bashunit::coverage::get_all_line_hits "$file") - # Function records (FN/FNDA/FNF/FNH) - local fn_total=0 fn_hit=0 - local fn_entry fn_name fn_start fn_end fn_rest + # Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk + # and buffer the matching FNDA lines for emission after, per + # LCOV convention. + local fn_total=0 fn_hit=0 fn_name fn_start fn_end fln any_hit local -a fn_dn_records=() local _fdi=0 - while IFS= read -r fn_entry; do - [ -z "$fn_entry" ] && continue - fn_name="${fn_entry%%|*}" - fn_rest="${fn_entry#*|}" - fn_start="${fn_rest%%|*}" - fn_end="${fn_rest#*|}" + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue echo "FN:${fn_start},${fn_name}" fn_total=$((fn_total + 1)) - # Function is "hit" if any executable line in its range has hits - local fln any_hit=0 + any_hit=0 for ((fln = fn_start; fln <= fn_end; fln++)); do - local fc="${hits_by_line[$fln]:-0}" - if [ "$fc" -gt 0 ]; then + if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then any_hit=1 break fi @@ -1346,7 +1321,6 @@ function bashunit::coverage::report_lcov() { [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) done < <(bashunit::coverage::extract_functions "$file") - # Emit FNDA records grouped after FN records (per LCOV convention) local fda for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do echo "$fda" From cfdee384d5f06ff655df2405dc6efc03ff73553d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 15:50:29 +0200 Subject: [PATCH 17/18] refactor(coverage): drop subshell capture in branch hit walk Converts _arm_taken from echo-via-subshell to a global-out (_BASHUNIT_ARM_TAKEN_OUT) so compute_branch_hits no longer pays a subshell per arm. Pulls the consecutive-line range compression in the uncovered-lines text report into _compress_ranges so the loop body stays focused on filtering and the formatting concern lives in one named helper. Bash 3.0+ verified on /bin/bash 3.2.57: 40 reporting+branch tests pass, 809 unit tests pass under --parallel, make sa and make lint stay green. --- src/coverage.sh | 97 ++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index d7860400..29c1257e 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -936,23 +936,23 @@ function bashunit::coverage::extract_branches() { done } -# Returns 1 (true/taken) iff any executable line in [arm_start..arm_end] -# has a recorded hit. Caller must have populated the hits_by_line and -# src_lines arrays in scope (Bash 3.0 cannot pass arrays in). -# Result is echoed as "0" or "1" so the caller can capture it. +# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in +# [arm_start..arm_end] has a recorded hit, else 0. Caller must have +# populated the hits_by_line and src_lines arrays in scope; Bash 3.0 +# cannot pass arrays into a function. Result is returned via the +# global to avoid a per-arm subshell. +_BASHUNIT_ARM_TAKEN_OUT=0 function bashunit::coverage::_arm_taken() { - local arm_start="$1" arm_end="$2" - local ln content h + local arm_start="$1" arm_end="$2" ln for ((ln = arm_start; ln <= arm_end; ln++)); do - content="${src_lines[$((ln - 1))]:-}" - bashunit::coverage::is_executable_line "$content" "$ln" || continue - h=${hits_by_line[$ln]:-0} - if [ "$h" -gt 0 ]; then - echo 1 + bashunit::coverage::is_executable_line \ + "${src_lines[$((ln - 1))]:-}" "$ln" || continue + if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then + _BASHUNIT_ARM_TAKEN_OUT=1 return fi done - echo 0 + _BASHUNIT_ARM_TAKEN_OUT=0 } # Compute branch hit data for a file. @@ -979,7 +979,7 @@ function bashunit::coverage::compute_branch_hits() { local block=0 decision_line _kind arms branch_entry local -a arm_specs=() - local arm arm_index taken + local arm arm_index while IFS= read -r branch_entry; do [ -z "$branch_entry" ] && continue IFS='|' read -r decision_line _kind arms <<<"$branch_entry" @@ -987,8 +987,8 @@ function bashunit::coverage::compute_branch_hits() { arm_index=0 IFS=',' read -ra arm_specs <<<"$arms" for arm in "${arm_specs[@]}"; do - taken=$(bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}") - echo "${decision_line}|${block}|${arm_index}|${taken}" + bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}" + echo "${decision_line}|${block}|${arm_index}|${_BASHUNIT_ARM_TAKEN_OUT}" arm_index=$((arm_index + 1)) done @@ -1140,6 +1140,38 @@ function bashunit::coverage::report_text() { fi } +# Compress a sorted list of integers into a comma-separated range +# string (e.g. "3 4 5 7 9 10" -> "3-5,7,9-10"). Result on +# _BASHUNIT_RANGES_OUT to avoid a subshell on each call. +_BASHUNIT_RANGES_OUT="" +function bashunit::coverage::_compress_ranges() { + local out="" start="" end="" n + for n in "$@"; do + if [ -z "$start" ]; then + start="$n" + end="$n" + elif [ "$n" -eq $((end + 1)) ]; then + end="$n" + else + if [ "$start" = "$end" ]; then + out="${out}${start}," + else + out="${out}${start}-${end}," + fi + start="$n" + end="$n" + fi + done + if [ -n "$start" ]; then + if [ "$start" = "$end" ]; then + out="${out}${start}" + else + out="${out}${start}-${end}" + fi + fi + _BASHUNIT_RANGES_OUT="${out%,}" +} + # List executable lines that were never hit, grouped by file. # Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed # when no uncovered lines exist so a fully-covered run stays quiet. @@ -1178,37 +1210,10 @@ function bashunit::coverage::report_text_uncovered() { fi local display_file="${file#"$(pwd)"/}" - local color reset="$_BASHUNIT_COLOR_DEFAULT" - color="$_BASHUNIT_COLOR_FAILED" - - # Compress consecutive line numbers into ranges (3-5 instead of 3,4,5) - local out="" prev_start="" prev_end="" ln - for ln in "${uncovered_lines[@]}"; do - if [ -z "$prev_start" ]; then - prev_start="$ln" - prev_end="$ln" - continue - fi - if [ "$ln" -eq $((prev_end + 1)) ]; then - prev_end="$ln" - else - if [ "$prev_start" = "$prev_end" ]; then - out="${out}${prev_start}," - else - out="${out}${prev_start}-${prev_end}," - fi - prev_start="$ln" - prev_end="$ln" - fi - done - if [ -n "$prev_start" ]; then - if [ "$prev_start" = "$prev_end" ]; then - out="${out}${prev_start}" - else - out="${out}${prev_start}-${prev_end}" - fi - fi - out="${out%,}" + local color="$_BASHUNIT_COLOR_FAILED" reset="$_BASHUNIT_COLOR_DEFAULT" + local out + bashunit::coverage::_compress_ranges "${uncovered_lines[@]}" + out="$_BASHUNIT_RANGES_OUT" printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" done < <(bashunit::coverage::get_tracked_files) From 137774366e1833fcf0df2e19a039e6b95a4c6b5a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 15:57:33 +0200 Subject: [PATCH 18/18] refactor(coverage): full Bash 3.0+ compliance Removes the dead get_function_coverage helper that relied on Bash 4.3+ nameref (local -n with a silent fall-through that left _hits_ref unset on Bash 3, returning all-zero coverage). The function had no callers; HTML and LCOV reports already inline the same hit-walk. Replaces the [[ ... ]] test operator with [ ... ] in the four coverage test files (coverage_core_test.sh, coverage_executable_test.sh, coverage_helpers_test.sh, coverage_reporting_test.sh) so the project's documented prohibition is uniformly applied. The whole coverage subtree now passes the Bash 3.0+ audit: zero [[, declare -A, ${var,,}, ${var^^}, ${arr[-1]}, &>>, or local -n outside string literals. Verified on /bin/bash 3.2.57 with 119 coverage tests plus the full unit suite (809 passed) under --parallel. --- src/coverage.sh | 43 -------------------------- tests/unit/coverage_core_test.sh | 13 ++++---- tests/unit/coverage_executable_test.sh | 13 ++++---- tests/unit/coverage_helpers_test.sh | 13 ++++---- tests/unit/coverage_reporting_test.sh | 13 ++++---- 5 files changed, 28 insertions(+), 67 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 29c1257e..ba228fa9 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -996,49 +996,6 @@ function bashunit::coverage::compute_branch_hits() { done < <(bashunit::coverage::extract_branches "$file") } -# Calculate coverage for a specific function in a file -# Returns: hit_lines:executable_lines:percentage -function bashunit::coverage::get_function_coverage() { - local file="$1" - local fn_start="$2" - local fn_end="$3" - shift 3 - - # Accept hits_by_line array as nameref (Bash 4.3+) or fall back to counting - local -n _hits_ref=$1 2>/dev/null || true - - local executable=0 - local hit=0 - local lineno=0 - - # Pre-load file lines into indexed array (avoids sed per line) - local -a fn_lines=() - local _fli=0 _fl - while IFS= read -r _fl || [ -n "$_fl" ]; do - fn_lines[_fli]="$_fl" - ((++_fli)) - done <"$file" - - for ((lineno = fn_start; lineno <= fn_end; lineno++)); do - local line_content="${fn_lines[$((lineno - 1))]:-}" - - if bashunit::coverage::is_executable_line "$line_content" "$lineno"; then - ((++executable)) - local line_hits=${_hits_ref[$lineno]:-0} - if [ "$line_hits" -gt 0 ]; then - ((++hit)) - fi - fi - done - - local pct=0 - if [ "$executable" -gt 0 ]; then - pct=$((hit * 100 / executable)) - fi - - echo "${hit}:${executable}:${pct}" -} - function bashunit::coverage::get_percentage() { local total_executable=0 local total_hit=0 diff --git a/tests/unit/coverage_core_test.sh b/tests/unit/coverage_core_test.sh index 2ded4a14..fdf0fc34 100644 --- a/tests/unit/coverage_core_test.sh +++ b/tests/unit/coverage_core_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_executable_test.sh b/tests/unit/coverage_executable_test.sh index 9e257752..e87a0116 100644 --- a/tests/unit/coverage_executable_test.sh +++ b/tests/unit/coverage_executable_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_helpers_test.sh b/tests/unit/coverage_helpers_test.sh index 15112d46..2439a82e 100644 --- a/tests/unit/coverage_helpers_test.sh +++ b/tests/unit/coverage_helpers_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 079c8951..bf09c265 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN