Skip to content

feat: add python line and branch coverage governance to ci - #317

Merged
0xKT merged 2 commits into
mainfrom
feat/python_coverage_ratchet
Aug 17, 2026
Merged

feat: add python line and branch coverage governance to ci#317
0xKT merged 2 commits into
mainfrom
feat/python_coverage_ratchet

Conversation

@forrestjgq

Copy link
Copy Markdown
Contributor

Summary

Adds Python coverage governance to CI so existing coverage cannot silently
regress and new code stays well tested, without pretending the project is
already near 100%.

  • scripts/coverage_gate.py plus make coverage* targets compute line and
    branch coverage for the raven package and drive four gates:
    • summary of totals and the lowest-covered files;
    • ratchet of total line and branch coverage against the audited
      .github/coverage-baseline.json (0.05pp tolerance for rounding noise);
    • diff coverage requiring 90% on the executable lines a PR changes
      (comments, deletions, and non-executable lines are excluded);
    • baseline-check rejecting any proposed baseline that lowers line or
      branch coverage relative to the target branch.
  • The CI unit job runs the suite once with coverage and reuses the report for
    every gate, uploads xml/json/html artifacts on every run (if: always()),
    and keeps diff coverage as a PR-only gate.
  • pyproject.toml configures coverage.py (branch mode, raven source, three
    documented omissions); generated reports are gitignored.
  • Key decision: the ratchet uses a manually bumped, audited baseline rather
    than auto-raising, so the diff-coverage gate is what guards new code while the
    ratchet only blocks regression.

The production-file pathspec uses git glob magic (:(glob)raven/**/*.py) so
top-level modules such as raven/__init__.py are not silently dropped from diff
coverage; a regression test covers this.

Type

  • Feature

Verification

  • uv run --frozen --python 3.12 pytest tests/test_coverage_gate.py -q -> 13 passed

  • uv run --frozen --python 3.12 --extra dev ruff check scripts/coverage_gate.py tests/test_coverage_gate.py -> All checks passed

  • uv run --frozen --python 3.12 --extra dev ruff format --check scripts/coverage_gate.py tests/test_coverage_gate.py -> already formatted

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

No runtime raven code changes; this is CI and tooling only. The new gates can
fail PRs that lower coverage, which is the intended behavior. Rollback is a
plain revert of this commit (or disabling the coverage steps in the unit job).

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

Add scripts/coverage_gate.py and make targets that measure line and
branch coverage for the raven package and enforce three gates: a
monotonic ratchet against an audited baseline, 90% coverage on the
executable lines a pull request changes, and a baseline that cannot be
lowered relative to the target branch. The CI unit job runs the suite
once with coverage and reuses the report for every gate, uploading xml,
json, and html artifacts on every run.

Co-authored-by: Claude (claude-opus-4-8) <noreply@anthropic.com>
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @forrestjgq's task in 1m 32s —— View job


Review: python coverage governance CI

No blockers; this can merge.

  • Read prior review comments on this PR
  • Diffed the two commits (af70757 feat, e6e4b31 fix) against origin/main
  • Verified the two previously-flagged findings
  • Swept the rest of the diff (Makefile, pyproject.toml, coverage_gate.py, tests) for new issues

Both findings from the earlier review round were fixed in the follow-up commit e6e4b31:

  • .github/workflows/ci.yml: the unit job's install step is back to uv sync --locked --all-extras --dev --python ${{ matrix.python }}, so lockfile-freshness enforcement is restored while still pinning the matrix interpreter.
  • scripts/coverage_gate.py:196: parse_changed_lines now resets path = None on any +++ line that isn't +++ b/... (e.g. +++ /dev/null), with a regression test (test_parse_changed_lines_ignores_deleted_file_hunks) covering it.

The coverage-baseline.json was also regenerated against the Ubuntu/3.12 CI run itself so the ratchet reproduces at delta 0.00, matching the commit's stated intent.

No new issues found in the delta: gate logic (ratchet, diff, baseline-check), Makefile targets, and pyproject.toml coverage config are consistent with the PR description, and new files carry module docstrings per the updated AGENTS.md §1.1 rule.

Comment thread .github/workflows/ci.yml Outdated

