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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@

## Unreleased

### Added
- Branch coverage now reports loop constructs (`while`/`until`/`for`/`select`): the loop body is a single-arm branch, marked covered only when the loop ran at least once, so a never-entered zero-iteration loop surfaces as an uncovered branch (#855)

### Changed
- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844)
- The array, date, duration, json, files and folders assertions now resolve their failure label through the fork-free slot helper instead of a per-call command substitution β€” same labels, fewer forks
Expand Down
17 changes: 16 additions & 1 deletion adrs/adr-007-branch-coverage-mvp.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,22 @@ 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`).

## Update (2026-07-24): loop-body branches

Loop constructs (`while`/`until`/`for`/`select`) are now emitted as **single-arm**
branch points (`<line>|loop|<body_start>:<body_end>`), extending the same static
+ line-hit-inference model with no runtime-cost change. The body arm is "taken"
iff an executable line inside it was hit β€” i.e. the loop ran at least once β€” so a
never-entered loop body surfaces as an uncovered zero-iteration branch. Every
`done`-closed opener (including `select`) pushes a loop frame so `done` pairs
with the correct opener when nested.

Still deferred, and for the same root reason β€” the DEBUG trap records **line**
hits, not sub-line outcomes: same-line short-circuit guards (`[ cond ] && action`,
`... || action`) cannot be measured, because both outcomes share one line. Those
require a finer-grained mechanism (see the coverage-tracing-engine spike) rather
than the line-range model.

## Links

Expand Down
25 changes: 25 additions & 0 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -940,6 +940,23 @@ function bashunit::coverage::_branch_open_case_pattern() {
case_in_pattern[idx]=1
}

# A loop (while/until/for/select) is a single-arm branch: its body. The arm is
# taken iff the loop ran at least once (an executable body line was hit); a
# never-taken body is an uncovered zero-iteration branch. Every `done`-closed
# construct must push so `done` pairs with the right opener when nested.
function bashunit::coverage::_branch_push_loop() {
local lineno=$1
loop_decision_line[loop_depth]=$lineno
loop_arm_start[loop_depth]=$((lineno + 1))
loop_depth=$((loop_depth + 1))
}

function bashunit::coverage::_branch_emit_loop() {
local lineno=$1 idx=$((loop_depth - 1))
echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))"
loop_depth=$idx
}

function bashunit::coverage::extract_branches() {
local file="$1"

Expand All@@ -958,6 +975,8 @@ function bashunit::coverage::extract_branches() {
local if_depth=0
local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=()
local case_depth=0
local -a loop_decision_line=() loop_arm_start=()
local loop_depth=0

local lineno=0 line trimmed first
while [ "$lineno" -lt "$total_lines" ]; do
Expand All@@ -982,6 +1001,12 @@ function bashunit::coverage::extract_branches() {
'esac')
[ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno"
;;
'while' | 'until' | 'for' | 'select')
bashunit::coverage::_branch_push_loop "$lineno"
;;
'done')
[ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$lineno"
;;
*)
[ "$case_depth" -eq 0 ] && continue
case "$trimmed" in
Expand Down
129 changes: 128 additions & 1 deletion tests/unit/coverage_branches_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,86 @@ function tear_down() {

# extract_branches output format:
# <decision_line>|<kind>|<arm_start>:<arm_end>[,<arm_start>:<arm_end>]...
# kind ∈ {if, case}
# kind ∈ {if, case, loop}
# A loop is a single-arm branch: the body range, taken iff the loop ran at
# least once (any executable body line was hit).

function test_extract_branches_finds_while_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_for_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2 3; do
echo "$x"
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_until_loop() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
until [ "$ok" = "yes" ]; do
ok=$(check)
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

assert_contains "2|loop|3:3" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_nested_loops() {
local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
for x in 1 2; do
while [ "$x" -gt 0 ]; do
echo hi
x=$((x - 1))
done
done
EOF

local result
result=$(bashunit::coverage::extract_branches "$fixture")

# Inner while (line 3) body on 4-5; outer for (line 2) body on 3-6.
assert_contains "3|loop|4:5" "$result"
assert_contains "2|loop|3:6" "$result"

rm -f "$fixture"
}

function test_extract_branches_finds_simple_if_else() {
local fixture
Expand DownExpand Up@@ -209,6 +288,54 @@ EOF
rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_taken_when_iterated() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# The loop body (line 3) ran at least once.
echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE"

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

# Single-arm loop branch on line 2, arm taken.
assert_contains "2|0|0|1" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_marks_loop_body_not_taken_on_zero_iterations() {
bashunit::coverage::init

local fixture
fixture=$(mktemp)
cat >"$fixture" <<'EOF'
#!/usr/bin/env bash
while [ "$i" -lt 3 ]; do
echo "$i"
done
EOF

echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES"
# No hits recorded: the loop never iterated (zero-iteration branch uncovered).

local result
result=$(bashunit::coverage::compute_branch_hits "$fixture")

assert_contains "2|0|0|0" "$result"

rm -f "$fixture"
}

function test_compute_branch_hits_assigns_distinct_blocks_per_decision() {
bashunit::coverage::init

Expand Down
Loading