Skip to content

test(§89): lint for the bash-3.2/BSD hazards CI structurally cannot see - #148

Merged
rappdw merged 1 commit into
mainfrom
test/bash32-portability-lint
Aug 13, 2026
Merged

test(§89): lint for the bash-3.2/BSD hazards CI structurally cannot see#148
rappdw merged 1 commit into
mainfrom
test/bash32-portability-lint

Conversation

@rappdw

@rappdw rappdw commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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 -n in CI passes and the break appears on exactly one machine — usually mid-task.

All three have already bitten this repo. None is speculative.

Code Construct How it failed here
SRCSUB nested source <(...) inside $( ) bash 3.2 sources nothing → following calls exit 127 → ERR trap aborts the run (§83 — every section after it silently never executed)
PYBACK backtick / $( inside a double-quoted python3 -c "..." body bash expands it regardless of Python comment syntax — a `sandy` in a comment made the test suite execute the real sandy binary (§68)
APOSCS apostrophe in a comment inside a multi-line $( ) bash 3.2 doesn't skip comments while scanning a command substitution → unterminated quote → parse abort (§86)

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:

bash test/lint-bash32.sh              # lint the repo's shell scripts (14 files)
bash test/lint-bash32.sh --self-test  # prove the detectors still detect
bash test/lint-bash32.sh --list       # the target set

--list exists 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:

73a118c^:6831  APOSCS: # shellcheck disable=SC2034  # consumed by the eval'd $_hb_blk below,
d8b685c^:6681  SRCSUB: _hd_out="$(bash -c 'source <(sed -n "/^_sandy_head_display()/,/^}/p" "$1"); ...
d8b685c^:5146  PYBACK: # Regression: the proxy of a `sandy`-named workspace must join to sandy-...

That last line is §68's actual bug: a backticked sandy inside 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:

Mutation Check that caught it
reintroduce the real §86 bug in-tree repo shell scripts are free of bash-3.2 hazards
APOSCS detector rots (stops matching) detectors self-test: all three fire
target set silently narrowed lint target set includes run-tests.sh and the acceptance harnesses
unmutated control 6/6 pass

False 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; PYBACK terminates 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 -E ERR traps firing in command-substitution subshells (real — see sandy:1043 — but not reliably detectable statically, and here a false positive is worse than a miss). Also unchecked: BWK-awk \$ anchoring and BSD wc -l whitespace, both from the same family but needing semantic context this pass doesn't have.

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
rappdw force-pushed the test/bash32-portability-lint branch from 20360b9 to aa104d3 Compare August 13, 2026 21:44
@rappdw
rappdw merged commit f34d307 into main Aug 13, 2026
2 checks passed
@rappdw
rappdw deleted the test/bash32-portability-lint branch August 13, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant