compare_to_bash.py decides whether a pytest port is one-for-one with its bash suite, which is #432's definition of done. Its bash-side extractor reads only 5 of the 8 check helpers test/lib.sh defines, so a bash property asserted through any of the other three is invisible to the tool and can be reported as complete when it is not.
Found while fixing the python side in #1038, and filed separately because closing it reddens a pair and is a port's worth of work rather than a tool fix.
This issue was edited after review. Its first version quoted a sweep total of "89 invisible invocations across 8 helpers". That was wrong twice over: it mixed helpers lib.sh defines with helpers individual suites define, and it counted check_stack_depth, which is not one of these helpers. @OffgridwithJD caught it. See Why no total is quoted below, which is now part of the finding.
The extractor
test/pytest/compare_to_bash.py:222
r'\bcheck(?:_num|_ratio|_text|_timing)?\s+"([^"]+)"'
check_unrunnable and the rest match no branch: after check the pattern allows only those four suffixes or nothing, then requires whitespace.
What test/lib.sh actually defines
Derived from the definitions in lib.sh, not swept for:
| helper |
defined at |
read by the tool |
check |
lib.sh:1244 |
READ |
check_num |
lib.sh:1307 |
READ |
check_text |
lib.sh:1296 |
READ |
check_ratio |
lib.sh:1339 |
READ |
check_timing |
lib.sh:1411 |
READ |
check_unrunnable |
lib.sh:1231 |
INVISIBLE |
check_skip |
lib.sh:1407 |
INVISIBLE |
check_ratio_needs_quiet_machine |
lib.sh:1460 |
INVISIBLE |
8 defined, 5 read, 3 invisible.
Four more are defined locally by a single suite and are invisible to the same regex: check_structure, check_reconstruct, check_split_happened (parallel_copy.sh) and check_float (parquet_export_stats.sh).
A second trap in the other direction
Thirteen suites define their own check(), shadowing lib.sh's: audit.sh, bench_guards.sh, concurrency.sh, docs_style.sh, pg_upgrade.sh, phase2.sh, phase3.sh, phase4.sh, phase5.sh, phase6.sh, smoke.sh, unique_conc.sh, update_conc.sh.
Eleven of the thirteen source lib.sh and then override it at top level -- not in a heredoc, not conditionally. Two never source it at all and are standalone. Each of the eleven carries the same comment, "lib.sh for the check vocabulary (#965)", so the shadowing is deliberate:
test/phase4.sh:26 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
test/phase4.sh:100 check() {
local name="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then pgc_record PASS "$name" ...
| file |
defines check() at |
sources lib.sh |
audit.sh |
145 |
yes |
bench_guards.sh |
32 |
no |
concurrency.sh |
194 |
yes |
docs_style.sh |
60 |
no |
pg_upgrade.sh |
37 |
yes |
phase2.sh |
94 |
yes |
phase3.sh |
103 |
yes |
phase4.sh |
100 |
yes |
phase5.sh |
102 |
yes |
phase6.sh |
106 |
yes |
smoke.sh |
133 |
yes |
unique_conc.sh |
184 |
yes |
update_conc.sh |
182 |
yes |
The eleven take lib.sh's vocabulary and replace its recorder. check "NAME" in phase4.sh and check "NAME" in a lib.sh-using suite are two different functions with two different recording paths, and the tool reads both through one regex branch as if they were one.
That is harmless for extracting a NAME, which is all the tool does today. It is a constraint on the fix: a helper list derived from lib.sh is wrong for these 13 files in the direction of looking more authoritative than it is, so the fix must state which population it covers rather than imply "check is lib.sh's check".
What it hides today
Widening the regex by |_unrunnable and changing nothing else:
| pair |
as shipped |
with _unrunnable |
| hilbert_locality |
rc=0 missing=0 |
rc=1 missing=2 |
| every other pair |
unchanged |
unchanged |
MISSING box $box: groups read, Hilbert
MISSING box $box: groups read, Z-order
hilbert_locality.sh:574-581 emits four check_unrunnable records per box when CURVE_DIFFERS != different; test_hilbert_locality.py emits one cannot_run record, named UNMET_PRECONDITION. Two of the four have no partner. Reproduced independently by both agents.
The invisible invocations: 50, and the two traps that produced three wrong counts
50 over test/*.sh, reconciled between two agents and two independent methods that agree helper for helper:
| helper |
invocations |
check_unrunnable |
25 |
check_skip |
23 |
check_ratio_needs_quiet_machine |
2 |
| total |
50 |
Four sweeps read 89, 64, 54 and 50 before this settled. Each is named by its METHOD rather than by a winner, because the method is the checkable part and a scoreboard is a worse artefact than a method note:
| reading |
method |
whose |
| 89 |
counted each helper's own DEFINITION line as an invocation |
mine |
| 64, 54 |
counted COMMENTS, including each helper's own trailing usage comment |
@OffgridwithJD's |
| 50 |
definitions and comments excluded, and a call accepted after ;, && or || |
the reconciled answer, reached by both passes |
50 is not cleanly either of ours and should not be claimed by one. A command-position-only sweep — anchoring on line start alone — returns 24 for check_unrunnable and a total of 49; accepting a call after && returns 25 and 50. The discriminating site is hilbert_curve.sh:321, [ -n "$_a" ] && check_unrunnable "$_a" "$2" "$3", which @OffgridwithJD identified. Either pass reaches 50 only with that clause. The three wrong ones were defects, not method-sensitivity, and both causes are worth recording because anyone re-deriving this meets them:
1. A \bNAME\s sweep counts each helper's own definition line. The definitions carry a trailing usage comment that repeats the name followed by a space:
lib.sh:1231 check_unrunnable() {<TAB># check_unrunnable NAME REASON_CODE DETAIL
lib.sh:1407 check_skip() {<TAB># check_skip NAME DISPLAY [REASON]
So the definition matches as though it were a call. Two further matches were ordinary prose (lib.sh:1338, planner_choice_quality.sh:149). That accounts for 89 (definitions included) and 54 (comments included).
2. A command-position match misses an invocation after &&.
hilbert_curve.sh:321 [ -n "$_a" ] && check_unrunnable "$_a" "$2" "$3"
Anchoring on ^ alone yields 24 for that helper instead of 25.
3. The population is half the number. test/*.sh is the 265 top-level suites -- the only files compare_to_bash.py grades, since it takes a test/<stem>.sh and a test/pytest/test_<stem>.py. Glob test/**/*.sh instead and the answer is 56:
| population |
unrunnable |
skip |
ratio_nqm |
total |
test/*.sh (265 files, what the tool grades) |
25 |
23 |
2 |
50 |
test/**/*.sh (312 files, adds harness selftests) |
29 |
25 |
2 |
56 |
The extra 6 are all in test/selftest/, which the tool never reads. Neither number is wrong; a number without its population is.
4. git grep will not give you that population. Git pathspecs are wildmatch without FNM_PATHNAME, so * crosses / and the natural spelling is silently recursive:
git grep -e check_unrunnable REV -- 'test/*.sh' 9 files, 4 under selftest/
git grep -e check_unrunnable REV -- ':(glob)test/*.sh' 5 files, 0 under selftest/
This matters because git grep is the instrument anyone reaches for to measure the number at an older revision, and there the top-level spelling looks correct. Use :(glob).
test/selftest/ is out of scope for a second reason too: it is the SHELL harness's own self-test, and the two harnesses stay independent by standing rule, so counting its call sites into a claim about what the pytest parity tool grades would cross that line even if the tool could read them.
The recipe that reproduces 50: over test/*.sh only -- spelled :(glob)test/*.sh if you are using git grep -- strip trailing comments as well as whole-line ones, exclude definition lines, and accept a call at the start of a line or after ;, && or ||.
A history table needs one row measured a second, independent way. Both of us built the revision table above and one of us first got 0 + 0 + 0 for main, from a git archive <ref> | tar -x that extracted zero files -- real counts of an empty tree, with nothing in the pipeline complaining. It was caught only by already having 25 for main from a worktree. An all-zero row and a broken extraction are indistinguishable without a cross-check.
The exposure is recent and growing, which changes the priority
Same method, same population (test/*.sh), at three revisions:
| revision |
date |
unrunnable |
skip |
ratio_nqm |
invisible total |
v1.0-alpha3 |
2026-09-02 |
0 |
0 |
2 |
2 |
0cbf574 the extractor lands |
2026-09-08 |
21 |
0 |
2 |
23 |
main |
2026-09-13 |
25 |
23 |
2 |
50 |
Two things follow.
The regex never covered these helpers; it did not drift out of date. 0cbf574 is the commit that introduced check(?:_num|_ratio|_text|_timing)?, and on that day check_unrunnable already had 21 call sites. The pattern was written past them.
check_skip went from 0 to 23 in five days, so the blind spot is widening faster than the suite count. 2 -> 23 -> 50 in eleven days.
The 2026-09-02 row also independently corroborates the CHANGELOG entry for 1.0-alpha3, which says of the INCOMPLETE dispatch: "Latent today. check_unrunnable has no production call site, so no real suite can reach the INCOMPLETE state in a matrix run yet." That was true when written and is no longer -- 25 call sites now reach it, and the tool cannot see any of them.
Suggested shape of a fix
- Derive the helper list from
lib.sh's definitions rather than hard-coding a second list — a hand-maintained list is exactly the derived value that went stale here.
- Decide explicitly what to do about the 13 suite-local
check() definitions and the 4 suite-local helpers; a lib.sh-derived list silently covers neither.
- Expect
hilbert_locality to go red, and port the two missing properties (or state, in the file, why one record stands for four).
- Add an arm asserting the extractor's helper list matches what
lib.sh defines, so this cannot silently recur.
A guard that cannot see a helper reports the same 0 as a suite with nothing missing, which is why this needs an arm and not just a wider regex.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
compare_to_bash.pydecides whether a pytest port is one-for-one with its bash suite, which is #432's definition of done. Its bash-side extractor reads only 5 of the 8 check helperstest/lib.shdefines, so a bash property asserted through any of the other three is invisible to the tool and can be reported as complete when it is not.Found while fixing the python side in #1038, and filed separately because closing it reddens a pair and is a port's worth of work rather than a tool fix.
The extractor
test/pytest/compare_to_bash.py:222r'\bcheck(?:_num|_ratio|_text|_timing)?\s+"([^"]+)"'check_unrunnableand the rest match no branch: aftercheckthe pattern allows only those four suffixes or nothing, then requires whitespace.What
test/lib.shactually definesDerived from the definitions in
lib.sh, not swept for:checklib.sh:1244check_numlib.sh:1307check_textlib.sh:1296check_ratiolib.sh:1339check_timinglib.sh:1411check_unrunnablelib.sh:1231check_skiplib.sh:1407check_ratio_needs_quiet_machinelib.sh:14608 defined, 5 read, 3 invisible.
Four more are defined locally by a single suite and are invisible to the same regex:
check_structure,check_reconstruct,check_split_happened(parallel_copy.sh) andcheck_float(parquet_export_stats.sh).A second trap in the other direction
Thirteen suites define their own
check(), shadowinglib.sh's:audit.sh,bench_guards.sh,concurrency.sh,docs_style.sh,pg_upgrade.sh,phase2.sh,phase3.sh,phase4.sh,phase5.sh,phase6.sh,smoke.sh,unique_conc.sh,update_conc.sh.Eleven of the thirteen source
lib.shand then override it at top level -- not in a heredoc, not conditionally. Two never source it at all and are standalone. Each of the eleven carries the same comment, "lib.sh for the check vocabulary (#965)", so the shadowing is deliberate:check()atlib.shaudit.shbench_guards.shconcurrency.shdocs_style.shpg_upgrade.shphase2.shphase3.shphase4.shphase5.shphase6.shsmoke.shunique_conc.shupdate_conc.shThe eleven take
lib.sh's vocabulary and replace its recorder.check "NAME"inphase4.shandcheck "NAME"in alib.sh-using suite are two different functions with two different recording paths, and the tool reads both through one regex branch as if they were one.That is harmless for extracting a NAME, which is all the tool does today. It is a constraint on the fix: a helper list derived from
lib.shis wrong for these 13 files in the direction of looking more authoritative than it is, so the fix must state which population it covers rather than imply "checkislib.sh'scheck".What it hides today
Widening the regex by
|_unrunnableand changing nothing else:_unrunnablerc=0 missing=0rc=1 missing=2hilbert_locality.sh:574-581emits fourcheck_unrunnablerecords per box whenCURVE_DIFFERS != different;test_hilbert_locality.pyemits onecannot_runrecord, namedUNMET_PRECONDITION. Two of the four have no partner. Reproduced independently by both agents.The invisible invocations: 50, and the two traps that produced three wrong counts
50 over
test/*.sh, reconciled between two agents and two independent methods that agree helper for helper:check_unrunnablecheck_skipcheck_ratio_needs_quiet_machineFour sweeps read 89, 64, 54 and 50 before this settled. Each is named by its METHOD rather than by a winner, because the method is the checkable part and a scoreboard is a worse artefact than a method note:
;,&&or||50 is not cleanly either of ours and should not be claimed by one. A command-position-only sweep — anchoring on line start alone — returns 24 for
check_unrunnableand a total of 49; accepting a call after&&returns 25 and 50. The discriminating site ishilbert_curve.sh:321,[ -n "$_a" ] && check_unrunnable "$_a" "$2" "$3", which @OffgridwithJD identified. Either pass reaches 50 only with that clause. The three wrong ones were defects, not method-sensitivity, and both causes are worth recording because anyone re-deriving this meets them:1. A
\bNAME\ssweep counts each helper's own definition line. The definitions carry a trailing usage comment that repeats the name followed by a space:So the definition matches as though it were a call. Two further matches were ordinary prose (
lib.sh:1338,planner_choice_quality.sh:149). That accounts for 89 (definitions included) and 54 (comments included).2. A command-position match misses an invocation after
&&.Anchoring on
^alone yields 24 for that helper instead of 25.3. The population is half the number.
test/*.shis the 265 top-level suites -- the only filescompare_to_bash.pygrades, since it takes atest/<stem>.shand atest/pytest/test_<stem>.py. Globtest/**/*.shinstead and the answer is 56:test/*.sh(265 files, what the tool grades)test/**/*.sh(312 files, adds harness selftests)The extra 6 are all in
test/selftest/, which the tool never reads. Neither number is wrong; a number without its population is.4.
git grepwill not give you that population. Git pathspecs are wildmatch withoutFNM_PATHNAME, so*crosses/and the natural spelling is silently recursive:This matters because
git grepis the instrument anyone reaches for to measure the number at an older revision, and there the top-level spelling looks correct. Use:(glob).test/selftest/is out of scope for a second reason too: it is the SHELL harness's own self-test, and the two harnesses stay independent by standing rule, so counting its call sites into a claim about what the pytest parity tool grades would cross that line even if the tool could read them.The recipe that reproduces 50: over
test/*.shonly -- spelled:(glob)test/*.shif you are usinggit grep-- strip trailing comments as well as whole-line ones, exclude definition lines, and accept a call at the start of a line or after;,&&or||.A history table needs one row measured a second, independent way. Both of us built the revision table above and one of us first got
0 + 0 + 0formain, from agit archive <ref> | tar -xthat extracted zero files -- real counts of an empty tree, with nothing in the pipeline complaining. It was caught only by already having 25 formainfrom a worktree. An all-zero row and a broken extraction are indistinguishable without a cross-check.The exposure is recent and growing, which changes the priority
Same method, same population (
test/*.sh), at three revisions:v1.0-alpha30cbf574the extractor landsmainTwo things follow.
The regex never covered these helpers; it did not drift out of date.
0cbf574is the commit that introducedcheck(?:_num|_ratio|_text|_timing)?, and on that daycheck_unrunnablealready had 21 call sites. The pattern was written past them.check_skipwent from 0 to 23 in five days, so the blind spot is widening faster than the suite count. 2 -> 23 -> 50 in eleven days.The 2026-09-02 row also independently corroborates the CHANGELOG entry for
1.0-alpha3, which says of the INCOMPLETE dispatch: "Latent today.check_unrunnablehas no production call site, so no real suite can reach the INCOMPLETE state in a matrix run yet." That was true when written and is no longer -- 25 call sites now reach it, and the tool cannot see any of them.Suggested shape of a fix
lib.sh's definitions rather than hard-coding a second list — a hand-maintained list is exactly the derived value that went stale here.check()definitions and the 4 suite-local helpers; alib.sh-derived list silently covers neither.hilbert_localityto go red, and port the two missing properties (or state, in the file, why one record stands for four).lib.shdefines, so this cannot silently recur.A guard that cannot see a helper reports the same
0as a suite with nothing missing, which is why this needs an arm and not just a wider regex.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK