Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contracts/linter-lexer-context-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ metadata:
- 'GH-262: SC2046 fires in three contexts where the shell performs no field splitting (assignment RHS, inside double quotes, case word)'
- 'GH-235: SC1078 - an apostrophe inside a double-quoted string opens a single quote (fixed; pinned)'
- 'GH-258: SC1140 - a trailing # comment after a [ ] test is an unexpected token (fixed; pinned)'
- 'GH-439: SC2075 - a backslash before the closing quote of ''\\'' or ''\'' is read as an escaped quote, so the string is paired with the next single-quoted string on the line'
- 'GH-226, GH-272: linter::quoting resolves quoting once and hands QUOTE_SENSITIVE_RULES a masked copy'
- 'GH-228: linter::shell_words - a command substitution opens a fresh quoting context (POSIX 2.6.3); already treats $(( )) as no expansion (test_SW_020) and honours backslash escapes inside double quotes'
- 'docs/audit/quality-report09-2026.md section 4: the lexer reads non-shell text as shell'
Expand Down Expand Up @@ -139,6 +140,7 @@ invariants:
- 'bashrs corpus run over the full 17,942-entry corpus scores no worse than the 7.0.2 baseline (84.4/100 B, 333 failures)'
- 'SC2105 processes loop-start keywords, done and break/continue in the byte order they occur on a physical line, not by summing a whole line''s keyword counts before checking break/continue (PMAT-244); a bare break/continue outside any loop still fires'
- 'shell_words::Expansion covers command substitutions ($( ), nested $( $( ) ), and backticks, including inside double quotes) as well as $NAME/${NAME}; SC2046 has no private quote-aware scanner of its own (PMAT-250)'
- 'SC2075 never reports a single-quoted string whose content ends in a backslash when the closing quote is followed by a space, a shell operator, another quote or the end of the line - inside ''...'' a backslash is literal and the next quote closes (GH-439); it still reports `echo ''it\''s''`, where the closing quote runs into a word and a later quote closes the string its author meant, and not `echo ''a\''b` with no later quote, the valid word a\b'

kani_harnesses:
- id: KANI-LEXER_CONTEXT-001
Expand Down Expand Up @@ -300,3 +302,8 @@ falsification_tests:
prediction: 'a break inside parentheses in a for loop reports SC2106, while a plain break in a loop body does not'
test: cargo test -p bashrs --lib linter::rules::sc2106::tests::test_PMAT258_sc2106_break_in_subshell_inside_for_loop_fires
if_fails: 'Contract violation: SC2106 carries a non-shellcheck meaning again, so a suppression written against shellcheck silences the wrong rule, as in GH-303'
- id: F-LCX-021
rule: inside '...' a backslash is literal, so the quote after it closes the string (GH-439)
prediction: 'lint_shell reports no SC2075 and no error on `echo ''a\b'' | tr -d ''\\'' | grep -x ''ab''`, on the lone-backslash form `echo a | tr -d ''\'' | grep -x ''a''`, or on the same pipeline inside "$( ... )"; `echo ''it\''s''` still reports SC2075'
test: cargo test -p bashrs --lib linter::lexer_context_tests::test_PMAT439_gh439_backslash_before_a_closing_single_quote_is_literal
if_fails: 'Contract violation: SC2075 reads `\''` inside single quotes as an escaped quote again and pairs the string with the next quoted string on the line (GH-439), or the rule was blunted so that it no longer fires on its true positive'
136 changes: 136 additions & 0 deletions docs/audits/impl-PMAT-439-receipt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# PMAT-439 — receipt: SC2075 pairs a backslash string with the next quoted string

**Ticket:** PMAT-439 (bashrs#439): SC2075 reports an [error] on a valid `'\\'` or `'\'` when another
single-quoted string follows on the same line. **Kind:** code.
**Branch:** `fix/439-sc2075-backslash`, from `63abc48ec4`.

## The defect

SC2075 was the regex `'[^']*\\'[^']*'`. That regex reads `\'` as an escaped quote. Inside '...' a
backslash is literal and the next quote closes the string, so in

echo 'a\b' | tr -d '\\' | grep -x 'ab'

