Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 199 additions & 16 deletions tools/coverage_trend.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,107 @@ def _load_json(path: Path) -> dict[str, Any]:
return {}


# The keys a `config/coverage-baseline.json` may use for the percentage. BOTH are real and
# both are in service today: stranske/Workflows and stranske/learning-management-system write
# `coverage`, stranske/Trend_Model_Project writes `line`. `tools/coverage_guard.py` -- the OTHER
# consumer of the very same file -- has always read `payload.get("line", payload.get("coverage"))`,
# so the two scripts disagreed about their shared config and only one of them said so.
BASELINE_KEYS = ("line", "coverage")


# Why `baseline` is Optional and never 0.0: this function used to return a bare float, defaulting
# to 0.0 whenever the file was missing, unparseable, or keyed differently. That made ONE sentinel
# mean two opposite things -- "there is no baseline to compare against" and "the baseline is zero"
# -- and only the second is a measurement. The visible result was a summary reading
# `Baseline 0.00% | Delta +83.32% | Status Pass` on a repo whose baseline file says 85.0 and whose
# real coverage of 83.32% is a BREACH. A comparison against a silently-absent baseline cannot fail,
# so it was reporting success at the exact moment it had nothing to report.
def _resolve_baseline(path: Path | None) -> tuple[float | None, str]:
"""Return (baseline, status). `None` means NOT CONFIGURED -- never the number zero.

status is one of: ok, unset, absent, unreadable, no_recognised_key. Each names a different
fix, which is the whole point of not collapsing them: `absent` means write the file,
`no_recognised_key` means it is there and this script cannot read it.
"""
if path is None:
return None, "unset"
if not path.exists():
return None, "absent"
payload = _load_json(path)
if not payload:
return None, "unreadable"
for key in BASELINE_KEYS:
if key in payload:
try:
return float(payload[key]), "ok"
except (TypeError, ValueError):
return None, "unreadable"
return None, "no_recognised_key"


def _partition_files(
files: dict[str, Any], project_root: Path | None
) -> tuple[dict[str, Any], list[str]]:
"""Split coverage rows into rows inside the project and rows measured somewhere else.

A test that copies the source tree into a tmpdir and exercises the copy makes coverage.py
record BOTH: the real module under its relative path, and the copy under an absolute
`/tmp/...` path. The copy is barely executed, so it sorts to the top of every hotspot table
and is counted a second time in the total. Observed on stranske/Trend_Model_Project, where 13
of the 15 reported worst files were
`/tmp/pytest-of-runner/pytest-0/popen-gw0/test_autofix_pipeline_repairs_0/workspace/src/...`
-- paths that do not exist in the repository, so the one actionable output of this script
pointed at files nobody could open.

Relative paths are always in-project: coverage.py records repo files relative to the run root,
so an ABSOLUTE path outside that root is the tell. Reported, never silently dropped from the
headline number -- see `current_project_only`.
"""
if project_root is None:
return files, []
root = project_root.resolve()
inside: dict[str, Any] = {}
foreign: list[str] = []
for filepath, data in files.items():
candidate = Path(filepath)
if candidate.is_absolute() and not candidate.resolve().is_relative_to(root):
foreign.append(filepath)
else:
inside[filepath] = data
return inside, foreign


def _percent_from_rows(files: dict[str, Any]) -> float | None:
"""Recompute a coverage percentage from per-file rows, or None when there are none.

ON THE SAME BASIS AS `current`, which is coverage.py's `totals.percent_covered`. With branch
coverage enabled that is (covered_lines + covered_branches) over (statements + branches), NOT
statements alone -- coverage.py reports the statements-only figure separately, as
`percent_statements_covered`.

The first version summed lines only, and the failure did not look like a failure: it produced
a plausible number about three points away, so `current_project_only` read as though
contamination had cost three points on repos where NOTHING was contaminated. Measured on
stranske/Pension-Data (foreign_file_count 0): statements-only 90.96%, line+branch 87.78%, and
coverage.py's own percent_covered 87.78%. The invariant that catches it is cheap and is now a
test -- with no foreign rows this MUST equal `current`.
"""
covered = 0
missing = 0
covered_branches = 0
num_branches = 0
for data in files.values():
summary = data.get("summary", {}) if isinstance(data, dict) else {}
covered += int(summary.get("covered_lines", 0) or 0)
missing += int(summary.get("missing_lines", 0) or 0)
covered_branches += int(summary.get("covered_branches", 0) or 0)
num_branches += int(summary.get("num_branches", 0) or 0)
denominator = covered + missing + num_branches
if denominator <= 0:
return None
return (covered + covered_branches) / denominator * 100.0


