From 7688aa8a779e6e781ea4428898cbd4ca67dec2c2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 4 May 2026 09:50:40 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 }