mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Bump Python tooling to Ruff 0.16.2 and datamodel-code-generator 0.72.3, add the py313 target in pre-commit, and refresh the engine lockfile. This also narrows broad exception handling and applies small Python cleanups across the engine and helper scripts to satisfy newer lint rules without changing runtime behavior.
314 lines
11 KiB
Python
314 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a single coverage matrix combining backend + frontend, per area.
|
|
|
|
Rows:
|
|
- Frontend (e2e only) - Playwright fixture V8 capture
|
|
- Frontend (all) - Vitest unit tests + Playwright V8
|
|
- Backend (e2e only) - Playwright live JaCoCo + cucumber JaCoCo
|
|
- Backend (all) - JUnit + e2e:live + cucumber JaCoCo
|
|
- Both (e2e only) - frontend (e2e) + backend (e2e), summed
|
|
- Both (all) - frontend (all) + backend (all), summed
|
|
|
|
Columns:
|
|
- core, proprietary, saas, desktop - bucketed per area
|
|
- ALL - sum across the row
|
|
|
|
Bucketing rules:
|
|
Backend (JaCoCo package -> area):
|
|
stirling/software/SPDF/** -> core (the main backend module)
|
|
stirling/software/common/** -> core (shared infra, attributed to core)
|
|
org/apache/pdfbox/** -> core (vendored helpers in common/core)
|
|
stirling/software/proprietary/** -> proprietary (unless saas-flavoured)
|
|
stirling/software/saas/** -> saas
|
|
*desktop* -> desktop (no real backend match today)
|
|
Frontend (source path -> area):
|
|
frontend/editor/src/core/** -> core
|
|
frontend/editor/src/proprietary/** -> proprietary
|
|
frontend/editor/src/saas/** -> saas
|
|
frontend/editor/src/desktop/** -> desktop
|
|
|
|
Cells render as `pct% (covered/total)` where the metric is:
|
|
- Backend: JaCoCo METHOD counter (most directly comparable to JS funcs)
|
|
- Frontend: function counts from vitest per-file summary
|
|
|
|
Playwright (live) frontend V8 dumps only know about bundled JS URLs, not
|
|
source paths, so they only feed the ALL column for the Frontend rows.
|
|
Per-area Playwright contributions show "n/a" with a footnote.
|
|
|
|
Missing inputs are tolerated: any source that isn't passed renders as `-`
|
|
and the row aggregates from what is available.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from defusedxml.ElementTree import ParseError as _XMLParseError
|
|
|
|
# defusedxml hardens the parser against XXE / billion-laughs / entity-
|
|
# expansion attacks. JaCoCo XML on a CI runner is trusted input today,
|
|
# but using the hardened parser is a one-line change and silences
|
|
# scanners that pattern-match on `xml.etree.ElementTree.parse`.
|
|
from defusedxml.ElementTree import parse as _xml_parse
|
|
|
|
AREAS = ("core", "proprietary", "saas", "desktop")
|
|
|
|
|
|
@dataclass
|
|
class Bucket:
|
|
"""Covered + total counters that can be combined across sources."""
|
|
|
|
covered: int = 0
|
|
total: int = 0
|
|
|
|
@property
|
|
def pct(self) -> float:
|
|
return 100.0 * self.covered / self.total if self.total else 0.0
|
|
|
|
def add(self, other: Bucket) -> None:
|
|
self.covered += other.covered
|
|
self.total += other.total
|
|
|
|
|
|
@dataclass
|
|
class RowBuckets:
|
|
"""Per-area buckets for one row of the matrix."""
|
|
|
|
by_area: dict[str, Bucket] = field(default_factory=lambda: {a: Bucket() for a in AREAS})
|
|
# Some inputs (Playwright V8) don't have source-path info, so they
|
|
# only contribute to ALL without an area attribution. Track those
|
|
# separately so per-area cells stay honest.
|
|
unattributed: Bucket = field(default_factory=Bucket)
|
|
|
|
@property
|
|
def all(self) -> Bucket:
|
|
agg = Bucket()
|
|
for b in self.by_area.values():
|
|
agg.add(b)
|
|
agg.add(self.unattributed)
|
|
return agg
|
|
|
|
def merge(self, other: RowBuckets) -> None:
|
|
for area in AREAS:
|
|
self.by_area[area].add(other.by_area[area])
|
|
self.unattributed.add(other.unattributed)
|
|
|
|
|
|
# --------------------------------------------------------------------- jacoco
|
|
|
|
|
|
def _classify_backend(package_name: str) -> str | None:
|
|
"""Map a JaCoCo package name to an area, or None to skip."""
|
|
if not package_name:
|
|
return None
|
|
p = package_name.replace("/", ".")
|
|
if "desktop" in p:
|
|
return "desktop"
|
|
if p.startswith("stirling.software.saas"):
|
|
return "saas"
|
|
# Proprietary saas-flavoured sub-package: rare, but kept for forward
|
|
# compatibility if a future build moves saas under proprietary.
|
|
if p.startswith("stirling.software.proprietary") and ".saas" in p:
|
|
return "saas"
|
|
if p.startswith("stirling.software.proprietary"):
|
|
return "proprietary"
|
|
if p.startswith(("stirling.software.SPDF", "stirling.software.common", "org.apache.pdfbox")):
|
|
return "core"
|
|
return None
|
|
|
|
|
|
def parse_jacoco_methods(path: Path) -> RowBuckets:
|
|
row = RowBuckets()
|
|
if not path.exists():
|
|
return row
|
|
try:
|
|
root = _xml_parse(path).getroot()
|
|
except _XMLParseError as exc:
|
|
print(f"::warning::Failed to parse {path}: {exc}", file=sys.stderr)
|
|
return row
|
|
for pkg in root.findall("package"):
|
|
area = _classify_backend(pkg.get("name", ""))
|
|
if area is None:
|
|
continue
|
|
for counter in pkg.findall("counter"):
|
|
if counter.get("type") != "METHOD":
|
|
continue
|
|
covered = int(counter.get("covered") or 0)
|
|
missed = int(counter.get("missed") or 0)
|
|
row.by_area[area].add(Bucket(covered=covered, total=covered + missed))
|
|
return row
|
|
|
|
|
|
# --------------------------------------------------------------- vitest (frontend)
|
|
|
|
|
|
def _classify_frontend(file_path: str) -> str | None:
|
|
"""Map a vitest per-file path (anything containing src/<area>/) to an area."""
|
|
if not file_path:
|
|
return None
|
|
norm = file_path.replace("\\", "/")
|
|
for area in AREAS:
|
|
if f"/src/{area}/" in norm:
|
|
return area
|
|
return None
|
|
|
|
|
|
def parse_vitest_per_file(path: Path) -> RowBuckets:
|
|
row = RowBuckets()
|
|
if not path.exists():
|
|
return row
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(f"::warning::Failed to parse vitest summary {path}: {exc}", file=sys.stderr)
|
|
return row
|
|
for file_path, metrics in data.items():
|
|
if file_path == "total":
|
|
continue
|
|
area = _classify_frontend(file_path)
|
|
if area is None:
|
|
continue
|
|
fn = (metrics or {}).get("functions") or {}
|
|
covered = int(fn.get("covered") or 0)
|
|
total = int(fn.get("total") or 0)
|
|
row.by_area[area].add(Bucket(covered=covered, total=total))
|
|
return row
|
|
|
|
|
|
# -------------------------------------------------- playwright frontend (V8)
|
|
|
|
|
|
def parse_playwright_frontend_total(path: Path) -> RowBuckets:
|
|
"""Playwright's V8 dump aggregator only knows the grand total.
|
|
|
|
Without source maps we can't bucket per area, so the whole number
|
|
goes into `unattributed`. The matrix renderer surfaces this in the
|
|
ALL column and shows "n/a" in per-area cells with a footnote.
|
|
"""
|
|
row = RowBuckets()
|
|
if not path.exists():
|
|
return row
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(
|
|
f"::warning::Failed to parse Playwright summary {path}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return row
|
|
fn = (data.get("total") or {}).get("functions") or {}
|
|
covered = int(fn.get("covered") or 0)
|
|
total = int(fn.get("total") or 0)
|
|
row.unattributed.add(Bucket(covered=covered, total=total))
|
|
return row
|
|
|
|
|
|
# ---------------------------------------------------------------- rendering
|
|
|
|
|
|
def _cell(bucket: Bucket) -> str:
|
|
if bucket.total == 0:
|
|
return "-"
|
|
return f"{bucket.pct:.1f}% ({bucket.covered}/{bucket.total})"
|
|
|
|
|
|
def render(rows: dict[str, RowBuckets], title: str) -> str:
|
|
cols = list(AREAS) + ["ALL"]
|
|
header = "| Row | " + " | ".join(c for c in cols) + " |"
|
|
sep = "|---" * (len(cols) + 1) + "|"
|
|
lines = [f"## {title}", "", header, sep]
|
|
for row_label, row in rows.items():
|
|
is_backend_row = row_label.lower().startswith("backend")
|
|
cells = [row_label]
|
|
for area in AREAS:
|
|
b = row.by_area[area]
|
|
unattr = row.unattributed
|
|
if area == "desktop" and is_backend_row:
|
|
cells.append("n/a")
|
|
continue
|
|
if b.total == 0 and unattr.total > 0:
|
|
cells.append("n/a")
|
|
else:
|
|
cells.append(_cell(b))
|
|
cells.append(_cell(row.all))
|
|
lines.append("| " + " | ".join(cells) + " |")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ------------------------------------------------------------------- main
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--jacoco-all", type=Path, help="aggregate jacoco-all XML")
|
|
parser.add_argument("--jacoco-e2e", type=Path, help="aggregate jacoco-e2e XML")
|
|
parser.add_argument("--vitest", type=Path, help="vitest coverage-summary.json")
|
|
parser.add_argument(
|
|
"--playwright-frontend",
|
|
type=Path,
|
|
help="Playwright frontend coverage-summary.json (from playwright-coverage-summary.py)",
|
|
)
|
|
parser.add_argument("--title", default="Coverage matrix")
|
|
parser.add_argument("--out", type=Path)
|
|
parser.add_argument("--github-step-summary", action="store_true")
|
|
parser.add_argument("--stdout", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
|
|
# Build each row from its source(s).
|
|
fe_e2e = parse_playwright_frontend_total(args.playwright_frontend) if args.playwright_frontend else RowBuckets()
|
|
fe_unit = parse_vitest_per_file(args.vitest) if args.vitest else RowBuckets()
|
|
fe_all = RowBuckets()
|
|
fe_all.merge(fe_unit)
|
|
fe_all.merge(fe_e2e)
|
|
|
|
be_e2e = parse_jacoco_methods(args.jacoco_e2e) if args.jacoco_e2e else RowBuckets()
|
|
be_all = parse_jacoco_methods(args.jacoco_all) if args.jacoco_all else RowBuckets()
|
|
|
|
both_e2e = RowBuckets()
|
|
both_e2e.merge(fe_e2e)
|
|
both_e2e.merge(be_e2e)
|
|
|
|
both_all = RowBuckets()
|
|
both_all.merge(fe_all)
|
|
both_all.merge(be_all)
|
|
|
|
rows = {
|
|
"Frontend (e2e only)": fe_e2e,
|
|
"Frontend (all)": fe_all,
|
|
"Backend (e2e only)": be_e2e,
|
|
"Backend (all)": be_all,
|
|
"Both (e2e only)": both_e2e,
|
|
"Both (all)": both_all,
|
|
}
|
|
|
|
body = render(rows, args.title) + "\n"
|
|
|
|
# Always write to stdout when no sink is selected so the script
|
|
# is useful from the command line.
|
|
sinks: list[Path] = []
|
|
if args.out:
|
|
sinks.append(args.out)
|
|
if args.github_step_summary:
|
|
import os
|
|
|
|
gh = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if gh:
|
|
sinks.append(Path(gh))
|
|
|
|
for sink in sinks:
|
|
sink.parent.mkdir(parents=True, exist_ok=True)
|
|
with sink.open("a", encoding="utf-8") as handle:
|
|
handle.write(body)
|
|
|
|
if args.stdout or not sinks:
|
|
print(body)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|