Repository navigation
Description
Summary
src/coverage.sh is 2548 lines and 59 functions — the largest file in src/, bigger
than src/runner.sh was. Apply the same module split: src/coverage/ behind a thin
aggregator.
Blocked by #923 (path-keyed embed markers). Do after #924 so the
runner split sets the convention and this one just follows it.
This is a pure move
Namespace stays bashunit::coverage::*. No renames, no call-site changes,
no behavior changes. bashunit:76 keeps its single
source "$BASHUNIT_ROOT_DIR/src/coverage.sh"; that file becomes an aggregator
holding only source lines and comments.
Step 1: map the responsibilities first
Unlike #924, the target layout is not pre-decided here — nobody has mapped
this file yet. Produce the grouping before moving a single line:
- one-line summary per function
- internal call graph (which
bashunit::coverage::*functions call which) - every
_BASHUNIT_*global: which function assigns it, which read it, and whether
anything outsidesrc/coverage.shtouches it - proposed cohesive groups with an acyclic layering
Post that map on this issue for review, then split. Existing hints to fold in:
adrs/adr-007-branch-coverage-mvp.md and adrs/adr-009-coverage-tracing-engine.md
describe the intended internal boundaries, and the 9 existing
tests/unit/coverage_*_test.sh files (core, engine, branches, helpers, percent,
reporting, executable, subshell, parallel_aggregation) are a strong prior on what the
natural seams already are.
Known constraints specific to this file
.editorconfig:35has a per-file rule,[src/coverage.sh] max_line_length = unset. Splitting leaves the new files under the global[**.sh]
max_line_length = 120. The longest current line is 370 chars, so either widen the
glob to cover the directory, or reflow the offending lines. Decide deliberately and
say which in the PR — silently inheriting 120 will failmake lint.src/coverage.sh:224reads_BASHUNIT_RUNNER_RESULT_ORDINAL, a runner global.
That cross-module read stays as-is; do not "fix" it in this PR.- Intentional dynamic-scope mutation.
_branch_push_ifand friends deliberately
mutate caller locals (if_decision_line,if_arms,if_depth,if_arm_start),
documented inline atsrc/coverage.sh:818-821and called out in
.claude/rules/bash-style.md. Those helpers and their callers must land in the
same file, and the inline justification comment must travel with them. - DEBUG-trap code paths only run under
--coverage, which is a nightly
non-gating workflow (.github/workflows/coverage.yml). The default suite will not
catch a regression here — run./bashunit --coverage --parallel tests/explicitly
before opening the PR. src/coverage.sh:173carries a comment about a name kept "from the trap-only
era" that referencesrunner.sh. Update the reference if the runner path changed
in refactor(runner): split src/runner.sh into a src/runner/ module #924.
Tests: keep them flat
make test globs one level deep only. Do not create tests/unit/coverage/.
The existing 9 flat coverage_*_test.sh files already mirror a module split — keep
that shape and rename only where a file's scope genuinely moves.
Acceptance criteria
- Responsibility map posted on this issue and agreed before any code moves
-
git diffshows only relocations — no renamed functions, no changed logic -
src/coverage.shcontains onlysourcelines and comments - Every new file's first line is
#!/usr/bin/env bash -
.editorconfigline-length decision made explicitly and stated in the PR -
./bashunit tests/green -
./bashunit --parallel tests/green -
./bashunit --coverage --parallel tests/green -
make sa && make lintgreen -
bash build.sh bin -vprints✅ Build verified ✅ - Fork-budget acceptance tests green
- Bash 3.0+ compatible
- No CHANGELOG entry — internal, no user-visible behavior change
-
.claude/rules/architecture-map.mdcoverage row updated to the module table
Do not
- Do not start moving code before the responsibility map is reviewed
- Do not rename any function or change any behavior
- Do not create
tests/unit/coverage/ - Do not run
shfmt -w;make lintis the format gate
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Chemaclass commentedon Jul 30, 2026
Responsibility map (step 1 — for review before any code moves)
src/coverage.shonmain(c34bff5): 2548 lines, 59 functions. Data below is derivedmechanically from the file, not from memory.
Proposed layout — 12 files
coverage.shsourcelines + comments onlycoverage/config.shauto_discover_paths,init,xtrace_is_supported,resolve_engine— path discovery, engine selectioncoverage/paths.shnormalize_path,should_track,get_tracked_files,path_to_filename— is-this-file-tracked decisions + cachescoverage/engine.shenable_trap/disable_trap,_enable_xtrace/_disable_xtrace,record_line,flush_buffer,_resolve_output_files,finalize,cleanup,aggregate_parallel— capture, buffering, teardowncoverage/lines.shis_executable_line,get_executable_lines,get_hit_lines,compute_file_coverage,_ends_with_continuation,get_all_line_hits,load_hits_by_line,get_all_line_testscoverage/stats.shget_coverage_class,get_color_for_class,calculate_percentage,get_file_stats,precompute_file_stats,get_cached_stats,get_percentage,check_thresholdcoverage/functions.shextract_functionscoverage/branches.sh_append_arm,_is_case_pattern_line, the nine_branch_*,extract_branches,_arm_taken,compute_branch_hitscoverage/report_text.shreport_text+_uncovered/_line_hits/_functions,_compress_rangescoverage/report_lcov.shreport_lcovcoverage/report_html.shhtml_escape,report_html(the orchestrator)coverage/html_index.shgenerate_index_htmlcoverage/html_file.shgenerate_file_htmlAll 59 functions are assigned; none dropped, none duplicated (verified by set difference).
Layering is acyclic
Derived from all 91 internal call edges:
No edge runs backwards. Nothing in
paths/lines/stats/branchescalls intoengine,so the capture path is a true sink.
branchesdepends onlinesonly(
_arm_taken → is_executable_line,compute_branch_hits → load_hits_by_line).Globals
Every
_BASHUNIT_COVERAGE_*global stays with the group that writes it:config_DATA_FILE,_TRACKED_FILES,_TRACKED_CACHE_FILE,_TEST_HITS_FILE,_ENGINE_RESOLVED,_IS_PARALLELpaths_TRACK_CACHE,_PATH_CACHEengine_BUFFER,_BUFFER_COUNT,_BUFFER_LIMIT,_HITS_BUFFER,_XTRACE_*(6),_DATA_TARGET_OUT,_HITS_TARGET_OUT, both awk program stringslines_NONEXEC_PATTERN,_HITS_BY_LINEstats_STATS_FILES/EXEC/HIT/PCT/CLASS/COUNT/LOOKUPbranches_BASHUNIT_BRANCH_ARMS_OUT,_BASHUNIT_ARM_TAKEN_OUTreport_text_BASHUNIT_RANGES_OUTCrossing the module boundary — only three, all pre-existing:
_BASHUNIT_COVERAGE_CURRENT_TEST_FILEand_BASHUNIT_COVERAGE_CURRENT_TEST_FNarewritten by
src/runner/context.shand read here. Inbound; unchanged by the split._BASHUNIT_RUNNER_RESULT_ORDINALis read here and owned bysrc/runner/payload.sh.Stays a plain global — not to be "fixed" in this PR.
The
.editorconfigdecision — narrower than the issue assumedThe issue notes the 370-char line. The real number: 56 lines exceed 120 chars, and they
are concentrated almost entirely in the two HTML template emitters:
So the recommendation is not to widen the glob to the whole directory. Replace
[src/coverage.sh] max_line_length = unsetwith the same rule scoped tohtml_index.shandhtml_file.shonly, and reflow the remaining 6 lines. Every othermodule then honours the global 120 limit — a net tightening rather than a blanket exemption.
Constraints carried forward
branches.shmust stay one file.extract_brancheskeepsif_decision_line,if_arms,if_depth,if_arm_startas locals that the_branch_*helpers mutatethrough Bash's dynamic scoping. The justification comment (now
src/coverage.sh:1111-1112and
:1200-1201, not:818-821as the issue says) travels with them.--coveragepaths are nightly / non-gating (.github/workflows/coverage.yml), so./bashunit --coverage --parallel tests/has to be run explicitly before the PR.tests/unit/coverage_*_test.shfiles alreadyapproximate this split; they get renamed to match the new modules, never moved into
tests/unit/coverage/(make testglobs one level).src/coverage.sh:173's reference torunner.shwas already retargeted torunner/exec.sh+runner/hooks.shin refactor(runner): split src/runner.sh into a src/runner/ module #927.Verification plan
Same method that made #924 reviewable: compare the non-blank line multiset before and
after (must be identical, each line exactly once) and the function count (59), then
make sa,make lint, sequential,--parallel,--parallel --simple --strict,--coverage --parallel, andbash build.sh bin -v.Holding here for review, per acceptance criterion 1 — no code has moved. Two calls I'd
like confirmed before starting:
report_html.sh?.editorconfigscoping (2 files exempt, 6 lines reflowed) over wideningthe exemption to the whole
src/coverage/directory?Chemaclass commentedon Jul 31, 2026
Responsibility map for
src/coverage.shStep 1 deliverable, posted for review before any code moves. Measured against
c34bff5(post-#927): 2548 lines, 59 functions, 33 module-owned globals, 13 of those functions
called from outside the file.
Call graph and globals table below are generated from the file, not eyeballed.
Proposed layout
src/coverage/behind a thin aggregator, mirroringsrc/runner/. Namespace unchanged,no renames,
bashunit:76keeps its singlesource.coverage/paths.shnormalize_pathshould_trackauto_discover_pathsget_tracked_filescoverage/lines.shis_executable_lineget_executable_linesget_hit_linescoverage/hits.shget_all_line_hitsload_hits_by_lineget_all_line_tests_ends_with_continuationcoverage/functions.shextract_functionscoverage/branches.sh_append_arm_is_case_pattern_line_branch_push_if_branch_close_if_arm_branch_emit_if_branch_push_case_branch_close_case_arm_branch_emit_case_branch_open_case_pattern_branch_push_loop_branch_emit_loopextract_branches_arm_takencompute_branch_hitscoverage/engine.shinitcleanupxtrace_is_supportedresolve_engineenable_trapdisable_trap_enable_xtrace_disable_xtracerecord_line_resolve_output_filesflush_bufferfinalizecoverage/parallel.shaggregate_parallelcoverage/stats.shget_coverage_classget_color_for_classcalculate_percentagecompute_file_coverageget_file_statsprecompute_file_statsget_cached_statsget_percentagecheck_thresholdcoverage/report_text.shreport_text_compress_rangesreport_text_uncoveredreport_text_line_hitsreport_text_functionscoverage/report_lcov.shreport_lcovcoverage/report_html.shhtml_escapepath_to_filenamereport_htmlgenerate_index_htmlgenerate_file_htmlLayering (leaves first, acyclic — verified against the generated call graph):
Aggregator source order:
paths · lines · hits · functions · branches · engine · parallel · stats · report_text · report_lcov · report_html.Function summaries
paths.sh — what counts as a tracked file
normalize_path— collapse a path to the canonical form used as the data-file keyshould_track— whether a path is inside the configured coverage roots (memoised, 99 lines, the hottest predicate on the trap path)auto_discover_paths— derive coverage roots when none configuredget_tracked_files— list the files that ended up trackedlines.sh — static line classification
is_executable_line— whether a source line can ever be hit, via_BASHUNIT_COVERAGE_NONEXEC_PATTERNget_executable_lines— count them for a fileget_hit_lines— count executable lines with at least one hithits.sh — reading recorded hit data
get_all_line_hits— parse the data file into per-line hit counts, folding\-continuations_ends_with_continuation— the continuation test that folding relies onload_hits_by_line— memoising wrapper populating_BASHUNIT_COVERAGE_HITS_BY_LINEget_all_line_tests— per-line attribution of which test hit itfunctions.sh
extract_functions— locate function definitions and their line spans (used by lcov, text and html reports)branches.sh — branch coverage (ADR-007)
extract_branches— single pass building the arm table for if/case/loop_append_arm— push an arm onto_BASHUNIT_BRANCH_ARMS_OUT_is_case_pattern_line— recognise acasepattern line_branch_push_if_branch_close_if_arm_branch_emit_if— if/elif/else arm bookkeeping_branch_push_case_branch_close_case_arm_branch_emit_case_branch_open_case_pattern— case arm bookkeeping_branch_push_loop_branch_emit_loop— loop arm bookkeeping_arm_taken— whether an arm was executedcompute_branch_hits— taken/total for a fileengine.sh — tracing lifecycle (ADR-009)
init— one-time setup of data files, caches, engine choice (cross-cutting, see below)cleanup— remove the run's data filesxtrace_is_supported—BASH_XTRACEFDprobe (4.1+)resolve_engine— resolveautotoxtraceortrapenable_trap/disable_trap— the seamrunner/exec.shandrunner/hooks.shcall_enable_xtrace/_disable_xtrace— xtrace engine, PS4 swap and trace drainrecord_line— the DEBUG-trap handler, bufferedflush_buffer— write the buffer out_resolve_output_files— pick the data/hits targets (parallel-aware)finalize— flush and normalise at end of runparallel.sh
aggregate_parallel— merge per-worker data files after a parallel runstats.sh — percentages and the precomputed cache
calculate_percentage·get_coverage_class·get_color_for_class— arithmetic and threshold bandingcompute_file_coverage— executable/hit pair for one fileget_file_stats— that pair plus percentage and classprecompute_file_stats/get_cached_stats— fill and read the_BASHUNIT_COVERAGE_STATS_*arraysget_percentage— run-wide percentagecheck_threshold— exit-code gatereport_text.sh / report_lcov.sh / report_html.sh — the three renderers, plus
_compress_ranges(uncovered line ranges),html_escape,path_to_filename.Globals
33 module-owned globals. Full assign/read map generated; the parts that constrain the split:
_BASHUNIT_COVERAGE_TRACKED_FILES_TRACK_CACHE_PATH_CACHE_TRACKED_CACHE_FILEinit,record_line_BASHUNIT_COVERAGE_NONEXEC_PATTERN_BASHUNIT_COVERAGE_HITS_BY_LINEload_hits_by_line_BASHUNIT_BRANCH_ARMS_OUT_BASHUNIT_ARM_TAKEN_OUT_append_arm,_arm_taken_BASHUNIT_COVERAGE_BUFFER*_HITS_BUFFER_DATA_FILE_TEST_HITS_FILE_ENGINE_RESOLVED_IS_PARALLEL_XTRACE_*_DATA/HITS_TARGET_OUTinit,record_line,flush_buffer,_resolve_output_files,_enable/_disable_xtrace_BASHUNIT_COVERAGE_STATS_*(7)init,precompute_file_stats_BASHUNIT_RANGES_OUT_compress_rangesCrossing the file boundary, both read-only and left as-is:
_BASHUNIT_RUNNER_RESULT_ORDINAL(src/coverage.sh:224) — as the issue instructs_BASHUNIT_COVERAGE_CURRENT_TEST_FILE/_CURRENT_TEST_FN— written bysrc/runner/context.sh_BASHUNIT_COLOR_*,_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_{LOW,HIGH}— normal shared configThree things the map turned up that need a decision
1.
initis the one genuinely cross-cutting function. It assigns globals owned byfive proposed modules (paths caches, engine buffers, stats arrays, tracked files, engine
choice). Splitting it would be a behaviour change, which this issue forbids, so it stays
whole in
engine.shwith a comment naming the modules it resets. This is a deliberate,documented cross-module write — the mirror of the
_BASHUNIT_RUNNER_RESULT_ORDINALcross-module read already sanctioned above. Flagging it rather than hiding it.
2.
.editorconfig— scope the exception to one file, do not widen to the directory.Of the 56 lines over 120 chars, 50 are in
generate_index_html(26) andgenerate_file_html(24), both landing inreport_html.sh. So:Every other new file inherits the global 120. That leaves 6 lines to reflow by hand
(
aggregate_parallel×3,record_line,init,get_cached_stats) — the only non-relocationedit in the diff, and I'd call it out explicitly in the PR. Widening the glob to
src/coverage/**would silently drop the limit for nine files that do not need it.3. Two file-scope ordering constraints (ADR-010). The aggregator takes only
sourcelines and comments, so every file-scope initializer moves into its module. Two of them are
order-dependent within their file and must stay contiguous and in order:
_BASHUNIT_COVERAGE_XTRACE_PS4expands$_BASHUNIT_COVERAGE_XTRACE_FSat file scope → both inengine.sh, FS first_BASHUNIT_COVERAGE_NONEXEC_PATTERNis built across 7 successive="${...}"'|...'lines → all inlines.shAlso worth noting: the "name kept from the trap-only era" comment the issue asks about is
already updated — #927 rewrote it to reference
runner/exec.shandrunner/hooks.sh.No action needed on that bullet.
Tests
The 9 existing flat
tests/unit/coverage_*_test.shfiles map onto this split cleanly andstay flat, no
tests/unit/coverage/:core→paths+engine ·engine→engine ·executable→lines ·branches→branches ·percent+helpers→stats ·reporting→report_* ·parallel_aggregation→parallel ·subshell→engineNo renames look justified; every existing file's scope maps to one or two new modules
without moving.
Awaiting review before moving any code.
Chemaclass commentedon Jul 31, 2026
Agreed: the 13-file layout from the 30 Jul map, with the narrow
.editorconfigscoping (exempthtml_index.sh+html_file.sh, reflow the remaining 6 lines).The second map posted on 31 Jul was a duplicate — I had read the issue body but not the existing comments. It reached the same conclusions independently (same 56/50 long-line split, same narrow editorconfig recommendation, same single-file
branches.sh, same note that #927 already retargeted the trap-only-era comment), so treat it as corroboration and follow the 30 Jul layout. Its only correction worth keeping: the dynamic-scope justification comment is at:1111-1112/:1200-1201, not:818-821as the issue body says.Starting the split now.
Chemaclass commentedon Jul 31, 2026
Done in #928 (merged).
docs(adr): record what the coverage split taught ADR-010