ref(bash): codify dynamic-scope safety for outvar helpers#675
Conversation
Bash 'local' is dynamically scoped, so helpers that take a caller-named variable as an outvar must defend against the callee's locals shadowing the caller's same-named variable; otherwise an eval-based assignment silently writes to the local and the caller's variable keeps its old value. This regression caused 12 parallel test failures in #662 before the prefix was added. - Document the convention in .claude/rules/bash-style.md (Outvar helpers section) with the failing example, the required pattern, and a pointer to the intentional dynamic-scope use in src/coverage.sh. - Defensive rename in src/test_doubles.sh: all internal locals on the indirect-read paths (${!file_var}) use the __bu_ prefix so a caller local cannot shadow the resolved global file path. - Add regression tests in tests/unit/runner_test.sh that pass each helper's documented internal variable name as the caller's outvar to catch any future drop of the prefix. Closes #674
After review feedback on PR #674: mock/spy were left unprotected in the first pass. Both helpers take a caller-named command and write derived globals via 'export "${variable}_..._file"=...' — if a caller has a local with the same constructed name in scope, the export updates the local instead of creating a clean env binding (dynamic scoping). Same hazard class as the indirect-read sites, applied consistently now. Updates the rule in .claude/rules/bash-style.md to call out the whole test_doubles.sh surface, not just the indirect-read sites.
|
Good catch on the audit gap. Extended in 60a31d1:
Final audit state across
|
Drops the eval-based outvar pattern (and its mandatory __bu_ prefix on internal locals) in favor of dedicated global return slots, one per helper: _BASHUNIT_RUNNER_FIELD_OUT (extract_encoded_field) _BASHUNIT_RUNNER_TOTAL_OUT (compute_total_assertions) _BASHUNIT_RUNNER_TYPE_OUT (extract_subshell_type) _BASHUNIT_RUNNER_OUTPUT_OUT (format_subshell_output) Same perf as outvar (no $(...) fork per call) but eliminates the ergonomic cost of __bu_-prefixed internal locals and removes the dynamic scoping pitfall entirely — the helper never touches caller-named variables. For test_doubles.sh the real fix wasn't on the helper's locals at all: spy state is keyed by command name and stored via 'export NAME=value' where NAME is constructed from the command. The fix is to namespace the constructed name itself under _BASHUNIT_SPY_*, so a caller doing 'local foo_times_file=...' cannot collide with the helper's _BASHUNIT_SPY_foo_TIMES_FILE. All test_doubles.sh internal locals are restored to natural names. The bash-style.md rule is rewritten around three patterns: 1. Preferred: dedicated global return slot 2. For helpers that build dynamic names: namespace the constructed name 3. Last-resort outvar-by-name (requires __bu_ prefix + regression test) Bash 3.0+ compatibility preserved.
|
Switched to simpler option after review. runner.sh hot-path helpers now return via dedicated global slots, no eval, no prefix:
Internal locals back to natural names ( test_doubles.sh — the real fix was on the constructed global names, not on helper locals. Internal locals restored to natural names; spy state moved from Rule in bash-style.md rewritten as three patterns in priority order:
|
Summary
src/runner.shreturn via dedicated global slots (_BASHUNIT_RUNNER_*_OUT) instead of stdout. No$(...)fork, no eval, no shadowing risk — caller-named locals are simply never touched.src/test_doubles.shis namespaced under_BASHUNIT_SPY_*, so a test that uses alocal foo_times_file=...cannot collide with the framework's_BASHUNIT_SPY_foo_TIMES_FILE..claude/rules/bash-style.mddocuments three patterns in priority order:__bu_prefix + regression test (none in tree).tests/unit/runner_test.shconfirm helpers do not touch caller-named locals.Why
Bash
localis dynamically scoped, so any helper that touches caller-named variables (viaeval "$name=...",printf -v,${!name}orexport "$name"=...) can silently corrupt a caller that picks a natural name. The fix is to avoid that pattern entirely: return through a fixed framework-owned slot, or namespace the names we construct.Test plan
./bashunit tests/unit/green (839 passed, 1m20s)./bashunit --parallel tests/green (1074 passed, 46.58s)make sacleanmake lintcleandeclare -n, noprintf -v)Closes #674