Assessment: {peak_label} peak
Most callables are easy to reason about, but the maximum CC of {peak['cc']} in {html.escape(peak['name'])} makes change risk uneven. Review high-complexity routines before adding more paths.
diff --git a/.github/scripts/generate_complexity_report.py b/.github/scripts/generate_complexity_report.py new file mode 100755 index 0000000..9cc2da5 --- /dev/null +++ b/.github/scripts/generate_complexity_report.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# ruff: noqa: E501 +"""Generate the release-time source complexity HTML report. + +Usage: generate_complexity_report.py [WORKTREE] [-o REPORT] +""" + +import argparse +import ast +import datetime +import html +import pathlib +import subprocess + +WORKSPACE = pathlib.Path.cwd() +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "worktree", + nargs="?", + type=pathlib.Path, + default=WORKSPACE, + help="worktree to analyze (default: %(default)s)", +) +parser.add_argument( + "-o", + "--output", + type=pathlib.Path, + default=WORKSPACE / "code-complexity-report.html", + help="HTML report path (default: %(default)s)", +) +args = parser.parse_args() +ROOT = args.worktree.resolve() +SOURCE = ROOT / "src" +OUTPUT = args.output.resolve() +if not SOURCE.is_dir(): + parser.error(f"Python source directory not found: {SOURCE}") + + +def rank(cc): + if cc <= 5: + return "A", "low" + if cc <= 10: + return "B", "moderate" + if cc <= 20: + return "C", "high" + if cc <= 30: + return "D", "very-high" + if cc <= 40: + return "E", "severe" + return "F", "critical" + + +class Complexity(ast.NodeVisitor): + def __init__(self, root): + self.root = root + self.score = 1 + + def visit_FunctionDef(self, node): + if node is self.root: + self.generic_visit(node) + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node): + if node is self.root: + self.generic_visit(node) + + def visit_If(self, node): + self.score += 1 + self.generic_visit(node) + + visit_For = visit_If + visit_AsyncFor = visit_If + visit_While = visit_If + visit_IfExp = visit_If + visit_Assert = visit_If + + def visit_Try(self, node): + self.score += len(node.handlers) + self.generic_visit(node) + + def visit_BoolOp(self, node): + self.score += max(0, len(node.values) - 1) + self.generic_visit(node) + + def visit_comprehension(self, node): + self.score += 1 + len(node.ifs) + self.generic_visit(node) + + def visit_Match(self, node): + self.score += sum( + not ( + isinstance(case.pattern, ast.MatchAs) + and case.pattern.name is None + and case.pattern.pattern is None + ) + for case in node.cases + ) + self.generic_visit(node) + + +def complexity(node): + visitor = Complexity(node) + visitor.visit(node) + return visitor.score + + +def nesting(node): + branch_types = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.With, ast.AsyncWith, ast.Match) + maximum = 0 + + def walk(current, depth): + nonlocal maximum + if current is not node and isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return + next_depth = depth + 1 if isinstance(current, branch_types) else depth + maximum = max(maximum, next_depth) + for child in ast.iter_child_nodes(current): + walk(child, next_depth) + + walk(node, 0) + return maximum + + +def params(node): + return len(node.args.posonlyargs) + len(node.args.args) + len(node.args.kwonlyargs) + + +modules = [] +callables = [] +classes = [] +for path in sorted(SOURCE.rglob("*.py")): + relative = str(path.relative_to(ROOT)) + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + tree = ast.parse(text) + module_callables = [] + module_classes = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + item = { + "path": relative, + "name": node.name, + "kind": "function", + "line": node.lineno, + "end": node.end_lineno, + "loc": node.end_lineno - node.lineno + 1, + "cc": complexity(node), + "nesting": nesting(node), + "params": params(node), + "returns": sum(isinstance(child, ast.Return) for child in ast.walk(node)), + } + callables.append(item) + module_callables.append(item) + elif isinstance(node, ast.ClassDef): + methods = [] + for method in node.body: + if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)): + item = { + "path": relative, + "name": f"{node.name}.{method.name}", + "kind": "method", + "line": method.lineno, + "end": method.end_lineno, + "loc": method.end_lineno - method.lineno + 1, + "cc": complexity(method), + "nesting": nesting(method), + "params": params(method), + "returns": sum(isinstance(child, ast.Return) for child in ast.walk(method)), + } + methods.append(item) + callables.append(item) + module_callables.append(item) + class_item = { + "path": relative, + "name": node.name, + "line": node.lineno, + "end": node.end_lineno, + "loc": node.end_lineno - node.lineno + 1, + "methods": len(methods), + "cc_total": sum(item["cc"] for item in methods) or 1, + "cc_max": max((item["cc"] for item in methods), default=0), + } + classes.append(class_item) + module_classes.append(class_item) + modules.append( + { + "path": relative, + "loc": len(lines), + "sloc": sum(bool(line.strip()) and not line.lstrip().startswith("#") for line in lines), + "functions": sum(item["kind"] == "function" for item in module_callables), + "classes": len(module_classes), + "cc_total": sum(item["cc"] for item in module_callables), + "cc_avg": sum(item["cc"] for item in module_callables) / len(module_callables) if module_callables else 0, + "cc_max": max((item["cc"] for item in module_callables), default=0), + } + ) + +hotspots = sorted((item for item in callables if item["cc"] >= 11), key=lambda item: (-item["cc"], -item["loc"])) +size_hotspots = sorted((item for item in callables if item["loc"] >= 75), key=lambda item: -item["loc"]) +total_loc = sum(module["loc"] for module in modules) +total_sloc = sum(module["sloc"] for module in modules) +total_cc = sum(item["cc"] for item in callables) +avg_cc = total_cc / len(callables) +low_count = sum(item["cc"] <= 5 for item in callables) +moderate_count = sum(6 <= item["cc"] <= 10 for item in callables) +high_count = len(hotspots) +test_modules = list((ROOT / "tests").glob("test_*.py")) +test_loc = sum(len(path.read_text(encoding="utf-8").splitlines()) for path in test_modules) +test_count = 0 +for path in test_modules: + test_count += sum( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_") + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + ) +if not callables: + parser.error(f"No Python functions or methods found below {SOURCE}") +commit = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() +created = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M UTC") +largest_module = max(modules, key=lambda item: item["loc"]) +largest_share = largest_module["loc"] / total_loc +peak = max(callables, key=lambda item: item["cc"]) +peak_label = rank(peak["cc"])[1].replace("-", " ") +class_hotspots = sum(item["cc_max"] >= 11 for item in classes) + + +def badge(cc): + letter, label = rank(cc) + return f'{letter} · {label.replace("-", " ")}' + + +def module_rows(): + rows = [] + for module in sorted(modules, key=lambda item: (-item["cc_max"], -item["loc"])): + rows.append( + f"
{html.escape(module['path'])}{html.escape(item['name'])}"
+ f"{item['kind']}{html.escape(item['path'])}:{item['line']}–{item['end']}{html.escape(item['name'])}{html.escape(item['path'])}:{item['line']}–{item['end']}{html.escape(item['name'])}{html.escape(item['path'])}:{item['line']}–{item['end']}{html.escape(item['name'])} — CC {item['cc']}, "
+ f"{item['loc']} LOC at {html.escape(item['path'])}:{item['line']}. "
+ "Reduce branches along its existing execution paths before introducing new abstractions.{html.escape(item['name'])}: {item['loc']} LOC but CC {item['cc']}; "
+ "size alone does not justify refactoring.The codebase is mostly made of small, low-complexity functions, but risk is concentrated in a few execution paths. {html.escape(largest_module['path'])} contains {largest_share:.1%} of production LOC; the peak is {html.escape(peak['name'])} at CC {peak['cc']}.
physical source lines
{total_sloc:,} nonblank/non-comment
functions + methods
{len(classes)} classes
mean cyclomatic complexity
{total_cc} aggregate decisions
high-or-higher callables
{high_count/len(callables):.1%} of callables
Most callables are easy to reason about, but the maximum CC of {peak['cc']} in {html.escape(peak['name'])} makes change risk uneven. Review high-complexity routines before adding more paths.
{test_count} test functions across {len(test_modules)} test modules and {test_loc:,} test LOC ({test_loc/total_loc:.2f}× production LOC). This is a useful safety net, but runtime coverage was not re-measured for this static report, so no coverage percentage is claimed.
| Module | LOC | SLOC | Functions | Classes | Total CC | Mean CC | Max CC | Max rank |
|---|
Every callable at CC ≥ 11 is listed. These are the routines most likely to require extra review and focused tests when changed.
| Callable | Source lines | LOC | CC | Rank | Max nesting | Parameters | Returns |
|---|
Long routines are shown separately because generated strings and parser declarations can be large while having few branches.
| Callable | Source lines | LOC | CC | Rank |
|---|
| Class | Source lines | LOC | Methods | Method CC total | Max method CC | Rank |
|---|
| Callable | Source lines | LOC | CC | Rank | Max nesting | Parameters | Returns |
|---|
Cyclomatic complexity (CC) starts at 1 and adds paths for branches, loops, exception handlers, boolean branches, assertions, comprehensions, ternaries, and match cases. Ratings follow common McCabe bands:
LOC spans the AST definition from first to last source line. SLOC excludes blank and comment-only lines. Max nesting counts nested control structures. Parameters include positional and keyword-only arguments; returns count explicit return statements.