it matched `'\\' | grep -x '`, from the opening quote of `'\\'` to the opening quote of `'ab'`, and
reported 1:20-36 as an Error. bash runs the line and prints `ab`. Any caller that refuses a bashrs
error therefore refuses a valid script.

## The fix

A per-line scanner replaces the regex. It tracks the quoting context the line is in: code, `$( )`,
backticks or `"..."`. It skips comments, escapes in code and `$'...'` bodies, and steps over `$$`, so
the quote after `$$` opens a plain '...'. bash confirms this: `echo $$'a\nb'` prints the pid, then
`a\nb` literally. Inside '...':

- An attempt is a `\'` followed by a letter, digit or underscore, as in `'it\'s'`, `'can\'t'` or
`'a\'1'`. It is reported only when a later quote on the line closes the string its author meant.
- Every other backslash is literal, and the next quote closes the string: `'\\'`, `'\'`,
`'a\'"b"`, `'a\'$x`, `awk -F'\' '{print $2}'`.
- With no later quote on the line, `echo 'a\'b` is the valid word a\b (bash prints `a\b`) and is not
reported.

`linter::quoting` is not used. It reads the line the way the shell does, so it closes the string at
`\'` and is out of step with the author from there on. The existing `'don\'t' 'won\'t'` test needs
two reports. The rule header says why.

The four existing true-positive fixtures keep their spans: `echo 'can\'t'` 1:6-14,
`msg='it\'s broken'` 1:5-19, `echo 'don\'t' 'won\'t'` 1:6-14 and 1:15-23, and
`result=$(echo 'can\'t')` 1:15-23. Max cyclomatic complexity in the file is 7.

## Where each claim lives

| Claim | Where |
|---|---|
| the reproducer, the lone `'\'` form and the `"$( ... )"` form report no SC2075 | `rash/src/linter/rules/sc2075.rs`, `test_PMAT439_sc2075_backslashes_then_another_quoted_string`, `..._lone_backslash_then_another_quoted_string`, `..._same_pipeline_inside_quoted_substitution` |
| the whole linter reports no Error from any rule on those three; `echo 'it\'s'` still reports SC2075 | `rash/src/linter/lexer_context_tests.rs`, `test_PMAT439_gh439_backslash_before_a_closing_single_quote_is_literal` (contract F-LCX-021) |
| a backslash string followed by a separate word is clean, including `awk -F'\'` and `'a\'b` with no later quote | `test_PMAT439_sc2075_backslash_string_then_a_separate_word` |
| `\'` inside `"..."`, `$'...'` or unquoted code is not an attempt | `test_PMAT439_sc2075_not_single_quoted_context` |
| the true positive still fires as an Error: alone, after a valid backslash string, with a digit, and after `$$` | `test_PMAT439_sc2075_true_positive_still_fires` |
| an attempt followed by a space, `'can\' t'`, is not clean: SC1078 reports the quote it leaves open | lexer_context_tests, same test, the `shell_fires(..., "SC1078")` line |
| contract rows | `contracts/linter-lexer-context-v1.yaml`: the GH-439 reference, the SC2075 invariant, F-LCX-021 |

## Commits

| Commit | What |
|---|---|
| `121a2b041e` | RED: tests, contract rows, roadmap entry |
| `0bdc20364d` | GREEN: the scanner |
| `3a1a4f72dc` | pins `awk -F'\'` and the SC1078 control |
| `b797e6b2eb` | an attempt is reported only when the string it meant closes; `$$` |

The commit that adds this receipt adds only files under `docs/audits/`.

## Evidence that the tests discriminate

- At `121a2b041e`, against the old regex: `cargo test -p bashrs --lib -- PMAT439 sc2075` gave 11
passed and 7 failed. At `0bdc20364d` it gave 18 passed.
- Reverting the rule body to the regex with the tests kept gave 11 passed and 7 failed. Restoring
it gave 18 passed.
- `b797e6b2eb`'s new rows against `3a1a4f72dc`'s rule: 16 passed and 2 failed. `'a\'b` was
reported, and `$$'a\'b'` was silent. With the fix: 18 passed.
- Control 1 changes `is_word_byte` to accept digits and underscore only. It fails 6 tests:
`true_positive_still_fires`, `escaped_quote`, `its`, `multiple`, `in_command_sub` and the
lexer_context GH-439 test.
- Control 2 records an attempt only when the close is followed by whitespace or the end of the
line. It fails 1 test: `in_command_sub`.

