test(§89): lint for the bash-3.2/BSD hazards CI structurally cannot see - #148
Merged
Conversation
CI is Ubuntu + bash 5 + GNU userland; the maintainer runs macOS + bash 3.2 +
BSD. Three constructs parse or expand differently there, so `bash -n` in CI
passes and the break appears on exactly one machine. All three have already
bitten this repo, and each failed in a way that did not announce itself:
SRCSUB nested source <(...) inside $( ) -> bash 3.2 sources nothing, the
following calls exit 127, the ERR trap aborts the run (§83) — every
section after it silently never executed.
PYBACK backtick or $( inside a DOUBLE-quoted python3 -c "..." body -> bash
expands it regardless of Python comment syntax. A `sandy` inside a
comment made the test suite EXECUTE the real sandy binary (§68).
APOSCS apostrophe in a comment inside a multi-line $( ) -> bash 3.2 does not
skip comments while scanning a command substitution, so the quote
never closes and the file dies with 'unexpected EOF'. Because it is a
PARSE error it killed the whole file while the summary still printed
'945 passed, 0 failed' (§86).
That last property is the actual argument for this guard: these do not surface
as red tests, they surface as a green run that did less than it claimed.
test/lint-bash32.sh is the detector, independently runnable, with --self-test
(positive controls plus a clean-file negative control) and --list (the target
set, so coverage can be asserted separately from outcome — a scope assertion
that only holds on passing runs is worthless).
Validated by replay against this repo's own history: pointed at the commit
before each fix, it flags all three original defects at their exact lines
(73a118c^:6831 APOSCS, d8b685c^:6681 SRCSUB, d8b685c^:5146 PYBACK). So the
patterns are drawn from real incidents, not from a style guide.
§89 asserts the tree is clean AND that the detectors still fire — a linter
whose patterns quietly stopped matching would report success forever.
Mutation-tested, each caught by the check designed for it: reintroducing the
real §86 bug fails the cleanliness check; rotting the APOSCS detector fails the
self-test check; narrowing the target set fails the coverage check; the
unmutated control passes 6/6.
False positives were the main design risk, so blocks that never terminate
within 80 lines are abandoned rather than reported, PYBACK terminates on the
first UNESCAPED quote (which is usually at end-of-line, not start — an anchored
rule ran on into unrelated code), and the linter excludes itself, since its own
fixtures are deliberate instances of all three bugs.
No workflow change needed: CI already runs run-tests.sh.
Deliberately not checked: set -E ERR traps firing in command-substitution
subshells (real — sandy:1043 — but not reliably detectable statically, and a
false positive is worse than a miss).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rappdw
force-pushed
the
test/bash32-portability-lint
branch
from
August 13, 2026 21:44
20360b9 to
aa104d3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CI is Ubuntu + bash 5 + GNU userland. The maintainer's machine is macOS + bash 3.2 + BSD. Three constructs parse or expand differently there, so
bash -nin CI passes and the break appears on exactly one machine — usually mid-task.All three have already bitten this repo. None is speculative.
SRCSUBsource <(...)inside$( )PYBACK$(inside a double-quotedpython3 -c "..."body`sandy`in a comment made the test suite execute the real sandy binary (§68)APOSCS$( )Why this is worth a guard
These don't surface as red tests. They surface as a green run that did less than it claimed.
§86 is the clearest case: a parse error killed the entire file, and the summary still printed
945 passed, 0 failed. §83 aborted the run at section 83 of 88. §68 corrupted the suite's own working tree by executing sandy. In each case the signal said "fine."What's here
test/lint-bash32.sh— the detector, independently runnable:--listexists so coverage can be asserted separately from the lint's own pass/fail. My first version derived coverage from the success message, which meant the scope assertion only held on runs that already passed — worthless precisely when it matters.Validated against this repo's own history
Not "does it look right" — pointed at the commit before each fix, it flags all three original defects at their exact lines:
That last line is §68's actual bug: a backticked
sandyinside a Python comment.§89 and mutation testing
§89 asserts the tree is clean and that the detectors still fire on known-bad fixtures — a linter whose patterns quietly stopped matching would report success forever.
Each mutation is caught by the check designed for it, with no cross-coupling:
repo shell scripts are free of bash-3.2 hazardsdetectors self-test: all three firelint target set includes run-tests.sh and the acceptance harnessesFalse positives were the main design risk
A lint that cries wolf gets switched off, so: blocks that don't terminate within 80 lines are abandoned rather than reported;
PYBACKterminates on the first unescaped quote (usually at end-of-line — an anchored^\s*"rule ran on into unrelated code and produced 9 false hits on §77/§78); and the linter excludes itself, since its own fixtures are deliberate instances of all three bugs. Current tree: clean, 14 files.Scope
No workflow change — CI already runs
run-tests.sh, so §89 rides along.Deliberately not checked:
set -EERR traps firing in command-substitution subshells (real — seesandy:1043— but not reliably detectable statically, and here a false positive is worse than a miss). Also unchecked: BWK-awk\$anchoring and BSDwc -lwhitespace, both from the same family but needing semantic context this pass doesn't have.