You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/ is half-migrated. runner and coverage are module directories (#924, #925); the
other 37 files are still flat, and nine of them are large enough to deserve the same
treatment. Finish the migration, one module per PR.
Measured on main (7eecc45): 40 flat .sh files + 3 module directories, 15,614 lines total.
Convention: settled
The aggregator lives inside the module directory as index.sh:
src/runner/index.sh <- entry point
src/runner/exec.sh
src/coverage/index.sh
src/coverage/engine.sh
Decided and applied to both existing modules in #934; ADR-010 amended with the reasoning.
The original "beside" choice (src/runner.sh + src/runner/*.sh) rested on matching the src/assertions.sh precedent, which does not apply — assertions.sh aggregates flat files
and has no directory. What settled it: the "aggregators hold only source lines" rule was
enforced against a hand-maintained list that had already drifted (#928 added src/coverage.sh
and never appended it), and a predictable src/*/index.sh lets that test discover modules by
glob instead.
Every new module in this issue uses index.sh. Do not mix placements.
What is left to split
File
Lines
Fns
Note
src/main.sh
1422
24
flag parsing + every subcommand's cmd_* in one file
src/learn.sh
1213
82
the interactive tutorial; self-contained, lowest risk
src/assert.sh
970
42
the core assertion catalogue — cohesive, may not want splitting
JUnit/HTML/TAP/JSON writers — one per format is the obvious seam
src/state.sh
404
42
counters + payload encode/decode + TAP conversion
Separately, ~20 files under 120 lines (colors, str, math, io, globals, check_os, dependencies, …) could group into a src/util/ module. Lower value than the
big files — grouping small cohesive files buys navigation, not comprehension. Treat as
optional and last.
Do not split all nine in one PR. One module per PR, same as #924 and #925, so each stays
reviewable as a pure relocation.
Post a responsibility map on the issue and get it agreed before moving code: one-line
summary per function, the internal call graph, every _BASHUNIT_* global with who assigns
and who reads it, and a proposed acyclic layering. Generate it from the file, do not
eyeball it.
Split. git diff must show only relocations — no renamed functions, no changed logic.
Verify (below), then open the PR.
Constraints — all of these have already drawn blood
Aggregators may contain only source lines and comments.build::process_file emits a
file's body and then recurses into its sources, so any statement in an aggregator runs
before its dependencies in the built binary but after them in dev mode. Enforced by test_module_aggregators_hold_only_source_lines_and_comments.
File-scope initialisers must move with their module, and order-dependent ones must stay
contiguous. In refactor(coverage): split src/coverage.sh into a src/coverage/ module #928, _BASHUNIT_COVERAGE_XTRACE_PS4 expands $_BASHUNIT_COVERAGE_XTRACE_FS
at file scope, and _NONEXEC_PATTERN is built across 7 successive lines. Grep for
file-scope assignments that read another file-scope variable before splitting.
.gitignore can silently swallow a new module directory.coverage/ was unanchored, so
it matched src/coverage/ and excluded all twelve new files from git — git status showed
no ?? entries at all. Fixed in refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 by anchoring to /coverage/, but check git check-ignore -v src/<newmodule>/<file>.sh before committing any new module.
CI runs ShellCheck per file without -x, so it cannot follow source. A monolith hides
cross-file variable use; splitting exposes it as SC2034. In refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 this surfaced a genuinely
dead local. Reproduce CI's mode locally before pushing: for f in $(git ls-files '*.sh') bashunit; do shellcheck --format=gcc "$f"; done
with SHELLCHECK_OPTS="-e SC1091 -e SC2155 -e SC2016".
Check .editorconfig for per-file rules on the file being split.[src/coverage.sh] max_line_length = unset would have been lost by the split; refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 rescoped it to the two
files that actually needed it and reflowed the rest. Decide deliberately and state the
decision in the PR.
Dynamic-scope helper groups cannot be separated. Functions that mutate a caller's locals
must land in the same file as their caller, with the justification comment
(.claude/rules/bash-style.md). src/coverage/branches.sh is the precedent.
Tests stay flat.make test globs exactly one level — never create tests/unit/<module>/.
Prove the change is a relocation by comparing the non-blank line multiset before and after —
it must differ only by the new shebangs and module header comments — plus an unchanged
function count:
No CHANGELOG entry — internal, no user-visible behaviour change
Do not
Do not start moving code before the responsibility map is posted
Do not rename functions or change behaviour
Do not split more than one module per PR
Do not create tests/unit/<module>/
Do not run shfmt -w; make lint is the format gate
Note on build.sh — no work needed
build.sh already handles arbitrary nesting. build::process_file is a depth-first walk of source statements, not a directory walk: it emits a file's body, recurses into every ^source line, dedupes on repo-relative path (#923), and strips all source lines from the
artifact. Verified on main with a throwaway three-level module
(src/x.sh → src/x/mid.sh → src/x/deep/leaf.sh): all three bodies embedded in DFS order,
zero source lines left in the output. Depth is pinned by a regression test in a separate
issue.
Summary
src/is half-migrated.runnerandcoverageare module directories (#924, #925); theother 37 files are still flat, and nine of them are large enough to deserve the same
treatment. Finish the migration, one module per PR.
Measured on
main(7eecc45): 40 flat.shfiles + 3 module directories, 15,614 lines total.Convention: settled
The aggregator lives inside the module directory as
index.sh:Decided and applied to both existing modules in #934; ADR-010 amended with the reasoning.
The original "beside" choice (
src/runner.sh+src/runner/*.sh) rested on matching thesrc/assertions.shprecedent, which does not apply —assertions.shaggregates flat filesand has no directory. What settled it: the "aggregators hold only
sourcelines" rule wasenforced against a hand-maintained list that had already drifted (#928 added
src/coverage.shand never appended it), and a predictable
src/*/index.shlets that test discover modules byglob instead.
Every new module in this issue uses
index.sh. Do not mix placements.What is left to split
src/main.shcmd_*in one filesrc/learn.shsrc/assert.shsrc/helpers.shsrc/console_results.shsrc/env.shBASHUNIT_*defaults, config files, scratch dirssrc/test_doubles.shsrc/reports.shsrc/state.shSeparately, ~20 files under 120 lines (
colors,str,math,io,globals,check_os,dependencies, …) could group into asrc/util/module. Lower value than thebig files — grouping small cohesive files buys navigation, not comprehension. Treat as
optional and last.
Do not split all nine in one PR. One module per PR, same as #924 and #925, so each stays
reviewable as a pure relocation.
Per-module process (proven by #924 and #925)
summary per function, the internal call graph, every
_BASHUNIT_*global with who assignsand who reads it, and a proposed acyclic layering. Generate it from the file, do not
eyeball it.
git diffmust show only relocations — no renamed functions, no changed logic.Constraints — all of these have already drawn blood
sourcelines and comments.build::process_fileemits afile's body and then recurses into its sources, so any statement in an aggregator runs
before its dependencies in the built binary but after them in dev mode. Enforced by
test_module_aggregators_hold_only_source_lines_and_comments.contiguous. In refactor(coverage): split src/coverage.sh into a src/coverage/ module #928,
_BASHUNIT_COVERAGE_XTRACE_PS4expands$_BASHUNIT_COVERAGE_XTRACE_FSat file scope, and
_NONEXEC_PATTERNis built across 7 successive lines. Grep forfile-scope assignments that read another file-scope variable before splitting.
.gitignorecan silently swallow a new module directory.coverage/was unanchored, soit matched
src/coverage/and excluded all twelve new files from git —git statusshowedno
??entries at all. Fixed in refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 by anchoring to/coverage/, but checkgit check-ignore -v src/<newmodule>/<file>.shbefore committing any new module.-x, so it cannot followsource. A monolith hidescross-file variable use; splitting exposes it as SC2034. In refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 this surfaced a genuinely
dead local. Reproduce CI's mode locally before pushing:
for f in $(git ls-files '*.sh') bashunit; do shellcheck --format=gcc "$f"; donewith
SHELLCHECK_OPTS="-e SC1091 -e SC2155 -e SC2016"..editorconfigfor per-file rules on the file being split.[src/coverage.sh] max_line_length = unsetwould have been lost by the split; refactor(coverage): split src/coverage.sh into a src/coverage/ module #928 rescoped it to the twofiles that actually needed it and reflowed the rest. Decide deliberately and state the
decision in the PR.
must land in the same file as their caller, with the justification comment
(
.claude/rules/bash-style.md).src/coverage/branches.shis the precedent.make testglobs exactly one level — never createtests/unit/<module>/..claude/rules/perf-fork-budget.md).Verification, per PR
Prove the change is a relocation by comparing the non-blank line multiset before and after —
it must differ only by the new shebangs and module header comments — plus an unchanged
function count:
Then:
./bashunit tests/·./bashunit --parallel tests/·./bashunit --parallel --simple --strict tests/·make sa·make lint·fork-budget acceptance tests ·
bash build.sh bin -vprinting✅ Build verified ✅.Add
./bashunit --coverage --parallel tests/when the module touches coverage.Acceptance criteria (per module PR)
git diffshows only relocations — no renamed functions, no changed logicsourcelines and comments#!/usr/bin/env bashgit check-ignore -vconfirms the new directory is not ignored-x) clean.editorconfigper-file rules reviewed; decision stated in the PRbash build.sh bin -v.claude/rules/architecture-map.mdmodule table updatedDo not
tests/unit/<module>/shfmt -w;make lintis the format gateNote on
build.sh— no work neededbuild.shalready handles arbitrary nesting.build::process_fileis a depth-first walk ofsourcestatements, not a directory walk: it emits a file's body, recurses into every^sourceline, dedupes on repo-relative path (#923), and strips allsourcelines from theartifact. Verified on
mainwith a throwaway three-level module(
src/x.sh → src/x/mid.sh → src/x/deep/leaf.sh): all three bodies embedded in DFS order,zero
sourcelines left in the output. Depth is pinned by a regression test in a separateissue.