Skip to content

fix(lint): five false positives that made users disable bashrs lint (GH-217, GH-209) - #219

Merged
noahgift merged 3 commits into
mainfrom
fix/heredoc-regions
Aug 11, 2026
Merged

fix(lint): five false positives that made users disable bashrs lint (GH-217, GH-209)#219
noahgift merged 3 commits into
mainfrom
fix/heredoc-regions

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

Closes #217. Closes #209.

Both issues have the same ending: a user turned bashrs lint off. #217 blocked commits via forjar's pre-commit hook; #209's reporter dropped the bashrs lint Makefile step from CI entirely after no combination of suggested fixes could make a correct Makefile exit 0.

A rule that cannot be satisfied doesn't get fixed — it gets removed.


GH-217 — quoted heredoc bodies are not shell

python3 - <<'PY'
p = 1
q = 2
PY

bash -n accepts this. Published 6.66.2 reports 2 × SC1007Severity::Error, so it blocks commits.

Root cause is one level above the symptom. The rules see a flat stream of physical lines with no notion of heredoc regions, so any line-oriented rule fires inside these bodies. SC1007 is just the one that got hit. (#211 was the same shape: sc2188 iterating physical instead of logical lines.)

The machinery already existed and wasn't shared — sc2006 carried a private get_quoted_heredoc_lines from issue #96; the other 384 rules had nothing. A per-rule fix can't generalise, so this is applied once, where diagnostics are aggregated. Every current and future rule inherits it. sc2006's copy is deleted, so there's one definition instead of two that can drift.

Written as a state machine, not the regex-per-line approach sc2006 used: scanning every line for openers also matches openers inside a body (a heredoc documenting a heredoc), silently extending the suppressed region past its end. There's a test for exactly that.

Unquoted heredocs are deliberately still linted — their bodies undergo expansion, so they really are shell.


GH-209 — four Makefile defects

1. --fail-on was ignored for the common invocation. Single-file lint routed to output_lint_results, which picked the exit code itself (has_warnings → exit 1) and never saw opts.fail_on. The flag worked only under --ci. That function is removed rather than taught about the flag, so no caller can reintroduce an exit decision that bypasses the threshold. Default stays Warning; out-of-the-box exit codes are unchanged.

2. MAKE003 read $$ as a variable. $$ is Make's escape for a literal $. The parser fell into its $VAR branch, matched zero characters, and emitted a one-character diagnostic whose autofix was "$". The trigger is the self-documenting-help idiom in most Makefiles, where $$1/$$2 are awk field references — quoting them breaks the awk program.

3. MAKE010 missed compound error handlers. has_error_handling was a substring test for the literal "|| exit", so curl … || { echo "✗ failed"; exit 1; } read as unhandled — though it's better than a bare || exit 1. The autofix appended a second || exit 1 after a block that already exits.

4. MAKE016's autofix broke the build. It flagged $(VAR) in prerequisites and offered to quote it. GNU Make does no quote removal in prerequisite lists, so sakila: "$(DIR)"/x.sql asks for a file whose name literally starts with " — and the build then fails pointing at the prerequisite, not at the linter that wrote it. The rule's own doc-comment carried the broken form as its ✅ GOOD example, so the premise was wrong from the first commit. There is no correct version of "quote this prerequisite" — recipes are shell, prerequisites are not — so it's retired to a total no-op, ID still registered so existing --ignore MAKE016 configs keep parsing.


Verification

RED, published 6.66.2 binary 2 × SC1007 on valid shell
Targeted tests 45 passed, 0 failed (heredoc + make003/010/016 + existing property tests)
Full suite 14,596 passed, 0 failed

The full suite is the one that mattered: a filter at the aggregation layer could have silently suppressed diagnostics hundreds of tests depend on. It didn't.

Every fix is paired with a test asserting the true positive still fires, so none of these trades a false positive for a false negative.

Two self-inflicted bugs caught before shipping, both worth noting:

  • || { fail "x"; } failed my own MAKE010 fix — { tokenises separately, so stripping the brace from the first word left an empty string.
  • My first MAKE003 test was vacuous: $$1 inside the awk single-quotes is already skipped by pre-existing quote tracking, so it would have passed with or without the fix. Moved outside quotes so it discriminates.

🤖 Generated with Claude Code

noahgift and others added 3 commits August 11, 2026 20:07
…217)

`bashrs lint` reported SC1007 (Severity::Error, so it BLOCKS commits) on Python
embedded in a quoted heredoc:

    python3 - <<'PY'
    p = 1
    q = 2
    PY

`bash -n` accepts that file. A quoted delimiter means the body is literal text —
that is the entire point of quoting it — so no shell rule may analyse it.
Confirmed against the published 6.66.2 binary: 2 errors on valid shell.

Root cause is one level up from the symptom. The rules see a flat stream of
physical lines with no notion of heredoc regions, so ANY line-oriented rule
fires inside these bodies; SC1007 is just the one that happened to hit. #211 was
the same shape (sc2188 iterating physical rather than logical lines).

The machinery already existed and was not shared: sc2006 carried a private
get_quoted_heredoc_lines (issue #96) and the other 384 rules did not. A per-rule
fix cannot generalise, and the next line-oriented rule reintroduces the bug — so
this is applied ONCE where diagnostics are aggregated, next to the existing
inline-suppression filter. Every current rule and every future rule inherits it.

sc2006's private copy is deleted in favour of the shared one (-34 lines, plus
two now-dead regexes), so there is a single definition rather than two that can
drift.

Implemented as a state machine, not "regex-match every line" as sc2006 did:
scanning all lines for openers also matches openers INSIDE a body (a heredoc
documenting a heredoc), which silently extends the suppressed region past its
end and swallows real diagnostics. Covered by a test.

Unquoted heredocs are deliberately NOT skipped: their bodies undergo parameter
expansion and command substitution, so they really are shell and SC2006 et al.
should still fire there.

6 unit tests: the GH-217 repro, unquoted-is-not-a-region, `<<-"EOF"`, here-string
(`<<<` has no body), opener-inside-a-body, and two heredocs on one line
consuming bodies in order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om CI (Refs #209)

Reported against a ~70-line production Makefile whose recipes are correct. After
applying every fix the linter suggested it still produced 5 unsatisfiable
warnings, so the PR removed the `bashrs lint Makefile` step entirely. A rule
that cannot be satisfied trains people to delete the linter.

1. --fail-on was ignored for the common invocation.
   Single-file lint routed to output_lint_results, which chose the exit code
   itself (has_warnings -> exit 1) and never saw opts.fail_on. --fail-on was
   honoured only under --ci, so `bashrs lint --fail-on error Makefile` still
   exited 1 on a warnings-only run. That function is REMOVED rather than taught
   about the flag, so no caller can reintroduce an exit decision that bypasses
   the threshold: printing and exiting are now separate and exit_for_fail_on is
   the single decider. Default stays Warning, so out-of-the-box exit codes are
   unchanged.

2. MAKE003 read `$$` as a variable.
   `$$` is Make's escape for a literal `$`. parse_variable_reference fell into
   its `$VAR` branch, matched zero alphanumerics (next char is `$`), and emitted
   a ONE-CHARACTER diagnostic whose autofix was `"$"` — which changes what the
   shell receives. Canonical trigger is the self-documenting-help idiom in most
   Makefiles, where `$$1`/`$$2` are awk FIELD references and quoting them breaks
   the awk program.

3. MAKE010 did not recognise compound error handlers.
   has_error_handling was a substring test for the literal "|| exit", so
   `curl … || { echo "✗ failed"; exit 1; }` was reported as unhandled — though
   it handles errors better than a bare `|| exit 1`, since the user learns what
   failed. The autofix appended a SECOND `|| exit 1` after a block that already
   exits. Now the `||` tail is inspected, so `exit`/`return` and die/fail/abort
   helpers all count.

4. MAKE016 suggested a fix that BREAKS THE BUILD.
   It flagged `$(VAR)` in prerequisites and offered to quote it. GNU Make does
   no quote removal in prerequisite lists: `sakila: "$(DIR)"/x.sql` asks for a
   file whose name literally starts with `"`, and the build then fails pointing
   at the prerequisite rather than at the linter that wrote it. The rule's own
   doc-comment carried the broken form as its GOOD example, so the premise was
   wrong from the first commit. There is no correct version of "quote this
   prerequisite" — recipes are shell, prerequisites are not — so it is retired
   to a total no-op. The ID stays registered so existing `--ignore MAKE016`
   configs keep parsing, and the reasoning lives where the next person looks.

Tests: 45 passed, 0 failed across make003/make010/make016 plus the existing
property tests. Each fix is paired with a test asserting the TRUE positive still
fires, so none of these trades a false positive for a false negative.

The MAKE003 test uses an UNQUOTED `$$1`: the awk-in-single-quotes form from the
issue is already skipped by the pre-existing quote tracking, so a test using it
would have passed with or without the fix.

`|| { fail "x"; }` initially failed my own fix — `{` tokenises separately, so
stripping the brace from the first word left an empty string. Caught by the test
before it shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift
noahgift merged commit e0f2ecd into main Aug 11, 2026
9 checks passed
@noahgift
noahgift deleted the fix/heredoc-regions branch August 11, 2026 19:16
noahgift added a commit that referenced this pull request Aug 12, 2026
Ships the user-facing fixes accumulated since 6.66.2, none of which
reach anyone until this is published:

  - five lint false positives that were driving users to disable
    `bashrs lint` (#219, GH-217, GH-209)
  - CLI stack overflow from an oversized clap frame (#216, #215)
  - RUSTSEC-2026-0204, crossbeam-epoch 0.9.20 (#210)

plus internal repairs: kani harnesses compile under cfg(kani) again
(#221), bashrs-oracle's test module compiles and the workspace is
actually tested (#223), and a workflow template stopped being run as a
workflow (#222).

Cargo.lock regenerated in the same commit -- forjar's 1.12.4 release
tripped its lockfile-preflight by bumping Cargo.toml alone.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant