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:104 get_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-116 the stats cache and get_cached_stats
src/coverage/report_text.sh:39-62 iterate the precomputed arrays
src/coverage/paths.sh:5-7 cache declarations
src/coverage/engine.sh:215-273 record_line, both hot-path caches
src/coverage/config.sh:90-103 cache reset in init
Acceptance criteria
Repo checklist (agent)
- TDD: RED then GREEN then REFACTOR. Write the failing test first.
- Bash 3.0+ only: no
printf -v, no += append, no declare -A, no [[ ]], no ${var,,}, no &>>, no ${arr[-1]}. Expanding a possibly-empty array under set -u needs ${arr[@]+"${arr[@]}"}.
- Dynamic scope: everything written through
eval must stay in the _BASHUNIT_* namespace, see .claude/rules/bash-style.md.
- Gates:
make sa, make lint, ./bashunit tests/, ./bashunit --parallel tests/. Never run shfmt -w.
- CHANGELOG.md: one line under
## Unreleased (performance entry with the measured factor).
- One issue = one PR.
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 lineMeasured on Bash 3.2 arm64, macOS.
report_textuses the stats cache and does nothing else expensive, yet its per-file cost grows faster than the file count:report_textIsolated 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_texttime. 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):${!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 thatperf-fork-budget.mdwarns about for large strings.For the stats cache there is a simpler fix that removes the lookup entirely:
precompute_file_statsalready walksget_tracked_filesin order and fills the_BASHUNIT_COVERAGE_STATS_*indexed arrays.report_textshould 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 ininitAcceptance criteria
report_textcost per file stops growing with file count (measure at 11, 40, 80, 121 tracked files)bashunit::coverage::initso a second run in the same shell cannot inherit stale decisions--parallel, where each worker builds its own cachesBASHUNIT_COVERAGE_ENGINE=trapandxtrace)Repo checklist (agent)
printf -v, no+=append, nodeclare -A, no[[ ]], no${var,,}, no&>>, no${arr[-1]}. Expanding a possibly-empty array underset -uneeds${arr[@]+"${arr[@]}"}.evalmust stay in the_BASHUNIT_*namespace, see.claude/rules/bash-style.md.make sa,make lint,./bashunit tests/,./bashunit --parallel tests/. Never runshfmt -w.## Unreleased(performance entry with the measured factor).