diff --git a/testing/ci/quality_gate.py b/testing/ci/quality_gate.py index 3640186..e369dd3 100644 --- a/testing/ci/quality_gate.py +++ b/testing/ci/quality_gate.py @@ -221,6 +221,38 @@ def check_coverage( return issues +def _coverage_values_for_paths( + paths: Iterable[Path], + *, + backend_report: Path, + frontend_report: Path, +) -> list[float]: + """Return per-file line coverage values for tracked backend/frontend files.""" + + backend_cov = _load_backend_coverage(backend_report) if backend_report.exists() else {} + frontend_cov = _load_frontend_coverage(frontend_report) if frontend_report.exists() else {} + values: list[float] = [] + + for path in paths: + if not path.exists(): + continue + rel = path.relative_to(ROOT).as_posix() if path.is_absolute() else _normalize_key(str(path)) + if rel.startswith("backend/"): + metrics = _coverage_lookup(backend_cov, rel) + if metrics is None: + continue + values.append(float(metrics.get("lines", 0.0))) + continue + if rel.startswith("frontend/"): + lookup = rel.split("frontend/", 1)[1] + metrics = _coverage_lookup(frontend_cov, lookup) + if metrics is None: + continue + pct = metrics.get("lines", {}).get("pct", 0.0) + values.append(float(pct)) + return values + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run the repo's unified quality gate") parser.add_argument("--contract", default=str(DEFAULT_CONTRACT), help="Path to the JSON gate contract") @@ -242,12 +274,21 @@ def run_gate(contract_path: Path, *, backend_coverage: Path, frontend_coverage: issues.extend(check_file_sizes(managed_files, max_lines=max_lines)) issues.extend(check_docstrings(docstring_files)) issues.extend(check_coverage(coverage_files, backend_report=backend_coverage, frontend_report=frontend_coverage, threshold=threshold)) + coverage_values = _coverage_values_for_paths( + coverage_files, + backend_report=backend_coverage, + frontend_report=frontend_coverage, + ) + workspace_line_coverage_percent = round(sum(coverage_values) / len(coverage_values), 3) if coverage_values else 0.0 + source_lines_over_500 = sum(1 for issue in issues if issue.check == "loc") report = { "managed_files": [str(path.relative_to(ROOT)) for path in managed_files], "docstring_files": [str(path.relative_to(ROOT)) for path in docstring_files], "coverage_files": [str(path.relative_to(ROOT)) for path in coverage_files], "max_lines": max_lines, "coverage_threshold_pct": threshold, + "workspace_line_coverage_percent": workspace_line_coverage_percent, + "source_lines_over_500": source_lines_over_500, "issue_count": len(issues), "issues": [issue.__dict__ for issue in issues], } diff --git a/testing/tests/test_quality_gate.py b/testing/tests/test_quality_gate.py index 1d45ab9..bf461b7 100644 --- a/testing/tests/test_quality_gate.py +++ b/testing/tests/test_quality_gate.py @@ -3,11 +3,13 @@ from __future__ import annotations import json from pathlib import Path +import testing.ci.quality_gate as quality_gate_module from testing.ci.quality_gate import ( _js_node_issues, _python_node_issues, check_coverage, check_file_sizes, + run_gate, ) @@ -79,3 +81,75 @@ def test_check_coverage_reads_backend_and_frontend_reports(tmp_path: Path) -> No ) assert issues == [] + + +def test_run_gate_reports_workspace_coverage_and_loc_totals(tmp_path: Path) -> None: + root_before = quality_gate_module.ROOT + quality_gate_module.ROOT = tmp_path + try: + backend_dir = tmp_path / "backend" / "atlas_portal" + frontend_dir = tmp_path / "frontend" / "src" + backend_dir.mkdir(parents=True) + frontend_dir.mkdir(parents=True) + + (backend_dir / "app_factory.py").write_text( + '"""Factory docs."""\n\n' + "def create_app():\n" + ' """Create the app."""\n' + " return object()\n" + ) + (backend_dir / "too_long.py").write_text("\n".join(f"line_{idx}" for idx in range(501))) + (frontend_dir / "auth.js").write_text( + "/**\n" + " * WHY: auth wrapper exists.\n" + " * @param {string} token - jwt.\n" + " * @returns {string} token.\n" + " */\n" + "function auth(token) {\n" + " return token;\n" + "}\n" + ) + + backend_report = tmp_path / "backend.xml" + backend_report.write_text( + '' + '' + '' + ) + frontend_report = tmp_path / "frontend.json" + frontend_report.write_text( + json.dumps( + { + "src/auth.js": { + "lines": {"pct": 100}, + "statements": {"pct": 100}, + "branches": {"pct": 100}, + "functions": {"pct": 100}, + } + } + ) + ) + + contract = { + "managed_files": [ + "backend/atlas_portal/app_factory.py", + "backend/atlas_portal/too_long.py", + ], + "docstring_files": ["backend/atlas_portal/app_factory.py"], + "coverage_files": [ + "backend/atlas_portal/app_factory.py", + "frontend/src/auth.js", + ], + "max_lines": 500, + "coverage_threshold_pct": 95, + } + contract_path = tmp_path / "contract.json" + contract_path.write_text(json.dumps(contract)) + + issues, report = run_gate(contract_path, backend_coverage=backend_report, frontend_coverage=frontend_report) + + assert any(issue.check == "loc" for issue in issues) + assert report["workspace_line_coverage_percent"] == 100.0 + assert report["source_lines_over_500"] == 1 + finally: + quality_gate_module.ROOT = root_before