Skip to content

feat(ci): a floor under the size of the test suite itself (#405) - #409

Merged
zaebee merged 4 commits into
mainfrom
test-count-floor-405
Aug 17, 2026
Merged

feat(ci): a floor under the size of the test suite itself (#405)#409
zaebee merged 4 commits into
mainfrom
test-count-floor-405

Conversation

@zaebee

@zaebee zaebee commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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

>= 72 in test_recordings_from_corpus.py and >= 16 in test_backfill_calibration_fingerprint.py were 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 in ci.yml, not a test.

The load-bearing half: two baselines

source role
git show origin/<base>:.github/test-count-baseline the floor — a branch cannot edit it
the file in the branch's tree the copy a branch is allowed to raise

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 checking a branch against its own file compares 1264 to 1264 and passes. Against the base branch's 1932 it fails.

fetch-depth: 0 is already on the checkout step (for Sonar), which is what makes origin/<base> readable.

Four rules

  1. actual >= floor — the suite may not shrink below the base branch's number. Cannot be evaded by editing the file in the branch.
  2. here >= floor — the baseline may only rise, or a branch lowers it and every branch cut afterwards inherits a weaker floor.
  3. here <= actual — it may not claim more tests than exist, or the next branch starts red through no fault of its own.
  4. 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 suite collects 1304 tests; 1932 were recorded on the base branch. 628 have gone
missing. Deleting a module together with its tests leaves CI green, which is why this
is checked separately from whether the tests pass.
exit=1

The intact tree exits 0.

Parsing, measured rather than assumed

pytest --collect-only -q ends in three shapes:

1957 tests collected in 1.84s
137/1957 tests collected (1820 deselected) in 1.95s     # under -k — the *right* number is 1957
no tests collected in 0.00s                             # collection failed; not a count of zero

The third raises CannotCountError and 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 None rather 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.yml would run no check at all; branch protection with required status checks is the answer to that, not this PR.

🤖 Generated with Claude Code

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/check_test_count.py Outdated
Comment on lines +77 to +86
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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))

Comment on lines +74 to +77
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'")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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) == 1932

zaebee and others added 2 commits August 17, 2026 10:55
SonarCloud 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>
@zaebee

zaebee commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/check_test_count.py Outdated
#: 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._/-]*$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 @.

Suggested change
_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/~^@-]*$")

Comment on lines +127 to +131
_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."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the validation error message to reflect the newly allowed characters (~, ^, and @) in the git revision validation.

Suggested change
_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."
)

Comment thread scripts/check_test_count.py Outdated
Comment on lines +91 to +97
result = subprocess.run(
["uv", "run", "--frozen", "pytest", "--collect-only", "-q"],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Comment thread scripts/check_test_count.py Outdated
Comment on lines +152 to +157
resolved = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
cwd=repo_root,
capture_output=True,
check=False,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Comment thread tests/unit/test_check_test_count.py Outdated
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"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
@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>
@zaebee
zaebee force-pushed the test-count-floor-405 branch from 0817ba3 to a12ef0e Compare August 17, 2026 11:13
@sonarqubecloud

Copy link
Copy Markdown

@zaebee
zaebee merged commit 0fbcbac into main Aug 17, 2026
3 checks passed
@zaebee
zaebee deleted the test-count-floor-405 branch August 17, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI has no floor on its own size: a push deleting 658 tests passed Python Verification

1 participant