## Verification

verification:
cmd=cargo test -p bashrs --lib -- PMAT439 sc2075 claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # 18 passed; 0 failed
cmd=cargo nextest run --workspace claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # at b797e6b2eb: 17347 passed; 0 failed; 163 skipped
cmd=cargo clippy --workspace --all-targets -- -D warnings claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # at b797e6b2eb
cmd=cargo fmt --all -- --check claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0
cmd=pv lint contracts claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # Result: PASS
cmd=bashrs corpus run claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # 18814 entries; 18814 passed; 0 failed; V2 99.4/100 (A+): A 100.0, B1 99.9, B2 99.9, B3 98.7, D 100.0, F 99.6, G 99.7
cmd=bashrs lint <the issue's script> claimed_exit=0 rerun_exit=0 log_path=docs/audits/impl-PMAT-439-receipt.md sha256=0 # and exit 2 (SC2075) on echo 'it\'s'

The corpus ran the way `make corpus-score` runs it: under bwrap, from an empty temporary directory,
using this branch's binary from `CARGO_TARGET_DIR`. It did not use `$(PWD)/target`, which is a
different build. The first run scored 88.7 (B), with B3 and G at 9.8 percent and no entry failed.
`TMPDIR` pointed at a directory that `--ro-bind / /` made read-only, so the behavioural runs had
nowhere to write. Bound writable, the same binary gives the line above, which matches the 7.4.x
release figures. Lint clean misses 3 entries, B-17300, M-397 and M-443. Linting their output
reports SC2135, SC2168 and SC2148, and no SC2075.

## Review

Two blind rounds, each with three lanes: sonnet, sonnet adversarial and haiku. These are
**degraded: same-family**: the author is Opus 5.5, and no lane used the author's model. Each lane got
a snapshot of the base files and two candidate diffs, A and B, with `index` lines removed. One was
the real diff and one a control. Lane r2 saw them in the opposite order. Verdicts and findings are in
`docs/audits/quorum-PMAT-439-review.json`.

- **Round 1, on `3a1a4f72dc`'s diff.** Real diff PASS 3/3; control 1 caught 3/3. The lanes found two
regressions against the old regex, `echo 'a\'b` reported and `$$'a\'b'` silent. Both were checked
in bash and fixed in `b797e6b2eb`.
- **Round 2, on `b797e6b2eb`'s diff, which is the judged diff.** `git diff 63abc48ec4 b797e6b2eb` has
sha256 `671890d15c791bd704ce630d3e01113f042769b55316b0fa4b86c507694ced3c`. Real diff PASS 3/3;
control 2 caught 3/3. Every lane traced control 2 to `test_sc2075_in_command_sub`, which is the one
test cargo fails for it.

Two hooks refused commands during the work.

- A pre-edit hook refused the first edit because no active ticket was recorded. The ticket id was
recorded as the hook instructs, and then the edit was made.
- A deletion guard refused two shell commands, one with a variable path and one that only quoted the
guard. Neither command ran, and both were reissued without any deletion.

The post-commit hook restages `.pmat/baseline.json` after each commit. That churn was restored each
time and is not part of the branch, matching #427, #430 and #433.

## What this receipt does NOT claim

