feat(ci): a floor under the size of the test suite itself (#405) - #409
Conversation
On 2026-08-17 a stale-tree push over #339 deleted 62 files — 5 modules under src/cgis and 24 test files — and Python Verification passed. 1922 tests became 1264 and nothing reported it, because the tests that would have failed were deleted by the same commit. To CI, 'the module and its tests are gone' and 'both still pass' are one observation. Every floor the repository had (>= 72 in test_recordings_from_corpus, >= 16 in test_backfill_calibration_fingerprint) was written against this exact silence and could not help: they live inside test files, and the push that trips them also removes them. So this is a workflow step, not a test. The floor is read from the base branch and the ceiling from the branch's own copy, which is the load-bearing half. A stale tree carries a stale baseline: the tree in the incident was six days old and held roughly the count it had then, so a check against its own file would have compared 1264 to 1264 and passed. Against origin/main's 1932 it fails — verified end to end on a worktree with 24 test files removed: 628 gone missing, exit 1, and exit 0 on the intact tree. Four rules: the suite may not fall below the base branch's number; the baseline may only rise; it may not claim more tests than exist; and it may not fall more than 150 behind, or it stops bounding anything. Rules 3 and 4 are suppressed while rule 1 is broken, because 'lower the baseline' is the wrong advice to print beside '628 tests have gone missing'. A base ref with no baseline yet returns None rather than a number, and the step says on stderr that the anti-deletion rule did not apply. That covers the bootstrap commit and branches cut before it; it cannot be reached by deleting the file later, because such a branch fails on the working-tree read first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to prevent silent test deletions by establishing a baseline test count floor. It adds a baseline file, a script (check_test_count.py) to verify the current test count against the base branch's baseline, and a comprehensive test suite for this script. The review feedback suggests improving the robustness of the test count parser by selecting the last regex match in the output to avoid false matches from logs, along with adding a corresponding unit test.
| match = _COLLECTED.search(output) | ||
| if match is None: | ||
| _msg = ( | ||
| "Could not find a collected-test count in pytest's output. The last line is " | ||
| "normally '<N> tests collected'; 'no tests collected' means collection failed " | ||
| "and is not a count of zero.\n" | ||
| f"--- output ---\n{output.strip()[-2000:]}" | ||
| ) | ||
| raise CannotCountError(_msg) | ||
| return int(match.group(2)) |
There was a problem hiding this comment.
Using _COLLECTED.search(output) returns the first match in the pytest output. If any test, plugin, or warning prints a message containing a matching pattern (e.g., in stdout or stderr during collection), it could result in an incorrect test count being parsed. \n\nTo make this more robust, we should find all matches and select the last one, as the actual pytest summary line is guaranteed to be at the very end of the output.
matches = list(_COLLECTED.finditer(output))\n if not matches:\n _msg = (\n \"Could not find a collected-test count in pytest's output. The last line is \"\n \"normally '<N> tests collected'; 'no tests collected' means collection failed \"\n \"and is not a count of zero.\\n\"\n f\"--- output ---\\n{output.strip()[-2000:]}\"\n )\n raise CannotCountError(_msg)\n return int(matches[-1].group(2))| def test_the_refusal_carries_the_output_it_could_not_parse(self) -> None: | ||
| """A "could not run" with no output is a bug report nobody can action.""" | ||
| with pytest.raises(CannotCountError, match="ImportError: cannot import name"): | ||
| parse_collected("ImportError: cannot import name 'x' from 'y'") |
There was a problem hiding this comment.
Add a unit test to verify that parse_collected correctly handles multiple matches in the output (e.g., when logs or test names contain matching patterns) and always selects the last match (the actual summary line).
def test_the_refusal_carries_the_output_it_could_not_parse(self) -> None:\n \"\"\"A 'could not run' with no output is a bug report nobody can action.\"\"\"\n with pytest.raises(CannotCountError, match=\"ImportError: cannot import name\"):\n parse_collected(\"ImportError: cannot import name 'x' from 'y'\")\n\n def test_multiple_matches_picks_the_last_one(self) -> None:\n \"\"\"If earlier output or log messages contain matching text, the last one (the summary) is chosen.\"\"\"\n output = \"10 tests collected\\nSome other logs...\\n1932 tests collected in 1.83s\"\n assert parse_collected(output) == 1932SonarCloud put new-code coverage at 73.3%, and the uncovered lines were collect_count, check and main — the refusals and the exit codes, which is the whole contract. The same shape #399 hit: a refusal nobody executes is a comment with a raise in it. Covers both streams being joined before parsing (uv writes on stderr), a broken collection refusing rather than returning a count, the flag saying whether the floor was in force, and the three exit codes — 0, 1, and 2 for 'could not tell', which must not read as 'the suite is intact'. 99% now; the remaining line is the __main__ guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…S8705) SonarCloud put Security Rating at C on this PR: both subprocess calls in baseline_on take the ref straight from argv. Fixed rather than exempted — scripts/ already carries exemptions for S8707 and S2083, and adding a third would be silencing the one finding here with substance. Nothing is passed through a shell, so the metacharacter cases are not exploitable. The leading dash is: '--upload-pack=...' reaches git as an option, not a revision — the same argument-injection shape evidence.py guards against on changed-file paths, and the reason that guard exists there is that a value which never looked like its type has no business reaching a subprocess, exploitable or not. Refused rather than sanitised. Stripping a bad ref into something git accepts would compare against a revision nobody asked for, which is this file's own failure mode wearing a different hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new test-count baseline guard script and its corresponding unit tests to prevent silent test suite deletions in CI. The reviewer feedback suggests expanding the git revision validation regex to support relative references (such as HEAD~1 and HEAD^), updating the validation error message and unit tests accordingly, and handling potential FileNotFoundError exceptions when executing uv and git subprocesses to ensure clean exits.
| #: through a shell, so this is not about quoting; it is that a string which | ||
| #: never looked like a ref has no business reaching a subprocess, exploitable or | ||
| #: not (pythonsecurity:S8705). | ||
| _REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") |
There was a problem hiding this comment.
The current _REF regular expression is overly restrictive and rejects valid git revision expressions such as HEAD~1, HEAD^, or origin/main~2. This limits the usability of the script in CI workflows where relative references are commonly used. Consider updating the regex to allow common git revision characters like ~, ^, and @.
| _REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") | |
| _REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/~^@-]*$") |
| _msg = ( | ||
| f"{ref!r} is not a usable git revision. It must start with a letter or digit and " | ||
| f"hold only letters, digits, and `._/-` — a value beginning with `-` reaches git " | ||
| f"as an option rather than a revision." | ||
| ) |
There was a problem hiding this comment.
Update the validation error message to reflect the newly allowed characters (~, ^, and @) in the git revision validation.
| _msg = ( | |
| f"{ref!r} is not a usable git revision. It must start with a letter or digit and " | |
| f"hold only letters, digits, and `._/-` — a value beginning with `-` reaches git " | |
| f"as an option rather than a revision." | |
| ) | |
| _msg = ( | |
| f"{ref!r} is not a usable git revision. It must start with a letter or digit and " | |
| f"hold only letters, digits, and `._/-~^@` — a value beginning with `-` reaches git " | |
| f"as an option rather than a revision." | |
| ) |
| result = subprocess.run( | ||
| ["uv", "run", "--frozen", "pytest", "--collect-only", "-q"], | ||
| cwd=repo_root, | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| ) |
There was a problem hiding this comment.
If uv is not installed or not available in the system's PATH, subprocess.run will raise a FileNotFoundError. This unhandled exception will cause the script to crash with a traceback and exit with status 1, rather than exiting cleanly with status 2 as intended for "could not run" scenarios. Wrapping the call in a try-except block to catch FileNotFoundError and raise CannotCountError ensures robust error handling.
| result = subprocess.run( | |
| ["uv", "run", "--frozen", "pytest", "--collect-only", "-q"], | |
| cwd=repo_root, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| try: | |
| result = subprocess.run( | |
| ["uv", "run", "--frozen", "pytest", "--collect-only", "-q"], | |
| cwd=repo_root, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| except FileNotFoundError as exc: | |
| raise CannotCountError( | |
| f"Could not run 'uv'. Please ensure 'uv' is installed and available in your PATH. " | |
| f"Original error: {exc}" | |
| ) from exc |
| resolved = subprocess.run( | ||
| ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], | ||
| cwd=repo_root, | ||
| capture_output=True, | ||
| check=False, | ||
| ) |
There was a problem hiding this comment.
Similarly to the uv process execution, if git is not installed or not available in the system's PATH, subprocess.run will raise a FileNotFoundError. Wrapping this call in a try-except block to catch FileNotFoundError and raise CannotCountError ensures a clean exit with status 2 and an actionable error message.
| resolved = subprocess.run( | |
| ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], | |
| cwd=repo_root, | |
| capture_output=True, | |
| check=False, | |
| ) | |
| try: | |
| resolved = subprocess.run( | |
| ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], | |
| cwd=repo_root, | |
| capture_output=True, | |
| check=False, | |
| ) | |
| except FileNotFoundError as exc: | |
| raise CannotCountError( | |
| f"Could not run 'git'. Please ensure 'git' is installed and available in your PATH. " | |
| f"Original error: {exc}" | |
| ) from exc |
| with pytest.raises(CannotCountError, match="not a usable git revision"): | ||
| baseline_on(ref, REPO_ROOT) | ||
|
|
||
| @pytest.mark.parametrize("ref", ["main", "origin/main", "HEAD", "release/1.2", "98380bc"]) |
There was a problem hiding this comment.
Add test cases for relative git references (such as HEAD~1, HEAD^, and origin/main~2) to ensure they are correctly accepted by the updated _validated_ref logic.
| @pytest.mark.parametrize("ref", ["main", "origin/main", "HEAD", "release/1.2", "98380bc"]) | |
| @pytest.mark.parametrize("ref", ["main", "origin/main", "HEAD", "HEAD~1", "HEAD^", "origin/main~2", "release/1.2", "98380bc"]) |
The best of them: an absent uv or git raised FileNotFoundError, Python exited 1, and 1 is the code for "the suite shrank" — so a machine without the toolchain would have reported as a deletion. Telling "could not find out" from "tests are missing" is this file's whole job, and it was not doing it in its own plumbing. Both call sites now go through _run and refuse at exit 2. Last match rather than first: pytest's summary is the final line, so a plugin printing something count-shaped earlier is not the answer. Taken with a change, because as suggested it inverts on the case the code was written for — stderr is appended after stdout, so "last match" over the joined text would let uv's chatter outrank the real summary. stdout is read first and the joined form only as a fallback, which is also what the refusal reports. Relative revisions admitted: HEAD~1, HEAD^, origin/main~2. CI passes origin/<base>, but those are what a person debugging locally types, and none of them introduces an option or a shell. Writing that test showed the guard encoded the wrong rule. Requiring the first character to be alphanumeric rejected "@", a legal revision meaning HEAD, while the property that matters is only that a ref must not begin with a dash. The pattern now says that directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0817ba3 to
a12ef0e
Compare
|



