Skip to content

test(1788): house-rules.sh gets a suite, a mutation tier, and an adversarial pass - #291

Merged
LukasWodka merged 4 commits into
developfrom
test/1788-house-rules-selftest
Aug 20, 2026
Merged

test(1788): house-rules.sh gets a suite, a mutation tier, and an adversarial pass#291
LukasWodka merged 4 commits into
developfrom
test/1788-house-rules-selftest

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes backend#1788.

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 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 discuss set -o pipefail and SIGPIPE at length, and client's 44 installers with real curl/helm:

release-train 19 -> 0 findings client 45 -> 0 findings
e2e-test-agent 25 -> 0 findings cli 11 -> 0 findings
client-runtime 3 -> 0 findings

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 --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.

A mutation tier, because .github had none

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

mutationwhy the suite couldn't see it
--tlsv1\.[23]--tlsv1every case used a compliant flag, so the version floor — the actual rule — was never exercised
lmasklrawre-introduces a documented, already-fixed bug where set -o pipefail inside a string marks a whole file safe. Nothing pinned the 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. 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 isn't — the table is built with git grep, so a /tmp fixture can never reach it. The harness now runs inside a throwaway git repo with fixtures git added.
  • 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 didn't become a wrong ticket.

One behaviour pinned rather than fixed

A named path that doesn't 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 CIcode-quality.yml only ever builds --all or --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 — --base over a diff that deleted a shell file — is asserted to still check the survivors.

Wiring

selftest-house-rules joins the already-requiredselftests context, and the mutation run gets its own step there rather than a new context that would sit unrequired until someone armed it. make check resolves anchors only (~ms); the full ~58s run is in check-all and CI. 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 its own family with its own coverage assertion.

Item 4 of the ticket, not done here

The ticket asks whether house-rules needs to be required on staging/prod at all, since the content was already linted into develop. 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 check exit 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.sh matcher 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/--base behavior, and an adversarial --all pass 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.py mutates 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) and make check-all; mutation-house-rules-dry (~ms) is on make lint for fast anchor checks.

Makefile / coverage:selftest-house-rules joins SELFTEST_TARGETS; selftests-cover also tracks *-mutations.py via MUTATION_FILES / MUTATION_TARGETS so 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.

…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>
@LukasWodkaLukasWodka self-assigned this Aug 20, 2026
Comment threadscripts/tests/house-rules-selftest.sh
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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/tests/house-rules-selftest.sh
Comment threadscripts/tests/house-rules-selftest.sh Outdated
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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/tests/house-rules-selftest.sh Outdated
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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 trap cleaning the checkout and not just $WORK. .hr-control.sh is itself a house-rules hit, so an interrupt between the git add and cleanup would leave it staged and the next make check would fail pointing at the wrong thing.
  • here-strings over printf … | grep -q. grep -q exiting on first match SIGPIPEs the writer, pipefail turns that into 141, and the negated form reads 141 as "absent" — so a disable: 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:

  1. The headline corpus number isn't what CI reproduces. The description leads with 103 real shell files across five repos; the standing check is --all over 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.
  2. 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-758 only ever builds --all or --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.

@LukasWodka
LukasWodka merged commit a8b7db2 into developAug 20, 2026
10 checks passed
@LukasWodka
LukasWodka deleted the test/1788-house-rules-selftest branch August 20, 2026 09:47
LukasWodka added a commit that referenced this pull request Aug 20, 2026
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

Copy link
Copy Markdown
ContributorAuthor

/fr-pass

Sign up for freeto 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.

2 participants

@LukasWodka@saadqbal