test: the self-test must not exit 0 having evaluated nothing (#934) - #940
Conversation
…prompt#934) Handed a `pg_config` the box does not have, `test/harness_selftest.sh` printed four lines, never reached its summary, and EXITED 0. Measured on main: /usr/local/pg18a/bin/pg_config rc=0 1246 lines checks run: 610 /usr/local/pgNOPE/bin/pg_config rc=0 4 lines no summary at all A caller cannot tell the second from the first, and it is not hypothetical: it cost me a whole mutation round. The control and the mutated arm both reported rc=0 with zero FAIL lines, which reads exactly like "the mutation changed nothing" -- the conclusion the run existed to test. The log being 4 lines instead of 1246 is the only thing that gave it away. The default argument is `/usr/local/pg17/bin/pg_config`, which the audit container does not have, so the WRONG invocation is the easy one to make. TWO CAUSES, and the second is the one that generalises. `_bindir` was assigned from a command that had failed, so every later PATH was wrong. Part 010, which is SOURCED, then took its own skip path and called `exit 0` -- which exits the DRIVER rather than the part, with a status that says success. THE DRIVER REFUSES ITS ARGUMENT BEFORE SOURCING ANYTHING, on TWO predicates because one is not enough: a `pg_config` can exist and be executable and still answer nothing, which is exactly the shape that produced the empty `_bindir`. So `-x` alone would have accepted it. BOTH OF PART 010's SKIP PATHS NOW EXIT 66, the status lib.sh calls PGC_EXIT_SKIPPED, paired with the `SKIPPED (ran no checks)` line the runners already require beside it -- two independent signals, which is what lib.sh's own comment says 66 needs. The literal is spelled rather than the constant because lib.sh arrives in part 020 and 010 runs first; part 410 asserts the literal still equals the constant, so the copy cannot drift. A DRIVER-LEVEL EXIT TRAP WOULD NOT HAVE WORKED, and that is worth recording because it was my first design. Part 010 arms `trap squatter_down EXIT` for its own cleanup, and bash has one EXIT trap: the part would have clobbered the driver's guard. The static sweep closes the class instead. MEASURED BEFORE BUILDING. Seven parts contain `exit 0` and 13 occurrences in total, but only TWO are top-level bail-outs of a sourced part -- both in 010. The rest are inside heredocs, functions or subshells, in fixture scripts that legitimately end that way. So the sweep in part 410 is heredoc-aware: it sees 2 sites before this change and 0 after, where a flat `grep -c 'exit 0'` sees 13 either way. Part 410's arms, red before the fix and green after: handed a pg_config that does not exist, it refuses instead of exiting 0 and the refusal is the guard's own, naming the path and it does not pretend to have run checks a pg_config whose --bindir is empty is refused too control: the pg_config THIS run was handed satisfies both predicates part 010's skip status is the one lib.sh calls PGC_EXIT_SKIPPED and part 010 exits with exactly that status on its skip paths and pairs each with the marker the runners require no selftest part exits 0, which would exit the driver before its summary THE CONTROL DOES NOT RE-RUN THE SUITE. Invoking the driver with a good pg_config would source every part again, so the control is the two predicates the guard tests, applied to the pg_config this run was given -- plus the existence of this run, which is the guard having accepted it. TWO OF MY OWN ARMS WERE WRONG, both caught by running them rather than reading them: * `grep -cE '^\texit 66$'` reported 0 against a file holding two, because in POSIX ERE a backslash-t is a literal `t`. THIRD time that has cost me a measurement today, in three different sweeps. It is `[[:space:]]` now, with the reason written beside it. * The marker count found THREE where the part emits two: the third was inside my own comment explaining why the other two are there. A sweep that counts its own documentation is the trap the raises scan already records. It matches echo lines. And one arm could not discriminate: "it names the path" passed before the guard existed, satisfied by bash's own "No such file or directory" naming the same path. It now requires the guard's own `no-pg-config` token. After: pg18a/pg_config rc=0 632 lines checks run: 604 pgNOPE/pg_config rc=2 1 line no summary a nonexistent dir rc=2 1 line no summary exists, answers nothing rc=2, naming the empty --bindir Gate: harness_selftest 604 checks, 604 passed + 0 failed + 0 unrunnable, PASSED pytest corpus 253 passed with a cluster on pg18a shellcheck -S error over test/*.sh and test/selftest/*.sh rc=0 The part's own comment said why this mattered, four lines above the first `exit 0`: "a quiet skip means the guard stops being tested that run without anyone noticing." Fixes commandprompt#934. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…the number first commandprompt#925 adds `410-a-check-must-have-been-red.sh` and `420-a-deleted-part-must-be-visible.sh`, and commandprompt#923 under it adds `400-a-check-result-must-be-machine.sh`. Two of us picked the same numbers from the same free range, which is the residual commandprompt#554's one-file-per-subject rule leaves open: the rule stops two additions touching the same LINE, and a number is not a line. commandprompt#925 is approved and answering blockers, so it keeps the number and this part moves. The subjects never overlapped -- theirs are the red-check ledger and the part manifest -- so this is numbering and nothing else. A NOTE FOR WHOEVER MERGES SECOND. commandprompt#925 also adds `test/selftest/parts.manifest` and a part comparing it against the glob, because a glob cannot notice a deletion. If commandprompt#925 lands first, this part has to be added to that manifest or its arm reddens -- correctly, because that is the arm doing its job rather than a conflict. The reverse order needs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
Renumbered, and there is a merge-order note. #925 adds #925 is approved and answering blockers, so it keeps the number. The subjects never overlapped — theirs are the red-check ledger and the part manifest — so this is numbering and nothing else. For whoever merges second: #925 also adds Re-gated after the rename: |
jdatcmd
left a comment
There was a problem hiding this comment.
Requesting changes at exact head 47c80514837787ff6c12556bb7fcd4d5bdc517ff on one line of comment, and it is the renumber that caused it. Everything else below reproduced.
The blocker: a cross-reference the renumber left pointing at nothing, and soon at the wrong thing
test/selftest/010-stand-up-a-squatter-on-a.sh:22
# literal because lib.sh arrives in part 020 and this part runs first, and part 410
# asserts the literal still equals the constant so the two cannot drift.
There is no part 410 in this tree. The arm it names is in 430, lines 97 and 104. That alone is only a dangling pointer — but #925 adds 410-a-check-must-have-been-red.sh, so after that merges the reference resolves to a real part about the red-check ledger, which asserts nothing about 66. A reader checking whether the literal is guarded will find a healthy part that does not guard it, and stop looking. A dangling reference gets noticed; one that resolves to the wrong file does not.
The PR body says 410 twice as well.
This is the residual you named in your own message about the collision: the rename moved the file and the number, and a number written into prose is not a line git can conflict on.
Everything else reproduced, on the head, by removal
The premise is real. I ran the driver on main 79882992 myself rather than reading your table:
main pg_config that does not exist rc=0 4 lines no summary
main exists but answers nothing rc=0 3 lines no summary
head pg_config that does not exist rc=2 2 lines no summary
The removal proof, and it needs BOTH halves. Neutering the driver's two predicates alone gives rc=66, not rc=0 — part 010's other fix catches it. So the change is defence in depth, and only removing both restores the bug:
both predicates -> false, part 010's two `exit 66` -> `exit 0` rc=0 the bug is back
Each mutation asserted its anchor present, bash -n confirmed the file still parsed before running, and the tree was checked back to zero modified files after each.
My first attempt at that proof was void and I nearly filed it. I cut the guard by byte offsets, the file stopped parsing, and both arms reported rc=2 — which I could have read as "the guard is not load-bearing". It was bash refusing the file. rc=2 from a syntax error and rc=2 from exit 2 are the same number; the mutation above changes the predicate and leaves the syntax whole, which is why it can fail.
The sweep discriminates, measured on main rather than on the branch:
heredoc-aware sweep on main 2 sites, both in 010: lines 21 and 55
after the fix 0
flat grep -c 'exit 0' on main 13
FNR == 1 { hd = "" } resets the heredoc state per file, so this one does not have the awk-globals-leak-across-files bug that has cost this repo measurements before. I checked because that is where I expected it to be wrong.
Two notes, neither blocking
1. "13 either way" is true of main and false of the branch it ships in. Part 430's comment says a flat grep sees 13 either way. On this head it sees 22, because part 430 itself adds ten exit 0 lines in its own fixtures. The real numbers argue your point better than the retyped one does: flat grep 13 before and 22 after, heredoc-aware 2 before and 0 after — the flat count moves for a reason that has nothing to do with the defect.
2. The sweep closes one spelling of the mode. exit 0 ends the driver with success; so do exit (bare, which exits with the last status, very often 0), exit $?, and exit "$rc". Measured on this head, each of those is zero, so nothing is open and this is not a hole today — but the subject is "a sourced part must not end the driver claiming success", and the rule is a claim about the literal 0. One line in the comment saying which spellings it does and does not see would keep the next reader from assuming the class is closed.
Recorded
Your "a driver-level EXIT trap would not have worked, because part 010 arms its own and bash has one" is the kind of rejected design worth keeping in the PR. It is not obvious, and without it the next person tries the trap first.
…to the wrong part @jdatcmd's blocker, and it is the residual I named in my own message about the number collision: the rename moved the file and the number, and a number written into PROSE is not a line git can conflict on. `010-stand-up-a-squatter-on-a.sh:22` said "part 410 asserts the literal still equals the constant". The arm is in 430. On its own that is a dangling pointer -- but commandprompt#925 adds `410-a-check-must-have-been-red.sh`, so once that merges the reference RESOLVES, to a healthy part about the red-check ledger that asserts nothing about 66. A reader checking whether the literal is guarded would find a real file that does not guard it and stop looking. A dangling reference gets noticed; one that resolves to the wrong thing does not. TWO NON-BLOCKING NOTES, both taken, because both were numbers I retyped rather than measured. "A flat grep sees 13 either way" is true of main and false of the branch it ships in. Measured on this head it sees 24, because part 430's own fixtures add eleven `exit 0` lines. The real numbers argue the point better than the round one did, so the comment now carries both and says why the flat count moves for a reason unrelated to the defect: heredoc-aware sweep 2 before the fix, 0 after flat grep -c 'exit 0' 13 before, 24 after And the sweep closed ONE SPELLING of the mode. `exit 0` ends the driver with success; so does a BARE `exit`, which exits with the last command's status and is very often 0. That one is catchable, so the sweep now catches it, with an arm planting it and a control asserting a deliberate `exit 66` is not an offence. `exit $?` and `exit "$rc"` cannot be decided statically -- flagging them would refuse a part that legitimately exits non-zero -- so the comment names them as what the sweep does not see. Measured on this head: bare `exit` 0 sites, `exit $?` 0, `exit "$var"` 0, so nothing is open today. Gate: 606 checks, 606 passed + 0 failed + 0 unrunnable, PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at exact head af84fbb46fb10502951d612160b664a608d837f0. All three re-verified by running them, not by reading the diff.
The cross-reference. 010-stand-up-a-squatter-on-a.sh:22 now says part 430, that part exists here, and grep -rn 'part 410' over test/ and CHANGELOG.md returns 0. So the string is gone rather than moved.
The corrected count. I measured the flat count myself rather than taking 24 from the comment: grep -h 'exit 0' test/selftest/*.sh | wc -l on this head is 24. 13 before, 24 after, and the comment now says why the flat number moves for a reason unrelated to the defect.
The bare-exit extension, proved by removal.
shipped sweep over the tree 0 offences
bare-exit plant, both rules 1 (the arm wants 1)
bare-exit plant, exit-0 rule only 0 <- the arm goes red
control `exit 66`, both rules 0 (the arm wants 0)
The control is the half that matters: without it, widening the rule to any exit at all would have passed the plant and silently condemned part 010's own skip status.
And the guard still does its job on this head, including the full removal proof rather than only the happy path:
pg_config that does not exist rc=2 no summary
exists but answers nothing rc=2 no summary
both predicates false AND part 010's two `exit 66` back to `exit 0` rc=0 the bug returns
bash -n confirmed the file still parsed before that last run, which is the thing my first attempt got wrong — and the tree checked back to zero modified files after.
Two checks are still pending at this SHA, suites (PG 17) and suites (PG 18); the other 11 are green. Ready to merge once those land, on the owner's word.
…grep -c branch # Conflicts: # CHANGELOG.md
…eadable branch Part 010 conflicted, and resolving it found a latent defect of MINE that main's side fixes. Taking main's side wholesale, not as a compromise. THIS BRANCH KEPT `exit 0` IN A SOURCED PART, which is exactly the defect #934 names and #940 closed. My change to part 010 swapped the bare `echo "SKIP ..."` for `check_skip`, to route the outcome through pgc_record as @linuxhikerpm asked, and left the `exit 0` below it untouched. AND THE `check_skip` COULD NEVER HAVE RECORDED ANYTHING. `lib.sh` is sourced by part 020; part 010 runs before it, which is the same fact #940's own comment turns on when it spells 66 as a literal rather than `$PGC_EXIT_SKIPPED`. So `check_skip` in part 010 is not a call to a helper, it is a command that does not exist yet. Measured rather than argued: type check_skip, at the point part 010 runs NOT DEFINED calling it under the driver's `set -uo pipefail` bash: check_skip: command not found, rc=127, execution CONTINUES Under `-u -o pipefail` without `-e` that is not fatal, so the bail path would have printed a bash error, recorded nothing, and then exited the driver with status 0 -- the false green, with a diagnostic line in front of it. It has never fired because the squatter-port path does not trigger in a normal run, which is why no gate has ever executed those two lines. main's version is correct on all three axes: `exit 66` rather than `exit 0`, the `SKIPPED (ran no checks)` marker the runners pair with 66, and no call to a function that has not been defined yet. PART 010 IS THEREFORE AN EXCEPTION TO THE RULE THIS PR ARGUES, and it should be named as one rather than quietly left out. Every per-check outcome routes through `pgc_record` except in the one part that runs before `pgc_record` exists. There the right shape is a part-level bail, which is what #940 built. Checked rather than eyeballed, on the merged tree: parts sorting before 020 010 only part 010: exit 0 sites / exit 66 / markers 0 / 2 / 2 part 010: check_skip calls 0 part 430's arms: PGC_EXIT_SKIPPED=66 in lib.sh 1 exit-0 and bare-exit sweep over all parts 0 driver handed a bad pg_config rc=2, no summary accounting assertions still on the 4-field form none driver-free pytest job 10 files, 183 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
…ew rules (#918) Merging the updated #923 base, which now carries #939, #940 and #941, puts this branch under two rules that did not exist when it was written. Neither is a conflict git could have shown me: both are arms that read the WHOLE tree, so a file this branch never touched changed what this branch has to satisfy. THE MANIFEST, which is this PR's own arm firing correctly. Part 420 compares `parts.manifest` against the glob in both directions. #940 and #941 added parts 430 and 440 to main, so the manifest was two short and the arm would have gone red -- which is the guard working rather than a merge problem. @OffgridwithJD called this exact ordering on both of their PRs before either landed. on disk 44, listed 42, only-on-disk: 430-..., 440-... after, on disk 44, listed 44, both directions empty still live: drop 430 from the comparison and it is named again Added in sorted position rather than appended, because the file is sorted and a manifest that stops being sorted is a diff nobody can read. AND ONE MORE STRING-COMPARED `grep -c`, MINE, at `run_all_versions.sh:1382`: [ "$(grep -c '^RESULT\t' "$builddir/${s}.log" || true)" != 0 ] Zero such sites on main and zero on the #923 base, so this branch added it -- #929's defect, reintroduced while #941 was closing it. `grep -c` prints nothing on a pattern that does not compile and `[ "" != 0 ]` is TRUE, so this answers "the log has RESULT records" for a question grep never asked. One input to grep, checked, which is #941's condition for the conversion being behaviour-preserving. #941's sweep on the merged tree, before 1 after 0 still live: one planted back 1 Checked on the merged tree, not on this branch alone: #940's exit-0 and bare-exit sweep 0 the driver handed a bad pg_config rc=2, no summary ledger control / BOGUS verdict / one rc=0 / rc=2 / rc=2 --mutation against two failures driver-free pytest job 195 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
…#918) CI refused this branch, and the refusal was this PR's own gate working. Every suite passed; the gate stopped the run because parts 430 and 440 -- #940 and #941, which landed on main after this ledger was generated -- contribute checks the ledger has never seen: ledger census: rows=794 | never observed red=794, ever red=0, new this run=32 PG17 has a check the ledger has never seen, which is not a pass I HALF-LEARNED THE LESSON @OffgridwithJD ALREADY GAVE ME. They called the parts.manifest ordering before either of their PRs landed, and I updated the manifest for 430 and 440 -- and did not then ask what ELSE in this PR is a tree-wide artifact that a new part invalidates. There are two, and I fixed one. The manifest lists parts; the ledger lists CHECKS, so it moves whenever any part gains or loses one, which is far more often. REGENERATED FROM A REAL RUN, not from the CI log. The failing job prints all 32 as `not in the ledger: suite<TAB>part<TAB>name`, so they could have been parsed out -- but a ledger whose rows came from someone reading a log is the artifact this ledger exists to replace, and the merge path validates a log against its own `checks run:` count, which scraped text would not have. bash test/harness_selftest.sh /usr/local/pg17/bin/pg_config rc=0, checks run: 827, accounting: 827 passed + 0 failed + 0 unrunnable + 0 skipped = 827 of those, 32 are from parts 430 and 440 -- the number CI named MY FIRST RUN OF THAT WAS UNUSABLE AND I NEARLY MERGED IT. I copied the tree without `.git`, and 15 checks failed on "premise: the source tree is a git checkout: got [no]". Merging that log would have written 15 environment-induced reds into a ledger whose entire subject is which checks have ever been red -- poisoning the record with the one kind of entry it must never contain. Re-run from a real checkout: 0 failures. The merge is additive and nothing existing moved: ledger 794 -> 826 rows, delta 32 added: 18 from 430-the-self-test-must-not-report, 14 from 440-a-count-grep-never-produced rows removed or altered: 0 And the comparison that failed CI, run both ways so it can fail: checks in the run absent from the OLD ledger 32 (18/14, as CI said) checks in the run absent from the NEW ledger 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
Fixes #934.
Handed a
pg_configthe box does not have,test/harness_selftest.shprinted four lines, never reached its summary, and exited 0. Measured on main:A caller cannot tell the second from the first. It is not hypothetical — it cost me a whole mutation round. The control and the mutated arm both reported
rc=0with zero FAIL lines, which reads exactly like "the mutation changed nothing": the conclusion the run existed to test. The log being 4 lines instead of 1246 was the only thing that gave it away.The default argument is
/usr/local/pg17/bin/pg_config, which the audit container does not have, so the wrong invocation is the easy one to make.Two causes, and the second is the one that generalises
_bindirwas assigned from a command that had failed, so every laterPATHwas wrong. Part 010, which is sourced, then took its own skip path and calledexit 0— which exits the driver rather than the part, with a status that says success.The driver now refuses its argument before sourcing anything, on two predicates because one is not enough: a
pg_configcan exist and be executable and still answer nothing, which is exactly the shape that produced the empty_bindir.Both of part 010's skip paths now exit 66 — the status
lib.shcallsPGC_EXIT_SKIPPED— paired with theSKIPPED (ran no checks)line the runners already require beside it. Two independent signals, which is whatlib.sh's own comment says 66 needs. The literal is spelled rather than the constant becauselib.sharrives in part 020 and 010 runs first; part 410 asserts the literal still equals the constant so the copy cannot drift.A driver-level EXIT trap would not have worked
Worth recording, because it was my first design. Part 010 arms
trap squatter_down EXITfor its own cleanup, and bash has one EXIT trap — the part would have clobbered the driver's guard. The static sweep closes the class instead.Measured before building
Seven parts contain
exit 0, 13 occurrences in total, but only two are top-level bail-outs of a sourced part — both in 010. The rest sit inside heredocs, functions or subshells, in fixture scripts that legitimately end that way.So part 410's sweep is heredoc-aware: it sees 2 sites before this change and 0 after, where a flat
grep -c 'exit 0'sees 13 either way.The arms, red before and green after
The control does not re-run the suite. Invoking the driver with a good
pg_configwould source every part again, so the control is the two predicates the guard tests applied to thepg_configthis run was given — plus the existence of this run, which is the guard having accepted it.Two of my own arms were wrong, both caught by running them
grep -cE '^\texit 66$'reported 0 against a file holding two, because in POSIX ERE a backslash-tis a literalt. Third time that has cost me a measurement today, in three different sweeps. It is[[:space:]]now, with the reason written beside it.pytest.raisesscan already records. It matchesecholines now.And one arm could not discriminate: "it names the path" passed before the guard existed, satisfied by bash's own
No such file or directorynaming the same path. It now requires the guard's ownno-pg-configtoken.After
pg18a/bin/pg_configchecks run: 604pgNOPE/bin/pg_config--bindirGate
Based on main
79882992. Part numbered 410 rather than 400, because #923 claims 400 and a collision there is the#554conflict this directory numbers parts to avoid.The part's own comment said why this mattered, four lines above the first
exit 0: "a quiet skip means the guard stops being tested that run without anyone noticing."🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a