def _extract_coverage_percent(coverage_json: dict[str, Any]) -> float:
"""Extract overall coverage percentage from coverage.json."""
totals = coverage_json.get("totals", {})
Expand All @@ -35,13 +136,19 @@ def _get_hotspots(
coverage_json: dict[str, Any],
limit: int = 15,
low_threshold: float = 50.0,
project_root: Path | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Extract hotspot files from coverage.json.

`project_root` defaults to None, which keeps every row -- the behaviour callers had before
contamination filtering existed. Pass a root to drop rows measured outside the project.
The two-value return shape is deliberately preserved because consumer repositories call this
helper directly; callers that need the excluded paths can use ``_partition_files``.

Returns:
Tuple of (all_hotspots sorted by coverage, low_coverage_files below threshold)
"""
files = coverage_json.get("files", {})
files, _foreign = _partition_files(coverage_json.get("files", {}), project_root)
all_files = []

for filepath, data in files.items():
Expand Down Expand Up @@ -93,6 +200,15 @@ def main(args: list[str] | None = None) -> int:
parser.add_argument("--coverage-xml", type=Path, help="Path to coverage.xml")
parser.add_argument("--coverage-json", type=Path, help="Path to coverage.json")
parser.add_argument("--baseline", type=Path, help="Path to baseline JSON")
parser.add_argument(
"--project-root",
type=Path,
default=Path.cwd(),
help=(
"Project root used to tell repository files from files a test copied elsewhere "
"(coverage rows with an absolute path outside this root). Defaults to the cwd."
),
)
parser.add_argument("--summary-path", type=Path, help="Path to output summary markdown")
parser.add_argument("--job-summary", type=Path, help="Path to GITHUB_STEP_SUMMARY")
parser.add_argument("--artifact-path", type=Path, help="Path to output trend artifact")
Expand All @@ -112,32 +228,42 @@ def main(args: list[str] | None = None) -> int:
coverage_data = _load_json(parsed.coverage_json)
current_coverage = _extract_coverage_percent(coverage_data)

# Load baseline
baseline_coverage = 0.0
if parsed.baseline and parsed.baseline.exists():
baseline_data = _load_json(parsed.baseline)
baseline_coverage = float(baseline_data.get("coverage", 0.0))
# Load baseline. None means not configured; it is NEVER swapped for 0.0 (see _resolve_baseline).
baseline_coverage, baseline_status = _resolve_baseline(parsed.baseline)

# Calculate delta
delta = current_coverage - baseline_coverage
# Calculate delta. Absent baseline -> absent delta, rather than a delta against zero that
# renders as a large improvement on every run.
delta = None if baseline_coverage is None else current_coverage - baseline_coverage
passes_minimum = current_coverage >= parsed.minimum

# Get hotspots
hotspots, low_coverage = _get_hotspots(
coverage_data,
limit=parsed.hotspot_limit,
low_threshold=parsed.low_threshold,
project_root=parsed.project_root,
)
project_files, foreign_files = _partition_files(
coverage_data.get("files", {}), parsed.project_root
)
project_only = _percent_from_rows(project_files)

# Generate trend record
trend_record = {
"current": current_coverage,
"baseline": baseline_coverage,
"baseline_status": baseline_status,
"delta": delta,
"minimum": parsed.minimum,
"passes_minimum": passes_minimum,
"hotspot_count": len(hotspots),
"low_coverage_count": len(low_coverage),
# Contamination reporting. `current` stays exactly as coverage.py computed it, so this
# record still agrees with coverage.xml and with the delta job; the project-only figure
# sits BESIDE it, and the gap between the two is the size of the problem.
"foreign_file_count": len(foreign_files),
"foreign_files": foreign_files[:10],
"current_project_only": project_only,
}

# Write outputs
Expand All @@ -152,18 +278,55 @@ def main(args: list[str] | None = None) -> int:
parsed.artifact_path.write_text(json.dumps(artifact_data, indent=2), encoding="utf-8")

status = "✅ Pass" if passes_minimum else "❌ Below minimum"
if baseline_coverage is None:
baseline_cell = f"⚠️ not configured ({baseline_status})"
delta_cell = "n/a — nothing to compare against"
else:
baseline_cell = f"{baseline_coverage:.2f}%"
delta_cell = f"{delta:+.2f}%"
summary = f"""## Coverage Trend

| Metric | Value |
|--------|-------|
| Current | {current_coverage:.2f}% |
| Baseline | {baseline_coverage:.2f}% |
| Delta | {delta:+.2f}% |
| Baseline | {baseline_cell} |
| Delta | {delta_cell} |
| Minimum | {parsed.minimum:.2f}% |
| Status | {status} |

"""

if baseline_coverage is None:
summary += (
f"> **No baseline was read ({baseline_status}), so the delta above is not a "
"measurement.** `Status` reflects only the `--minimum` floor. Write "
"`config/coverage-baseline.json` with a `line` or `coverage` percentage to enable "
"the comparison; that file is deliberately not synced from Workflows, so each repo "
"owns its own.\n\n"
)

if foreign_files and not hotspots:
summary += (
f"> **⚠️ EVERY coverage row ({len(foreign_files)}) is outside `{parsed.project_root}`, "
"so nothing could be attributed to this project.** That is almost certainly a wrong "
"`--project-root`, not a contaminated test run — a real fixture copy leaves the "
"genuine rows behind. Check the working directory the reporter runs in before reading "
"anything below.\n\n"
)
elif foreign_files:
shown = "\n".join(f"> - `{path}`" for path in foreign_files[:5])
more = f"\n> - …and {len(foreign_files) - 5} more" if len(foreign_files) > 5 else ""
project_cell = f"{project_only:.2f}%" if project_only is not None else "not computable"
summary += (
f"> **⚠️ {len(foreign_files)} file(s) were measured OUTSIDE the project root, so "
f"`Current` above is contaminated.** A test is copying the source tree somewhere "
f"else and exercising the copy, so those modules are counted twice — once as "
f"themselves and once as a barely-executed duplicate. Project-only coverage is "
f"**{project_cell}**. They are excluded from the tables below because their paths do "
f"not exist in the repository. Fix by adding the copy location to "
f"`[tool.coverage.run] omit`.\n{shown}{more}\n\n"
)

# Add hotspot tables if we have coverage data
if hotspots:
summary += _format_hotspot_table(hotspots, "Top Coverage Hotspots (lowest coverage)")
Expand All @@ -185,17 +348,37 @@ def main(args: list[str] | None = None) -> int:
if parsed.github_output:
parsed.github_output.parent.mkdir(parents=True, exist_ok=True)
with parsed.github_output.open("w", encoding="utf-8") as f:
# Plain locals rather than f-strings nested inside f-strings: the nested form is
# valid only from 3.12 (PEP 701) and this file ships to 3.12 AND 3.13 runners.
baseline_out = "" if baseline_coverage is None else f"{baseline_coverage:.2f}"
delta_out = "" if delta is None else f"{delta:.2f}"
f.write(f"coverage={current_coverage:.2f}\n")
f.write(f"baseline={baseline_coverage:.2f}\n")
f.write(f"delta={delta:.2f}\n")
# Empty, not 0.00, when there is no baseline: a reader testing `-n "$baseline"` then
# sees the difference, where 0.00 is indistinguishable from a real measurement.
f.write(f"baseline={baseline_out}\n")
f.write(f"baseline_status={baseline_status}\n")
f.write(f"delta={delta_out}\n")
f.write(f"passes_minimum={'true' if passes_minimum else 'false'}\n")
f.write(f"hotspot_count={len(hotspots)}\n")
f.write(f"low_coverage_count={len(low_coverage)}\n")
f.write(f"foreign_file_count={len(foreign_files)}\n")

print(
f"Coverage: {current_coverage:.2f}% "
f"(baseline: {baseline_coverage:.2f}%, delta: {delta:+.2f}%)"
)
if baseline_coverage is None:
print(
f"Coverage: {current_coverage:.2f}% "
f"(no baseline: {baseline_status} — delta not computed)"
)
else:
print(
f"Coverage: {current_coverage:.2f}% "
f"(baseline: {baseline_coverage:.2f}%, delta: {delta:+.2f}%)"
)
if foreign_files:
project_only_out = "n/a" if project_only is None else f"{project_only:.2f}%"
print(
f"WARNING: {len(foreign_files)} file(s) measured outside the project root; "
f"`Current` is contaminated (project-only: {project_only_out})"
)
if hotspots:
print(f"Hotspots: {len(hotspots)} files with lowest coverage")

Expand Down
Loading