Problem
report_lcov is a Bash loop over every line of every tracked source file, and it is the largest single cost in a coverage run.
Measured on Bash 3.2 arm64, macOS, report phase only, synthetic hits:
| tracked files | report_lcov | share of default report |
|---|
| 11 | 0.91s | 72% |
| 40 | 2.66s | 69% |
| 121 | 9.93s | 61% |
At 121 files the default report phase (precompute plus text plus LCOV) is 16.4s, of which 9.93s is LCOV emission. src/ is 18,751 lines, and the emitter walks all of them in Bash while calling is_executable_line per line.
.claude/rules/perf-fork-budget.md already records the rule this violates: a while read loop lost to a single awk fork by 5x on 600 lines, and file scans stay awk for that reason. LCOV emission is an 18,000-line file scan written as a Bash loop.
Proposal
Move line classification and LCOV emission into one awk program per run.
- Port the
is_executable_line rules (src/coverage/lines.sh:21-115) to awk. The rules are pure text tests, no shell state. - Feed awk the tracked-file list plus the aggregated hit counts, emit the whole LCOV file in one pass.
- Keep the Bash implementation as the reference for the differential test, not as a second code path shipped to users.
The classifier's documented quirks must be reproduced exactly, or coverage numbers move silently. .claude/rules/perf-fork-budget.md prescribes the method used for #1005: run both implementations over every git ls-files '*.sh' line, compare per line, and mutation-test the harness so a differential that cannot fail is not mistaken for a passing one.
Two known traps from #1005, both of which change numbers without erroring:
- inside a POSIX bracket expression a backslash is a literal member of the set, so
[^\)] also excludes \ [\{\}] matches a lone \, so a bare line continuation counts as a brace-only line
Note the ordering with the classifier bug fix: if the case-pattern rule is corrected first, port the corrected rules and diff against the corrected Bash version.
Where to change
src/coverage/report_lcov.sh the emittersrc/coverage/lines.sh:21-115 the rules being ported, kept as the reference implementationtests/unit/coverage/executable_test.sh the rule tests, which must pass against both implementations- new differential test covering every tracked
*.sh file in the repo
Acceptance criteria
Repo checklist (agent)
- TDD: RED then GREEN then REFACTOR. Write the failing test first.
- Bash 3.0+ only in the shell parts: no
printf -v, no += append, no declare -A, no [[ ]], no ${var,,}, no &>>, no ${arr[-1]}. - Keep the awk program in a single-quoted variable like
_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK (src/coverage/engine.sh:132) so the build's line-length and quoting rules hold. - 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). - Consider an ADR: this changes which language owns the classification rules.
- One issue = one PR.
Problem
report_lcovis a Bash loop over every line of every tracked source file, and it is the largest single cost in a coverage run.Measured on Bash 3.2 arm64, macOS, report phase only, synthetic hits:
report_lcovAt 121 files the default report phase (precompute plus text plus LCOV) is 16.4s, of which 9.93s is LCOV emission.
src/is 18,751 lines, and the emitter walks all of them in Bash while callingis_executable_lineper line..claude/rules/perf-fork-budget.mdalready records the rule this violates: awhile readloop lost to a single awk fork by 5x on 600 lines, and file scans stay awk for that reason. LCOV emission is an 18,000-line file scan written as a Bash loop.Proposal
Move line classification and LCOV emission into one awk program per run.
is_executable_linerules (src/coverage/lines.sh:21-115) to awk. The rules are pure text tests, no shell state.The classifier's documented quirks must be reproduced exactly, or coverage numbers move silently.
.claude/rules/perf-fork-budget.mdprescribes the method used for #1005: run both implementations over everygit ls-files '*.sh'line, compare per line, and mutation-test the harness so a differential that cannot fail is not mistaken for a passing one.Two known traps from #1005, both of which change numbers without erroring:
[^\)]also excludes\[\{\}]matches a lone\, so a bare line continuation counts as a brace-only lineNote the ordering with the classifier bug fix: if the case-pattern rule is corrected first, port the corrected rules and diff against the corrected Bash version.
Where to change
src/coverage/report_lcov.shthe emittersrc/coverage/lines.sh:21-115the rules being ported, kept as the reference implementationtests/unit/coverage/executable_test.shthe rule tests, which must pass against both implementations*.shfile in the repoAcceptance criteria
git ls-files '*.sh'file and fails on any disagreementreport_lcovat 121 tracked files is measurably faster, before and after numbers in the PR descriptiontests/acceptance/bashunit_coverage_forks_test.shalready uses$AWKis used, not a bareawk, so the pinned binary rule holds (src/config/env.sh)trapandxtrace)Repo checklist (agent)
printf -v, no+=append, nodeclare -A, no[[ ]], no${var,,}, no&>>, no${arr[-1]}._BASHUNIT_COVERAGE_XTRACE_EMIT_AWK(src/coverage/engine.sh:132) so the build's line-length and quoting rules hold.make sa,make lint,./bashunit tests/,./bashunit --parallel tests/. Never runshfmt -w.## Unreleased(performance entry with the measured factor).