- name: Install dependencies
run: uv sync --locked --all-extras --dev
run: uv sync --frozen --all-extras --dev --python ${{ matrix.python }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This silently removes the only CI check that verifies uv.lock is up to date with pyproject.toml. The previous step used uv sync --locked ..., which fails if the lockfile is stale; switching to --frozen (needed to add --python ${{ matrix.python }}) makes uv sync accept a stale lockfile without error. Grepping the repo, no other job or Makefile target uses --locked for this purpose (Makefile's install-deps already used --frozen before this PR, but this was the one CI step enforcing lockfile freshness). After this change, a PR that edits a dependency in pyproject.toml without running uv lock will no longer be caught by CI.

Suggest keeping the freshness check, e.g. uv sync --locked --all-extras --dev and setting the interpreter via uv python pin/UV_PYTHON env instead of the --python flag, or adding a separate uv lock --check/--locked step.

Fix this →

Comment thread scripts/coverage_gate.py Outdated
Comment on lines +188 to +193
def parse_changed_lines(diff: str) -> dict[str, set[int]]:
"""Return new-side hunk lines; deleted lines never enter the denominator."""
changed: dict[str, set[int]] = {}
path: str | None = None
for line in diff.splitlines():
if line.startswith("+++ b/"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] path is never reset/cleared between files, and only updated on +++ b/... lines. A deleted file's diff shows +++ /dev/null, which doesn't match, so its hunks would silently be attributed to whatever path was last set from an earlier file. Currently harmless because the only caller (git_diff_inputs) filters with --diff-filter=ACMR (no deletions ever reach this function), but it's a latent bug if parse_changed_lines is ever reused on a raw diff. Consider setting path = None when the new-file line isn't +++ b/....

The bootstrap baseline was generated on macOS; the Ubuntu 3.12 CI run
produces slightly lower line and branch coverage, so the ratchet failed
by more than its 0.05pp tolerance. Replace it with the candidate emitted
by that CI run so the ratchet reproduces at delta 0.00.

Also restore uv sync --locked on the unit job, since dropping it for
--frozen removed the only CI check that uv.lock matches pyproject.toml,
and reset the parser path on a +++ /dev/null target so a deleted file's
hunks cannot be misattributed if parse_changed_lines runs on an
unfiltered diff.

Co-authored-by: Claude (claude-opus-4-8) <noreply@anthropic.com>

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

No blockers; this can merge as far as I am concerned.

My own pass over github/main...e6e4b31f (independent of the bot review already on the thread). What I covered: AGENTS.md rules, the whole diff (workflow, Makefile, pyproject, gate script, baseline, tests, tests/README.md), the gate's callers and the CI job log for the current head, backward compatibility of the changed Make targets, and whether the tests actually assert.

Verification I ran

  • uv run --frozen --python 3.12 --all-extras pytest tests/test_coverage_gate.py -q -> 14 passed, 0 skipped. No graceful-skip, no env-gated assertion; test_git_diff_inputs_includes_top_level_raven_files builds a real temp repo and exercises the :(glob)raven/**/*.py pathspec, which is exactly the trap the comment above it describes.
  • Read the unit job log for this head: every gate really executed. Line 75.56% / Branch 64.04%, Ratchet line delta=+0.00pp, Ratchet branch delta=+0.00pp. The committed baseline reproduces bit-for-bit on CI, so the ratchet is not sitting on rounding luck.
  • Note for the record: Diff coverage: 100.00% (0/0 executable changed lines) -- this PR touches no raven/ file, so the diff gate is green vacuously here. Its logic is covered by unit tests, not by this run.

Both earlier findings are genuinely fixed in e6e4b31f, not just closed: uv sync --locked --all-extras --dev --python 3.12 restores the lockfile-freshness check while keeping the matrix pin (--frozen on the later uv run calls only skips the lock check, the sync already enforced it), and parse_changed_lines now resets path = None on a non-b/ target with a test for the +++ /dev/null case.

Things I looked at and decided are not findings

  • OMITTED_PATHS duplicated between pyproject.toml and the script: already guarded by test_documented_omissions_match_run_and_report_configuration, so drift fails loudly.
  • exclude_also rather than exclude_lines, so coverage.py's pragma: no cover defaults survive. Correct choice.
  • --python $(PYTHON_VERSION) on every uv run will rebuild a contributor's 3.13 .venv at 3.12. That is the point of a reproducible baseline, and tests/README.md documents the override.
  • Baseline JSON is 96 KiB and not an asset type AGENTS.md section 7 blocks; repository files is green.

Three inline notes, all non-blocking. The first one is the only one I would actually act on, and it can be a follow-up.

Comment thread scripts/coverage_gate.py
target = float(previous["totals"][metric])
delta = value - target
print(f"Baseline {metric}: proposed={value:.6f}% target={target:.6f}% delta={delta:+.6f}pp")
if value < target:

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.

[non-blocking, but the one worth acting on] The two gates together make deleting well-covered production code unmergeable, with no documented escape.

Overall coverage is C/S. Remove a chunk whose own coverage c/s is above the project average and the total drops: deleting a 100%-covered 200-statement module takes line coverage from 32494/43003 = 75.5622% to 32294/42803 = 75.4497%, i.e. -0.11pp, past the 0.05pp tolerance -> coverage-ratchet fails. The obvious remedy is to lower the baseline in the same PR, but check_baseline_update rejects any decrease (if value < target, strict, no tolerance), so coverage-baseline-check fails too. tests/README.md closes the last door explicitly: "Never edit the percentages by hand or lower the baseline to make a change pass."

So the author's only options are editing COVERAGE_RATCHET_TOLERANCE in the Makefile (a permanent loosening for a one-off deletion) or padding unrelated tests until the number comes back. This is not an exotic case -- removing a dead but well-tested module is routine, and 200 statements is enough to trigger it.

Worth defining the sanctioned path before people hit it. Options, cheapest first: (a) document that a baseline decrease is allowed when the PR is a net deletion, and have check_baseline_update accept it behind an explicit marker rather than a blanket ban; (b) compare against coverage recomputed on the target branch instead of a static file, so deletions cancel out; or (c) ratchet on covered/total counts of surviving files rather than the whole-project ratio. Same reasoning applies to branch_percent.

Secondary point on this function: the ratchet tolerates 0.05pp of noise but check_baseline_update tolerates none, so a candidate refreshed from a main run that dipped by 0.000001pp is rejected. Worth sharing one tolerance.

}
},
"python": "3.12",
"reference_commit": "840d05101b393354e24bba0191b6d9ae40790df6",

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.

