|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Counts Rust lines of code in the ethlambda workspace via cargo-warloc and |
| 4 | +produces report files for Slack, Telegram, and the GitHub Actions step summary. |
| 5 | +
|
| 6 | +`cargo warloc` reports per-file `main`/`tests` line counts using a Rust AST |
| 7 | +parser, so inline `#[cfg(test)]` blocks are correctly classified as test code. |
| 8 | +
|
| 9 | +Inputs (optional): |
| 10 | + loc_report.json.old Previous run's report. Used to compute deltas. |
| 11 | +
|
| 12 | +Outputs: |
| 13 | + loc_report.json Machine-readable report for caching. |
| 14 | + loc_report_slack.json Slack Block Kit payload (daily). |
| 15 | + loc_report_telegram.txt Telegram HTML body (weekly). |
| 16 | + loc_report_github.txt Plain-text block for the workflow step summary. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import html |
| 22 | +import json |
| 23 | +import os |
| 24 | +import subprocess |
| 25 | +from datetime import datetime, timezone |
| 26 | +from pathlib import Path |
| 27 | + |
| 28 | + |
| 29 | +# Crates whose entire contents are test infrastructure and should never |
| 30 | +# appear in the "no tests" totals or per-crate listing. |
| 31 | +TEST_ONLY_CRATES = frozenset({ |
| 32 | + "crates/common/test-fixtures", |
| 33 | +}) |
| 34 | + |
| 35 | +# Individual files that are test infrastructure but live next to production |
| 36 | +# code inside an otherwise-production crate. Their lines are folded into the |
| 37 | +# owning crate's `tests` bucket. |
| 38 | +TEST_ONLY_FILES = frozenset({ |
| 39 | + "crates/net/rpc/src/test_driver.rs", |
| 40 | +}) |
| 41 | + |
| 42 | + |
| 43 | +def _run(cmd: list[str]) -> str: |
| 44 | + return subprocess.check_output(cmd, text=True) |
| 45 | + |
| 46 | + |
| 47 | +def warloc_by_file() -> dict: |
| 48 | + return json.loads(_run(["cargo", "warloc", "--by-file", "-o", "json"])) |
| 49 | + |
| 50 | + |
| 51 | +def workspace_crates() -> list[str]: |
| 52 | + md = json.loads(_run(["cargo", "metadata", "--no-deps", "--format-version", "1"])) |
| 53 | + cwd = os.getcwd() + "/" |
| 54 | + crates = [] |
| 55 | + for pkg in md["packages"]: |
| 56 | + path = pkg["manifest_path"][: -len("/Cargo.toml")] |
| 57 | + if path.startswith(cwd): |
| 58 | + path = path[len(cwd):] |
| 59 | + crates.append(path) |
| 60 | + # Sort longest first so longest-prefix match wins when grouping files. |
| 61 | + crates.sort(key=len, reverse=True) |
| 62 | + return crates |
| 63 | + |
| 64 | + |
| 65 | +def group_by_crate(by_file: dict, crates: list[str]) -> dict[str, dict[str, int]]: |
| 66 | + buckets = {c: {"main": 0, "tests": 0} for c in crates} |
| 67 | + for raw_path, stats in by_file["files"].items(): |
| 68 | + path = raw_path[2:] if raw_path.startswith("./") else raw_path |
| 69 | + owner = next((c for c in crates if path.startswith(c + "/")), None) |
| 70 | + if owner is None: |
| 71 | + continue |
| 72 | + is_test_only = owner in TEST_ONLY_CRATES or path in TEST_ONLY_FILES |
| 73 | + if is_test_only: |
| 74 | + # All lines from this file/crate count as tests. |
| 75 | + buckets[owner]["tests"] += stats["main"]["code"] + stats["tests"]["code"] |
| 76 | + else: |
| 77 | + buckets[owner]["main"] += stats["main"]["code"] |
| 78 | + buckets[owner]["tests"] += stats["tests"]["code"] |
| 79 | + return buckets |
| 80 | + |
| 81 | + |
| 82 | +def format_diff(cur: int, old: int) -> str: |
| 83 | + if cur > old: |
| 84 | + return f"(+{cur - old})" |
| 85 | + if cur < old: |
| 86 | + return f"(-{old - cur})" |
| 87 | + return "" |
| 88 | + |
| 89 | + |
| 90 | +def main() -> None: |
| 91 | + by_file = warloc_by_file() |
| 92 | + crates = workspace_crates() |
| 93 | + buckets = group_by_crate(by_file, crates) |
| 94 | + |
| 95 | + rows = [ |
| 96 | + {"path": c, "main": b["main"], "tests": b["tests"]} |
| 97 | + for c, b in buckets.items() |
| 98 | + ] |
| 99 | + rows.sort(key=lambda r: -r["main"]) |
| 100 | + |
| 101 | + total_main = sum(r["main"] for r in rows) |
| 102 | + total_tests = sum(r["tests"] for r in rows) |
| 103 | + total_with_tests = total_main + total_tests |
| 104 | + |
| 105 | + new_report = { |
| 106 | + "total_main": total_main, |
| 107 | + "total_tests": total_tests, |
| 108 | + "total_with_tests": total_with_tests, |
| 109 | + "crates": rows, |
| 110 | + } |
| 111 | + Path("loc_report.json").write_text(json.dumps(new_report)) |
| 112 | + |
| 113 | + # Resolve previous values (default = current → blank deltas on first run). |
| 114 | + old_path = Path("loc_report.json.old") |
| 115 | + if old_path.exists(): |
| 116 | + old = json.loads(old_path.read_text()) |
| 117 | + old_main = old.get("total_main", total_main) |
| 118 | + old_with = old.get("total_with_tests", total_with_tests) |
| 119 | + old_crates = {c["path"]: c["main"] for c in old.get("crates", [])} |
| 120 | + else: |
| 121 | + old_main = total_main |
| 122 | + old_with = total_with_tests |
| 123 | + old_crates = {r["path"]: r["main"] for r in rows} |
| 124 | + |
| 125 | + main_diff = format_diff(total_main, old_main) |
| 126 | + with_diff = format_diff(total_with_tests, old_with) |
| 127 | + |
| 128 | + sha = os.environ.get("GITHUB_SHA") or _run(["git", "rev-parse", "HEAD"]).strip() |
| 129 | + short = sha[:7] |
| 130 | + date_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
| 131 | + |
| 132 | + per_crate = [] |
| 133 | + for r in rows: |
| 134 | + # Test-only crates fold their lines into the tests bucket and have |
| 135 | + # main == 0; skip them in the per-crate "no tests" listing. |
| 136 | + if r["main"] == 0: |
| 137 | + continue |
| 138 | + old_loc = old_crates.get(r["path"], r["main"]) |
| 139 | + per_crate.append({ |
| 140 | + "path": r["path"], |
| 141 | + "loc": r["main"], |
| 142 | + "diff": format_diff(r["main"], old_loc), |
| 143 | + }) |
| 144 | + |
| 145 | + # --- GitHub step summary ------------------------------------------------- |
| 146 | + gh_lines = [ |
| 147 | + "```", |
| 148 | + f"ethlambda lines of code ({date_utc}, {short})", |
| 149 | + "============================================", |
| 150 | + "", |
| 151 | + "Per-crate (no tests)", |
| 152 | + "--------------------", |
| 153 | + ] |
| 154 | + gh_lines += [f"{r['path']}: {r['loc']} {r['diff']}".rstrip() for r in per_crate] |
| 155 | + gh_lines += [ |
| 156 | + "", |
| 157 | + f"Total Rust LoC (no tests): {total_main} {main_diff}".rstrip(), |
| 158 | + f"Total Rust LoC (with tests): {total_with_tests} {with_diff}".rstrip(), |
| 159 | + "```", |
| 160 | + ] |
| 161 | + Path("loc_report_github.txt").write_text("\n".join(gh_lines) + "\n") |
| 162 | + |
| 163 | + # --- Slack Block Kit ------------------------------------------------------ |
| 164 | + per_crate_slack = "\n".join( |
| 165 | + f"*{r['path']}*: {r['loc']} {r['diff']}".rstrip() for r in per_crate |
| 166 | + ) |
| 167 | + totals_slack = ( |
| 168 | + f"*Total (no tests):* {total_main} {main_diff}".rstrip() |
| 169 | + + "\n" |
| 170 | + + f"*Total (with tests):* {total_with_tests} {with_diff}".rstrip() |
| 171 | + ) |
| 172 | + slack_payload = { |
| 173 | + "blocks": [ |
| 174 | + {"type": "header", |
| 175 | + "text": {"type": "plain_text", "text": "Daily ethlambda LoC Report"}}, |
| 176 | + {"type": "section", |
| 177 | + "text": {"type": "mrkdwn", |
| 178 | + "text": f"_Date:_ {date_utc} • _Commit:_ `{short}`"}}, |
| 179 | + {"type": "divider"}, |
| 180 | + {"type": "header", |
| 181 | + "text": {"type": "plain_text", "text": "Per-crate (no tests)"}}, |
| 182 | + {"type": "section", |
| 183 | + "text": {"type": "mrkdwn", "text": per_crate_slack}}, |
| 184 | + {"type": "divider"}, |
| 185 | + {"type": "section", |
| 186 | + "text": {"type": "mrkdwn", "text": totals_slack}}, |
| 187 | + ] |
| 188 | + } |
| 189 | + Path("loc_report_slack.json").write_text(json.dumps(slack_payload)) |
| 190 | + |
| 191 | + # --- Telegram (HTML parse mode) ------------------------------------------ |
| 192 | + def esc(s: str) -> str: |
| 193 | + return html.escape(s, quote=False) |
| 194 | + |
| 195 | + tg_lines = [ |
| 196 | + "<b>Weekly ethlambda LoC Report</b>", |
| 197 | + f"Date: {date_utc} • Commit: <code>{esc(short)}</code>", |
| 198 | + "", |
| 199 | + "<b>Per-crate (no tests)</b>", |
| 200 | + ] |
| 201 | + tg_lines += [ |
| 202 | + f"<b>{esc(r['path'])}</b>: {r['loc']} {r['diff']}".rstrip() |
| 203 | + for r in per_crate |
| 204 | + ] |
| 205 | + tg_lines += [ |
| 206 | + "", |
| 207 | + f"<b>Total Rust LoC (no tests):</b> {total_main} {main_diff}".rstrip(), |
| 208 | + f"<b>Total Rust LoC (with tests):</b> {total_with_tests} {with_diff}".rstrip(), |
| 209 | + ] |
| 210 | + Path("loc_report_telegram.txt").write_text("\n".join(tg_lines) + "\n") |
| 211 | + |
| 212 | + |
| 213 | +if __name__ == "__main__": |
| 214 | + main() |
0 commit comments