Repository navigation
Description
Problem
Three coverage caches store their entries in one long string and look entries up with a leading-* glob. On Bash 3.2 that scan is quadratic in the number of entries, and at real file counts the cache costs more than the work it saves.
_BASHUNIT_COVERAGE_STATS_LOOKUP(src/coverage/stats.sh:104get_cached_stats), report path_BASHUNIT_COVERAGE_TRACK_CACHE(src/coverage/engine.sh:226), capture hot path, scanned per traced line_BASHUNIT_COVERAGE_PATH_CACHE(src/coverage/engine.sh:242), capture hot path, scanned per traced line
Measured on Bash 3.2 arm64, macOS. report_text uses the stats cache and does nothing else expensive, yet its per-file cost grows faster than the file count:
| tracked files | report_text |
per file |
|---|---|---|
| 11 | 0.03s | 3ms |
| 40 | 0.16s | 4ms |
| 80 | 0.85s | 11ms |
| 121 | 3.12s | 26ms |
Isolated micro-benchmark of the lookup alone, 121 entries, 8,254-byte lookup string, 605 lookups: 26.0 ms per lookup. 121 x 26ms = 3.1s, which is the whole measured report_text time. The cache is the cost.
The two capture-path caches use the same shape and are scanned once per traced line, so the same curve applies to every executed line of every tracked file.
Proposal
Replace string-scan lookup with Bash's own variable table, the associative-array emulation this repo already uses for spy state (_BASHUNIT_SPY_${variable}_TIMES_FILE, src/doubles/spy.sh):
# key -> a variable name in the reserved namespace
_bu_key="_BASHUNIT_COVERAGE_TRACK_${file//[^a-zA-Z0-9]/_}"
eval "$_bu_key=1"
decision="${!_bu_key:-}"${!name} is Bash 2+, ${var//pattern/} is Bash 3.0+, and the paths being sanitized are short, so the substitution stays off the quadratic path that perf-fork-budget.md warns about for large strings.
For the stats cache there is a simpler fix that removes the lookup entirely: precompute_file_stats already walks get_tracked_files in order and fills the _BASHUNIT_COVERAGE_STATS_* indexed arrays. report_text should iterate those arrays instead of re-reading the tracked list and looking each file up by name.
Where to change
src/coverage/stats.sh:66-116the stats cache andget_cached_statssrc/coverage/report_text.sh:39-62iterate the precomputed arrayssrc/coverage/paths.sh:5-7cache declarationssrc/coverage/engine.sh:215-273record_line, both hot-path cachessrc/coverage/config.sh:90-103cache reset ininit
Acceptance criteria
- Coverage totals, per-file rows and LCOV output are byte-identical to the current implementation for the same run
-
report_textcost per file stops growing with file count (measure at 11, 40, 80, 121 tracked files) - A file whose sanitized key collides with another file's key is handled, or collisions are proven impossible for the sanitizer chosen
- Cache state is reset by
bashunit::coverage::initso a second run in the same shell cannot inherit stale decisions - Works under
--parallel, where each worker builds its own caches - Both engines produce the same numbers (
BASHUNIT_COVERAGE_ENGINE=trapandxtrace)
Repo checklist (agent)
- TDD: RED then GREEN then REFACTOR. Write the failing test first.
- Bash 3.0+ only: no
printf -v, no+=append, nodeclare -A, no[[ ]], no${var,,}, no&>>, no${arr[-1]}. Expanding a possibly-empty array underset -uneeds${arr[@]+"${arr[@]}"}. - Dynamic scope: everything written through
evalmust stay in the_BASHUNIT_*namespace, see.claude/rules/bash-style.md. - Gates:
make sa,make lint,./bashunit tests/,./bashunit --parallel tests/. Never runshfmt -w. - CHANGELOG.md: one line under
## Unreleased(performance entry with the measured factor). - One issue = one PR.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Chemaclass commentedon Aug 12, 2026
Done in #1076 (merged).