Closes #405.
The incident
On 2026-08-17 a stale-tree push over #339 deleted 62 files — 5 modules under
src/cgis(review_fingerprint.py,evidence.py,martian.py,calibrate.py,registry.py) and 24 test files. Python Verification passed. 1922 collected tests became 1264 and nothing said so, because the tests that would have failed were deleted by the same commit.To CI, "the module and its tests are gone" and "both still pass" are one observation.
Why the existing ratchets could not help
>= 72intest_recordings_from_corpus.pyand>= 16intest_backfill_calibration_fingerprint.pywere written against exactly this shape of silence. They live inside test files, and this failure deletes the file. A guard shipped inside the thing it guards is removed by the event it exists to catch — so this is a step inci.yml, not a test.The load-bearing half: two baselines
git show origin/<base>:.github/test-count-baselineA stale tree carries a stale baseline. The tree in the incident was six days old and held roughly the count it had then, so checking a branch against its own file compares 1264 to 1264 and passes. Against the base branch's 1932 it fails.
fetch-depth: 0is already on the checkout step (for Sonar), which is what makesorigin/<base>readable.Four rules
actual >= floor— the suite may not shrink below the base branch's number. Cannot be evaded by editing the file in the branch.here >= floor— the baseline may only rise, or a branch lowers it and every branch cut afterwards inherits a weaker floor.here <= actual— it may not claim more tests than exist, or the next branch starts red through no fault of its own.actual - here <= 150— it may not fall far behind, or it stops bounding anything.Rules 3 and 4 are suppressed while rule 1 is broken: "lower the baseline to 1304" is the wrong advice to print beside "628 tests have gone missing".
Verified end to end, not only by unit test
A worktree of this branch with 24 test files removed:
The intact tree exits 0.
Parsing, measured rather than assumed
pytest --collect-only -qends in three shapes:The third raises
CannotCountErrorand exits 2. A floor that cannot fail is worse than no floor, because it reads as protection.Bootstrap
A base ref with no baseline yet returns
Nonerather than a number, and the step prints on stderr that the anti-deletion rule did not apply — a green step where the rule never ran must not look like one where it ran and passed. This covers the introducing commit and branches cut before it.It cannot be reached by deleting the file later: such a branch fails on the working-tree read first. An unresolvable base ref is a different thing (a broken workflow) and raises — told apart by resolving the ref, not by matching git's error text.
Out of scope, worth confirming separately
The workflow file is itself part of the pushed tree. A stale push that removed
ci.ymlwould run no check at all; branch protection with required status checks is the answer to that, not this PR.🤖 Generated with Claude Code