check_coverage.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. #!/usr/bin/env python3
  2. """Line-coverage floor checker for d-OAuth-Server.
  3. Reads an lcov tracefile produced by `cargo llvm-cov ... --lcov`, normalizes
  4. every `SF:` path to a workspace-relative path, applies the single canonical
  5. exclusion regex shared with CI, and enforces the floors in
  6. `scripts/coverage_floors.json` (optionally overridden on the command line).
  7. Usage:
  8. python3 scripts/check_coverage.py lcov.info
  9. python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
  10. python3 scripts/check_coverage.py lcov.info --global-floor 81.0 \\
  11. --security-floor 88.0 --module-floor crypto=90.0
  12. The `security` aggregate is exactly the ten core modules: crypto, jose,
  13. protocol, db, store, state, middleware, keys, error, routes. `ui`, `config`,
  14. and `tls` carry per-module floors only and are excluded from the aggregate.
  15. Exit code 0 only when the global line percentage, the security aggregate, and
  16. every listed module meet their floors. A listed module that is missing from the
  17. tracefile, or that contains zero executable lines, fails closed.
  18. Known limitation: inline `#[cfg(test)]` modules inside *production* files cannot
  19. be excluded by filename, so an `--all-targets` run records their lines and
  20. therefore modestly inflates production coverage. The per-module table makes the
  21. effect visible; it does not hide it.
  22. """
  23. from __future__ import annotations
  24. import argparse
  25. import json
  26. import re
  27. import sys
  28. from pathlib import Path
  29. REPO_ROOT = Path(__file__).resolve().parent.parent
  30. DEFAULT_FLOORS = REPO_ROOT / "scripts" / "coverage_floors.json"
  31. # The ONE canonical exclusion regex. CI passes this same pattern verbatim to
  32. # `cargo llvm-cov --ignore-filename-regex`; keep the two byte-identical.
  33. EXCLUDE_PATTERN = r"(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$"
  34. EXCLUDE_RE = re.compile(EXCLUDE_PATTERN)
  35. # Module -> workspace-relative path (directory prefix or exact file). Order is
  36. # the reporting order from the plan and is stable for tests and docs.
  37. MODULE_SPECS = {
  38. "crypto": "src/crypto/",
  39. "jose": "src/jose/",
  40. "protocol": "src/protocol/",
  41. "db": "src/db/",
  42. "store": "src/store/",
  43. "state": "src/state/",
  44. "middleware": "src/middleware/",
  45. "ui": "src/ui/",
  46. "config": "src/config/",
  47. "keys": "src/keys.rs",
  48. "error": "src/error.rs",
  49. "routes": "src/routes.rs",
  50. "tls": "src/tls.rs",
  51. }
  52. MODULE_ORDER = (
  53. "crypto",
  54. "jose",
  55. "protocol",
  56. "db",
  57. "store",
  58. "state",
  59. "middleware",
  60. "ui",
  61. "config",
  62. "keys",
  63. "error",
  64. "routes",
  65. "tls",
  66. )
  67. # The ten-module aggregate. ui/config/tls are deliberately absent: they are
  68. # operator-facing/peripheral and carry per-module floors only.
  69. CORE_MODULES = (
  70. "crypto",
  71. "jose",
  72. "protocol",
  73. "db",
  74. "store",
  75. "state",
  76. "middleware",
  77. "keys",
  78. "error",
  79. "routes",
  80. )
  81. def normalize_sf_path(raw: str, lcov_path: Path) -> str:
  82. """Return a workspace-relative path for one lcov `SF:` value.
  83. Handles absolute paths, a leading `./`, paths relative to the workspace
  84. root, and paths relative to the lcov file's own directory (the common
  85. "coverage file sits next to the sources" case).
  86. """
  87. raw = raw.strip()
  88. if not raw:
  89. return raw
  90. raw_path = Path(raw)
  91. if raw_path.is_absolute():
  92. candidates = [raw_path]
  93. else:
  94. candidates = [
  95. REPO_ROOT / raw_path,
  96. lcov_path.parent / raw_path,
  97. Path.cwd() / raw_path,
  98. ]
  99. repo_root = REPO_ROOT.resolve()
  100. for candidate in candidates:
  101. try:
  102. resolved = candidate.resolve()
  103. except OSError:
  104. continue
  105. try:
  106. return resolved.relative_to(repo_root).as_posix()
  107. except ValueError:
  108. continue
  109. # Lexical fallback for a path that cannot be placed under the workspace.
  110. lexical = raw_path.as_posix()
  111. while lexical.startswith("./"):
  112. lexical = lexical[2:]
  113. return lexical
  114. def parse_lcov(text: str) -> list[dict]:
  115. """Parse lcov `SF:`/`DA:`/`end_of_record` records into source records."""
  116. records: list[dict] = []
  117. current: dict | None = None
  118. for raw_line in text.splitlines():
  119. line = raw_line.strip()
  120. if not line:
  121. continue
  122. if line.startswith("SF:"):
  123. if current is not None:
  124. records.append(current)
  125. current = {"sf": line[3:], "lines": {}}
  126. elif line.startswith("DA:"):
  127. if current is None:
  128. raise ValueError(f"DA record without a preceding SF record: {line}")
  129. body = line[3:].split(",")
  130. if len(body) < 2:
  131. raise ValueError(f"malformed DA record: {line}")
  132. try:
  133. line_no = int(body[0])
  134. hits = int(body[1])
  135. except ValueError as exc:
  136. raise ValueError(f"malformed DA record: {line}") from exc
  137. previous = current["lines"].get(line_no)
  138. current["lines"][line_no] = hits if previous is None else max(previous, hits)
  139. elif line == "end_of_record":
  140. if current is not None:
  141. records.append(current)
  142. current = None
  143. if current is not None:
  144. records.append(current)
  145. return records
  146. def aggregate(records: list[dict], lcov_path: Path) -> dict[str, dict[int, int]]:
  147. """Merge records into `path -> {line: max hits}`, dropping excluded files."""
  148. totals: dict[str, dict[int, int]] = {}
  149. for record in records:
  150. path = normalize_sf_path(record["sf"], lcov_path)
  151. if not path or EXCLUDE_RE.search(path):
  152. continue
  153. lines = totals.setdefault(path, {})
  154. for line_no, hits in record["lines"].items():
  155. previous = lines.get(line_no)
  156. lines[line_no] = hits if previous is None else max(previous, hits)
  157. return totals
  158. def line_counts(lines: dict[int, int]) -> tuple[int, int]:
  159. """Return (executable lines, covered lines) for one file."""
  160. return len(lines), sum(1 for hits in lines.values() if hits > 0)
  161. def module_of(path: str) -> str | None:
  162. """Return the listed module a workspace-relative path belongs to, if any."""
  163. for name in MODULE_ORDER:
  164. spec = MODULE_SPECS[name]
  165. if spec.endswith("/"):
  166. if path.startswith(spec):
  167. return name
  168. elif path == spec:
  169. return name
  170. return None
  171. def module_counts(totals: dict[str, dict[int, int]]) -> dict[str, dict[str, int]]:
  172. """Aggregate (`files`, `found`, `hit`) per listed module."""
  173. counts = {
  174. name: {"files": 0, "found": 0, "hit": 0}
  175. for name in MODULE_ORDER
  176. }
  177. for path, lines in totals.items():
  178. name = module_of(path)
  179. if name is None:
  180. continue
  181. found, hit = line_counts(lines)
  182. counts[name]["files"] += 1
  183. counts[name]["found"] += found
  184. counts[name]["hit"] += hit
  185. return counts
  186. def percent(hit: int, found: int) -> float:
  187. if found == 0:
  188. return 0.0
  189. return 100.0 * hit / found
  190. def _number(value: object, label: str) -> float:
  191. if isinstance(value, bool) or not isinstance(value, (int, float)):
  192. raise ValueError(f"{label} must be a number")
  193. return float(value)
  194. def load_floors(path: Path, explicit: bool) -> dict:
  195. """Load `{"global", "security", "modules"}`; a missing default means zero."""
  196. floors = {"global": 0.0, "security": 0.0, "modules": {}}
  197. if not path.exists():
  198. if explicit:
  199. raise ValueError(f"--floors file not found: {path}")
  200. return floors
  201. try:
  202. data = json.loads(path.read_text(encoding="utf-8"))
  203. except (OSError, json.JSONDecodeError) as exc:
  204. raise ValueError(f"cannot read floors file {path}: {exc}") from exc
  205. if not isinstance(data, dict):
  206. raise ValueError(f"floors file {path} must contain a JSON object")
  207. if "global" in data:
  208. floors["global"] = _number(data["global"], "global floor")
  209. if "security" in data:
  210. floors["security"] = _number(data["security"], "security floor")
  211. modules = data.get("modules", {})
  212. if not isinstance(modules, dict):
  213. raise ValueError(f"floors file {path}: 'modules' must be an object")
  214. for name, value in modules.items():
  215. if name not in MODULE_SPECS:
  216. raise ValueError(f"floors file {path}: unknown module {name!r}")
  217. floors["modules"][name] = _number(value, f"module floor {name!r}")
  218. return floors
  219. def parse_module_overrides(values: list[str]) -> dict[str, float]:
  220. overrides: dict[str, float] = {}
  221. for item in values:
  222. name, sep, raw = item.partition("=")
  223. if not sep or not name:
  224. raise ValueError(f"--module-floor expects NAME=F, got {item!r}")
  225. if name not in MODULE_SPECS:
  226. raise ValueError(f"--module-floor: unknown module {name!r}")
  227. try:
  228. overrides[name] = float(raw)
  229. except ValueError as exc:
  230. raise ValueError(f"--module-floor {name}: not a number: {raw!r}") from exc
  231. return overrides
  232. def build_parser() -> argparse.ArgumentParser:
  233. parser = argparse.ArgumentParser(
  234. description="Enforce line-coverage floors over an lcov tracefile."
  235. )
  236. parser.add_argument("lcov", help="path to an lcov .info tracefile")
  237. parser.add_argument(
  238. "--floors",
  239. default=None,
  240. help=f"floors JSON (default: {DEFAULT_FLOORS})",
  241. )
  242. parser.add_argument(
  243. "--global-floor",
  244. type=float,
  245. default=None,
  246. help="override the global line-coverage floor (percent)",
  247. )
  248. parser.add_argument(
  249. "--security-floor",
  250. type=float,
  251. default=None,
  252. help="override the security aggregate floor (percent)",
  253. )
  254. parser.add_argument(
  255. "--module-floor",
  256. action="append",
  257. default=[],
  258. metavar="NAME=F",
  259. help="override one module floor (repeatable)",
  260. )
  261. return parser
  262. def main(argv: list[str] | None = None) -> int:
  263. args = build_parser().parse_args(argv)
  264. lcov_path = Path(args.lcov)
  265. floors_path = Path(args.floors) if args.floors else DEFAULT_FLOORS
  266. try:
  267. floors = load_floors(floors_path, explicit=args.floors is not None)
  268. if args.global_floor is not None:
  269. floors["global"] = float(args.global_floor)
  270. if args.security_floor is not None:
  271. floors["security"] = float(args.security_floor)
  272. floors["modules"].update(parse_module_overrides(args.module_floor))
  273. except ValueError as exc:
  274. print(f"coverage check: {exc}")
  275. return 1
  276. if not lcov_path.is_file():
  277. print(f"coverage check: lcov file not found: {lcov_path}")
  278. return 1
  279. try:
  280. records = parse_lcov(lcov_path.read_text(encoding="utf-8"))
  281. except (OSError, ValueError) as exc:
  282. print(f"coverage check: cannot parse {lcov_path}: {exc}")
  283. return 1
  284. totals = aggregate(records, lcov_path)
  285. counts = module_counts(totals)
  286. failures: list[str] = []
  287. global_found = sum(len(lines) for lines in totals.values())
  288. global_hit = sum(line_counts(lines)[1] for lines in totals.values())
  289. global_pct = percent(global_hit, global_found)
  290. global_floor = floors["global"]
  291. if global_pct < global_floor:
  292. failures.append("global below floor")
  293. global_status = "FAIL: global below floor"
  294. else:
  295. global_status = "OK"
  296. security_found = sum(counts[name]["found"] for name in CORE_MODULES)
  297. security_hit = sum(counts[name]["hit"] for name in CORE_MODULES)
  298. security_pct = percent(security_hit, security_found)
  299. security_floor = floors["security"]
  300. if security_found == 0:
  301. failures.append("security aggregate has zero executable lines (fail closed)")
  302. security_status = "FAIL: zero executable lines (fail closed)"
  303. elif security_pct < security_floor:
  304. failures.append("security below floor")
  305. security_status = "FAIL: security below floor"
  306. else:
  307. security_status = "OK"
  308. print(f"coverage check: {lcov_path}")
  309. print(
  310. f"global: {global_pct:.1f}% ({global_hit}/{global_found} lines), "
  311. f"floor {global_floor:.1f}% -> {global_status}"
  312. )
  313. print(
  314. f"security: {security_pct:.1f}% ({security_hit}/{security_found} lines), "
  315. f"floor {security_floor:.1f}% -> {security_status}"
  316. )
  317. print("per-module line coverage:")
  318. for name in MODULE_ORDER:
  319. stats = counts[name]
  320. pct = percent(stats["hit"], stats["found"])
  321. floor = floors["modules"].get(name, 0.0)
  322. if stats["files"] == 0:
  323. status = "FAIL: missing from lcov (fail closed)"
  324. failures.append(f"module '{name}' missing from lcov (fail closed)")
  325. elif stats["found"] == 0:
  326. status = "FAIL: zero executable lines (fail closed)"
  327. failures.append(f"module '{name}' has zero executable lines (fail closed)")
  328. elif pct < floor:
  329. status = f"FAIL: below floor ({floor:.1f}%)"
  330. failures.append(f"module '{name}' below floor: {pct:.1f}% < {floor:.1f}%")
  331. else:
  332. status = "OK"
  333. scope = "" if name in CORE_MODULES else " (per-module only)"
  334. print(
  335. f" {name:<10} {pct:>6.1f}% ({stats['hit']}/{stats['found']} lines) "
  336. f"floor {floor:>5.1f}% {status}{scope}"
  337. )
  338. if failures:
  339. print("failed checks:")
  340. for failure in failures:
  341. print(f" - {failure}")
  342. print(f"RESULT: FAIL ({len(failures)} check(s))")
  343. return 1
  344. print("RESULT: PASS")
  345. return 0
  346. if __name__ == "__main__":
  347. sys.exit(main(sys.argv[1:]))