Uh oh!
There was an error while loading. Please reload this page.
test(1788): house-rules.sh gets a suite, a mutation tier, and an adversarial pass - #291
Conversation
…rsarial pass
852 lines of quote-aware, heredoc-aware, command-position-aware shell lexing, with
`quality / house-rules` a REQUIRED status check on develop, staging AND prod across
16 repos, and no test of any kind. A false positive here does not make noise; it
blocks every merge in the org, including the release train's own promotion PRs, so
the fix would travel through the pipeline it just blocked.
41 CASES. Four rules x fires/silent -- neither half alone is worth having: assert
only FIRES and a matcher that flags everything passes; assert only SILENT and one
that flags nothing does. Plus the lexer's six documented precision promises (each a
reason a rule does NOT fire, and therefore a way it could start crying wolf), all
three pragma forms, both repo-wide stand-downs, six config directives, the exit
contract including --soft-fail, and `--base` over a diff with a deleted file. Every
case pins the rule ID, not just a non-zero exit: four rules share an exit code.
THE ADVERSARIAL PASS, which is #1788's own argument. The checker matches arbitrary
source content, including comments that DISCUSS the patterns being matched. Run
over 103 real shell files in five repos -- release-train's 19 prose-dense scripts
that discuss `set -o pipefail` and SIGPIPE at length, client's 44 installers with
real curl and helm, cli, e2e-test-agent, client-runtime:
release-train 19 -> 0 client 45 -> 0 e2e-test-agent 25 -> 0
cli 11 -> 0 client-runtime 3 -> 0
AND A CONTROL, because "zero findings" and "the matcher stopped working" print the
same thing. A violation injected into that same corpus fires all four rules. Both
facts are cases; the control is what makes the clean result mean anything.
A MUTATION TIER, because .github had none at all. It applies each mutation to the
REAL script and runs the REAL suite -- no re-implemented rule inline, which reads
as satisfied while breaking the real thing reddens nothing. An anchor that matches
other than exactly once, or changes no text, is STALE and fails the run: an inert
mutation and good coverage are indistinguishable in a log.
IT EARNED ITS KEEP ON THE FIRST RUN. Two mutations passed the entire suite:
* loosening `--tlsv1\.[23]` to `--tlsv1` -- every case used a COMPLIANT flag, so
the version FLOOR, which is the actual rule, was never exercised;
* reading the raw line instead of the masked one, re-introducing a documented,
already-fixed bug where `set -o pipefail` inside a string marks a whole file
safe. Nothing pinned that fix.
Both are cases now. The second took three attempts: `echo "set -o pipefail"` does
NOT reproduce it, because the detector anchors on `(^|[[:space:];])set` and there
`set` follows a quote. I applied the mutation and enumerated which forms do --
`echo "please set -o pipefail here"`, `MSG="run set -o pipefail first"`,
`printf "x; set -o pipefail\n"` -- and a comment does not. A case written for a bug
it cannot construct is worth less than no case, because it reads like coverage.
TWO NEAR-MISSES, recorded because both were one commit from being wrong:
* The flag-var case failed and I read it for a minute as "the documented feature
is inert". It is not: the table is built with `git grep`, so a /tmp fixture
cannot reach it. The harness now runs inside a throwaway git repo with fixtures
`git add`ed -- load-bearing, since the sourced-file list is repo-wide too.
* Three fixtures appeared to show a live fail-open in the pipefail detector. Re-run
on the pristine script, all three fire correctly; the "finding" was an artefact
of a badly-escaped sed that had corrupted the awk program. Verifying on a clean
file is the only reason it did not become a wrong ticket.
ONE BEHAVIOUR PINNED RATHER THAN FIXED. A named path that does not exist reports
"no shell files to check" and exits 0. That looks like the fail-open this ticket is
about, and it is NOT REACHABLE FROM CI: code-quality.yml only ever builds `--all` or
`--base "$BASE_SHA"` (code-quality.yml:755-758). Asserted as current behaviour with
the reason, per the rule that a fix for an unreachable path costs more than filing
nothing. The reachable neighbour -- `--base` over a diff that DELETED a shell file
-- is asserted to still check the survivors.
WIRING. `selftest-house-rules` joins the REQUIRED `selftests` context, and the
mutation run gets its own step there rather than a new context that would sit
unrequired until somebody armed it. `make check` resolves the anchors only (~ms);
the full ~58s run is in `check-all` and in CI. Measured, and the numbers are in the
Makefile next to the choice.
AND THE COVERAGE GUARD LEARNED A SECOND FAMILY. `selftests-cover` rejected the
mutation runner for not matching `*-selftest.{py,sh}` -- correctly, since a file no
wildcard sees makes its assertion pass vacuously. Renaming it would have been a lie
that also nested the 58s tier inside the 3s one, so `*-mutations.py` is now its own
family with its own coverage assertion: a mutation runner nobody runs is the same
dead weight as an unwired selftest, with the extra cost that it LOOKS like the tier
exists.
make check exit 0 (captured, not piped). 41 cases, 5 mutations, 0 stale, 0 uncaught.
Refs backend#1788.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Uh oh!
There was an error while loading. Please reload this page.
Bugbot, Medium, and it is a dead parameter in the suite I wrote to prove the matcher works. `expect` took an expected finding COUNT as $3, computed `n`, and never compared them. So a non-zero case checked only rc=1 plus "the named rule appears in the output" -- and extra rules passed silently. BUGBOT'S EXAMPLE IS THE ONE THAT MATTERED: the scoped-pragma case `# house-rules: ignore=curl-tls` stayed green even if the scoping were a NO-OP, because `curl-timeout` fires either way and the test only asked whether `curl-timeout` appeared. A case whose entire purpose is to prove `ignore=` narrows to ONE rule, which could not have failed if it didn't. `n` was not even a finding count: its grep alternation included the word `house-rules`, which matches the summary line. THE FIX IS A SET, not a wired-up count. The expected value is now the exact comma-separated rule ids (`-` for clean), so extra findings fail, missing findings fail, and the wrong rule fails. Strictly more informative than a number, and it kills the parameter that read as an assertion and was not one. REGISTERED AS A MUTATION, so the case cannot go vacuous again: disabling the `ignore=` match now reddens exactly `a SCOPED pragma silences only the named rule` (40/1). Six mutations, 0 stale, 0 uncaught. THE STRICTER ASSERTION IMMEDIATELY CAUGHT TWO OF MY OWN CASES OVERSTATING WHAT THEY PINNED. A bare `curl -fsSL "$url"` has neither a TLS floor nor a time bound, so it violates BOTH rules; two cases claimed only `curl-tls` and passed under the loose helper. Corrected to `curl-timeout,curl-tls`. Same defect as Bugbot's, one level out: my expectation was looser than it read. AND THE FIRST EXTRACTION WAS NOT PORTABLE. I parsed the rule ids with `sed -n 's/.*\[\(a\|b\)\].*/\1/p'` -- `\|` is a GNU extension, so on BSD sed it matched nothing and every firing case reported "-", failing 11 cases at once. `grep -oE` instead. Portability is this script's whole design constraint (POSIX sh + awk, "a runner, a Mac, or a minimal container with equal results"), and the suite has to honour it too or it tests one platform. 41 cases green. make check exit 0. Refs backend#1788. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 20, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Two Bugbot findings. The first is a defect I have spent this session writing about in another repo and then wrote fresh here. `printf … | grep -q` UNDER pipefail. `grep -q` exits on its first match and SIGPIPEs the writer; the PIPELINE can then take status 141, and the negated form (`! … | grep -q`) reads 141 as "absent" -- so a `disable:` directive that did nothing would have recorded a PASS. Eight sites, all converted to here-strings. Honest about reach, because overclaiming it would be its own defect: `printf` of a short string usually completes before `grep` leaves, so 141 is unlikely here rather than impossible. That is the same measurement release-train's fr-evidence.sh records -- SIGPIPE needs the writer ALIVE when the reader exits, which for a fetch-write-exit process means output past the 64K pipe buffer. Changed anyway, and for release-train's stated reason: a here-string is simply the smaller construction, with no pipe and therefore no pipefail interaction for the next reader to re-derive. The rule is now written where someone would otherwise put a pipe back. Bugbot's own framing is the sharper one: "sibling tests already use a here-string instead of a pipe." The idiom existed in this org and I did not follow it. THE TRAP DID NOT CLEAN THE CHECKOUT. The adversarial control writes a deliberate `curl` violation INTO THIS REPO and `git add`s it -- necessarily, because `--all` enumerates with `git ls-files` and cannot see an untracked file. The EXIT trap only removed $WORK, so an interrupt between the add and the cleanup left `.hr-control.sh` STAGED. That file is itself a house-rules hit, so the next `make check` would have failed on leftover test state and pointed at the wrong thing -- a test that breaks the build it is meant to protect. Now a `cleanup()` on EXIT INT TERM that unstages and deletes it, with the path in one variable so the two call sites cannot drift. Verified by sending SIGINT mid-run: no leftover, `git status` clean. While fixing it the variable rename swept one assertion onto the wrong output buffer ($OUT where it wanted $OUT2, the adversarial control's own run). Caught by reading the diff rather than by the suite, which would have passed either way because both buffers contained the string. 41 cases green, 6 mutations 0 stale 0 uncaught, make check exit 0, tree clean. Refs backend#1788. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 20, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Bugbot, Medium, and it is the vacuous-pin shape in the case written to prove the directive works -- the third variation of that defect this suite has produced. The fixture put a COMPLIANT curl behind the wrapper and recorded success from a clean exit alone: directive honoured `guard` is a wrapper, so curl is in COMMAND position and the *-timeout rules stand down -> clean, rc 0 directive IGNORED `guard` is not a wrapper, so curl is in ARGUMENT position and is not a command at all -> clean, rc 0 Identical outcome. A no-op `timeout-wrapper:` was indistinguishable from a working one, exactly as Bugbot puts it. DROPPING `--tlsv1.2` SPLITS THEM, because `timeout-wrapper:` waives only the *-timeout rules and leaves curl-tls live. Measured both ways before writing the assertion: with the directive rc=1 rules=[curl-tls] curl IS a command; timeout waived without the directive rc=0 rules=[-] curl is just an argument So the case now asserts the exact rule set, which also makes it say something stronger than "clean": that the directive waives the timeout rules AND ONLY THOSE. REGISTERED AS A MUTATION -- parse `timeout-wrapper:` and throw the value away. It reddens exactly this case. Seven mutations, 0 stale, 0 uncaught. Worth naming the pattern, because it has now happened three times in one file and each time the case LOOKED like coverage: an assertion that passes for two different reasons is not an assertion. `expect` asking "does this rule appear" rather than "which rules fired"; the scoped-pragma case leaning on a rule that fired anyway; this one reading a clean exit that both branches produce. All three were found by review or by mutation, none by reading the case. 41 cases green, make check exit 0. Refs backend#1788. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 20, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 400b9e0. Configure here.
saadqbal
left a comment
There was a problem hiding this comment.
Ran both tiers rather than reading the tables. Selftest 41 passed, 0 failed; mutation tier 7 mutations, 0 stale, 0 uncaught — including the two the description says initially sailed through the whole suite:
caught curl-tls accepts ANY --tlsv1, so the version floor is gone
by: curl-tls still fires on a DOWNGRADED --tlsv1.0, curl-tls still fires on --tlsv1.1
caught pipefail reads the RAW line, so a quoted string marks a file safe
by: a quoted "set -o pipefail" does NOT mark the file safe, ...nor does one in a variable assignment
Those two are the whole argument for the mutation tier existing. "Every case used a compliant flag, so the version floor was never exercised" is the classic shape — full green on a rule whose actual content nothing touched. And the note that echo "set -o pipefail" does not reproduce the lraw bug, because the detector anchors on (^|[[:space:];])set and there set follows a quote, is the detail that makes the second case real: a case written for a bug it cannot construct is worse than none, because it reads as coverage. Worth the three attempts.
Applying mutations to the real script and running the real suite — no inline re-implementation — is the right construction, and treating an anchor that matches other than exactly once as stale is what stops the tier rotting into a no-op after a refactor. That's the failure mode a mutation harness normally acquires within a year.
Also good, and both apparently Bugbot-driven:
- the
trapcleaning the checkout and not just$WORK..hr-control.shis itself a house-rules hit, so an interrupt between thegit addand cleanup would leave it staged and the nextmake checkwould fail pointing at the wrong thing. - here-strings over
printf … | grep -q.grep -qexiting on first match SIGPIPEs the writer,pipefailturns that into 141, and the negated form reads 141 as "absent" — so adisable:directive that did nothing would have recorded a PASS. That's a test harness that lies in the safe-looking direction, which is the worst kind.
Two notes, neither blocking:
- The headline corpus number isn't what CI reproduces. The description leads with 103 real shell files across five repos; the standing check is
--allover this repo's tracked files, which reported 5 in my run, plus the injected control. Both are worth having — the 103-file sweep is the evidence for #1788's central question, the 5-file pass is the regression guard — but they read as one thing. Might be worth a line in the suite saying the cross-repo sweep was one-time evidence, so nobody later assumes CI is still doing it. - Pinning the named-missing-path behaviour (
no shell files to check, rc 0) as current-and-CI-unreachable rather than fixing it is the right call, and I'm glad the reachability argument is written next to it (code-quality.yml:755-758only ever builds--allor--base). That reasoning is the thing that would go stale if the workflow changed, so it being cited by line is what makes it checkable later.
Good PR. 852 lines of quote-aware lexing gating every merge in the org with no test of any kind was a real exposure.
Uh oh!
There was an error while loading. Please reload this page.
saadqbal approved with one fix, and it is the file's own rule turned on itself. THE PROSE SAID 13 IN TWO PLACES while `EXEMPT` holds 12. His diagnosis is right: 13 is the mint STEP count the audit reports -- 12 unscoped plus one already scoped -- so the two are different populations, and the smaller one drifts the moment a row is burnt down. Which is the point of the guard. And his recommended fix is the right one: not "write 12", but let the number come from `len(_exempt())`, which the run already prints. A hardcoded tally sitting directly above the list it counts is the exact pattern backend#1729 is cited for -- in the file that cites it. Both prose sites now carry no number, and the docstring says why, so the next person does not helpfully add one back. MERGE CONFLICT resolved against develop, which moved three times underneath this branch (#288, #289, #291). All three hunks wanted BOTH sides, not one: Makefile `lint` mint-scope AND mutation-house-rules-dry SELFTEST_TARGETS selftest-mint-scope AND selftest-house-rules selftests.yml the mint-scope audit step AND the house-rules mutation step Makefile CI map one line naming all three, since the required `selftests` context now runs all of them repo-inventory.yml kept OURS deliberately: develop still carries the pre-release-train#93 wording ("the header alone"), which is now false -- #93 added the scoping, so the divergence really is two comment blocks and nothing else, and ours is the version with the checkable claim in it. Two comments in selftests.yml also lost their case counts on the way through, for the same reason as the docstring: `mint-scope` prints its own totals. make check exit 0. mint-scope: 13 steps, 12 exempted, 0 findings. 41 house-rules cases. mint-scope selftest 13/13. Refs backend#2157. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 21, 2026
/fr-pass |
Closes backend#1788.
852 lines of quote-aware, heredoc-aware, command-position-aware shell lexing, with
quality / house-rulesa required status check on develop, staging and prod across 16 repos — and no test of any kind. A false positive here doesn't make noise; it blocks every merge in the org, including the release train's own promotion PRs, so the fix would have to travel through the pipeline it just blocked.The adversarial pass — the ticket's central question
#1788's argument is that this checker matches arbitrary source content, including comments that discuss the patterns being matched. Its evidence was a five-pattern matcher over 588 real PR titles producing a false positive on a domain term — and that was on titles, the most uniform text we have.
Run over 103 real shell files in five repos, including
release-train's 19 prose-dense scripts that discussset -o pipefailand SIGPIPE at length, andclient's 44 installers with realcurl/helm:And a control, because "zero findings" and "the matcher stopped working" print the same thing. A violation injected into that same corpus fires all four rules. Both are cases; the control is what makes the clean result mean anything.
41 cases
Four rules × fires / silent — neither half alone is worth having: assert only fires and a matcher that flags everything passes; assert only silent and one that flags nothing does. Plus the lexer's six documented precision promises (each a reason a rule does not fire, and therefore a way it could start crying wolf), all three pragma forms, both repo-wide stand-downs, six config directives, the exit contract including
--soft-fail, and--baseover a diff with a deleted file.Every case pins the rule ID, not just a non-zero exit — four rules share an exit code.
A mutation tier, because
.githubhad noneIt applies each mutation to the real script and runs the real suite — no re-implemented rule inline, which reads as satisfied while breaking the real thing reddens nothing. An anchor matching other than exactly once, or changing no text, is STALE and fails the run.
It earned its keep on the first run. Two mutations passed the entire suite:
--tlsv1\.[23]→--tlsv1lmask→lrawset -o pipefailinside a string marks a whole file safe. Nothing pinned the fixBoth are cases now. The second took three attempts:
echo "set -o pipefail"does not reproduce it, because the detector anchors on(^|[[:space:];])setand theresetfollows a quote. I applied the mutation and enumerated which forms do. A case written for a bug it cannot construct is worth less than no case, because it reads like coverage.Two near-misses, recorded because both were one commit from being wrong
git grep, so a/tmpfixture can never reach it. The harness now runs inside a throwaway git repo with fixturesgit added.sedthat had corrupted the awk program. Verifying on a clean file is the only reason it didn't become a wrong ticket.One behaviour pinned rather than fixed
A named path that doesn't exist reports
no shell files to checkand exits 0. That looks like the fail-open this ticket is about, and it is not reachable from CI —code-quality.ymlonly ever builds--allor--base "$BASE_SHA"(:755-758). Asserted as current behaviour with the reason, per the rule that a fix for an unreachable path costs more than filing nothing. The reachable neighbour —--baseover a diff that deleted a shell file — is asserted to still check the survivors.Wiring
selftest-house-rulesjoins the already-requiredselftestscontext, and the mutation run gets its own step there rather than a new context that would sit unrequired until someone armed it.make checkresolves anchors only (~ms); the full ~58s run is incheck-alland CI. The numbers are in the Makefile next to the choice.And the coverage guard learned a second family.
selftests-coverrejected the mutation runner for not matching*-selftest.{py,sh}— correctly, since a file no wildcard sees makes its assertion pass vacuously. Renaming it would have been a lie that also nested the 58s tier inside the 3s one, so*-mutations.pyis its own family with its own coverage assertion.Item 4 of the ticket, not done here
The ticket asks whether
house-rulesneeds to be required onstaging/prodat all, since the content was already linted intodevelop. That's a branch-protection change across 16 repos × 2 roles and a judgement call, not a test — written up as a comment on #1788 rather than actioned unilaterally.make checkexit 0 (captured, not piped). 41 cases, 5 mutations, 0 stale, 0 uncaught.Note
Low Risk
Changes add and wire tests and CI/Makefile targets only; they do not modify
house-rules.shmatcher behavior or production gate logic.Overview
Adds test coverage for
house-rules.sh, a required org-wide gate that previously had no tests.New suites:
house-rules-selftest.sh(~41 cases) exercises the four rules (fires/silent pairs), lexer precision, pragmas, repo-wide stand-downs, config directives, exit/--soft-fail/--basebehavior, and an adversarial--allpass over this repo’s tracked scripts with a staged violation control. Cases assert exact rule IDs, not just non-zero exit.Mutation tier:
house-rules-mutations.pymutates the real script and re-runs the real suite; stale anchors and uncaught breaks fail the run. Full mutations (~58s) run in CI (selftests.yml) andmake check-all;mutation-house-rules-dry(~ms) is onmake lintfor fast anchor checks.Makefile / coverage:
selftest-house-rulesjoinsSELFTEST_TARGETS;selftests-coveralso tracks*-mutations.pyviaMUTATION_FILES/MUTATION_TARGETSso unwired mutation runners cannot pass vacuously.Reviewed by Cursor Bugbot for commit 400b9e0. Bugbot is set up for automated code reviews on this repo. Configure here.