repo_stats

repo_stats — the README's CI-health, LOC and Python-coverage badges, as shields.io endpoint JSON.

scripts/repo_stats.py write --out-dir stats         --repo marola-dev/marola --run-id 123 --exclude-job repo-stats
scripts/repo_stats.py write --out-dir stats     # LOC + Python coverage (no --run-id: no CI badge)
scripts/repo_stats.py write --out-dir stats --no-python-coverage   # skip coverage.py
scripts/repo_stats.py python-coverage           # just print the measured % (no files written)
scripts/repo_stats.py --self-test               # shaping + counting rules (just quality-other)

Four files, the same shields.io "endpoint" shape ci.yml already writes for the Scala coverage ({"schemaVersion": 1, "label": ..., "message": ..., "color": ...}), published to marola-site's site-data branch and copied into the map by its site.yml, where the README reads them live:

ci.json               "ci steps"        19/20 green
scala-loc.json        "scala"           6,865 LOC
python-loc.json       "python"          2,877 LOC
python-coverage.json  "py-cov" 73%

CI health is step-level, not job-level: ci.yml has three jobs but ~20 named steps, and "build-test passed" hides which of them actually ran. Only steps that ran count — a step whose conclusion is skipped (most of them are gated on needs.changes.outputs.*) or still null (this job's own later steps) is left out of both numerator and denominator, so a docs-only push reports 8/8 rather than a misleading 8/20. --exclude-job drops the reporting job itself, whose steps are by definition still running while it asks.

LOC is cloc (from nix develop .#lint, locally and in ci.yml), counted over the four Scala modules and the Python trees, code lines only — blanks and comments excluded by cloc, and target/, __pycache__/, virtualenvs and node_modules/ excluded by path.

Python coverage is measured, never estimated — but read the label narrowly. marola has no pytest suite; every scripts/**/*.py is tested by its own --self-test flag, the list just quality-other runs. So this badge is statement coverage of scripts/ while those self-tests run, and nothing more: a branch a self-test never bothers to call is uncovered by construction, which is why the figure sits in the 70s rather than the 90s. dspy/ and finetune/ are out of scope — no --self-test entry point, and importing them needs torch/DSPy — so they are neither numerator nor denominator, while a scripts/*.py that grows without a self-test does count (at 0%), which is the point. Mechanically: one coverage run --parallel-mode per self-test into a temp data file, then coverage combine + coverage json.

Standard library only; cloc and coverage are the external tools, and only write needs them.

  1#!/usr/bin/env python3
  2"""repo_stats — the README's CI-health, LOC and Python-coverage badges, as shields.io endpoint JSON.
  3
  4    scripts/repo_stats.py write --out-dir stats \
  5        --repo marola-dev/marola --run-id 123 --exclude-job repo-stats
  6    scripts/repo_stats.py write --out-dir stats     # LOC + Python coverage (no --run-id: no CI badge)
  7    scripts/repo_stats.py write --out-dir stats --no-python-coverage   # skip coverage.py
  8    scripts/repo_stats.py python-coverage           # just print the measured % (no files written)
  9    scripts/repo_stats.py --self-test               # shaping + counting rules (just quality-other)
 10
 11Four files, the same shields.io "endpoint" shape ci.yml already writes for the Scala coverage
 12(`{"schemaVersion": 1, "label": ..., "message": ..., "color": ...}`), published to
 13marola-site's `site-data` branch and copied into the map by its site.yml, where the README reads
 14them live:
 15
 16    ci.json               "ci steps"        19/20 green
 17    scala-loc.json        "scala"           6,865 LOC
 18    python-loc.json       "python"          2,877 LOC
 19    python-coverage.json  "py-cov" 73%
 20
 21CI health is *step*-level, not job-level: ci.yml has three jobs but ~20 named steps, and
 22"build-test passed" hides which of them actually ran. Only steps that ran count — a step whose
 23`conclusion` is `skipped` (most of them are gated on `needs.changes.outputs.*`) or still `null`
 24(this job's own later steps) is left out of both numerator and denominator, so a docs-only push
 25reports 8/8 rather than a misleading 8/20. `--exclude-job` drops the reporting job itself, whose
 26steps are by definition still running while it asks.
 27
 28LOC is `cloc` (from `nix develop .#lint`, locally and in ci.yml), counted over the four
 29Scala modules and the Python trees, code lines only — blanks and comments excluded by cloc, and
 30`target/`, `__pycache__/`, virtualenvs and `node_modules/` excluded by path.
 31
 32Python coverage is measured, never estimated — but read the label narrowly. marola has no pytest
 33suite; every `scripts/**/*.py` is tested by its own `--self-test` flag, the list `just
 34quality-other` runs. So this badge is *statement coverage of `scripts/` while those self-tests
 35run*, and nothing more: a branch a self-test never bothers to call is uncovered by construction,
 36which is why the figure sits in the 70s rather than the 90s. `dspy/` and `finetune/` are out of
 37scope — no `--self-test` entry point, and importing them needs torch/DSPy — so they are neither
 38numerator nor denominator, while a `scripts/*.py` that grows without a self-test does count
 39(at 0%), which is the point. Mechanically: one `coverage run --parallel-mode` per self-test into
 40a temp data file, then `coverage combine` + `coverage json`.
 41
 42Standard library only; `cloc` and `coverage` are the external tools, and only `write` needs them.
 43"""
 44
 45import argparse
 46import json
 47import shutil
 48import subprocess
 49import sys
 50import tempfile
 51from pathlib import Path
 52
 53SCHEMA = 1
 54
 55SCALA_PATHS = ("core", "local", "cli")
 56PYTHON_PATHS = ("dspy", "finetune", "scripts")
 57EXCLUDE_DIRS = ("target", "__pycache__", ".venv", "venv", "node_modules")
 58
 59SCALA_COLOR = "DC322F"  # = the README's hand-written Scala badge
 60PYTHON_COLOR = "3776AB"  # = python.org's brand blue, as used by shields' own python logo
 61
 62# Every `python3 <script> --self-test` line of justfile's `quality-other`, in its order. Keeping
 63# the two lists equal is what makes the badge honest: the number below is exactly what that gate
 64# already runs, not a second, friendlier suite. (The `.sh` self-tests in the same recipe are not
 65# Python and cannot contribute statements.)
 66SELF_TEST_SCRIPTS = (
 67    "scripts/smoke_record.py",
 68    "scripts/benchmark_gate.py",
 69    "scripts/repo_stats.py",
 70    "scripts/arxiv_digest.py",
 71    "scripts/awesome_agentic_digest.py",
 72    "scripts/ocr-post.py",
 73    "scripts/mip_graph.py",
 74    "scripts/strip_external_scripts.py",
 75    "scripts/analyze_training.py",
 76)
 77# Measured tree. `dspy/`/`finetune/` are excluded on purpose — see the module docstring.
 78COVERAGE_SOURCE = "scripts"
 79
 80# A step that reached one of these actually executed; anything else (`skipped`, `neutral`, or a
 81# null conclusion for a step still queued/running) is not evidence either way and is not counted.
 82RAN = ("success", "failure", "cancelled", "timed_out")
 83GREEN = ("success",)
 84
 85
 86# ---------------------------------------------------------------------------------------------
 87# Pure shaping — everything below self-tests without a network call or a `cloc` on PATH.
 88
 89
 90def badge(label: str, message: str, color: str) -> dict:
 91    """One shields.io endpoint document (https://shields.io/badges/endpoint-badge)."""
 92    return {"schemaVersion": SCHEMA, "label": label, "message": message, "color": color}
 93
 94
 95def count_steps(jobs: list[dict], exclude_job: str | None = None) -> tuple[int, int]:
 96    """(green, ran) over every step of every job of one run — skipped/unfinished steps ignored."""
 97    green = ran = 0
 98    for job in jobs:
 99        if exclude_job and job.get("name") == exclude_job:
100            continue
101        for step in job.get("steps") or []:
102            conclusion = step.get("conclusion")
103            if conclusion not in RAN:
104                continue
105            ran += 1
106            if conclusion in GREEN:
107                green += 1
108    return green, ran
109
110
111def ci_badge(green: int, ran: int) -> dict:
112    """Green only when every step that ran passed — a single red step is not a rounding error."""
113    if ran == 0:
114        return badge("ci steps", "no data", "lightgrey")
115    color = "brightgreen" if green == ran else "yellow" if green >= 0.9 * ran else "red"
116    return badge("ci steps", f"{green}/{ran} green", color)
117
118
119def loc_badge(label: str, code: int, color: str) -> dict:
120    return badge(label, f"{code:,} LOC", color)
121
122
123def parse_cloc(payload: str, language: str) -> int:
124    """Code lines for one language out of `cloc --json`; 0 when it found none of that language."""
125    return int(json.loads(payload).get(language, {}).get("code", 0))
126
127
128def coverage_badge(percent: float | None) -> dict:
129    """Statement coverage of `scripts/` under its own self-tests. Same thresholds as ci.yml's
130    Scala badge (red < 50 ≤ yellow < 80 ≤ green) so the two read on one scale. The label is
131    `py-cov`, paired with ci.yml's `sc-cov` — short enough that the two badges sit side by side
132    without wrapping, and still distinguishable at a glance, which is the only thing the label
133    has to do."""
134    if percent is None:
135        return badge("py-cov", "no data", "lightgrey")
136    color = "red" if percent < 50 else "yellow" if percent < 80 else "green"
137    return badge("py-cov", f"{percent:.0f}%", color)
138
139
140def parse_coverage_json(payload: str) -> float:
141    """The overall statement percentage out of `coverage json` (`totals.percent_covered`)."""
142    return float(json.loads(payload)["totals"]["percent_covered"])
143
144
145def coverage_run_argv(exe: list[str], script: str, data_file: Path) -> list[str]:
146    """One instrumented self-test run. `--parallel-mode` keeps the ten runs from overwriting each
147    other's data file; `--source` fixes the measured tree so a `scripts/*.py` that no self-test
148    imports still lands in the denominator at 0% instead of vanishing from the report."""
149    return [
150        *exe,
151        "run",
152        "--parallel-mode",
153        f"--data-file={data_file}",
154        f"--source={COVERAGE_SOURCE}",
155        script,
156        "--self-test",
157    ]
158
159
160# ---------------------------------------------------------------------------------------------
161# The two effectful sources: this run's jobs (GitHub API, via `gh`) and `cloc`.
162
163
164def fetch_jobs(repo: str, run_id: str) -> list[dict]:
165    """Every job of one workflow run, steps included. Needs `actions: read` on the token."""
166    jobs: list[dict] = []
167    page = 1
168    while True:
169        url = f"repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}"
170        out = subprocess.run(
171            ["gh", "api", "-H", "Accept: application/vnd.github+json", url],
172            capture_output=True,
173            text=True,
174            check=True,
175        ).stdout
176        batch = json.loads(out).get("jobs") or []
177        jobs.extend(batch)
178        if len(batch) < 100:
179            return jobs
180        page += 1
181
182
183def cloc_code(paths: tuple[str, ...], language: str, root: Path) -> int:
184    """Code lines of `language` under `paths`; a path that does not exist is simply skipped."""
185    if not shutil.which("cloc"):
186        raise SystemExit(
187            "repo_stats: `cloc` is not on PATH (nix develop has it, and ci.yml takes it from nix develop .#lint)"
188        )
189    present = [p for p in paths if (root / p).exists()]
190    if not present:
191        return 0
192    out = subprocess.run(
193        [
194            "cloc",
195            "--json",
196            "--quiet",
197            f"--exclude-dir={','.join(EXCLUDE_DIRS)}",
198            f"--include-lang={language}",
199            *present,
200        ],
201        capture_output=True,
202        text=True,
203        check=True,
204        cwd=root,
205    ).stdout
206    # cloc prints nothing at all when no file of that language survived the filters.
207    return parse_cloc(out, language) if out.strip() else 0
208
209
210def coverage_exe(which=shutil.which, has_module=None) -> list[str]:
211    """How to invoke coverage.py here, as an argv prefix.
212
213    Two shapes: nix's `python3Packages.coverage` puts a wrapped `coverage` on PATH but *not* on
214    this interpreter's import path, while a pip or distro install does the opposite. Prefer the
215    executable, fall back to `-m`, fail loudly if neither.
216    """
217    if has_module is None:
218
219        def has_module() -> bool:
220            import importlib.util
221
222            return importlib.util.find_spec("coverage") is not None
223
224    if which("coverage"):
225        return ["coverage"]
226    if has_module():
227        return [sys.executable, "-m", "coverage"]
228    raise SystemExit(
229        "repo_stats: coverage.py is not installed (nix develop has it, and ci.yml takes it "
230        "from nix develop .#lint) — or pass --no-python-coverage"
231    )
232
233
234def python_coverage(root: Path) -> float:
235    """Run every self-test under coverage.py and return the combined statement percentage.
236
237    The data files live in a temp directory, so a run leaves no `.coverage*` behind in the repo.
238    A self-test that *fails* aborts the measurement rather than quietly reporting a smaller
239    number — `just quality-other` is the gate for that, and a green badge over a red self-test
240    would be worse than no badge.
241    """
242    exe = coverage_exe()
243    with tempfile.TemporaryDirectory() as tmp:
244        data_file = Path(tmp) / ".coverage"
245        for script in SELF_TEST_SCRIPTS:
246            subprocess.run(
247                coverage_run_argv(exe, script, data_file),
248                cwd=root,
249                check=True,
250                capture_output=True,
251                text=True,
252            )
253        subprocess.run(
254            [*exe, "combine", f"--data-file={data_file}", tmp],
255            cwd=root,
256            check=True,
257            capture_output=True,
258            text=True,
259        )
260        report = Path(tmp) / "coverage.json"
261        subprocess.run(
262            [*exe, "json", f"--data-file={data_file}", "-o", str(report)],
263            cwd=root,
264            check=True,
265            capture_output=True,
266            text=True,
267        )
268        return parse_coverage_json(report.read_text())
269
270
271def write(out_dir: Path, badges: dict[str, dict]) -> list[Path]:
272    out_dir.mkdir(parents=True, exist_ok=True)
273    written = []
274    for name, doc in badges.items():
275        path = out_dir / name
276        path.write_text(json.dumps(doc) + "\n")
277        written.append(path)
278    return written
279
280
281def collect(args) -> dict[str, dict]:
282    root = Path(args.root)
283    badges = {
284        "scala-loc.json": loc_badge("scala", cloc_code(SCALA_PATHS, "Scala", root), SCALA_COLOR),
285        "python-loc.json": loc_badge(
286            "python", cloc_code(PYTHON_PATHS, "Python", root), PYTHON_COLOR
287        ),
288    }
289    if args.run_id:
290        green, ran = count_steps(fetch_jobs(args.repo, args.run_id), args.exclude_job)
291        badges["ci.json"] = ci_badge(green, ran)
292    if not args.no_python_coverage:
293        badges["python-coverage.json"] = coverage_badge(python_coverage(root))
294    return badges
295
296
297# ---------------------------------------------------------------------------------------------
298
299
300def self_test() -> int:
301    jobs = [
302        {
303            "name": "build-test",
304            "steps": [
305                {"name": "checkout", "conclusion": "success"},
306                {"name": "compile+test", "conclusion": "success"},
307                {"name": "coverage", "conclusion": "skipped"},  # main-only, on a PR
308            ],
309        },
310        {
311            "name": "quality-other",
312            "steps": [
313                {"name": "ruff", "conclusion": "success"},
314                {"name": "actionlint", "conclusion": "failure"},
315                {"name": "hadolint", "conclusion": "skipped"},
316                {"name": "docker compose config", "conclusion": None},  # never reached
317            ],
318        },
319        {
320            "name": "repo-stats",  # the reporting job itself: excluded, steps still running
321            "steps": [
322                {"name": "checkout", "conclusion": "success"},
323                {"name": "badges", "conclusion": None},
324            ],
325        },
326    ]
327    assert count_steps(jobs, "repo-stats") == (3, 4), count_steps(jobs, "repo-stats")
328    # Without the exclusion the reporting job's own finished steps leak in.
329    assert count_steps(jobs) == (4, 5), count_steps(jobs)
330    # A job with no steps at all (queued, or `steps` absent) contributes nothing, never crashes.
331    assert count_steps([{"name": "changes"}, {"name": "x", "steps": None}]) == (0, 0)
332
333    # Skipped steps stay out of the denominator: a docs-only push is 2/2, not 2/9.
334    docs_only = [
335        {
336            "name": "quality-other",
337            "steps": [{"conclusion": "success"}] * 2 + [{"conclusion": "skipped"}] * 7,
338        }
339    ]
340    assert count_steps(docs_only) == (2, 2)
341    assert ci_badge(*count_steps(docs_only))["message"] == "2/2 green"
342
343    assert ci_badge(20, 20) == {
344        "schemaVersion": 1,
345        "label": "ci steps",
346        "message": "20/20 green",
347        "color": "brightgreen",
348    }
349    assert ci_badge(19, 20)["color"] == "yellow", "one red step out of twenty: not green, not red"
350    assert ci_badge(17, 20)["color"] == "red"
351    assert ci_badge(0, 0) == {
352        "schemaVersion": 1,
353        "label": "ci steps",
354        "message": "no data",
355        "color": "lightgrey",
356    }
357
358    assert loc_badge("scala", 6865, SCALA_COLOR)["message"] == "6,865 LOC"
359    assert loc_badge("python", 0, PYTHON_COLOR)["message"] == "0 LOC"
360
361    cloc_json = json.dumps(
362        {
363            "header": {"cloc_version": "2.10"},
364            "Scala": {"nFiles": 84, "blank": 1050, "comment": 1673, "code": 6865},
365            "SUM": {"blank": 1050, "comment": 1673, "code": 6865, "nFiles": 84},
366        }
367    )
368    assert parse_cloc(cloc_json, "Scala") == 6865
369    assert parse_cloc(cloc_json, "Python") == 0, "a language cloc did not find is 0, not an error"
370
371    # --- Python coverage: shaping, parsing, the argv builder and how coverage.py is located.
372    assert coverage_badge(73.0) == {
373        "schemaVersion": 1,
374        "label": "py-cov",
375        "message": "73%",
376        "color": "yellow",
377    }
378    assert coverage_badge(80.0)["color"] == "green", "the ci.yml Scala badge's own boundary"
379    assert coverage_badge(49.9)["color"] == "red"
380    assert coverage_badge(100.0)["message"] == "100%", "no decimals on the badge"
381    assert coverage_badge(None) == {
382        "schemaVersion": 1,
383        "label": "py-cov",
384        "message": "no data",
385        "color": "lightgrey",
386    }
387    # Both coverage badges must be distinguishable at a glance — this is the whole reason the
388    # label is not just "coverage" like ci.yml's Scala one used to be. `py-cov` here pairs with
389    # `sc-cov` in ci.yml; if one is renamed the other has to follow.
390    assert coverage_badge(73.0)["label"] != ci_badge(1, 1)["label"]
391
392    cov_json = json.dumps(
393        {
394            "meta": {"version": "7.15.4"},
395            "files": {"scripts/repo_stats.py": {"summary": {"percent_covered": 75.0}}},
396            "totals": {
397                "covered_lines": 1328,
398                "num_statements": 1820,
399                "percent_covered": 72.96703296703296,
400            },
401        }
402    )
403    assert round(parse_coverage_json(cov_json), 2) == 72.97
404    assert coverage_badge(parse_coverage_json(cov_json))["message"] == "73%"
405
406    argv = coverage_run_argv(["coverage"], "scripts/mip_graph.py", Path("/tmp/x/.coverage"))
407    assert argv[:2] == ["coverage", "run"]
408    assert "--parallel-mode" in argv, "ten runs into one data file need parallel mode"
409    assert "--data-file=/tmp/x/.coverage" in argv, "data files stay out of the repo"
410    assert f"--source={COVERAGE_SOURCE}" in argv
411    assert argv[-2:] == ["scripts/mip_graph.py", "--self-test"]
412    assert coverage_run_argv([sys.executable, "-m", "coverage"], "s.py", Path("d"))[1] == "-m"
413
414    assert coverage_exe(which=lambda _: "/usr/bin/coverage") == ["coverage"], "prefer the exe"
415    assert coverage_exe(which=lambda _: None, has_module=lambda: True) == [
416        sys.executable,
417        "-m",
418        "coverage",
419    ], "nix's coverage is on PATH; Ubuntu's python3-coverage is only importable"
420    try:
421        coverage_exe(which=lambda _: None, has_module=lambda: False)
422        raise AssertionError("a missing coverage.py must fail loudly, not report 0%")
423    except SystemExit as exc:
424        assert "--no-python-coverage" in str(exc), str(exc)
425
426    # The badge is only honest while this list is exactly justfile's; drift is the failure mode.
427    root = Path(__file__).resolve().parent.parent
428    for script in SELF_TEST_SCRIPTS:
429        assert (root / script).exists(), f"{script} is in SELF_TEST_SCRIPTS but not on disk"
430    justfile = (root / "justfile").read_text()
431    for script in SELF_TEST_SCRIPTS:
432        assert f"python3 {script} --self-test" in justfile, f"{script} left quality-other"
433    in_recipe = {
434        line.split()[1]
435        for line in justfile.splitlines()
436        if line.strip().startswith("python3 scripts/") and line.strip().endswith("--self-test")
437    }
438    assert in_recipe == set(SELF_TEST_SCRIPTS), sorted(in_recipe ^ set(SELF_TEST_SCRIPTS))
439
440    with tempfile.TemporaryDirectory() as tmp:
441        out = Path(tmp) / "stats"
442        paths = write(out, {"ci.json": ci_badge(20, 20), "scala-loc.json": loc_badge("s", 1, "x")})
443        assert [p.name for p in paths] == ["ci.json", "scala-loc.json"]
444        assert json.loads((out / "ci.json").read_text())["message"] == "20/20 green"
445        # Rewriting replaces rather than appends — every run publishes a whole document.
446        write(out, {"ci.json": ci_badge(1, 2)})
447        assert json.loads((out / "ci.json").read_text())["message"] == "1/2 green"
448
449    write_args = build_parser().parse_args(["write", "--out-dir", "x"])
450    assert write_args.repo == "marola-dev/marola", write_args.repo
451
452    print(
453        "repo_stats self-test: ok (step counting, badge shaping, cloc/coverage parsing, "
454        "the coverage argv + exe resolution, SELF_TEST_SCRIPTS vs. justfile, write, "
455        "the --repo default)"
456    )
457    return 0
458
459
460def build_parser() -> argparse.ArgumentParser:
461    ap = argparse.ArgumentParser(
462        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
463    )
464    ap.add_argument("--self-test", action="store_true")
465    sub = ap.add_subparsers(dest="cmd")
466    w = sub.add_parser("write", help="write the badge JSONs into --out-dir")
467    w.add_argument("--out-dir", required=True, type=Path)
468    w.add_argument("--root", default=".", help="repo root the LOC paths are relative to")
469    w.add_argument(
470        "--repo", default="marola-dev/marola", help="owner/name, for the CI-health badge"
471    )
472    w.add_argument("--run-id", help="workflow run to report on; omitted = LOC badges only")
473    w.add_argument("--exclude-job", help="job name to leave out (the reporting job itself)")
474    w.add_argument(
475        "--no-python-coverage",
476        action="store_true",
477        help="skip the coverage.py run (no coverage.py installed, or LOC/CI badges only)",
478    )
479    c = sub.add_parser(
480        "python-coverage", help="print the measured statement %% of scripts/ and exit"
481    )
482    c.add_argument("--root", default=".", help="repo root the self-test paths are relative to")
483    return ap
484
485
486def main(argv: list[str]) -> int:
487    ap = build_parser()
488    args = ap.parse_args(argv)
489    if args.self_test:
490        return self_test()
491    if args.cmd == "python-coverage":
492        percent = python_coverage(Path(args.root))
493        print(f"{percent:.1f}% ({coverage_badge(percent)['message']} on the badge)")
494        return 0
495    if args.cmd != "write":
496        ap.print_help()
497        return 2
498    badges = collect(args)
499    for path in write(args.out_dir, badges):
500        print(f"{path}: {path.read_text().strip()}")
501    return 0
502
503
504if __name__ == "__main__":
505    sys.exit(main(sys.argv[1:]))
SCHEMA = 1
SCALA_PATHS = ('core', 'local', 'cli')
PYTHON_PATHS = ('dspy', 'finetune', 'scripts')
EXCLUDE_DIRS = ('target', '__pycache__', '.venv', 'venv', 'node_modules')
SCALA_COLOR = 'DC322F'
PYTHON_COLOR = '3776AB'
SELF_TEST_SCRIPTS = ('scripts/smoke_record.py', 'scripts/benchmark_gate.py', 'scripts/repo_stats.py', 'scripts/arxiv_digest.py', 'scripts/awesome_agentic_digest.py', 'scripts/ocr-post.py', 'scripts/mip_graph.py', 'scripts/strip_external_scripts.py', 'scripts/analyze_training.py')
COVERAGE_SOURCE = 'scripts'
RAN = ('success', 'failure', 'cancelled', 'timed_out')
GREEN = ('success',)
def badge(label: str, message: str, color: str) -> dict:
91def badge(label: str, message: str, color: str) -> dict:
92    """One shields.io endpoint document (https://shields.io/badges/endpoint-badge)."""
93    return {"schemaVersion": SCHEMA, "label": label, "message": message, "color": color}

One shields.io endpoint document (https://shields.io/badges/endpoint-badge).

def count_steps(jobs: list[dict], exclude_job: str | None = None) -> tuple[int, int]:
 96def count_steps(jobs: list[dict], exclude_job: str | None = None) -> tuple[int, int]:
 97    """(green, ran) over every step of every job of one run — skipped/unfinished steps ignored."""
 98    green = ran = 0
 99    for job in jobs:
100        if exclude_job and job.get("name") == exclude_job:
101            continue
102        for step in job.get("steps") or []:
103            conclusion = step.get("conclusion")
104            if conclusion not in RAN:
105                continue
106            ran += 1
107            if conclusion in GREEN:
108                green += 1
109    return green, ran

(green, ran) over every step of every job of one run — skipped/unfinished steps ignored.

def ci_badge(green: int, ran: int) -> dict:
112def ci_badge(green: int, ran: int) -> dict:
113    """Green only when every step that ran passed — a single red step is not a rounding error."""
114    if ran == 0:
115        return badge("ci steps", "no data", "lightgrey")
116    color = "brightgreen" if green == ran else "yellow" if green >= 0.9 * ran else "red"
117    return badge("ci steps", f"{green}/{ran} green", color)

Green only when every step that ran passed — a single red step is not a rounding error.

def loc_badge(label: str, code: int, color: str) -> dict:
120def loc_badge(label: str, code: int, color: str) -> dict:
121    return badge(label, f"{code:,} LOC", color)
def parse_cloc(payload: str, language: str) -> int:
124def parse_cloc(payload: str, language: str) -> int:
125    """Code lines for one language out of `cloc --json`; 0 when it found none of that language."""
126    return int(json.loads(payload).get(language, {}).get("code", 0))

Code lines for one language out of cloc --json; 0 when it found none of that language.

def coverage_badge(percent: float | None) -> dict:
129def coverage_badge(percent: float | None) -> dict:
130    """Statement coverage of `scripts/` under its own self-tests. Same thresholds as ci.yml's
131    Scala badge (red < 50 ≤ yellow < 80 ≤ green) so the two read on one scale. The label is
132    `py-cov`, paired with ci.yml's `sc-cov` — short enough that the two badges sit side by side
133    without wrapping, and still distinguishable at a glance, which is the only thing the label
134    has to do."""
135    if percent is None:
136        return badge("py-cov", "no data", "lightgrey")
137    color = "red" if percent < 50 else "yellow" if percent < 80 else "green"
138    return badge("py-cov", f"{percent:.0f}%", color)

Statement coverage of scripts/ under its own self-tests. Same thresholds as ci.yml's Scala badge (red < 50 ≤ yellow < 80 ≤ green) so the two read on one scale. The label is py-cov, paired with ci.yml's sc-cov — short enough that the two badges sit side by side without wrapping, and still distinguishable at a glance, which is the only thing the label has to do.

def parse_coverage_json(payload: str) -> float:
141def parse_coverage_json(payload: str) -> float:
142    """The overall statement percentage out of `coverage json` (`totals.percent_covered`)."""
143    return float(json.loads(payload)["totals"]["percent_covered"])

The overall statement percentage out of coverage json (totals.percent_covered).

def coverage_run_argv(exe: list[str], script: str, data_file: pathlib.Path) -> list[str]:
146def coverage_run_argv(exe: list[str], script: str, data_file: Path) -> list[str]:
147    """One instrumented self-test run. `--parallel-mode` keeps the ten runs from overwriting each
148    other's data file; `--source` fixes the measured tree so a `scripts/*.py` that no self-test
149    imports still lands in the denominator at 0% instead of vanishing from the report."""
150    return [
151        *exe,
152        "run",
153        "--parallel-mode",
154        f"--data-file={data_file}",
155        f"--source={COVERAGE_SOURCE}",
156        script,
157        "--self-test",
158    ]

One instrumented self-test run. --parallel-mode keeps the ten runs from overwriting each other's data file; --source fixes the measured tree so a scripts/*.py that no self-test imports still lands in the denominator at 0% instead of vanishing from the report.

def fetch_jobs(repo: str, run_id: str) -> list[dict]:
165def fetch_jobs(repo: str, run_id: str) -> list[dict]:
166    """Every job of one workflow run, steps included. Needs `actions: read` on the token."""
167    jobs: list[dict] = []
168    page = 1
169    while True:
170        url = f"repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}"
171        out = subprocess.run(
172            ["gh", "api", "-H", "Accept: application/vnd.github+json", url],
173            capture_output=True,
174            text=True,
175            check=True,
176        ).stdout
177        batch = json.loads(out).get("jobs") or []
178        jobs.extend(batch)
179        if len(batch) < 100:
180            return jobs
181        page += 1

Every job of one workflow run, steps included. Needs actions: read on the token.

def cloc_code(paths: tuple[str, ...], language: str, root: pathlib.Path) -> int:
184def cloc_code(paths: tuple[str, ...], language: str, root: Path) -> int:
185    """Code lines of `language` under `paths`; a path that does not exist is simply skipped."""
186    if not shutil.which("cloc"):
187        raise SystemExit(
188            "repo_stats: `cloc` is not on PATH (nix develop has it, and ci.yml takes it from nix develop .#lint)"
189        )
190    present = [p for p in paths if (root / p).exists()]
191    if not present:
192        return 0
193    out = subprocess.run(
194        [
195            "cloc",
196            "--json",
197            "--quiet",
198            f"--exclude-dir={','.join(EXCLUDE_DIRS)}",
199            f"--include-lang={language}",
200            *present,
201        ],
202        capture_output=True,
203        text=True,
204        check=True,
205        cwd=root,
206    ).stdout
207    # cloc prints nothing at all when no file of that language survived the filters.
208    return parse_cloc(out, language) if out.strip() else 0

Code lines of language under paths; a path that does not exist is simply skipped.

def coverage_exe(which=<function which>, has_module=None) -> list[str]:
211def coverage_exe(which=shutil.which, has_module=None) -> list[str]:
212    """How to invoke coverage.py here, as an argv prefix.
213
214    Two shapes: nix's `python3Packages.coverage` puts a wrapped `coverage` on PATH but *not* on
215    this interpreter's import path, while a pip or distro install does the opposite. Prefer the
216    executable, fall back to `-m`, fail loudly if neither.
217    """
218    if has_module is None:
219
220        def has_module() -> bool:
221            import importlib.util
222
223            return importlib.util.find_spec("coverage") is not None
224
225    if which("coverage"):
226        return ["coverage"]
227    if has_module():
228        return [sys.executable, "-m", "coverage"]
229    raise SystemExit(
230        "repo_stats: coverage.py is not installed (nix develop has it, and ci.yml takes it "
231        "from nix develop .#lint) — or pass --no-python-coverage"
232    )

How to invoke coverage.py here, as an argv prefix.

Two shapes: nix's python3Packages.coverage puts a wrapped coverage on PATH but not on this interpreter's import path, while a pip or distro install does the opposite. Prefer the executable, fall back to -m, fail loudly if neither.

def python_coverage(root: pathlib.Path) -> float:
235def python_coverage(root: Path) -> float:
236    """Run every self-test under coverage.py and return the combined statement percentage.
237
238    The data files live in a temp directory, so a run leaves no `.coverage*` behind in the repo.
239    A self-test that *fails* aborts the measurement rather than quietly reporting a smaller
240    number — `just quality-other` is the gate for that, and a green badge over a red self-test
241    would be worse than no badge.
242    """
243    exe = coverage_exe()
244    with tempfile.TemporaryDirectory() as tmp:
245        data_file = Path(tmp) / ".coverage"
246        for script in SELF_TEST_SCRIPTS:
247            subprocess.run(
248                coverage_run_argv(exe, script, data_file),
249                cwd=root,
250                check=True,
251                capture_output=True,
252                text=True,
253            )
254        subprocess.run(
255            [*exe, "combine", f"--data-file={data_file}", tmp],
256            cwd=root,
257            check=True,
258            capture_output=True,
259            text=True,
260        )
261        report = Path(tmp) / "coverage.json"
262        subprocess.run(
263            [*exe, "json", f"--data-file={data_file}", "-o", str(report)],
264            cwd=root,
265            check=True,
266            capture_output=True,
267            text=True,
268        )
269        return parse_coverage_json(report.read_text())

Run every self-test under coverage.py and return the combined statement percentage.

The data files live in a temp directory, so a run leaves no .coverage* behind in the repo. A self-test that fails aborts the measurement rather than quietly reporting a smaller number — just quality-other is the gate for that, and a green badge over a red self-test would be worse than no badge.

def write(out_dir: pathlib.Path, badges: dict[str, dict]) -> list[pathlib.Path]:
272def write(out_dir: Path, badges: dict[str, dict]) -> list[Path]:
273    out_dir.mkdir(parents=True, exist_ok=True)
274    written = []
275    for name, doc in badges.items():
276        path = out_dir / name
277        path.write_text(json.dumps(doc) + "\n")
278        written.append(path)
279    return written
def collect(args) -> dict[str, dict]:
282def collect(args) -> dict[str, dict]:
283    root = Path(args.root)
284    badges = {
285        "scala-loc.json": loc_badge("scala", cloc_code(SCALA_PATHS, "Scala", root), SCALA_COLOR),
286        "python-loc.json": loc_badge(
287            "python", cloc_code(PYTHON_PATHS, "Python", root), PYTHON_COLOR
288        ),
289    }
290    if args.run_id:
291        green, ran = count_steps(fetch_jobs(args.repo, args.run_id), args.exclude_job)
292        badges["ci.json"] = ci_badge(green, ran)
293    if not args.no_python_coverage:
294        badges["python-coverage.json"] = coverage_badge(python_coverage(root))
295    return badges
def self_test() -> int:
301def self_test() -> int:
302    jobs = [
303        {
304            "name": "build-test",
305            "steps": [
306                {"name": "checkout", "conclusion": "success"},
307                {"name": "compile+test", "conclusion": "success"},
308                {"name": "coverage", "conclusion": "skipped"},  # main-only, on a PR
309            ],
310        },
311        {
312            "name": "quality-other",
313            "steps": [
314                {"name": "ruff", "conclusion": "success"},
315                {"name": "actionlint", "conclusion": "failure"},
316                {"name": "hadolint", "conclusion": "skipped"},
317                {"name": "docker compose config", "conclusion": None},  # never reached
318            ],
319        },
320        {
321            "name": "repo-stats",  # the reporting job itself: excluded, steps still running
322            "steps": [
323                {"name": "checkout", "conclusion": "success"},
324                {"name": "badges", "conclusion": None},
325            ],
326        },
327    ]
328    assert count_steps(jobs, "repo-stats") == (3, 4), count_steps(jobs, "repo-stats")
329    # Without the exclusion the reporting job's own finished steps leak in.
330    assert count_steps(jobs) == (4, 5), count_steps(jobs)
331    # A job with no steps at all (queued, or `steps` absent) contributes nothing, never crashes.
332    assert count_steps([{"name": "changes"}, {"name": "x", "steps": None}]) == (0, 0)
333
334    # Skipped steps stay out of the denominator: a docs-only push is 2/2, not 2/9.
335    docs_only = [
336        {
337            "name": "quality-other",
338            "steps": [{"conclusion": "success"}] * 2 + [{"conclusion": "skipped"}] * 7,
339        }
340    ]
341    assert count_steps(docs_only) == (2, 2)
342    assert ci_badge(*count_steps(docs_only))["message"] == "2/2 green"
343
344    assert ci_badge(20, 20) == {
345        "schemaVersion": 1,
346        "label": "ci steps",
347        "message": "20/20 green",
348        "color": "brightgreen",
349    }
350    assert ci_badge(19, 20)["color"] == "yellow", "one red step out of twenty: not green, not red"
351    assert ci_badge(17, 20)["color"] == "red"
352    assert ci_badge(0, 0) == {
353        "schemaVersion": 1,
354        "label": "ci steps",
355        "message": "no data",
356        "color": "lightgrey",
357    }
358
359    assert loc_badge("scala", 6865, SCALA_COLOR)["message"] == "6,865 LOC"
360    assert loc_badge("python", 0, PYTHON_COLOR)["message"] == "0 LOC"
361
362    cloc_json = json.dumps(
363        {
364            "header": {"cloc_version": "2.10"},
365            "Scala": {"nFiles": 84, "blank": 1050, "comment": 1673, "code": 6865},
366            "SUM": {"blank": 1050, "comment": 1673, "code": 6865, "nFiles": 84},
367        }
368    )
369    assert parse_cloc(cloc_json, "Scala") == 6865
370    assert parse_cloc(cloc_json, "Python") == 0, "a language cloc did not find is 0, not an error"
371
372    # --- Python coverage: shaping, parsing, the argv builder and how coverage.py is located.
373    assert coverage_badge(73.0) == {
374        "schemaVersion": 1,
375        "label": "py-cov",
376        "message": "73%",
377        "color": "yellow",
378    }
379    assert coverage_badge(80.0)["color"] == "green", "the ci.yml Scala badge's own boundary"
380    assert coverage_badge(49.9)["color"] == "red"
381    assert coverage_badge(100.0)["message"] == "100%", "no decimals on the badge"
382    assert coverage_badge(None) == {
383        "schemaVersion": 1,
384        "label": "py-cov",
385        "message": "no data",
386        "color": "lightgrey",
387    }
388    # Both coverage badges must be distinguishable at a glance — this is the whole reason the
389    # label is not just "coverage" like ci.yml's Scala one used to be. `py-cov` here pairs with
390    # `sc-cov` in ci.yml; if one is renamed the other has to follow.
391    assert coverage_badge(73.0)["label"] != ci_badge(1, 1)["label"]
392
393    cov_json = json.dumps(
394        {
395            "meta": {"version": "7.15.4"},
396            "files": {"scripts/repo_stats.py": {"summary": {"percent_covered": 75.0}}},
397            "totals": {
398                "covered_lines": 1328,
399                "num_statements": 1820,
400                "percent_covered": 72.96703296703296,
401            },
402        }
403    )
404    assert round(parse_coverage_json(cov_json), 2) == 72.97
405    assert coverage_badge(parse_coverage_json(cov_json))["message"] == "73%"
406
407    argv = coverage_run_argv(["coverage"], "scripts/mip_graph.py", Path("/tmp/x/.coverage"))
408    assert argv[:2] == ["coverage", "run"]
409    assert "--parallel-mode" in argv, "ten runs into one data file need parallel mode"
410    assert "--data-file=/tmp/x/.coverage" in argv, "data files stay out of the repo"
411    assert f"--source={COVERAGE_SOURCE}" in argv
412    assert argv[-2:] == ["scripts/mip_graph.py", "--self-test"]
413    assert coverage_run_argv([sys.executable, "-m", "coverage"], "s.py", Path("d"))[1] == "-m"
414
415    assert coverage_exe(which=lambda _: "/usr/bin/coverage") == ["coverage"], "prefer the exe"
416    assert coverage_exe(which=lambda _: None, has_module=lambda: True) == [
417        sys.executable,
418        "-m",
419        "coverage",
420    ], "nix's coverage is on PATH; Ubuntu's python3-coverage is only importable"
421    try:
422        coverage_exe(which=lambda _: None, has_module=lambda: False)
423        raise AssertionError("a missing coverage.py must fail loudly, not report 0%")
424    except SystemExit as exc:
425        assert "--no-python-coverage" in str(exc), str(exc)
426
427    # The badge is only honest while this list is exactly justfile's; drift is the failure mode.
428    root = Path(__file__).resolve().parent.parent
429    for script in SELF_TEST_SCRIPTS:
430        assert (root / script).exists(), f"{script} is in SELF_TEST_SCRIPTS but not on disk"
431    justfile = (root / "justfile").read_text()
432    for script in SELF_TEST_SCRIPTS:
433        assert f"python3 {script} --self-test" in justfile, f"{script} left quality-other"
434    in_recipe = {
435        line.split()[1]
436        for line in justfile.splitlines()
437        if line.strip().startswith("python3 scripts/") and line.strip().endswith("--self-test")
438    }
439    assert in_recipe == set(SELF_TEST_SCRIPTS), sorted(in_recipe ^ set(SELF_TEST_SCRIPTS))
440
441    with tempfile.TemporaryDirectory() as tmp:
442        out = Path(tmp) / "stats"
443        paths = write(out, {"ci.json": ci_badge(20, 20), "scala-loc.json": loc_badge("s", 1, "x")})
444        assert [p.name for p in paths] == ["ci.json", "scala-loc.json"]
445        assert json.loads((out / "ci.json").read_text())["message"] == "20/20 green"
446        # Rewriting replaces rather than appends — every run publishes a whole document.
447        write(out, {"ci.json": ci_badge(1, 2)})
448        assert json.loads((out / "ci.json").read_text())["message"] == "1/2 green"
449
450    write_args = build_parser().parse_args(["write", "--out-dir", "x"])
451    assert write_args.repo == "marola-dev/marola", write_args.repo
452
453    print(
454        "repo_stats self-test: ok (step counting, badge shaping, cloc/coverage parsing, "
455        "the coverage argv + exe resolution, SELF_TEST_SCRIPTS vs. justfile, write, "
456        "the --repo default)"
457    )
458    return 0
def build_parser() -> argparse.ArgumentParser:
461def build_parser() -> argparse.ArgumentParser:
462    ap = argparse.ArgumentParser(
463        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
464    )
465    ap.add_argument("--self-test", action="store_true")
466    sub = ap.add_subparsers(dest="cmd")
467    w = sub.add_parser("write", help="write the badge JSONs into --out-dir")
468    w.add_argument("--out-dir", required=True, type=Path)
469    w.add_argument("--root", default=".", help="repo root the LOC paths are relative to")
470    w.add_argument(
471        "--repo", default="marola-dev/marola", help="owner/name, for the CI-health badge"
472    )
473    w.add_argument("--run-id", help="workflow run to report on; omitted = LOC badges only")
474    w.add_argument("--exclude-job", help="job name to leave out (the reporting job itself)")
475    w.add_argument(
476        "--no-python-coverage",
477        action="store_true",
478        help="skip the coverage.py run (no coverage.py installed, or LOC/CI badges only)",
479    )
480    c = sub.add_parser(
481        "python-coverage", help="print the measured statement %% of scripts/ and exit"
482    )
483    c.add_argument("--root", default=".", help="repo root the self-test paths are relative to")
484    return ap
def main(argv: list[str]) -> int:
487def main(argv: list[str]) -> int:
488    ap = build_parser()
489    args = ap.parse_args(argv)
490    if args.self_test:
491        return self_test()
492    if args.cmd == "python-coverage":
493        percent = python_coverage(Path(args.root))
494        print(f"{percent:.1f}% ({coverage_badge(percent)['message']} on the badge)")
495        return 0
496    if args.cmd != "write":
497        ap.print_help()
498        return 2
499    badges = collect(args)
500    for path in write(args.out_dir, badges):
501        print(f"{path}: {path.read_text().strip()}")
502    return 0