From d3dee6c340c0e8eda250fe567dc09e960c035921 Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Fri, 18 Sep 2026 01:31:07 +0000 Subject: [PATCH] test: the inequality scan covered two operators of ten (#1030) _hand_rolled_inequalities refuses `int()` passed to an expect call -- the idiom that throws both values away -- and its docstring said exactly that. The code required an Eq or a NotEq, so `int(a != b)` was refused and `int("x" in got)` was not. The scanned class was EMPTY while 28 live sites sat outside it. Widened to any comparison, and to ANY boolean combination: `int(a and b)` cannot say which half was false. The operator list is named and pinned against `ast` itself, so a new operator reddens an arm rather than narrowing the rule by not being in a tuple. THE FIRST ATTEMPT AT THE BOOLEAN HALF INHERITED THE BUG IT WAS FIXING: it required a Compare inside the BoolOp, leaving `int(p.exists() and q.exists())` live -- a premise arm about a PAIR, whose job is to say which half is missing. Reported by jdatcmd, who probed the boundary rather than reading the branch. A single truthiness stays honest: `int(p.exists())` has one value and nothing to disambiguate; it is the combining that loses the answer. THE POPULATION WAS 28, NOT THE 7 THE ISSUE MEASURED; main moved in between. 21 `in`, five `> 0`, two boolean pairs, over eight files -- seven where the change is to live call sites, plus test_layer.py where it is to probe fixtures. Expect.contains(got, want, name, absent=False) is new, because 21 sites of one shape is a missing word in the vocabulary, not 21 local mistakes: collapsed : how-to names clustering: got 0 want 1 contains : how-to names clustering: cluster is absent from this document talks about join keys and nothing else at all Its parameters are got/want deliberately: test_failed_query_sentinel.py partitions the layer by the first two parameter names, so any other spelling puts it in neither bucket and opens the silent hole that file refuses. Registered in its shape table, so the failed-query sentinel sweep covers it too. The five `int(len(x) > 0)` sites became at_least. Both boolean pairs became two arms each, naming their own halves. Removal proof, both directions: control 44 passed one collapsed `in` site put back the sweep FAILS the same site, with the OLD Eq/NotEq scanner the sweep PASSES Boundary probe after the BoolOp widening: Compare/NotEq, Compare/In, Compare/Gt, BoolOp/Compare and BoolOp/Call all FLAGGED; int(x) and int(p.exists()) not seen. Guard half 347 passed, 932 checks, 0 failed. The two cluster-side files touched: 22 passed, 79 checks, 0 failed. guard_tests re-derived by collection, 346 -> 347. Pytest corpus only. No bash suite, no ledger row, no budget number moves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs --- CHANGELOG.md | 63 +++++++++++++ test/pytest/TESTS.md | 3 +- test/pytest/expected_tests.txt | 2 +- test/pytest/pgc_vacuity.py | 43 +++++++++ test/pytest/test_compare_to_bash.py | 46 ++++++---- test/pytest/test_docs_join_clustering.py | 12 +-- test/pytest/test_docs_stripe_floor.py | 22 +++-- test/pytest/test_failed_query_sentinel.py | 3 + test/pytest/test_guards_pinned.py | 10 +- test/pytest/test_harness_deps.py | 9 +- test/pytest/test_layer.py | 107 ++++++++++++++++++++-- test/pytest/test_mutation_ledger.py | 29 +++--- test/pytest/test_stats_privilege.py | 5 +- 13 files changed, 290 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e521a6af..6f720ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,69 @@ true until the next version shipped. The gate's printed recipe said ``, singular, so following it exactly produced the broken row. It now names one log per gated major and says why. +- The hand-rolled-inequality scan covered two operators of ten, so its scanned + class was empty while 27 live sites sat outside it (#1030). + + `test_layer.py`'s `_hand_rolled_inequalities` refuses `int()` passed to + an `expect` call -- the idiom that throws both values away. Its docstring said + exactly that. The code required an `Eq` or a `NotEq`: + + and any(isinstance(op, (ast.NotEq, ast.Eq)) for op in arg.args[0].ops) + + So `int(a != b)` was refused and `int("x" in got)` was not. A name that outran + its content, and the corpus reported clean because the shapes it actually used + were the ones the scan could not see. + + Widened to any comparison, and to ANY boolean combination, which is the worse + case: `int(a and b)` cannot say which half was false. The operator list is NAMED + and pinned against `ast` itself, so a future operator reddens an arm rather than + silently narrowing the rule. + + THE FIRST ATTEMPT AT THE BOOLEAN HALF INHERITED THE BUG IT WAS FIXING. It + required a comparison inside the BoolOp, which left + `int(p.exists() and q.exists())` live -- a PREMISE arm about a pair, whose whole + job is to say which half is missing, reporting `got 0 want 1`. The operator + widening fixed the comparison half completely and the boolean half kept the + original narrowing. Reported by @jdatcmd, who probed the boundary rather than + reading the branch. A single truthiness is still honest: `int(p.exists())` has + one value and nothing to disambiguate; it is the COMBINING that loses the answer. + + THE POPULATION WAS 28, NOT THE 7 THE ISSUE MEASURED -- main moved between the + measurement and the fix. Twenty-one `in`, five `> 0`, two boolean pairs, across + eight files. + + `Expect.contains(got, want, name, absent=False)` is new, because 21 sites of one + shape is a missing word in the vocabulary rather than 21 local mistakes. It + reports what was actually there: + + collapsed : how-to names clustering: got 0 want 1 + contains : how-to names clustering: 'cluster' is absent from 'this document + talks about join keys and nothing else at all' + + Its parameters are `got` and `want` deliberately: `test_failed_query_sentinel.py` + partitions the layer's assertions by their first two parameter names, so any + other spelling would have put it in neither bucket and opened the silent hole + that file exists to refuse. It is registered in that file's shape table, so the + failed-query sentinel sweep covers it like every other comparison. + + The five `int(len(x) > 0)` sites became `at_least`, which reports the number. The + boolean pair became two `at_least` arms, each naming its own half. + + Removal proof, both directions: + + control 44 passed + one collapsed `in` site put back the sweep FAILS + the same site, with the OLD Eq/NotEq scanner the sweep PASSES + + The third line is the finding: the old scan reports a clean corpus with the + collapsed site still in it. + + Eleven false-positive arms, five of them real `int()` calls from this corpus -- + a parsed regex group, a driver flag, a path premise, a value `num()` would refuse + as a string, and a single call, which has one value and nothing to disambiguate. + + Guard half 347 passed, 913 checks; the two cluster-side files touched, 22 passed, + 79 checks. `guard_tests` re-derived by collection, 346 -> 347. - Four secret-leak claims over the PG server log could pass having read nothing (#1032). diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 169fd37c..15194215 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -361,7 +361,8 @@ the general case was hand-rolled. | `test_differ_refuses_a_failed_query_on_either_side` | a failed arm is refused, left and right | | `test_differ_refuses_two_failed_queries` | **the inverse of #930's trap**; see below | | `test_the_inequality_scan_finds_a_planted_offence` | the AST scan fires on both spellings | -| `test_the_inequality_scan_does_not_flag_honest_code` | five shapes it must not flag | +| `test_the_inequality_scan_does_not_flag_honest_code` | eleven shapes it must not flag, five of them real int() calls from this corpus | +| `test_the_operator_list_is_what_ast_offers` | #1030, `_COMPARE_OPS` pinned against `ast`, so a new operator cannot narrow the rule | | `test_no_test_in_this_corpus_hand_rolls_an_inequality` | the population is zero, across 17 files | | `test_a_conftest_cannot_switch_off_the_order_collapse_scan` | #924, the route still open after #958 | | `test_a_conftest_cannot_switch_off_the_broad_except_scan` | the same hatch, a second scan | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index d6b3fd08..adb4d0bf 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -173,7 +173,7 @@ # 361 here, which is a coincidence of this merge rather than a method: the deltas were # measured against different trees. Re-derived by collection on the merged tree, which # is the only resolution this number has. -guard_tests 361 +guard_tests 362 # The complement: tests that need the driver and a throwaway cluster. Until #1016 these ran # in no CI job at all -- a quarter of the corpus, green when somebody ran them by hand and diff --git a/test/pytest/pgc_vacuity.py b/test/pytest/pgc_vacuity.py index 79b7983a..996225fe 100644 --- a/test/pytest/pgc_vacuity.py +++ b/test/pytest/pgc_vacuity.py @@ -716,6 +716,49 @@ def text(self, got, want, name): if got != want: raise AssertionError(f"{name}: got {got!r} want {want!r}") + # -- containment ------------------------------------------------------- + # + # A MISSING WORD IN THE VOCABULARY, not 21 local mistakes (#1030). Every site + # that wanted this wrote `expect.num(int(needle in hay), 1, name)`, which throws + # BOTH values away: the failure reads `got 0 want 1`, and a reader cannot tell a + # haystack that was EMPTY from one that was WRONG. That is the reason `differ` + # exists, applied to containment. + @_resolving + def contains(self, got, want, name, *, absent=False): + """Assert `want in got`, SHOWING `got` when it is not there. + + `got` is the haystack and `want` the text sought in it. The names are the + layer's own, deliberately: `test_failed_query_sentinel.py` partitions the + assertions by their FIRST TWO PARAMETER NAMES, so calling these anything + else would put this method in neither bucket and open the silent hole that + file exists to refuse. It is a caller-supplied value on the left, so a + failed-query sentinel can arrive in it and must be refused like any other. + + `absent=True` asserts the opposite and reports WHERE it was found, because + "it is present" is not useful without "here". + """ + self._refuse_failed_query(name, got, want) + if _empty(want): + raise VacuityError( + f"{name}: the text sought is empty, so every value contains it." + ) + # An empty haystack satisfies an absence claim without testing anything, + # which is the same vacuity `row_set` refuses without an explicit flag. + if absent and _empty(got): + raise VacuityError( + f"{name}: the value searched is empty, so nothing could have been " + f"found in it and this could not have failed." + ) + self._record(name) + shown = got if len(got) <= 300 else got[:300] + "...[clipped]" + if absent and want in got: + raise AssertionError( + f"{name}: {want!r} is present at offset {got.index(want)} and " + f"should not be, in {shown!r}" + ) + if not absent and want not in got: + raise AssertionError(f"{name}: {want!r} is absent from {shown!r}") + # -- SQLSTATE ---------------------------------------------------------- @_resolving def sqlstate(self, exc, want, name): diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index c4e14120..938f08f0 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -274,22 +274,25 @@ def test_the_loop_reader_invents_nothing_in_this_corpus(expect): expect.text(", ".join(_loop_names(ast.parse(split))), "one two three", "premise: the reader joins a wrapped name, which is why the relaxation " "is needed at all") - expect.num(int("one two three" in split), 0, - "and the raw source does NOT contain it, so the old predicate called a " - "wrapped name fabricated") - expect.num(int("one two three" in _joined(split)), 1, - "collapsing the file's own concatenation finds it") + expect.contains( + split, "one two three", + "and the raw source does NOT contain it, so the old predicate called a " + "wrapped name fabricated", absent=True) + expect.contains( + _joined(split), "one two three", + "collapsing the file's own concatenation finds it") built = ('P = "two"\n' 'for label, sql in ((f"one {P} three", "q"),):\n' ' expect.num(g, 1, label)\n') expect.text(", ".join(_loop_names(ast.parse(built))), "one {} three", "premise: an f-string name is read as a TEMPLATE, not refused") - expect.num(int("one {} three" in _joined(built)), 0, - "and collapsing the concatenation does NOT rescue it -- a template is " - "constructed, not found, so the relaxation keeps the guarantee it was " - "relaxed from. A port that writes an f-string loop name reddens this arm " - "by name, which is the designed outcome and not a new one") + expect.contains( + _joined(built), "one {} three", + "and collapsing the concatenation does NOT rescue it -- a template is " + "constructed, not found, so the relaxation keeps the guarantee it was " + "relaxed from. A port that writes an f-string loop name reddens this arm " + "by name, which is the designed outcome and not a new one", absent=True) # A port that parametrises a family its bash twin unrolls, which is the whole of @@ -503,13 +506,16 @@ def test_a_parametrized_name_is_resolved_from_the_decorator(expect): ' expect.num(got, 1, "an unrelated property")\n' ) got = _names(src) - expect.num(int("a role with only schema USAGE is refused" in got), 1, - "a parametrized name is resolved through the module-level constant") - expect.num(int("and is refused reconstruct" in got), 1, "for every row of it") - expect.num(int("read_projection" in got), 0, - "while the OTHER column of the same decorator is not a name") - expect.num(int("reconstruct" in got), 0, - "and a parametrize with no name column contributes nothing") + expect.contains( + got, "a role with only schema USAGE is refused", + "a parametrized name is resolved through the module-level constant") + expect.contains(got, "and is refused reconstruct", "for every row of it") + expect.contains( + got, "read_projection", + "while the OTHER column of the same decorator is not a name", absent=True) + expect.contains( + got, "reconstruct", + "and a parametrize with no name column contributes nothing", absent=True) def test_the_parametrize_reader_takes_the_column_called_name(expect): @@ -1373,7 +1379,11 @@ def test_the_ported_suites_in_this_tree_are_graded_one_for_one(expect): verdicts = {} for stem in complete: sh, py = root / "test" / f"{stem}.sh", HERE / f"test_{stem}.py" - expect.num(int(sh.exists() and py.exists()), 1, f"premise: both halves of {stem} exist") + # TWO ARMS, NOT ONE FLAG (#1030). This is a premise about a PAIR, so + # "which half is missing" is exactly the question it should answer, and + # `int(a and b)` is the one shape that cannot. + expect.num(int(sh.exists()), 1, f"premise: the bash half of {stem} exists") + expect.num(int(py.exists()), 1, f"premise: the pytest half of {stem} exists") buf = io.StringIO() with contextlib.redirect_stdout(buf): rc = main(str(sh), str(py)) diff --git a/test/pytest/test_docs_join_clustering.py b/test/pytest/test_docs_join_clustering.py index 8b716350..cf514044 100644 --- a/test/pytest/test_docs_join_clustering.py +++ b/test/pytest/test_docs_join_clustering.py @@ -35,13 +35,10 @@ def test_how_to_names_join_key_clustering_for_the_runtime_filter(expect): """ expect.num(int(HOWTO.is_file()), 1, "premise: how-to.md is in the tree") section = _section_after(HOWTO, "## Skip fact-table work under a star-schema join") - expect.num(int(len(section) > 0), 1, - "premise: the star-schema join heading is present") + expect.at_least(len(section), 1, "premise: the star-schema join heading is present") low = section.lower() - expect.num(int("cluster" in low), 1, - "the runtime-filter how-to names clustering") - expect.num(int("join key" in low), 1, - "and it names the join key as the clustering column") + expect.contains(low, "cluster", "the runtime-filter how-to names clustering") + expect.contains(low, "join key", "and it names the join key as the clustering column") def test_best_practices_names_join_key_clustering_for_a_fact_table(expect): @@ -52,5 +49,4 @@ def test_best_practices_names_join_key_clustering_for_a_fact_table(expect): """ expect.num(int(PRACTICES.is_file()), 1, "premise: best-practices.md is in the tree") text = PRACTICES.read_text(encoding="utf-8").lower() - expect.num(int("join key" in text), 1, - "best-practices names clustering on the join key") + expect.contains(text, "join key", "best-practices names clustering on the join key") diff --git a/test/pytest/test_docs_stripe_floor.py b/test/pytest/test_docs_stripe_floor.py index 9e34c218..03b8e2b8 100644 --- a/test/pytest/test_docs_stripe_floor.py +++ b/test/pytest/test_docs_stripe_floor.py @@ -65,8 +65,9 @@ def _sections_containing(path, needle): def test_configuration_states_the_floor_where_it_documents_the_setting(expect): expect.num(int(CONFIG.is_file()), 1, "premise: configuration.md is in the tree") - expect.num(int(len(_floor_line_sections(CONFIG)) > 0), 1, - "configuration.md states the 1024 floor on the setting's own line") + expect.at_least( + len(_floor_line_sections(CONFIG)), 1, + "configuration.md states the 1024 floor on the setting's own line") def test_administration_states_it_in_the_section_that_says_to_lower_it(expect): @@ -78,16 +79,19 @@ def test_administration_states_it_in_the_section_that_says_to_lower_it(expect): """ expect.num(int(ADMIN.is_file()), 1, "premise: administration.md is in the tree") advice = _sections_containing(ADMIN, "lower this setting") - expect.num(int(len(advice) > 0), 1, - "premise: administration.md still tells a reader to lower the setting") + expect.at_least( + len(advice), 1, + "premise: administration.md still tells a reader to lower the setting") floor = _floor_line_sections(ADMIN) - expect.num(int(len(advice & floor) > 0), 1, - "and the 1024 floor is stated in that same section") + expect.at_least( + len(advice & floor), 1, + "and the 1024 floor is stated in that same section") low = ADMIN.read_text(encoding="utf-8").lower() - expect.num(int("fsst" in low), 1, "and names what lowering past it costs") + expect.contains(low, "fsst", "and names what lowering past it costs") def test_best_practices_carries_the_floor_with_the_load_sizing_advice(expect): expect.num(int(PRACTICES.is_file()), 1, "premise: best-practices.md is in the tree") - expect.num(int(len(_floor_line_sections(PRACTICES)) > 0), 1, - "the load-sizing advice states the floor on the same line") + expect.at_least( + len(_floor_line_sections(PRACTICES)), 1, + "the load-sizing advice states the floor on the same line") diff --git a/test/pytest/test_failed_query_sentinel.py b/test/pytest/test_failed_query_sentinel.py index a8fac3dd..d2bbfb24 100644 --- a/test/pytest/test_failed_query_sentinel.py +++ b/test/pytest/test_failed_query_sentinel.py @@ -141,6 +141,9 @@ def test_the_comparison_surface_is_what_this_file_thinks_it_is(expect): # refusal this assertion reports two blown-up statements as an observable # difference. The fix for one direction opened the other. "differ": ("abc", "xyz"), + # The haystack must actually CONTAIN the needle, or the assertion fails for a + # reason that is not the sentinel and the arm proves nothing. + "contains": ("abcdef", "cde"), } # NOT EVERY ASSERTION IS IN THIS SWEEP. `wrote` is outside it because its left diff --git a/test/pytest/test_guards_pinned.py b/test/pytest/test_guards_pinned.py index 38327dcf..3cb91263 100644 --- a/test/pytest/test_guards_pinned.py +++ b/test/pytest/test_guards_pinned.py @@ -371,5 +371,11 @@ def test_the_empty_plan_refusal_precedes_the_arms_it_protects(expect): expect.text(f"{i_refusal is not None} {i_absent is not None} {i_present is not None}", "True True True", "premise: all three were found, so the ordering can mean something") - expect.num(int(i_refusal < i_absent and i_refusal < i_present), 1, - "the empty-plan refusal precedes both arms it protects") + # TWO ARMS, NOT ONE FLAG (#1030). `int(a < b and a < c)` collapses two + # comparisons into 0 or 1, so a failure says `got 0 want 1` and cannot name + # WHICH ordering broke -- with all three indices in scope one line above. Two + # at_least arms each report a real distance and each name their own half. + expect.at_least(i_absent - i_refusal, 1, + "the empty-plan refusal precedes the absent arm it protects") + expect.at_least(i_present - i_refusal, 1, + "the empty-plan refusal precedes the present arm it protects") diff --git a/test/pytest/test_harness_deps.py b/test/pytest/test_harness_deps.py index bb679015..8fee9f47 100644 --- a/test/pytest/test_harness_deps.py +++ b/test/pytest/test_harness_deps.py @@ -654,8 +654,9 @@ def _run_without_psycopg(args, expect, pg_config=None): [sys.executable, "-c", "import psycopg"], cwd=str(HERE), env=env, capture_output=True, text=True, ) - expect.at_least(int("ImportError" in probe.stderr), 1, - "premise: the shim really does make `import psycopg` fail") + expect.contains( + probe.stderr, "ImportError", + "premise: the shim really does make `import psycopg` fail") return proc @@ -690,8 +691,8 @@ def test_a_cluster_test_still_needs_the_driver(expect, pytestconfig): pg_config=pytestconfig.getoption("--pg-config")) expect.at_least(proc.returncode, 1, "a cluster test cannot pass without the driver") - expect.at_least( - int("psycopg is shimmed out" in (proc.stdout + proc.stderr)), 1, + expect.contains( + proc.stdout + proc.stderr, "psycopg is shimmed out", "and it fails BECAUSE the driver is gone, naming the shim") diff --git a/test/pytest/test_layer.py b/test/pytest/test_layer.py index fed1798d..5aafa9fa 100644 --- a/test/pytest/test_layer.py +++ b/test/pytest/test_layer.py @@ -610,13 +610,55 @@ def test_both_arms_failed(expect): _INEQ_MSG = "int() of a comparison, passed to an expect call" +# EVERY COMPARISON OPERATOR, LOOKED UP BY NAME (#1030). The first version of this +# scan required an `Eq` or a `NotEq`: +# +# and any(isinstance(op, (ast.NotEq, ast.Eq)) for op in arg.args[0].ops) +# +# so `int(a != b)` was refused and `int(x in y)` was not. The docstring said +# `int()` and the code delivered `int()`, and by +# the time anyone read it the scanned class was EMPTY while seven live sites sat +# outside it. A name that outran its content, which is the shape this corpus keeps +# finding in other people's documents. +# +# Named rather than enumerated as classes, so a future operator cannot silently +# narrow the rule by not being in a tuple. The list is asserted against `ast` +# itself below: if Python grows an operator, that arm reddens rather than this scan +# quietly skipping it. +_COMPARE_OPS = ("Eq", "NotEq", "Lt", "LtE", "Gt", "GtE", "Is", "IsNot", "In", "NotIn") + + +def _collapses_to_a_flag(node, ast=None): + """Does this expression throw BOTH of its values away, leaving 0 or 1? + + A comparison does. So does ANY boolean combination -- and that one is worse, + because `int(a and b)` cannot say WHICH half was false. + + THE OPERANDS DO NOT MATTER, and the first version of this got that wrong by + requiring a Compare inside. It left `int(p.exists() and q.exists())` live in + `test_compare_to_bash.py`, a PREMISE arm whose whole job is to say which half + of a pair is missing, reporting `got 0 want 1` instead. Reported by @jdatcmd, + who probed the boundary rather than reading the branch: + + int(a == b and c == d) BoolOp of Compare -> flagged + int(p.exists() and q.exists()) BoolOp of Call -> was NOT flagged + + A single truthiness is still fine: `int(x)` and `int(p.exists())` have one + value and nothing to disambiguate. It is the COMBINING that loses the answer. + """ + import ast as _a + ast = ast or _a + return isinstance(node, (ast.Compare, ast.BoolOp)) + + def _hand_rolled_inequalities(source, filename=""): """-> ["file:line", ...] for `expect.X(int(a != b), ...)` and friends. - The shape is `int()` appearing as an ARGUMENT to a call on `expect`. A - bare `int(a != b)` assigned to a name is not flagged: it asserts nothing by - itself, and flagging it would be a claim about arithmetic rather than about an - assertion. + The shape is `int()`, for ANY comparison operator, appearing as an + ARGUMENT to a call on `expect` -- plus `int( and )`, which + collapses two of them at once. A bare `int(a != b)` assigned to a name is not + flagged: it asserts nothing by itself, and flagging it would be a claim about + arithmetic rather than about an assertion. """ import ast @@ -634,9 +676,7 @@ def visit_Call(self, node): and isinstance(arg.func, ast.Name) and arg.func.id == "int" and len(arg.args) == 1 - and isinstance(arg.args[0], ast.Compare) - and any(isinstance(op, (ast.NotEq, ast.Eq)) - for op in arg.args[0].ops)): + and _collapses_to_a_flag(arg.args[0], ast)): found.append(f"{filename}:{arg.lineno}") self.generic_visit(node) @@ -666,6 +706,44 @@ def test_the_inequality_scan_finds_a_planted_offence(expect): "def test_z(expect):\n" " expect.num(int(stated == disk), 0, 'disagrees')\n")), 1, "and finds the int(a == b) spelling, not only int(a != b)") + # EVERY OPERATOR, not just equality (#1030). The scan required an Eq or a NotEq + # while its docstring said `int()`, so the scanned class was EMPTY and + # 27 live sites sat outside it. One arm per operator family, because "any + # Compare" is the claim and a single `in` case would not show it. + for label, op in (("in", "'x' in got"), ("not in", "'x' not in got"), + ("<", "a < b"), (">", "a > b"), ("<=", "a <= b"), + (">=", "a >= b"), ("is", "a is b"), ("is not", "a is not b")): + expect.num(len(_hand_rolled_inequalities( + f"def t(expect):\n expect.num(int({op}), 1, 'n')\n")), 1, + f"the scan finds int(a {label} b)") + # AND THE BOOLEAN COMBINATION, which is the worst of them: it collapses two + # comparisons, so a failure cannot say which half broke. + expect.num(len(_hand_rolled_inequalities( + "def t(expect):\n expect.num(int(a < b and a < c), 1, 'n')\n")), 1, + "the scan finds int( and )") + # THE OPERANDS DO NOT MATTER. Requiring a Compare inside the BoolOp left + # `int(p.exists() and q.exists())` live -- a premise arm that exists to say + # WHICH half is missing, reporting `got 0 want 1`. Reported by @jdatcmd. + for label, src in (("calls", "int(p.exists() and q.exists())"), + ("plain names", "int(a and b)"), + ("or, not and", "int(a or b)"), + ("three operands", "int(a and b and c)")): + expect.num(len(_hand_rolled_inequalities( + f"def t(expect):\n expect.num({src}, 1, 'n')\n")), 1, + f"the scan finds a boolean pair of {label}, not only of comparisons") + + +def test_the_operator_list_is_what_ast_offers(expect): + """The list is named rather than enumerated as classes, so this arm is what stops + a future operator from silently narrowing the rule by not being in a tuple.""" + import ast + + offered = sorted(n for n in dir(ast) + if isinstance(getattr(ast, n), type) + and issubclass(getattr(ast, n), ast.cmpop) + and n != "cmpop") + expect.text(" ".join(offered), " ".join(sorted(_COMPARE_OPS)), + "the declared comparison operators are exactly what ast offers") def test_the_inequality_scan_does_not_flag_honest_code(expect): @@ -689,6 +767,21 @@ def test_the_inequality_scan_does_not_flag_honest_code(expect): ("the idiom inside a string", "def t(expect):\n s = \"expect.num(int(a != b), 1, 'x')\"\n" " expect.text(s, s, 'n')\n"), + # THE HONEST int() CALLS THIS CORPUS ACTUALLY MAKES (#1030). Widening the + # scan from Eq/NotEq to every comparison is only safe if these stay out, so + # the cost is measured here rather than assumed. Each is a real shape from + # the tree, not an invented one. + ("int() of a regex group, parsing a number", + "def t(expect):\n expect.num(int(m.group(1)), 3, 'n')\n"), + ("int() of a driver flag with nothing richer to show", + "def t(expect):\n expect.num(int(writes[0].acknowledged), 1, 'n')\n"), + ("int() of a path premise", + "def t(expect):\n expect.num(int(HOWTO.is_file()), 1, 'n')\n"), + ("int() of a value num() would refuse as a string", + "def t(expect):\n expect.num(int(level), 2, 'n')\n"), + # A SINGLE truthiness is honest: one value, nothing to disambiguate. + ("int() of a single call", + "def t(expect):\n expect.num(int(p.exists()), 1, 'n')\n"), ): expect.num(len(_hand_rolled_inequalities(src)), 0, f"not flagged: {label}") diff --git a/test/pytest/test_mutation_ledger.py b/test/pytest/test_mutation_ledger.py index 40db0a81..31a87325 100644 --- a/test/pytest/test_mutation_ledger.py +++ b/test/pytest/test_mutation_ledger.py @@ -474,9 +474,10 @@ def test_the_ledger_refuses_two_rows_sharing_one_key(tmp_path, expect): log = _w(tmp_path, "r.log", "RESULT\tdemo\tpart1\ta check\tPASS\t18\t\nchecks run: 1\n") out, rc = _run("gate", "--ledger", dup, "--budget", budget, "--registered", reg, log) - expect.num(int("a ledger row repeats a key" in out), 1, - "a duplicated ledger key is refused by name") - expect.num(int("a check" in out), 1, "and the row is named") + expect.contains( + out, "a ledger row repeats a key", + "a duplicated ledger key is refused by name") + expect.contains(out, "a check", "and the row is named") expect.num(rc, 2, "as an integrity failure, not a gate verdict") @@ -495,10 +496,12 @@ def test_a_ledger_with_no_duplicate_key_still_loads(tmp_path, expect): log = _w(tmp_path, "r.log", "RESULT\tdemo\tpart1\tfirst check\tPASS\t18\t\nchecks run: 1\n") out, rc = _run("gate", "--ledger", ok, "--budget", budget, "--registered", reg, log) - expect.num(int("a ledger row repeats a key" in out), 0, - "three distinct keys are not a duplicate") - expect.num(int("ledger census: rows=3" in out), 1, - "and all three rows loaded, so the refusal did not eat one") + expect.contains( + out, "a ledger row repeats a key", + "three distinct keys are not a duplicate", absent=True) + expect.contains( + out, "ledger census: rows=3", + "and all three rows loaded, so the refusal did not eat one") def test_the_gate_refuses_two_checks_sharing_one_ledger_key(tmp_path, expect): @@ -558,10 +561,11 @@ def test_the_gate_refuses_two_checks_sharing_one_ledger_key(tmp_path, expect): expect.at_least(len(out), 20, "premise: the gate produced output to read") expect.num(out.count("one ledger key covers 2 checks"), 1, "a key covering two checks in one run is refused, and named") - expect.num(int("part1" in out), 1, "with the part, since the part is half the key") + expect.contains(out, "part1", "with the part, since the part is half the key") expect.num(rc, 1, "and the gate fails rather than noting it") - expect.num(int("Traceback" in out), 0, - "premise: rc came from the refusal, not from a crash") + expect.contains( + out, "Traceback", + "premise: rc came from the refusal, not from a crash", absent=True) def test_a_shared_key_is_refused_in_a_suite_the_ledger_does_not_cover(expect, tmp_path): @@ -593,8 +597,9 @@ def test_a_shared_key_is_refused_in_a_suite_the_ledger_does_not_cover(expect, tm expect.num(out.count("one ledger key covers 2 checks"), 1, "a shared key in an uncovered suite is named") expect.num(rc, 1, "and refused, with no row in the ledger for that suite") - expect.num(int("Traceback" in out), 0, - "premise: rc came from the refusal, not from a crash") + expect.contains( + out, "Traceback", + "premise: rc came from the refusal, not from a crash", absent=True) expect.num(out.count("not in the ledger:"), 0, "premise: and not from the new-check refusal, which this suite escapes") diff --git a/test/pytest/test_stats_privilege.py b/test/pytest/test_stats_privilege.py index 16161820..64ccb8b0 100644 --- a/test/pytest/test_stats_privilege.py +++ b/test/pytest/test_stats_privilege.py @@ -153,5 +153,6 @@ def test_a_role_with_no_privilege_is_refused(pgc_cluster, pgc_conn, expect): _, err = _as(pgc_cluster, NONE, "SELECT count(*) FROM pgcolumnar.stats('st_t')", schema) expect.sqlstate(err, "42501", "a role with no privilege on the table is refused") - expect.num(int("st_t" in str(err)), 1, - "and the refusal names the TABLE, not a catalog table it never asked about") + expect.contains( + str(err), "st_t", + "and the refusal names the TABLE, not a catalog table it never asked about")