test/pytest: a broad raises must name a SQLSTATE (#432) - #927
Conversation
`pytest.raises(psycopg.Error)` asserts that one of 254 SQLSTATEs arrived. Measured
against psycopg 3.3.5, by walking its own exception classes:
Error, DatabaseError 254 SQLSTATEs across 42 classes
OperationalError 88 across 15 DataError 68 across 1
ProgrammingError 57 across 10 InternalError 20 across 5
IntegrityError 7 across 1 NotSupportedError 1 across 1
Warning, InterfaceError 0
So a test can assert "the server rejected it" while the server was never reached.
On unmodified main the offending test is `2 passed`, exit 0, and the error that
satisfied the raises was an `OperationalError` whose `sqlstate` is None -- a
connection that never opened. Against a live PostgreSQL 18.4 the same shape is
worse than vacuous: the setup line raises 42602 while the statement under test
raises 42704, so the test passes on an error it was not written about.
`expect.sqlstate(exc.value, "42704", name)` is the honest form, and a collection
scan refuses the broad families that name no SQLSTATE unless the block pins one.
WHY Warning AND InterfaceError ARE NOT REFUSED: zero SQLSTATEs each, measured
above. Demanding one of them would be a guard nobody could satisfy, and an
unsatisfiable guard is how a guard gets switched off. The four intermediate
families are not refused either -- narrowing to `OperationalError` is already a
claim about the error, and it legitimately covers failures with no SQLSTATE at all.
`raises-catches-setup` IS NOT CLOSED, ONLY MITIGATED, and it stays in section 3.4
rather than moving to section 2. Measured on this guard: a helper called from
inside the block is one top-level statement and can raise from its own setup, so
with a narrow class and a pinned SQLSTATE the offending shape is `1 passed`,
exit 0, and the scan reports zero offences. A single compound statement -- a `for`
holding several executes -- walks past the one-statement rule the same way. Naming
the unclosed id in backticks inside section 2 made the document's own counter
report 27 refused against 26 stated, which is the arbiter catching the attempt.
THE STATIC BUDGET, counted over the pre-existing corpus: five `pytest.raises`
call sites, four of them `pytest.raises(RuntimeError)` in test_build_refusal.py
asserting on the message and the fifth `pytest.raises(VacuityError)` at
test_guards_pinned.py:169. The scan reports 0 offences on all five. A naive grep
over the tree counts 35 occurrences, and the difference is the point: the rest are
inside `pytester.makepyfile` strings and in prose, which an ast walk does not see
and a line regex would have. That distinction is why the layer's broad-`except`
refusal had to be rewritten once already.
22 test functions under test/pytest/
55 static checks in test/selftest/440-a-raises-must-name-a-sqlstate.sh
harness_selftest on pg17a 497 passed + 0 failed + 0 unrunnable = 497, PASSED
pytest corpus 170 passed
RESIDUAL, beyond the unclosed sibling: the scan reads `with` blocks inside
function bodies, so a module-level raises or the plain-call form
`pytest.raises(E, fn, arg)` is invisible; the SQLSTATE pin is matched by NAME
rather than by dataflow, so a pin inside a helper is not followed; `match=` is
deliberately not accepted as a pin, being a regex over the message, which is the
substring claim this layer exists to remove; and only files carrying collected
test items are scanned, so a helper module is not.
AND ONE THAT IS NOT THIS BRANCH'S TO FIX: `_BROAD_RAISES` is a module-level name,
so a conftest rebinding it to `()` turns the first rule off. That is true of every
module-level name the plugin keeps, `_ORDER_KILLERS` in main included, and it is
filed as commandprompt#924 with a reproduction against main rather than patched here.
EVERY PREMISE NAME IN THIS PART NAMES ITS OWN SUBJECT, which is not a style
preference. The boilerplate premise names say "this part" so they can be copied
into any part, and main already carries two copies of
`premise: the pytest layer is where this part thinks it is` (in parts 360 and 370).
commandprompt#918's ledger keys a check's history on its NAME, so every sharer is one row and
one of them going red marks them all as observed red -- a claim about a check
nothing attacked. This part's premises therefore say the broad-raises part instead, and it
contributes zero duplicated names to test/selftest/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Both sides added a numbered TESTS.md section. main's 14 (test_suite_accounting.py) is kept where it is; this branch's section keeps its position at the end of the document and is renumbered 17 -> 18, so no prose moves and nothing is dropped. Verified structurally rather than by eye: 18 headings, 18 TOC entries, numbers contiguous 1..18, titles identical between the two lists, and every TOC anchor equal to the anchor GitHub derives from its heading. 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.
Reviewed at 50c2f5e1. I ran the scanner rather than reading it: a worktree of the branch, pgc_vacuity imported standalone against pytest 9.1.1 with no psycopg, and selftest 440 driven under a stub check. Baseline reproduces your numbers — 440 is 55 checks, 55 passed, 0 failed.
Three findings. Each one contradicts a claim the PR body makes for itself, which is why I am asking for changes rather than filing them as notes.
1. The pin is satisfied by mentioning .sqlstate, with nothing asserted (blocking)
The body says a broad raises "must bind the exception and pin its SQLSTATE". _sqlstate_pinned_names counts any ast.Attribute named sqlstate and records its root, so the token appearing anywhere in the function turns the broad-family rule off. It never requires the attribute to reach an assertion.
Measured — six files through _raises_sites, controls first so the instrument is visible:
OFFENCE A control: unpinned broad raises (MUST be an offence)
clean B bare `.sqlstate` expression, asserts nothing
clean C assigned to a variable never read
OFFENCE E positional, broad, unbound, two statements
clean F a real pin (MUST be clean)
A and F are the controls: the scan does fire, and does not false-positive on the honest form. B is this:
with pytest.raises(psycopg.Error) as exc:
conn.execute("SELECT pgc_no_such()")
exc.value.sqlstate # bare expression, asserts nothing
expect.num(1, 1, "the server rejected the call")That is byte-for-byte the vacuity the PR is named for — any of the 254 SQLSTATEs satisfies it — and it collects clean. C is the same with code = exc.value.sqlstate. This is not in your stated residual list, so a reader is told the mode is closed.
2. All three "a neutered arm is caught" fixtures are unfaithful, and the faithful neutering stays green (blocking)
This is the one I would fix first, because the Evidence section rests on it: "its fixtures are the ways this guard could quietly stop working, each asserted to be caught".
Fixture 3 writes if False and broad and (bound is None or b not in p): — it renames bound→b and pinned→p as well as prefixing False and. The arm greps _rq_body for the literal bound not in pinned. So it is the rename that drops the count to 0. The same shape is in fixture 4 (!= 1 rewritten to >= 1) and fixture 5 (the isinstance dropped).
Measured on the real pgc_vacuity.py, one arm at a time, each mutation diffed to prove it applied and the file restored byte-identical after:
| faithful mutation | 440 says | the guard |
|---|---|---|
if False and broad and (bound is None or bound not in pinned): |
55 checks, 55 passed, 0 failed | case A goes clean — blind to the unpinned broad raises |
if False and sites and len(node.body) != 1: |
55 checks, 55 passed, 0 failed | case E loses its two-statement offence |
... if isinstance(arg, ast.Tuple) and False else [arg] |
55 checks, 55 passed, 0 failed | raises((ValueError, psycopg.Error)) goes OFFENCE → clean |
Each mutation leaves the pinned substring intact, so a text arm cannot see it. The arms are text pins: 440 never executes the scanner — 0 python3 invocations, and 44 of its 55 checks are grep -c against the function's source text. A text pin catches a rewrite or a deletion. It cannot catch False and, which is how a guard actually dies.
The fix that closes all three at once is a behavioural arm: write a fixture file that contains the vacuity, run the scan over it, and assert the offence count. That arm fails under any neutering, faithful or not.
This matters more than it otherwise would because at this head nothing runs the corpus at all: grep -rn pytest .github/workflows/ returns 0 matches, and SUITES in run_all_versions.sh names no pytest entry. test_raises_sqlstate.py's 22 arms are never executed by the gate, so 440 is the only enforcement — and 440 is green under all three neuterings above.
Worth knowing: #921 is the PR that introduces the job which would run this (10 pytest matches in .github/workflows/ at its head, versus 0 here). That is an argument for landing #921 first, not against this one.
3. The keyword form escapes both rules (should-fix)
if tail != "raises" or not call.args: continue skips the item before it is appended to sites, so pytest.raises(expected_exception=...) is checked by neither rule. Measured — D and E differ only in how the argument is passed:
clean D pytest.raises(expected_exception=psycopg.Error): broad, unbound, two statements
OFFENCE E pytest.raises(psycopg.Error): broad, unbound, two statements
-> names no SQLSTATE
-> the block holds 2 statements, so which one raised is not pinned
The body states rule 2 unconditionally — "any raises block must hold exactly one top-level statement". As written that is false: the statement rule is silently conditional on the class being passed positionally. Either read call.keywords or narrow the sentence and add the form to the residual list.
Aliased imports (from psycopg import Error as PgErr) escape too. That one is inherent to a name-matching AST scan, so I would put it in the residual list rather than in the code.
What I could not fault
The AST-over-regex choice holds up: I re-measured the 35/5/30 and 22/8 splits and they reconcile. expect.sqlstate itself cannot pass vacuously — want must be five [0-9A-Z] characters and must equal got. The function-local family list really is unreachable from the corpus it polices, and the tuple-member hole from #905 is genuinely closed at the unmutated head. _walk_own not descending into a nested def is right, and the reasoning in the comment is right.
The guard is worth having. What it needs is an arm that runs it.
… an exemption, and the arms live in one harness (commandprompt#432) @linuxhikerpm found three defects and all three were real. Each is reproduced below before it is fixed, and the third one changed the shape of the change rather than a line of it. 1. MENTIONING `.sqlstate` SATISFIED THE PIN. `_sqlstate_pinned_names` counted any `ast.Attribute` named `sqlstate` anywhere in the body and never required it to reach an assertion. Measured through `_raises_sites`, controls first: OFFENCE A unpinned broad raises (the instrument works) clean B `exc.value.sqlstate` as a bare statement <- asserts nothing clean C `code = exc.value.sqlstate`, never read <- asserts nothing clean F a real pin (no false positive) B and C are byte-for-byte the vacuity this guard is named for. The rule now requires the read to reach a CALL, and follows ONE hop of assignment so the honest `code = exc.value.sqlstate` / `expect.text(code, ...)` form is not refused. One hop, not two: it is a floor, and the floor is stated rather than implied. Four honest forms were re-measured as controls and all four stay clean. 2. THE KEYWORD FORM ESCAPED BOTH RULES. `if tail != "raises" or not call.args: continue` skipped the item before it was recorded, so `pytest.raises(expected_exception=E)` was checked by neither. The two forms differ in nothing else: clean D pytest.raises(expected_exception=psycopg.Error) broad, 2 statements OFFENCE E pytest.raises(psycopg.Error) broad, 2 statements So the statement rule was silently conditional on the class being positional while the documentation stated it unconditionally. The class is now read from `args` or from the `expected_exception` keyword, and D reports the same two offences as E. 3. THE NEUTERING FIXTURES WERE UNFAITHFUL, AND THE FIX IS STRUCTURAL. All three renamed a variable AS WELL AS prefixing `False and` -- `bound`->`b`, `pinned`->`p` -- and the arm greps for the literal `bound not in pinned`. So the rename was what dropped the count to 0 and the `False and` was decoration. Measured on the real file, faithfully, nothing renamed: if False and broad and (bound is None or bound not in pinned): 55/55 GREEN, blind if False and sites and len(node.body) != 1: 55/55 GREEN, blind The root cause was that the shell part never ran the scanner: 44 of its 55 checks were `grep -c` against the function's text, 0 `python3` invocations. MY FIRST FIX WAS WRONG. I extracted the scan into a pytest-free module so the SHELL part could drive it -- which closed the coverage hole by creating a dependency. jd's rule, set while I was doing it: the shell tests and the pytest corpus are PARALLEL IN FUNCTIONALITY and must not call, import or reference each other outside docs. Each asserts against the product, in its own terms, never against the other harness's implementation. So `test/selftest/440-a-raises-must-name-a-sqlstate.sh` is DELETED rather than repaired. Its whole subject was this layer's source text, which is the coupling, and its text pins could not see the thing they claimed to. Every property it checked is either already covered behaviourally here or was one of those pins; the one that was not -- that `raises-catches-setup` stays listed as open -- is now an arm in this corpus, reading the document, which is the one place the two harnesses may meet. The neutering proof is now two arms that copy the layer, disable ONE condition with `False and` and nothing else, and require the copy to go blind while still containing the substring a grep arm would have pinned. They fail under any neutering. MEASURED pytest corpus 192 passed (22 -> 30 arms in test_raises_sqlstate.py) harness_selftest 538 checks, 538 passed + 0 failed + 0 unrunnable, rc 0 the six shapes A offence, B offence, C offence, D 2 offences, E 2 offences, F clean honest controls direct field read, one hop, narrow family, keyword+pin -- all clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
@linuxhikerpm all three findings were real, all three are fixed at 1. Mentioning
|
| case | before | after | |
|---|---|---|---|
| A | unpinned broad raises |
offence | offence |
| B | exc.value.sqlstate as a bare statement |
clean | offence |
| C | code = exc.value.sqlstate, never read |
clean | offence |
| F | a real pin | clean | clean |
The rule now requires the read to reach a call, and follows one hop of assignment so the honest form is not refused:
code = exc.value.sqlstate
expect.text(code, "42883", "the function does not exist") # still collectableOne hop, not two — it is a floor and the floor is now stated in the docstring rather than implied. I re-measured four honest forms as controls (helper pin, direct field read, one hop, narrow family) and all four stay clean, because a rule that refuses honest tests is a rule somebody switches off.
2. The keyword form escaped both rules
Your pair reproduced exactly: D clean, E two offences, differing in nothing but how the class is passed. The class is now read from args or from the expected_exception keyword, and D reports the same two offences as E. A pinned keyword form stays collectable, so the fix is not "refuse the keyword form".
You were right that the body's sentence was false as written. It is no longer conditional, so the sentence is now true rather than narrowed.
3. The neutering fixtures — and my first fix for them was wrong
Your measurement is exact, and the part I had not seen is that the False and in my fixtures was decoration: the arm greps for bound not in pinned, and the fixture renamed bound→b and pinned→p, so the rename is what dropped the count to zero. Fixtures 4 and 5 have the same shape. I had written the memo about a presence grep being unable to see a disabled arm before I wrote those fixtures.
My first fix created a worse problem. I extracted the scan into a pytest-free module so the shell part could drive it — which closed the coverage hole by creating a dependency between the two harnesses. jd set the rule while I was doing exactly that: the shell tests and the pytest corpus are parallel in functionality and must not call, import or reference each other outside docs. Each asserts against the product, in its own terms, never against the other harness's implementation. (It is now written down in #932.)
So test/selftest/440-a-raises-must-name-a-sqlstate.sh is deleted, not repaired. Two reasons pointing the same way: its whole subject was this layer's source text, which is the coupling; and its text pins could not see the thing they claimed to. Before deleting it I listed all 55 of its check names against the corpus's arms, so the removal is not a silent loss — every property was either already covered behaviourally here or was one of those pins. The single exception, that raises-catches-setup stays listed as open, is now an arm in this corpus reading the document, which is the one place the two harnesses may meet.
The neutering proof is now two arms that copy the layer, disable one condition with False and and nothing else, import the copy, and require it to go blind while still containing the substring a grep arm would have pinned:
test_disabling_the_sqlstate_rule_makes_the_scan_blind
test_disabling_the_statement_rule_makes_the_scan_blind
They fail under any neutering, faithful or not. And because they run the scan, they also make your point about enforcement land where it should: this corpus is where the guard executes, and #921 is the PR that puts this corpus in CI — which is an argument for landing #921 first, as you said.
Evidence
pytest corpus 192 passed (test_raises_sqlstate.py: 22 -> 30 arms)
harness_selftest 538 checks, 538 passed + 0 failed + 0 unrunnable, rc 0
the six shapes A offence · B offence · C offence · D 2 · E 2 · F clean
honest controls direct field, one hop, narrow family, keyword+pin — all clean
The selftest count dropping from 593 to 538 is the 55 deleted checks, and the run is green without them, which is the check that nothing else leaned on that part.
What I am not claiming
Aliased driver imports (from psycopg import Error as PgErr) still escape, and you were right that this is inherent to a name-matching AST scan rather than a bug in it. It is in the residual list in the docstring rather than in the code. The two raises-catches-setup residuals are unchanged and still pinned by their own arms.
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 294cb8cb, 12/12 SUCCESS, CLEAN. I re-ran the attacks that produced my three findings rather than reading the diff.
All three are closed, with the controls still holding. My original six probe files through _raises_sites:
OFFENCE A control: unpinned broad raises (was OFFENCE, still is)
OFFENCE B bare `.sqlstate` expression (was clean -- now caught)
OFFENCE C assigned to a variable never read (was clean -- now caught)
OFFENCE D keyword form, broad, unbound, 2 stmts (was clean -- now caught by BOTH rules)
OFFENCE E positional control for D (was OFFENCE, still is, both rules)
clean F a real pin (was clean, still clean)
F is the one that matters as much as B and C: the fix refuses more without refusing the honest form.
The removal proofs are now real, which was my blocking finding. The exact faithful mutation that left selftest 440 at 55 checks, 55 passed, 0 failed while the guard went blind:
if False and broad and (bound is None or bound not in pinned): -> 8 failed, 22 passed
if False and sites and len(node.body) != 1: -> 3 failed, 27 passed
_sqlstate_pinned_names returning set() unconditionally -> 6 failed, 24 passed
Baseline 30 passed; each mutation diffed to prove it applied; pgc_vacuity.py restored byte-identical afterwards. Text pins could not see False and. These arms run the scanner, so they can.
Deleting selftest 440 rather than repairing it is the right call under the harness-independence rule, and it closes the finding at the root instead of patching three fixtures.
One sequencing fact, not a fault in this PR. Nothing runs these 30 arms in CI at this head — grep -rn pytest .github/workflows/ returns 0. The arms themselves need no cluster (27 take pytester, 2 tmp_path, 1 only expect), but this branch's conftest.py:15 imports psycopg at module scope, so they cannot run driverless here either. #921 fixes exactly that and adds the job that would run them. So this guard becomes enforced when #921 lands, and not before. Worth saying plainly in the PR body rather than leaving a reader to discover it.
Nothing blocking. Good fix.
Both PRs moved a mode from VACUITY_MODES.md section 3 to section 2, so both edited the count rows and both claimed TESTS.md section 18. RESOLVED BY COMPOSING, not by choosing: section 2 keeps BOTH rows, the counts become 27 refused / 45 not refused against an unchanged 72 named, and the prose totals the inventory gates follow -- section 2s opening, section 3s opening, the closing paragraph, TESTS.md and README.md. The raises section keeps 18 and the sentinel section becomes 19. Verified structurally rather than by eye: 19 headings, 19 TOC entries, numbers contiguous 1..19, titles identical between the two lists, every TOC anchor equal to the anchor GitHub derives from its heading. AND THE FILE THAT DID NOT CONFLICT IS THE ONE THAT NEEDED RUNNING. git auto-merged pgc_vacuity.py, composing the raises scanner and the sentinel refusal without either side ever having run the composed file -- each was green only against its own base. @jdatcmd asked for the arms on the MERGED tree rather than on the branch, which is where each-green-separately-broken-together lives: test_raises_sqlstate.py + test_failed_query_sentinel.py 40 passed the whole corpus 203 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…080 sweep fix One conflict, CHANGELOG.md, and both sides append a bullet at the top of the same section -- kept both. commandprompt#927 deleted test/selftest/440 on main and this branch never touched it, so nothing else met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…harness-guards branch Two conflicts, both additive. CHANGELOG.md: two regions, both sides appending at the top of the same section -- kept both. TESTS.md: both sides number a section, and the raises section main landed at 18 collides with this branch, so it is renumbered 20 and its TOC entry and anchor follow. Verified structurally: 20 headings, 20 TOC entries, numbers contiguous 1..20, titles identical between the two lists, every TOC anchor equal to the anchor GitHub derives from its heading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…ies, and the job's list is the intersection (commandprompt#432) commandprompt#927 landing made this branch's own arm fire, by name, which is what it exists to do: disagreements: [1: undeclared:test_raises_sqlstate.py] THE OBVIOUS FIX WAS WRONG, and the arm that caught it was right. Declaring the file turned the driver-free job red: four of its arms fail with psycopg shimmed out, because the modules it hands to `pytester` import the driver. It requests no cluster fixture and it still cannot run where there is no driver. MY SECOND ATTEMPT WAS ALSO WRONG, and an existing arm refused it. Folding the driver condition into `partition()` contradicted `test_the_classifier_is_not_fooled_by_prose_that_names_the_driver`, which asserts that a generated inner test requesting a cluster fixture is the INNER run's requirement and not this file's. That arm is correct, and breaking it was the signal that I was overloading one property with two meanings. SO THERE ARE TWO PROPERTIES, derived separately: partition() -> does this file request a cluster? driver_dependent() -> does it need psycopg IMPORTABLE, even with no cluster? job_runnable() -> the intersection, which is what the job can run and `membership_report` compares the declaration against the intersection, with `needs-the-driver:` as a kind of its own -- `needs-a-cluster:` would be a wrong diagnosis and the reader's next action differs. TWO CONDITIONS FOR THE DRIVER PROPERTY, because a driver import in a string is not enough on its own. `test_harness_deps_classifier.py` writes fixture corpora containing `import psycopg` and only ever PARSES them -- nothing imports those files. Reading the string alone would have thrown that file out of the gate it exists to be in. The difference is whether the file drives `pytester`. cluster-free 9 files, including test_raises_sqlstate.py driver-dependent test_raises_sqlstate.py job runnable 8, which is the declaration disagreements [] Three arms pin it, including both controls: a file that only parses a driver import stays job-runnable, and prose naming the driver is not a dependency on it. MEASURED harness_selftest 550 checks, 550 passed + 0 failed + 0 unrunnable, rc 0 pytest corpus 227 passed the gated set as CI runs it 8 files, 150 passed, psycopg asserted absent Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Two corrections from @OffgridwithJD's review, and the first one is the document's own rule 3 catching the document. "39 call sites" was 36 calls plus the 3 definitions. The pattern `[^_a-z]_sh(` matches `def _sh(` as readily as a call, which is the same class of error as `[a-z_]+\.sh` matching `sharedir` -- already written three lines above as the thing not to do. Counted with ast now, and the entry says how, because a number in this section has to be re-derivable or it does not belong here. The heading said 4 python files. Three are on main; the fourth arrives with PR #923. The entry always said so, the heading did not, and a reader who stops at the bold line gets a count that is wrong today. Recounted against main at aa53c1b, after #927 and #931 landed: still 3 python files and 7 shell files. test_raises_sqlstate.py, new on main, adds neither -- it drives pytester, not the shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Unuuvh3fRR67SceiGpfeeK
#927, #931 and #930 landed while this waited on review, so main gained three CHANGELOG entries and two TESTS.md sections. CHANGELOG: both sides append at the top of the same section and neither replaces anything, so the union is the resolution. TESTS.md: the numbering collided. This branch inserted its file section at 15 and pushed "Adding a test", "What this corpus does NOT yet refuse" and "Traps this corpus records" to 16-18; main kept those at 15-17 and appended its two new file sections as 18 and 19. Auto-merge produced two sections numbered 18. Resolved main's way, because main's convention is now to append a new file section after the tail sections: this branch's section becomes 20, and the three tail sections go back to main's 15, 16 and 17. That renumbers one section of this branch rather than two of main's. Checked rather than eyeballed, because an anchor that stops resolving does not announce itself: 20 headings against 20 TOC entries, every TOC text equal to its heading, every anchor equal to what GitHub derives from that heading, and the numbering contiguous 1..20. On the composed tree: selftest 350 41 checks 0 failed, selftest 400 64 checks 0 failed, selftest 080 15 checks 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Unuuvh3fRR67SceiGpfeeK
…pt#930 landed (commandprompt#432) The arm named it rather than leaving a hole: cluster-free, not driver-dependent, so the driver-free job can run it and the declaration has to say so. Third time this arm has caught a merge-order consequence rather than a mistake -- commandprompt#922 brought test_suite_accounting.py, commandprompt#927 brought test_raises_sqlstate.py (which turned out to need the DRIVER and so is correctly excluded), and commandprompt#930 brings this one. declared 9 · cluster-free 10 · driver-dependent 1 · disagreements [] corpus 238 passed · the gated set 9 files, 161 passed with psycopg absent Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
pytest.raises(psycopg.Error)asserts that one of 254 SQLSTATEs arrived, across 42 SQLSTATE classes — counted against psycopg 3.3.5. It does not reliably assert even that much. Measured on this tree before the guard, this reported1 passed, exit 0:What satisfied it was
OperationalErrorwithsqlstateNone: the connect failed, nothing reached a server, and the statement under test never executed. The test passes, and it passes equally well with the feature it names deleted.What the layer now refuses
Two shapes, at collection time, found by walking the
ast— so an offending file does not collect at all rather than failing one test:raisesoverError,DatabaseError,ExceptionorBaseExceptionmust bind the exception and pin its SQLSTATE;raisesblock must hold exactly one top-level statement.expect.sqlstate(exc.value, "42883", name)is the honest form the refusal points at. It compares a typed field, and it refuses three ways of getting out of the claim: an empty set of codes (so the tuple escape hatch cannot become the hole), a two-character SQLSTATE class like"42"(a prefix claim wearing the shape of an exact one), and an exception whosesqlstateisNone(which would otherwise compareNoneagainst a real code for ever).AST, not a regex, and the gap is measured rather than asserted: the AST finds 5
pytest.raisescall sites in the corpus; apytest.raises(line regex matches 35 lines. A regex cannot tell code from a string literal, and this file is full of string literals naming the shape it forbids.The family list is bound inside the scan, not at module level. Every
conftest.pyundertest/pytest/is imported before collection, so a module-level tuple is writable from the corpus the rule polices:import pgc_vacuitythen assign an empty tuple, and the scan reports zero offences for ever with the suite green and nothing saying so. An arm writes three spellings of the name onto the module and requires the refusal to still arrive._walk_owndeliberately does not descend into a nesteddeforlambda, because a nested function is its own scope andast.walkwould attribute itswithto the enclosing test.What it does NOT close, stated rather than implied
This closes
raises-too-broad, which moves toVACUITY_MODES.mdsection 2. It only narrowsraises-catches-setup, which stays in section 3.4.The statement rule counts top-level statements, so two shapes still walk past it — each being one statement that performs the setup inside the block: a call to a helper, and a compound statement such as a
forholding both the setup and the statement under test. Both are measured at1 passed, exit 0, zero offences, and both have an arm asserting the scan reports nothing on them, so the residual is a measurement that will be noticed if it changes rather than a gap somebody may later discover.Evidence
test/selftest/440-a-raises-must-name-a-sqlstate.shis 55 checks and touches no database. Its fixtures are the ways this guard could quietly stop working, each asserted to be caught: a dropped tuple read, an unwired scan, a drifted offence string (from both the filter side and the producer side), a producer split across two lines, a regex-based scan, a SQLSTATE helper with no five-character check, a test the document does not cover, a suite missing the helper blind-spot arm, and a document that stopped calling the mode open.On the merged tree (
mainatf0f1f40merged in, tree clean,/usr/local/pg17a, under the lock):shellcheck -S error -s bash test/*.sh test/selftest/*.shis clean, which is CI's exact invocation.The merge commit
mainand this branch both added a numberedTESTS.mdsection. The merge keeps main's 14 (test_suite_accounting.py) where it is and renumbers this branch's section17 -> 18, keeping its position at the end of the document, so no prose moves and nothing is dropped.Verified structurally rather than by eye: 18 headings, 18 TOC entries, numbers contiguous 1..18, titles identical between the two lists, and every TOC anchor equal to the anchor GitHub derives from its heading.
Relationship to the open stack
No collision: this adds selftest part 440, while #923 adds 400 and #925 adds 410. It will conflict with both in
TESTS.mdandCHANGELOG.md— the append-at-the-top kind, not a code conflict — and I will resolve it whichever way round they land.🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a