Skip to content

compare_to_bash.py reads 5 of lib.sh's 8 check helpers, hiding 2 unported properties in hilbert_locality (#432) #1040

Description

@jdatcmd

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

  1. 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.
  2. 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.
  3. Expect hilbert_locality to go red, and port the two missing properties (or state, in the file, why one record stands for four).
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions