A bats test body has no assertions of its own: its verdict is the exit status of the last command it ran. Bash does not apply errexit to a negated command, so ! cmd on its own line decides the test when it is the final command of the body and is inert everywhere else. The line looks like a refutation either way, and which one it is depends only on where it sits.
Measured 2026-09-28 over every default branch in the organization that carries a bats file (19 of them):
shellcheck -f gcc --shell=bash <file>.bats | grep SC2314
57 inert negations in 8 repositories, beside 45 that are in final position and do assert. shellcheck grades the two apart, error: for the inert ones and note: for the rest, which is what makes the sweep exact.
| Repository |
File |
Inert |
Also converted (final position) |
| inithooks |
tests/test-ipconfig.bats |
17 |
6 |
| inithooks |
tests/test-init-fence.bats |
3 |
2 |
| inithooks |
tests/test-tagid.bats |
2 |
0 |
| inithooks |
tests/test-run.bats |
0 |
1 |
| keel-wordpress |
tests/boot-test.bats |
10 |
6 |
| keel-wordpress |
tests/hook.bats |
0 |
1 |
| .github |
tests/boot-test-nodes.bats |
9 |
3 |
| keel-nodebb |
tests/nodebb.bats |
4 |
3 |
| keel-nodebb |
tests/nginx.bats |
2 |
3 |
| keel-nodebb |
tests/hook.bats |
0 |
2 |
| unit-redis |
tests/conf.bats |
3 |
2 |
| unit-redis |
tests/unit.bats |
1 |
2 |
| unit-redis |
tests/hook.bats |
0 |
2 |
| keel-mariadb |
tests/boot-test.bats |
3 |
1 |
| keel-mariadb |
tests/hook.bats |
0 |
1 |
| apt |
tests/build-package.bats |
2 |
1 |
| common |
tests/postfix-local.bats |
1 |
2 |
Clean, confirmed rather than assumed: keel-postgresql, keel-transition, unit-mariadb, unit-postgresql, keel-core, keel, keel-redis, keel-lamp, keel-lapp, keel-apache-php, tkldev. keel-redis and keel-lamp were already written run ! throughout, which is where the form comes from.
The worst of them
inithooks tests/test-ipconfig.bats, "ip6_syntax rejects what is not an IPv6 address": thirteen rejection cases, twelve inert, only the last deciding anything. An IPv6 validator's rejection set, in an IPv6-first distribution, in the repository whose hook writes the address.
Closely followed by keel-wordpress, where five of the six cases of "is_global_ipv6 refuses link local, loopback, multicast and IPv4" never ran, and unit-redis, where the refutation that the bind fragment never says localhost — the one put there by the localhost entry in docs/traps.md — was itself inert.
What was found by turning them on
Every predicate turned out to answer as its test believed, so nothing shipped broken. That is luck, not evidence.
One newly live assertion failed: keel-nodebb tests/nodebb.bats, "proxy_conf without an address trusts nobody", asserted ! grep -q set_real_ip_from unanchored against a file whose own comment names the directive. It could never have passed, for any input, and nothing had ever run it. Anchored to ^set_real_ip_from, which is the property meant.
The fix and the gate
run ! cmd, with bats_require_minimum_version 1.5.0, which asserts wherever it stands. Every negation is converted, not only the inert ones: a file that mixes the two forms leaves a reader to work out which half is real.
The gate is one step in test-shell.yml of the .github repository, Negations that assert (SC2314), rather than a copy in each repository: every repository with a bats suite already calls that workflow at @main, and "copying a test library instead of sharing it" is already in docs/traps.md as a mistake this project made.
Merge order matters. The .github pull request should merge last. It is @main, so the moment it lands every repository that still has an inert negation goes red.
The pull requests
One per repository, each closing an issue in that repository. This issue is only the coordination.
A bats test body has no assertions of its own: its verdict is the exit status of the last command it ran. Bash does not apply errexit to a negated command, so
! cmdon its own line decides the test when it is the final command of the body and is inert everywhere else. The line looks like a refutation either way, and which one it is depends only on where it sits.Measured 2026-09-28 over every default branch in the organization that carries a bats file (19 of them):
57 inert negations in 8 repositories, beside 45 that are in final position and do assert. shellcheck grades the two apart,
error:for the inert ones andnote:for the rest, which is what makes the sweep exact.Clean, confirmed rather than assumed:
keel-postgresql,keel-transition,unit-mariadb,unit-postgresql,keel-core,keel,keel-redis,keel-lamp,keel-lapp,keel-apache-php,tkldev.keel-redisandkeel-lampwere already writtenrun !throughout, which is where the form comes from.The worst of them
inithookstests/test-ipconfig.bats, "ip6_syntax rejects what is not an IPv6 address": thirteen rejection cases, twelve inert, only the last deciding anything. An IPv6 validator's rejection set, in an IPv6-first distribution, in the repository whose hook writes the address.Closely followed by
keel-wordpress, where five of the six cases of "is_global_ipv6 refuses link local, loopback, multicast and IPv4" never ran, andunit-redis, where the refutation that the bind fragment never sayslocalhost— the one put there by thelocalhostentry indocs/traps.md— was itself inert.What was found by turning them on
Every predicate turned out to answer as its test believed, so nothing shipped broken. That is luck, not evidence.
One newly live assertion failed:
keel-nodebbtests/nodebb.bats, "proxy_conf without an address trusts nobody", asserted! grep -q set_real_ip_fromunanchored against a file whose own comment names the directive. It could never have passed, for any input, and nothing had ever run it. Anchored to^set_real_ip_from, which is the property meant.The fix and the gate
run ! cmd, withbats_require_minimum_version 1.5.0, which asserts wherever it stands. Every negation is converted, not only the inert ones: a file that mixes the two forms leaves a reader to work out which half is real.The gate is one step in
test-shell.ymlof the.githubrepository,Negations that assert (SC2314), rather than a copy in each repository: every repository with a bats suite already calls that workflow at@main, and "copying a test library instead of sharing it" is already indocs/traps.mdas a mistake this project made.Merge order matters. The
.githubpull request should merge last. It is@main, so the moment it lands every repository that still has an inert negation goes red.The pull requests
One per repository, each closing an issue in that repository. This issue is only the coordination.