From 36f76a98a2a5f8cf4f2c7018463235834c07c04b Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 17 Apr 2026 04:51:29 -0300 Subject: [PATCH] quality: add Ariadne LOC ratchet and platform metrics --- Jenkinsfile | 1 + ci/loc_hygiene_waivers.tsv | 13 +++++ scripts/check_file_sizes.py | 86 +++++++++++++++++++++++++++++++++ scripts/publish_test_metrics.py | 27 +++++++++++ 4 files changed, 127 insertions(+) create mode 100644 ci/loc_hygiene_waivers.tsv create mode 100644 scripts/check_file_sizes.py diff --git a/Jenkinsfile b/Jenkinsfile index 48d0787..0718ebb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -103,6 +103,7 @@ set -euo pipefail mkdir -p build python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt python -m ruff check ariadne scripts --select PLR +python scripts/check_file_sizes.py --roots ariadne scripts testing --max-lines 500 --waivers ci/loc_hygiene_waivers.tsv python -m slipcover \ --json \ --out "${COVERAGE_JSON}" \ diff --git a/ci/loc_hygiene_waivers.tsv b/ci/loc_hygiene_waivers.tsv new file mode 100644 index 0000000..5849c71 --- /dev/null +++ b/ci/loc_hygiene_waivers.tsv @@ -0,0 +1,13 @@ +# relative_pathwhy_it_is_allowlisted_for_now +ariadne/app.py core application router/orchestration; scheduled split in ratchet/cluster-state-split +ariadne/manager/provisioning.py provisioning workflow hub pending modular extraction +ariadne/services/firefly.py firefly service handlers pending split by endpoint domain +ariadne/services/jenkins_workspace_cleanup.py workspace cleanup service pending refactor into strategy modules +ariadne/services/nextcloud.py nextcloud integration surface pending staged decomposition +ariadne/services/vault.py vault integration flow pending dedicated auth/storage modules +ariadne/services/wger.py wger integration flow pending endpoint-layer split +ariadne/settings.py configuration map pending domain-specific config modules +testing/test_app.py broad integration assertions pending test-suite decomposition +testing/test_keycloak_admin.py keycloak contract tests pending helper extraction +testing/test_provisioning.py provisioning matrix tests pending split by workflow phase +testing/test_services.py service integration matrix pending split by service domain diff --git a/scripts/check_file_sizes.py b/scripts/check_file_sizes.py new file mode 100644 index 0000000..c3d0bcf --- /dev/null +++ b/scripts/check_file_sizes.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Enforce a ratcheted source file line-budget contract. + +The check fails when: +- a file exceeds the configured line budget and is not allowlisted; or +- an allowlist entry is stale (file removed or now within budget). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def _iter_source_files(roots: list[str], exts: set[str]) -> list[Path]: + files: list[Path] = [] + for root_text in roots: + root = Path(root_text) + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file(): + continue + if path.suffix not in exts: + continue + if "__pycache__" in path.parts or ".venv" in path.parts: + continue + files.append(path.resolve()) + return sorted(files) + + +def _load_waivers(path: Path) -> dict[str, str]: + waivers: dict[str, str] = {} + if not path.exists(): + return waivers + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split("\t") + rel_path = parts[0].strip() + reason = parts[1].strip() if len(parts) > 1 else "" + if rel_path: + waivers[rel_path] = reason + return waivers + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--roots", nargs="+", default=["ariadne", "scripts", "testing"]) + parser.add_argument("--max-lines", type=int, default=500) + parser.add_argument("--waivers", default="ci/loc_hygiene_waivers.tsv") + args = parser.parse_args() + + repo_root = Path.cwd().resolve() + waivers = _load_waivers(repo_root / args.waivers) + source_files = _iter_source_files(args.roots, {".py", ".sh"}) + + violations: dict[str, int] = {} + for path in source_files: + rel = path.relative_to(repo_root).as_posix() + lines = len(path.read_text(encoding="utf-8", errors="ignore").splitlines()) + if lines > args.max_lines: + violations[rel] = lines + + unexpected = sorted(rel for rel in violations if rel not in waivers) + stale = sorted(rel for rel in waivers if rel not in violations) + if not unexpected and not stale: + print( + f"[hygiene] source line budget check passed (limit={args.max_lines}, over_limit={len(violations)}, waivers={len(waivers)})" + ) + return 0 + + if unexpected: + print("[hygiene] files over budget missing from waiver list:") + for rel in unexpected: + print(f"- {rel}: {violations[rel]} lines (limit {args.max_lines})") + if stale: + print("[hygiene] stale waiver entries (remove from waiver list):") + for rel in stale: + print(f"- {rel}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_test_metrics.py b/scripts/publish_test_metrics.py index e8e42a3..18eaaa7 100644 --- a/scripts/publish_test_metrics.py +++ b/scripts/publish_test_metrics.py @@ -5,12 +5,15 @@ from __future__ import annotations import json import os +from pathlib import Path import sys import urllib.request import xml.etree.ElementTree as ET HTTP_BAD_REQUEST = 400 MIN_METRIC_FIELDS = 2 +SOURCE_SCAN_ROOTS = ("ariadne", "scripts", "testing") +SOURCE_EXTENSIONS = {".py", ".sh"} def _escape_label(value: str) -> str: @@ -100,7 +103,25 @@ def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, return 0.0 +def _count_source_files_over_limit(repo_root: Path, max_lines: int = 500) -> int: + count = 0 + for rel_root in SOURCE_SCAN_ROOTS: + base = repo_root / rel_root + if not base.exists(): + continue + for path in base.rglob("*"): + if not path.is_file(): + continue + if path.suffix not in SOURCE_EXTENSIONS: + continue + lines = len(path.read_text(encoding="utf-8", errors="ignore").splitlines()) + if lines > max_lines: + count += 1 + return count + + def main() -> int: + repo_root = Path(__file__).resolve().parents[1] coverage_path = os.getenv("COVERAGE_JSON", "build/coverage.json") junit_path = os.getenv("JUNIT_XML", "build/junit.xml") pushgateway_url = os.getenv( @@ -117,6 +138,7 @@ def main() -> int: raise RuntimeError(f"missing junit file {junit_path}") coverage = _load_coverage(coverage_path) + source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500) totals = _load_junit(junit_path) passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) @@ -157,6 +179,10 @@ def main() -> int: f'ariadne_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', "# TYPE ariadne_quality_gate_coverage_percent gauge", f'ariadne_quality_gate_coverage_percent{{suite="{suite}"}} {coverage:.3f}', + "# TYPE platform_quality_gate_workspace_line_coverage_percent gauge", + f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {coverage:.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}', "# TYPE ariadne_quality_gate_build_info gauge", f"ariadne_quality_gate_build_info{_label_str(labels)} 1", ] @@ -174,6 +200,7 @@ def main() -> int: "tests_errors": totals["errors"], "tests_skipped": totals["skipped"], "coverage_percent": round(coverage, 3), + "source_lines_over_500": source_lines_over_500, "ok_counter": ok_count, "failed_counter": failed_count, },