#!/usr/bin/env python3
"""Self-hosted code-quality grade: SQALE debt ratio plus a security axis.
Reads the linter outputs collected by the CI quality job from one directory
and prints a letter grade. No external service is involved at any point;
every threshold below traces to a published number (SonarQube's default
30 min/line development cost and its maintainability grid, which
Code Climate/Qlty publish almost verbatim).
score.py
[--gate-security GRADE] [--out quality.json]
Required files in (fail-closed: a missing file is an error, so a
broken collection step can never inflate the grade):
scc.json scc --format json (LOC denominator)
clippy.jsonl cargo clippy JSON messages, one per line
shellcheck.json shellcheck -f json1
cppcheck.xml cppcheck --xml (v2)
lizard.csv lizard --csv
jscpd-report.json jscpd --reporters json
audit-*.json cargo audit --json, one per crate
Exit 0 normally; 1 when --gate-security is given and the security grade is
worse; 2 on missing/unparseable input.
"""
import argparse
import csv
import glob
import json
import os
import sys
import xml.etree.ElementTree as ET
# Remediation minutes per finding severity (SQALE-style constants).
MINUTES = {"critical": 60, "major": 20, "minor": 5}
DUP_CLONE_MINUTES = 30
DEV_COST_PER_LINE = 30 # SonarQube's documented default.
# Languages that count as code for the LOC denominator.
CODE_LANGS = {"Rust", "C", "C Header", "Shell", "Python", "BASH", "Bourne Shell"}
GRADES = [(0.05, "A"), (0.10, "B"), (0.20, "C"), (0.50, "D"), (9e9, "F")]
GRADE_ORDER = "ABCDF"
# Shields palette hex codes (anybadge rejects the shields color names).
BADGE_COLORS = {"A": "#4c1", "B": "#97ca00", "C": "#dfb317", "D": "#fe7d37", "F": "#e05d44"}
def die(msg):
print(f"FATAL: {msg}", file=sys.stderr)
sys.exit(2)
def need(path):
if not os.path.exists(path):
die(f"missing required input {path}")
return path
def load_json(path):
with open(need(path)) as f:
return json.load(f)
def grade_from_ratio(ratio):
for cap, letter in GRADES:
if ratio < cap:
return letter
return "F"
def parse_scc(path):
langs = load_json(path)
loc = sum(l["Code"] for l in langs if l["Name"] in CODE_LANGS)
if loc <= 0:
die("scc reports zero code lines")
return loc
def parse_clippy(path, add):
# Raw `cargo clippy --message-format=json` stream: one JSON object per
# line, most of them build bookkeeping. Only lint diagnostics (messages
# carrying a code) count.
with open(need(path)) as f:
lines = f.read().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
try:
m = json.loads(line)
except ValueError:
die(f"unparseable clippy line: {line[:80]}")
if m.get("reason") != "compiler-message":
continue
msg = m.get("message") or {}
if not msg.get("code"):
continue
level = msg.get("level")
if level == "error":
add("clippy", "critical")
elif level == "warning":
add("clippy", "major")
def parse_shellcheck(path, add):
data = load_json(path)
for c in data.get("comments", []):
level = c.get("level")
sev = {"error": "critical", "warning": "major"}.get(level, "minor")
add("shellcheck", sev)
def parse_cppcheck(path, add):
root = ET.parse(need(path)).getroot()
for e in root.iter("error"):
sev = e.get("severity")
if sev == "information":
continue
mapped = {"error": "critical", "warning": "major"}.get(sev, "minor")
add("cppcheck", mapped)
def parse_lizard(path, add):
# CSV columns: nloc, ccn, tokens, params, length, location, path, name, ...
with open(need(path)) as f:
rows = list(csv.reader(f))
for row in rows:
if len(row) < 2 or not row[1].isdigit():
continue
ccn = int(row[1])
if ccn > 20:
add("complexity", "critical")
elif ccn > 15:
add("complexity", "major")
elif ccn > 10:
add("complexity", "minor", minutes=20)
def parse_ruff(path, add, security):
for f in load_json(path):
code = f.get("code") or ""
if code.startswith("S"):
add("ruff", "major")
security["findings"] += 1
elif code.startswith(("E9", "F")):
add("ruff", "major")
else:
add("ruff", "minor")
def parse_jscpd(path):
stats = load_json(path)["statistics"]["total"]
return int(stats["clones"]), float(stats["percentage"])
def parse_audits(pattern, security):
paths = glob.glob(pattern)
if not paths:
die(f"no cargo-audit outputs match {pattern}")
for p in paths:
d = load_json(p)
security["vulns"] += int(d["vulnerabilities"]["count"])
security["warnings"] += sum(len(v) for v in d.get("warnings", {}).values())
def security_grade(sec):
if sec["vulns"] >= 2:
return "D"
if sec["vulns"] == 1 or sec["findings"] > 0:
return "C"
if sec["warnings"] > 0:
return "B"
return "A"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dir")
ap.add_argument("--gate-security", choices=list(GRADE_ORDER), default=None)
ap.add_argument("--out", default=None)
args = ap.parse_args()
d = args.dir
counts = {}
minutes = [0.0]
def add(tool, sev, minutes_each=None, **kw):
counts.setdefault(tool, {}).setdefault(sev, [0, 0.0])
m = kw.get("minutes", minutes_each)
if m is None:
m = MINUTES[sev]
counts[tool][sev][0] += 1
counts[tool][sev][1] += m
minutes[0] += m
security = {"vulns": 0, "warnings": 0, "findings": 0}
loc = parse_scc(os.path.join(d, "scc.json"))
parse_clippy(os.path.join(d, "clippy.jsonl"), add)
parse_shellcheck(os.path.join(d, "shellcheck.json"), add)
parse_cppcheck(os.path.join(d, "cppcheck.xml"), add)
parse_lizard(os.path.join(d, "lizard.csv"), add)
parse_ruff(os.path.join(d, "ruff.json"), add, security)
clones, dup_pct = parse_jscpd(os.path.join(d, "jscpd-report.json"))
for _ in range(clones):
add("duplication", "minor", minutes=DUP_CLONE_MINUTES)
parse_audits(os.path.join(d, "audit-*.json"), security)
ratio = minutes[0] / (loc * DEV_COST_PER_LINE)
maint = grade_from_ratio(ratio)
sec = security_grade(security)
overall = max(maint, sec, key=GRADE_ORDER.index)
report = {
"grade": overall,
"maintainability": {"grade": maint, "debt_ratio_pct": round(ratio * 100, 3),
"remediation_minutes": round(minutes[0], 1), "code_lines": loc},
"security": {"grade": sec, **security},
"duplication_pct": round(dup_pct, 2),
"findings": {t: {s: {"count": v[0], "minutes": v[1]} for s, v in sevs.items()}
for t, sevs in counts.items()},
"badge_color": BADGE_COLORS[overall],
}
if args.out:
with open(args.out, "w") as f:
json.dump(report, f, indent=1)
print(f"code quality: {overall} "
f"(maintainability {maint}, debt ratio {ratio * 100:.2f}%, "
f"security {sec}, duplication {dup_pct:.1f}%)")
for tool, sevs in sorted(counts.items()):
line = ", ".join(f"{s}={v[0]}" for s, v in sorted(sevs.items()))
print(f" {tool}: {line}")
if args.gate_security and GRADE_ORDER.index(sec) > GRADE_ORDER.index(args.gate_security):
print(f"FAIL: security grade {sec} is worse than the {args.gate_security} gate",
file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())