| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- #!/usr/bin/env python3
- """Line-coverage floor checker for d-OAuth-Server.
- Reads an lcov tracefile produced by `cargo llvm-cov ... --lcov`, normalizes
- every `SF:` path to a workspace-relative path, applies the single canonical
- exclusion regex shared with CI, and enforces the floors in
- `scripts/coverage_floors.json` (optionally overridden on the command line).
- Usage:
- python3 scripts/check_coverage.py lcov.info
- python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
- python3 scripts/check_coverage.py lcov.info --global-floor 81.0 \\
- --security-floor 88.0 --module-floor crypto=90.0
- The `security` aggregate is exactly the ten core modules: crypto, jose,
- protocol, db, store, state, middleware, keys, error, routes. `ui`, `config`,
- and `tls` carry per-module floors only and are excluded from the aggregate.
- Exit code 0 only when the global line percentage, the security aggregate, and
- every listed module meet their floors. A listed module that is missing from the
- tracefile, or that contains zero executable lines, fails closed.
- Known limitation: inline `#[cfg(test)]` modules inside *production* files cannot
- be excluded by filename, so an `--all-targets` run records their lines and
- therefore modestly inflates production coverage. The per-module table makes the
- effect visible; it does not hide it.
- """
- from __future__ import annotations
- import argparse
- import json
- import re
- import sys
- from pathlib import Path
- REPO_ROOT = Path(__file__).resolve().parent.parent
- DEFAULT_FLOORS = REPO_ROOT / "scripts" / "coverage_floors.json"
- # The ONE canonical exclusion regex. CI passes this same pattern verbatim to
- # `cargo llvm-cov --ignore-filename-regex`; keep the two byte-identical.
- EXCLUDE_PATTERN = r"(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$"
- EXCLUDE_RE = re.compile(EXCLUDE_PATTERN)
- # Module -> workspace-relative path (directory prefix or exact file). Order is
- # the reporting order from the plan and is stable for tests and docs.
- MODULE_SPECS = {
- "crypto": "src/crypto/",
- "jose": "src/jose/",
- "protocol": "src/protocol/",
- "db": "src/db/",
- "store": "src/store/",
- "state": "src/state/",
- "middleware": "src/middleware/",
- "ui": "src/ui/",
- "config": "src/config/",
- "keys": "src/keys.rs",
- "error": "src/error.rs",
- "routes": "src/routes.rs",
- "tls": "src/tls.rs",
- }
- MODULE_ORDER = (
- "crypto",
- "jose",
- "protocol",
- "db",
- "store",
- "state",
- "middleware",
- "ui",
- "config",
- "keys",
- "error",
- "routes",
- "tls",
- )
- # The ten-module aggregate. ui/config/tls are deliberately absent: they are
- # operator-facing/peripheral and carry per-module floors only.
- CORE_MODULES = (
- "crypto",
- "jose",
- "protocol",
- "db",
- "store",
- "state",
- "middleware",
- "keys",
- "error",
- "routes",
- )
- def normalize_sf_path(raw: str, lcov_path: Path) -> str:
- """Return a workspace-relative path for one lcov `SF:` value.
- Handles absolute paths, a leading `./`, paths relative to the workspace
- root, and paths relative to the lcov file's own directory (the common
- "coverage file sits next to the sources" case).
- """
- raw = raw.strip()
- if not raw:
- return raw
- raw_path = Path(raw)
- if raw_path.is_absolute():
- candidates = [raw_path]
- else:
- candidates = [
- REPO_ROOT / raw_path,
- lcov_path.parent / raw_path,
- Path.cwd() / raw_path,
- ]
- repo_root = REPO_ROOT.resolve()
- for candidate in candidates:
- try:
- resolved = candidate.resolve()
- except OSError:
- continue
- try:
- return resolved.relative_to(repo_root).as_posix()
- except ValueError:
- continue
- # Lexical fallback for a path that cannot be placed under the workspace.
- lexical = raw_path.as_posix()
- while lexical.startswith("./"):
- lexical = lexical[2:]
- return lexical
- def parse_lcov(text: str) -> list[dict]:
- """Parse lcov `SF:`/`DA:`/`end_of_record` records into source records."""
- records: list[dict] = []
- current: dict | None = None
- for raw_line in text.splitlines():
- line = raw_line.strip()
- if not line:
- continue
- if line.startswith("SF:"):
- if current is not None:
- records.append(current)
- current = {"sf": line[3:], "lines": {}}
- elif line.startswith("DA:"):
- if current is None:
- raise ValueError(f"DA record without a preceding SF record: {line}")
- body = line[3:].split(",")
- if len(body) < 2:
- raise ValueError(f"malformed DA record: {line}")
- try:
- line_no = int(body[0])
- hits = int(body[1])
- except ValueError as exc:
- raise ValueError(f"malformed DA record: {line}") from exc
- previous = current["lines"].get(line_no)
- current["lines"][line_no] = hits if previous is None else max(previous, hits)
- elif line == "end_of_record":
- if current is not None:
- records.append(current)
- current = None
- if current is not None:
- records.append(current)
- return records
- def aggregate(records: list[dict], lcov_path: Path) -> dict[str, dict[int, int]]:
- """Merge records into `path -> {line: max hits}`, dropping excluded files."""
- totals: dict[str, dict[int, int]] = {}
- for record in records:
- path = normalize_sf_path(record["sf"], lcov_path)
- if not path or EXCLUDE_RE.search(path):
- continue
- lines = totals.setdefault(path, {})
- for line_no, hits in record["lines"].items():
- previous = lines.get(line_no)
- lines[line_no] = hits if previous is None else max(previous, hits)
- return totals
- def line_counts(lines: dict[int, int]) -> tuple[int, int]:
- """Return (executable lines, covered lines) for one file."""
- return len(lines), sum(1 for hits in lines.values() if hits > 0)
- def module_of(path: str) -> str | None:
- """Return the listed module a workspace-relative path belongs to, if any."""
- for name in MODULE_ORDER:
- spec = MODULE_SPECS[name]
- if spec.endswith("/"):
- if path.startswith(spec):
- return name
- elif path == spec:
- return name
- return None
- def module_counts(totals: dict[str, dict[int, int]]) -> dict[str, dict[str, int]]:
- """Aggregate (`files`, `found`, `hit`) per listed module."""
- counts = {
- name: {"files": 0, "found": 0, "hit": 0}
- for name in MODULE_ORDER
- }
- for path, lines in totals.items():
- name = module_of(path)
- if name is None:
- continue
- found, hit = line_counts(lines)
- counts[name]["files"] += 1
- counts[name]["found"] += found
- counts[name]["hit"] += hit
- return counts
- def percent(hit: int, found: int) -> float:
- if found == 0:
- return 0.0
- return 100.0 * hit / found
- def _number(value: object, label: str) -> float:
- if isinstance(value, bool) or not isinstance(value, (int, float)):
- raise ValueError(f"{label} must be a number")
- return float(value)
- def load_floors(path: Path, explicit: bool) -> dict:
- """Load `{"global", "security", "modules"}`; a missing default means zero."""
- floors = {"global": 0.0, "security": 0.0, "modules": {}}
- if not path.exists():
- if explicit:
- raise ValueError(f"--floors file not found: {path}")
- return floors
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise ValueError(f"cannot read floors file {path}: {exc}") from exc
- if not isinstance(data, dict):
- raise ValueError(f"floors file {path} must contain a JSON object")
- if "global" in data:
- floors["global"] = _number(data["global"], "global floor")
- if "security" in data:
- floors["security"] = _number(data["security"], "security floor")
- modules = data.get("modules", {})
- if not isinstance(modules, dict):
- raise ValueError(f"floors file {path}: 'modules' must be an object")
- for name, value in modules.items():
- if name not in MODULE_SPECS:
- raise ValueError(f"floors file {path}: unknown module {name!r}")
- floors["modules"][name] = _number(value, f"module floor {name!r}")
- return floors
- def parse_module_overrides(values: list[str]) -> dict[str, float]:
- overrides: dict[str, float] = {}
- for item in values:
- name, sep, raw = item.partition("=")
- if not sep or not name:
- raise ValueError(f"--module-floor expects NAME=F, got {item!r}")
- if name not in MODULE_SPECS:
- raise ValueError(f"--module-floor: unknown module {name!r}")
- try:
- overrides[name] = float(raw)
- except ValueError as exc:
- raise ValueError(f"--module-floor {name}: not a number: {raw!r}") from exc
- return overrides
- def build_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(
- description="Enforce line-coverage floors over an lcov tracefile."
- )
- parser.add_argument("lcov", help="path to an lcov .info tracefile")
- parser.add_argument(
- "--floors",
- default=None,
- help=f"floors JSON (default: {DEFAULT_FLOORS})",
- )
- parser.add_argument(
- "--global-floor",
- type=float,
- default=None,
- help="override the global line-coverage floor (percent)",
- )
- parser.add_argument(
- "--security-floor",
- type=float,
- default=None,
- help="override the security aggregate floor (percent)",
- )
- parser.add_argument(
- "--module-floor",
- action="append",
- default=[],
- metavar="NAME=F",
- help="override one module floor (repeatable)",
- )
- return parser
- def main(argv: list[str] | None = None) -> int:
- args = build_parser().parse_args(argv)
- lcov_path = Path(args.lcov)
- floors_path = Path(args.floors) if args.floors else DEFAULT_FLOORS
- try:
- floors = load_floors(floors_path, explicit=args.floors is not None)
- if args.global_floor is not None:
- floors["global"] = float(args.global_floor)
- if args.security_floor is not None:
- floors["security"] = float(args.security_floor)
- floors["modules"].update(parse_module_overrides(args.module_floor))
- except ValueError as exc:
- print(f"coverage check: {exc}")
- return 1
- if not lcov_path.is_file():
- print(f"coverage check: lcov file not found: {lcov_path}")
- return 1
- try:
- records = parse_lcov(lcov_path.read_text(encoding="utf-8"))
- except (OSError, ValueError) as exc:
- print(f"coverage check: cannot parse {lcov_path}: {exc}")
- return 1
- totals = aggregate(records, lcov_path)
- counts = module_counts(totals)
- failures: list[str] = []
- global_found = sum(len(lines) for lines in totals.values())
- global_hit = sum(line_counts(lines)[1] for lines in totals.values())
- global_pct = percent(global_hit, global_found)
- global_floor = floors["global"]
- if global_pct < global_floor:
- failures.append("global below floor")
- global_status = "FAIL: global below floor"
- else:
- global_status = "OK"
- security_found = sum(counts[name]["found"] for name in CORE_MODULES)
- security_hit = sum(counts[name]["hit"] for name in CORE_MODULES)
- security_pct = percent(security_hit, security_found)
- security_floor = floors["security"]
- if security_found == 0:
- failures.append("security aggregate has zero executable lines (fail closed)")
- security_status = "FAIL: zero executable lines (fail closed)"
- elif security_pct < security_floor:
- failures.append("security below floor")
- security_status = "FAIL: security below floor"
- else:
- security_status = "OK"
- print(f"coverage check: {lcov_path}")
- print(
- f"global: {global_pct:.1f}% ({global_hit}/{global_found} lines), "
- f"floor {global_floor:.1f}% -> {global_status}"
- )
- print(
- f"security: {security_pct:.1f}% ({security_hit}/{security_found} lines), "
- f"floor {security_floor:.1f}% -> {security_status}"
- )
- print("per-module line coverage:")
- for name in MODULE_ORDER:
- stats = counts[name]
- pct = percent(stats["hit"], stats["found"])
- floor = floors["modules"].get(name, 0.0)
- if stats["files"] == 0:
- status = "FAIL: missing from lcov (fail closed)"
- failures.append(f"module '{name}' missing from lcov (fail closed)")
- elif stats["found"] == 0:
- status = "FAIL: zero executable lines (fail closed)"
- failures.append(f"module '{name}' has zero executable lines (fail closed)")
- elif pct < floor:
- status = f"FAIL: below floor ({floor:.1f}%)"
- failures.append(f"module '{name}' below floor: {pct:.1f}% < {floor:.1f}%")
- else:
- status = "OK"
- scope = "" if name in CORE_MODULES else " (per-module only)"
- print(
- f" {name:<10} {pct:>6.1f}% ({stats['hit']}/{stats['found']} lines) "
- f"floor {floor:>5.1f}% {status}{scope}"
- )
- if failures:
- print("failed checks:")
- for failure in failures:
- print(f" - {failure}")
- print(f"RESULT: FAIL ({len(failures)} check(s))")
- return 1
- print("RESULT: PASS")
- return 0
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|