- **It does not claim the rule parses shell.** It scans one line at a time, as the regex did. A
`'...'` or `"..."` that spans lines, or a heredoc body, is scanned line by line.
- **It does not claim every escape attempt is SC2075.** A `\'` followed by a space or punctuation, as
in `'can\' t'`, `'say \'.'` or `'it\'-s'`, is no longer reported by SC2075. SC1078 reports the
quote it leaves open, and that is pinned for the space form only.
- **It does not clear two false positives the regex shares.** `echo 'a\'b 'c'` and `echo 'a\'b'c'`
are valid shell and both still report SC2075. In each, a later opening quote is read as the
closer. Checking that the closer ends a word would clear them. That is not done here, and it is no
regression.
- **It does not claim the `$( )` context is exact.** A `)` inside `$( )`, such as a case pattern,
ends the context early. On the tested lines that changes no quote decision.
- **It does not read the corpus score as evidence for the fix.** The corpus lints transpiler output,
which seldom contains these shapes. The score shows only that nothing regressed.
122 changes: 122 additions & 0 deletions docs/audits/quorum-PMAT-439-review.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
{
"ticket": "PMAT-439",
"phase": "review",
"dispatch": "PMAT-439 review: two blind rounds of width 3 (sonnet, sonnet adversarial, haiku). In each round, every lane judges two candidate diffs A/B against a snapshot of the base files: the real diff and a control that must FAIL. r2 sees them in reversed order. index lines are removed from the diffs so blob hashes cannot reveal which candidate was committed.",
"degraded": "same-family",
"author_model": "claude-opus-5-5",
"base": "63abc48ec4",
"judged_commit": "b797e6b2eb2ea20050cbca744edc528ce03a2fdd",
"judged_diff": {
"cmd": "git diff 63abc48ec4 b797e6b2eb",
"sha256": "671890d15c791bd704ce630d3e01113f042769b55316b0fa4b86c507694ced3c",
"lane_copy_sha256_index_lines_removed": "9159aaba4846",
"files": 4,
"insertions": 307,
"deletions": 14
},
"result": {
"real_pass": "3/3",
"control_caught": "3/3"
},
"diff_lane_reduce": {
"round": 2,
"width": 3,
"executor": "claude-code",
"control": "single_quoted records an attempt only when the closing quote is followed by whitespace or the end of the line (line.get(j + 1).is_none_or(u8::is_ascii_whitespace)); cargo: 17 passed, 1 failed (test_sc2075_in_command_sub)",
"lanes": [
{
"lane": "r1",
"model": "claude-sonnet-5-5",
"order": "A=real B=control",
"status": "SUCCESS",
"verdict": "PASS",
"control_verdict": "FAIL",
"summary": "Real diff: every added assertion and all 10 existing sc2075 tests trace to their expected counts; all three criteria shapes are clean and echo 'it\\'s' reports one Error. Control: the whitespace-only close check drops result=$(echo 'can\\'t'), so test_sc2075_in_command_sub fails.",
"findings": [
{
"claim": "Shared false positive: in echo 'a\\'b 'c' a later opening quote is read as the closer of the attempt. The old regex matched it too, so this is not a regression.",
"file": "rash/src/linter/rules/sc2075.rs",
"fix": "optional: require that the closer ends a word (whitespace, operator, ')' or end of line), never whitespace alone",
"grounding": "bash prints a\\b c; single_quoted sets attempt at \\'b and closes at the quote before c",
"line": 0
}
]
},
{
"lane": "r2",
"model": "claude-sonnet-5-5",
"order": "A=control B=real",
"status": "SUCCESS",
"verdict": "PASS",
"control_verdict": "FAIL",
"summary": "Adversarial. Real diff: traced the 10 existing counts (1,1,0,0,0,0,0,2,1,0) and every added assertion, and found no failing assertion. Control: test_sc2075_in_command_sub gets 0 where 1 is expected; the control also hides echo 'it\\'s'; foo, cmd 'it\\'s'|grep and echo 'it\\'s'>f, all of which the old regex reported.",
"findings": [
{
"claim": "Shared limit: the scan is per line, so a '...' spanning lines (an awk program) loses its quote state at the newline. The old regex was per line too.",
"file": "rash/src/linter/rules/sc2075.rs",
"fix": "none for this ticket",
"grounding": "awk ' / /x\\'y/ {print} / ' file: line 2 is scanned as code, and \\' is skipped as an escape",
"line": 71
},
{
"claim": "echo 'a\\'b' c is reported; the shell leaves a quote open there, so the script is broken anyway, and the old regex reported it too.",
"file": "rash/src/linter/rules/sc2075.rs",
"fix": "none",
"grounding": "attempt at \\'b, closed by the quote before ' c'",
"line": 150
}
]
},
{
"lane": "r3",
"model": "claude-haiku-5-5",
"order": "A=real B=control",
"status": "SUCCESS",
"verdict": "PASS",
"control_verdict": "FAIL",
"summary": "Real diff: every new assertion traces to its expected count and the existing tests keep theirs, including test_sc2075_in_command_sub. Control: the close of result=$(echo 'can\\'t') is followed by ')', so nothing is pushed (0, expected 1); echo 'it\\'s'; and echo 'it\\'s'|cat are lost too.",
"findings": [
{
"claim": "Shared false positive: echo 'a\\'b'c' is valid shell (the word a\\bc), yet it is reported. The old regex matches '\\'b' as well, so this is not a regression.",
"file": "rash/src/linter/rules/sc2075.rs",
"fix": "optional: check that the closer ends a word, keeping ')' as an ending so test_sc2075_in_command_sub holds",
"grounding": "attempt at \\'b, then the quote before c closes it",
"line": 169
}
]
}
]
},
"prior_round": {
"round": 1,
"judged_commit": "3a1a4f72dc",
"judged_diff_sha256": "1d340132080abae5bd3a4acd378d506853a3c939e0077a0c0b1f5396f51913cd",
"control": "is_word_byte accepts digits and underscore only; cargo: 12 passed, 6 failed (true_positive_still_fires, escaped_quote, in_command_sub, its, multiple, the lexer_context GH-439 test)",
"real_pass": "3/3",
"control_caught": "3/3",
"lanes": [
{"lane": "r1", "model": "claude-sonnet-5-5", "order": "A=real B=control", "verdict": "PASS", "control_verdict": "FAIL"},
{"lane": "r2", "model": "claude-sonnet-5-5", "order": "A=control B=real", "verdict": "PASS", "control_verdict": "FAIL"},
{"lane": "r3", "model": "claude-haiku-5-5", "order": "A=real B=control", "verdict": "PASS", "control_verdict": "FAIL"}
],
"findings_acted_on": [
{
"claim": "echo 'a\\'b with no later quote is reported, but the shell reads it as the valid word a\\b; the old regex needed a later quote and stayed silent (r3; r2 found the same with echo 'C:\\'dir)",
"fix": "b797e6b2eb: record an attempt only when a later quote closes the string",
"grounding": "bash -c \"echo 'a\\\\'b\" prints a\\b"
},
{
"claim": "echo $$'a\\'b' is silent: $' after $$ was read as ANSI-C, while the old regex reported it (r2)",
"fix": "b797e6b2eb: step over $$ as one token",
"grounding": "bash: echo $$'a\\nb' prints the pid, then a\\nb literally, so $' after $$ is not ANSI-C"
}
]
},
"delegate_receipt": "lane outputs are model reports; each verdict on a control was checked against cargo below",
"orchestrator_remeasure": {
"real": "cargo test -p bashrs --lib -- PMAT439 sc2075: 18 passed; 0 failed",
"control_round_1": "12 passed; 6 failed",
"control_round_2": "17 passed; 1 failed (test_sc2075_in_command_sub)",
"workspace": "cargo nextest run --workspace at b797e6b2eb: 17347 passed; 0 failed; 163 skipped"
}
}
21 changes: 21 additions & 0 deletions docs/roadmaps/roadmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2936,3 +2936,24 @@ roadmap:
- kind:docs
notes: null
epic: 426
- id: PMAT-439
github_issue: 439
item_type: task
title: 'SC2075: a single-quoted backslash followed by another single-quoted string is not an escaped quote (bashrs#439)'
status: inprogress
priority: high
assigned_to: null
created: 2026-10-10T07:13:54Z
updated: 2026-10-10T07:14:06.957847620+00:00
spec: null
acceptance_criteria:
- 'bashrs lint on `echo ''a\b'' | tr -d ''\\'' | grep -x ''ab''` reports 0 errors: inside single quotes a backslash is literal and the next quote closes the string'
- 'a regression test holds the reproducer (clean) and the true positive `echo ''it\''s''` (SC2075 still fires); reverting the fix turns the reproducer test red'
- 'shapes covered: ''\\'' followed by ''x'' on the same line; the same inside "$( ... )"; a lone ''\'' followed by ''x'''
phases: []
subtasks: []
estimated_effort: null
labels:
- kind:code
notes: null
epic: 426
Loading
Loading