diff --git a/ci/scripts/publish_test_metrics.py b/ci/scripts/publish_test_metrics.py index c31f4add..95a74b8e 100644 --- a/ci/scripts/publish_test_metrics.py +++ b/ci/scripts/publish_test_metrics.py @@ -88,6 +88,22 @@ def _load_summary(path: str) -> dict: return {} +def _summary_float(summary: dict, key: str) -> float: + value = summary.get(key) + if isinstance(value, (int, float)): + return float(value) + return 0.0 + + +def _summary_int(summary: dict, key: str) -> int: + value = summary.get(key) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return 0 + + def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float: text = _read_text(f"{pushgateway_url.rstrip('/')}/metrics") for line in text.splitlines(): @@ -114,6 +130,8 @@ def _build_payload( branch: str, build_number: str, summary: dict | None = None, + workspace_line_coverage_percent: float = 0.0, + source_lines_over_500: int = 0, ) -> str: passed = max(tests["tests"] - tests["failures"] - tests["errors"] - tests["skipped"], 0) build_labels = _label_str( @@ -137,6 +155,10 @@ def _build_payload( f'titan_iac_quality_gate_run_status{{suite="{suite}",status="failed"}} {1 if status == "failed" else 0}', "# TYPE titan_iac_quality_gate_build_info gauge", f"titan_iac_quality_gate_build_info{build_labels} 1", + "# TYPE platform_quality_gate_workspace_line_coverage_percent gauge", + f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {workspace_line_coverage_percent:.3f}', + "# TYPE platform_quality_gate_source_lines_over_500_total gauge", + f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {source_lines_over_500}', ] results = summary.get("results", []) if isinstance(summary, dict) else [] if results: @@ -166,6 +188,8 @@ def main() -> int: exit_code = _read_exit_code(exit_code_path) status = "ok" if exit_code == 0 else "failed" summary = _load_summary(summary_path) + workspace_line_coverage_percent = _summary_float(summary, "workspace_line_coverage_percent") + source_lines_over_500 = _summary_int(summary, "source_lines_over_500") ok_count = int( _fetch_existing_counter( @@ -195,6 +219,8 @@ def main() -> int: branch=branch, build_number=build_number, summary=summary, + workspace_line_coverage_percent=workspace_line_coverage_percent, + source_lines_over_500=source_lines_over_500, ) push_url = f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}" _post_text(push_url, payload) @@ -209,6 +235,8 @@ def main() -> int: "ok_count": ok_count, "failed_count": failed_count, "checks_recorded": len(summary.get("results", [])) if isinstance(summary, dict) else 0, + "workspace_line_coverage_percent": workspace_line_coverage_percent, + "source_lines_over_500": source_lines_over_500, } print(json.dumps(summary, sort_keys=True)) return 0 diff --git a/testing/quality_coverage.py b/testing/quality_coverage.py index a778bf56..f4910258 100644 --- a/testing/quality_coverage.py +++ b/testing/quality_coverage.py @@ -56,3 +56,25 @@ def run_check(contract: dict[str, Any], root: Path, xml_path: Path) -> list[str] ) return issues + + +def compute_workspace_line_coverage( + contract: dict[str, Any], + root: Path, + xml_path: Path, +) -> float: + """Compute mean line coverage across tracked files present in the XML.""" + + if not xml_path.exists(): + return 0.0 + + percentages = _load_percentages(xml_path, root) + samples: list[float] = [] + for relative_path in contract.get("coverage", {}).get("tracked_files", []): + normalized = relative_path.replace("\\", "/") + percent = percentages.get(normalized) + if percent is not None: + samples.append(percent) + if not samples: + return 0.0 + return round(sum(samples) / len(samples), 3) diff --git a/testing/quality_gate.py b/testing/quality_gate.py index cff6a659..3db27119 100644 --- a/testing/quality_gate.py +++ b/testing/quality_gate.py @@ -11,9 +11,15 @@ from pathlib import Path from typing import Any from testing.quality_contract import load_contract -from testing.quality_coverage import run_check as run_coverage_check +from testing.quality_coverage import ( + compute_workspace_line_coverage, + run_check as run_coverage_check, +) from testing.quality_docs import run_check as run_docs_check -from testing.quality_hygiene import run_check as run_hygiene_check +from testing.quality_hygiene import ( + count_files_over_line_limit, + run_check as run_hygiene_check, +) RUFF_SELECT = ["F", "B", "SIM", "C4", "UP"] @@ -146,11 +152,23 @@ def run_profile( results.append(_run_pytest_suite(root, check_name, suite)) status = "ok" if all(item["status"] == "ok" for item in results) else "failed" + workspace_line_coverage_percent = 0.0 + if "coverage" in profiles[profile_name]: + unit_suite = contract.get("pytest_suites", {}).get("unit", {}) + coverage_xml_rel = unit_suite.get("coverage_xml") + if coverage_xml_rel: + workspace_line_coverage_percent = compute_workspace_line_coverage( + contract, + root, + root / coverage_xml_rel, + ) return { "profile": profile_name, "status": status, "results": results, "manual_scripts": contract.get("manual_scripts", []), + "workspace_line_coverage_percent": workspace_line_coverage_percent, + "source_lines_over_500": count_files_over_line_limit(contract, root), } diff --git a/testing/quality_hygiene.py b/testing/quality_hygiene.py index de93fc6d..e4a8605d 100644 --- a/testing/quality_hygiene.py +++ b/testing/quality_hygiene.py @@ -35,3 +35,16 @@ def run_check(contract: dict[str, Any], root: Path) -> list[str]: ) return issues + + +def count_files_over_line_limit(contract: dict[str, Any], root: Path) -> int: + """Return the number of managed files that exceed the configured LOC cap.""" + + config = contract.get("hygiene", {}) + max_lines = int(config.get("max_lines", 500)) + count = 0 + for path in _expand_globs(root, config.get("line_limit_globs", [])): + line_count = sum(1 for _ in path.open("r", encoding="utf-8")) + if line_count > max_lines: + count += 1 + return count diff --git a/testing/tests/test_publish_test_metrics.py b/testing/tests/test_publish_test_metrics.py index e6a993da..b590860a 100644 --- a/testing/tests/test_publish_test_metrics.py +++ b/testing/tests/test_publish_test_metrics.py @@ -179,11 +179,15 @@ def test_build_payload_includes_summary_metrics(): {"name": "unit", "status": "failed"}, ] }, + workspace_line_coverage_percent=97.125, + source_lines_over_500=3, ) assert 'platform_quality_gate_runs_total{suite="titan-iac",status="ok"} 7' in payload assert 'titan_iac_quality_gate_checks_total{suite="titan-iac",check="docs",result="ok"} 1' in payload assert 'titan_iac_quality_gate_checks_total{suite="titan-iac",check="unit",result="failed"} 1' in payload + assert 'platform_quality_gate_workspace_line_coverage_percent{suite="titan-iac"} 97.125' in payload + assert 'platform_quality_gate_source_lines_over_500_total{suite="titan-iac"} 3' in payload def test_build_payload_skips_incomplete_results(): @@ -215,7 +219,13 @@ def test_main_uses_quality_gate_summary_and_junit_glob(tmp_path: Path, monkeypat ) (build_dir / "quality-gate.rc").write_text("1\n", encoding="utf-8") (build_dir / "quality-gate-summary.json").write_text( - json.dumps({"results": [{"name": "docs", "status": "ok"}, {"name": "glue", "status": "failed"}]}), + json.dumps( + { + "results": [{"name": "docs", "status": "ok"}, {"name": "glue", "status": "failed"}], + "workspace_line_coverage_percent": 96.4321, + "source_lines_over_500": 2, + } + ), encoding="utf-8", ) @@ -239,6 +249,8 @@ def test_main_uses_quality_gate_summary_and_junit_glob(tmp_path: Path, monkeypat assert posted["url"].endswith("/metrics/job/platform-quality-ci/suite/titan-iac") assert 'titan_iac_quality_gate_tests_total{suite="titan-iac",result="failed"} 1' in posted["payload"] assert 'titan_iac_quality_gate_checks_total{suite="titan-iac",check="glue",result="failed"} 1' in posted["payload"] + assert 'platform_quality_gate_workspace_line_coverage_percent{suite="titan-iac"} 96.432' in posted["payload"] + assert 'platform_quality_gate_source_lines_over_500_total{suite="titan-iac"} 2' in posted["payload"] def test_main_marks_successful_run(tmp_path: Path, monkeypatch, capsys): @@ -262,3 +274,5 @@ def test_main_marks_successful_run(tmp_path: Path, monkeypatch, capsys): assert rc == 0 assert summary["status"] == "ok" assert summary["checks_recorded"] == 0 + assert summary["workspace_line_coverage_percent"] == 0.0 + assert summary["source_lines_over_500"] == 0 diff --git a/testing/tests/test_quality_gate.py b/testing/tests/test_quality_gate.py index a1a1b918..8642da0d 100644 --- a/testing/tests/test_quality_gate.py +++ b/testing/tests/test_quality_gate.py @@ -52,6 +52,8 @@ def test_run_profile_aggregates_internal_and_pytest_results(tmp_path: Path, monk "unit", "coverage", ] + assert summary["workspace_line_coverage_percent"] == 0.0 + assert summary["source_lines_over_500"] == 0 assert calls[0][0][:3] == [quality_gate.sys.executable, "-m", "ruff"] assert any(result.get("junit") == "build/junit-unit.xml" for result in summary["results"])