[nit] This sha is not resolvable in a clone. git cat-file -t 840d0510 -> fatal: could not get object info; via the API it is Merge af70757b into cd686453, i.e. the ephemeral refs/pull/317/merge commit GitHub synthesises for the PR, reachable from no branch and gone after the squash-merge.

It comes from coverage-baseline-candidate running git rev-parse HEAD while the unit job sits on the merge ref (the pre-commit job pins ref: github.event.pull_request.head.sha for exactly this reason; the unit job does not). tests/README.md tells the reviewer to "Review the reference commit and totals in the candidate before replacement", which this value cannot support.

Post-bootstrap candidates from main push runs will record a real commit, so the damage is limited to the tracked bootstrap baseline plus any candidate a contributor pulls from a PR run to sanity-check a refresh. Cheap fix: thread --commit ${{ github.event.pull_request.head.sha || github.sha }} through the Make target.

Comment thread AGENTS.md

### §1.1 Top rule: don't add comments unless necessary

- Every new code file must document its purpose in English with an appropriate

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.

[nit] This is a new repo-wide hard constraint riding along in a CI-coverage PR. AGENTS.md is self-describing as "hard constraints only (violations get reverted / rejected)", so from merge onward every new file in the repo is subject to it, and reviewers looking at a coverage PR are not the audience that would weigh in on it. The bullet itself is fine and the follow-on sentence resolves the tension with the "don't comment unless necessary" top rule -- it is the bundling I would flag.

Also: the summary table at the top still gives section 1 as "Don't comment unless necessary; comments in English", which no longer describes a section that now mandates a module docstring in every new file. If the bullet stays, the gist row should mention it.

@0xKT
0xKT merged commit fb1398b into main Aug 17, 2026
15 checks passed
@0xKT
0xKT deleted the feat/python_coverage_ratchet branch August 17, 2026 13:21
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.

4 participants