From 83a4ac98cfd4639cff5090a93e88fa71238929e3 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:15:06 +0200 Subject: [PATCH] fix(drill): the stale-decision mutant changes behaviour instead of crashing Its replacement called control_plane.get from approvals.py, which does not import it, so the test failed on NameError and the drill printed "Caught" without proving that a stale read is detected (toolgate-5 F1). The mutant now reads the row outside the write transaction, and the drill fails as BROKEN MUTANT whenever a mutant's run shows NameError, ImportError or SyntaxError. --- toolgate/scripts/mutation_check.py | 15 +++++++++++-- toolgate/tests/test_mutation_check.py | 32 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/toolgate/scripts/mutation_check.py b/toolgate/scripts/mutation_check.py index 1225358..99f41f2 100644 --- a/toolgate/scripts/mutation_check.py +++ b/toolgate/scripts/mutation_check.py @@ -16,6 +16,8 @@ MCP = "toolgate/tests/test_mcp_adapter.py::" ARCHIVE = "toolgate/tests/test_execution_boundary.py::" INTEGRITY = "toolgate/tests/test_approval_integrity.py::" +# Failures from a mutant that cannot run, not from the behaviour it changes. +BROKEN_MUTANT = ("NameError", "ImportError", "SyntaxError", "IndentationError") # A mutant counts as caught only when its named behavioral test fails, never # when collection fails or the subprocess cannot start. @@ -93,9 +95,12 @@ if guard is not None: guard(conn, record) if record.get("status") != "pending":""", - """ record = get("request", request_id) - if not record: + # A stale read: the row is read outside the write transaction, then decided on. + """ with _conn() as read: + row = read.execute("SELECT * FROM v2_objects WHERE kind='request' AND id=?", (request_id,)).fetchone() + if not row: return None + record = _row(row) with _conn() as conn: if guard is not None: guard(conn, record) @@ -472,6 +477,12 @@ def main() -> int: return 1 finally: path.write_text(original, encoding="utf-8") + output = result.stdout + result.stderr + if any(crash in output for crash in BROKEN_MUTANT): + # A test failing on the mutant's own crash proves nothing about the behaviour. + print(output) + print(f"BROKEN MUTANT (it crashes instead of changing behaviour): {label}") + return 1 if result.returncode != 1 or f"FAILED {test}" not in result.stdout: print(result.stdout + result.stderr) print(f"NOT CAUGHT: {label}") diff --git a/toolgate/tests/test_mutation_check.py b/toolgate/tests/test_mutation_check.py index dd6c2dd..876174f 100644 --- a/toolgate/tests/test_mutation_check.py +++ b/toolgate/tests/test_mutation_check.py @@ -65,3 +65,35 @@ def fake_run_tests(root, tests, name): assert drill.main() == 1 assert calls == ["baseline", "mutant-0"] assert "TIMED OUT: drill anchor mutated" in capsys.readouterr().out + + +def test_a_mutant_that_crashes_is_reported_broken_not_caught(drill, monkeypatch, capsys): + crash = "E NameError: name 'get' is not defined\nFAILED some/test - NameError" + + def fake_run_tests(root, tests, name): + if name == "baseline": + return subprocess.CompletedProcess([], 0, stdout="", stderr="") + return subprocess.CompletedProcess([], 1, stdout=crash, stderr="") + + monkeypatch.setattr(drill, "run_tests", fake_run_tests) + monkeypatch.setattr( + drill, + "MUTANTS", + [("crashing mutant", ANCHOR[1], ANCHOR[0], ANCHOR[0] + " # mutated", "some/test")], + ) + assert drill.main() == 1 + out = capsys.readouterr().out + assert "BROKEN MUTANT (it crashes instead of changing behaviour): crashing mutant" in out + assert "Caught: crashing mutant" not in out + + +def test_the_stale_decision_mutant_reads_the_row_itself(): + # The stale-decision mutant once called control_plane.get from approvals.py, which does not + # import it: the test failed on NameError and the drill printed "Caught". + spec = importlib.util.spec_from_file_location("mutation_check_names", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + label, _file, _before, after, _test = next( + m for m in module.MUTANTS if m[0] == "stale decision overwrites consumption" + ) + assert 'get("request"' not in after, label