agent-readiness 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. agent_readiness/__init__.py +3 -0
  2. agent_readiness/__main__.py +5 -0
  3. agent_readiness/checks/__init__.py +115 -0
  4. agent_readiness/checks/agent_docs.py +117 -0
  5. agent_readiness/checks/branch_rulesets.py +139 -0
  6. agent_readiness/checks/churn.py +238 -0
  7. agent_readiness/checks/ci_check.py +105 -0
  8. agent_readiness/checks/devcontainer.py +70 -0
  9. agent_readiness/checks/entry_points.py +90 -0
  10. agent_readiness/checks/env_parity.py +120 -0
  11. agent_readiness/checks/git_history.py +85 -0
  12. agent_readiness/checks/gitignore.py +114 -0
  13. agent_readiness/checks/headless.py +169 -0
  14. agent_readiness/checks/hooks.py +81 -0
  15. agent_readiness/checks/lint_check.py +94 -0
  16. agent_readiness/checks/manifest.py +136 -0
  17. agent_readiness/checks/naming.py +82 -0
  18. agent_readiness/checks/readme.py +97 -0
  19. agent_readiness/checks/repo_shape.py +175 -0
  20. agent_readiness/checks/repo_templates.py +101 -0
  21. agent_readiness/checks/secrets.py +123 -0
  22. agent_readiness/checks/security.py +140 -0
  23. agent_readiness/checks/setup_steps.py +126 -0
  24. agent_readiness/checks/test_command.py +187 -0
  25. agent_readiness/checks/typecheck.py +91 -0
  26. agent_readiness/cli.py +366 -0
  27. agent_readiness/config.py +35 -0
  28. agent_readiness/context.py +217 -0
  29. agent_readiness/mcp_server.py +132 -0
  30. agent_readiness/models.py +129 -0
  31. agent_readiness/plugins.py +48 -0
  32. agent_readiness/renderers/__init__.py +6 -0
  33. agent_readiness/renderers/html_renderer.py +76 -0
  34. agent_readiness/renderers/json_renderer.py +12 -0
  35. agent_readiness/renderers/progress.py +143 -0
  36. agent_readiness/renderers/sarif.py +55 -0
  37. agent_readiness/renderers/terminal.py +143 -0
  38. agent_readiness/sandbox.py +334 -0
  39. agent_readiness/scaffold.py +145 -0
  40. agent_readiness/scorer.py +95 -0
  41. agent_readiness/templates/AGENTS.md +35 -0
  42. agent_readiness/templates/CODEOWNERS +10 -0
  43. agent_readiness/templates/SECURITY.md +31 -0
  44. agent_readiness/templates/dependabot.yml +21 -0
  45. agent_readiness/templates/devcontainer.json +11 -0
  46. agent_readiness/templates/gitignore +34 -0
  47. agent_readiness/templates/issue_template_bug.md +29 -0
  48. agent_readiness/templates/issue_template_feature.md +19 -0
  49. agent_readiness/templates/pre-commit-config.yaml +25 -0
  50. agent_readiness/templates/pull_request_template.md +17 -0
  51. agent_readiness-1.0.0.dist-info/METADATA +148 -0
  52. agent_readiness-1.0.0.dist-info/RECORD +54 -0
  53. agent_readiness-1.0.0.dist-info/WHEEL +4 -0
  54. agent_readiness-1.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,3 @@
1
+ """agent-readiness: benchmark how agent-ready a code repository is."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m agent_readiness`."""
2
+ from agent_readiness.cli import cli
3
+
4
+ if __name__ == "__main__":
5
+ cli()
@@ -0,0 +1,115 @@
1
+ """Check protocol and registry.
2
+
3
+ A "check" is a callable that takes a RepoContext and returns a CheckResult.
4
+ We use a Protocol rather than an ABC so testing fakes don't need
5
+ inheritance, and a decorator-based registry so adding a check is one
6
+ @register away.
7
+
8
+ Every check ships with:
9
+ - check_id (stable identifier, used in JSON output and `explain`)
10
+ - pillar (which pillar it scores into)
11
+ - weight (relative weight within the pillar; default 1.0)
12
+ - title (one-line human description)
13
+ - explanation (multi-line; surfaced by `agent-readiness explain <id>`)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Callable, Protocol
20
+
21
+ from agent_readiness.context import RepoContext
22
+ from agent_readiness.models import CheckResult, Pillar
23
+
24
+
25
+ class CheckFn(Protocol):
26
+ """A check function: pure-ish, deterministic, takes RepoContext."""
27
+ def __call__(self, ctx: RepoContext) -> CheckResult: ...
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CheckSpec:
32
+ """Metadata + the runnable for a single check."""
33
+ check_id: str
34
+ pillar: Pillar
35
+ title: str
36
+ explanation: str
37
+ fn: CheckFn
38
+ weight: float = 1.0
39
+
40
+
41
+ # Registry is module-level. Order of registration is preserved (Python 3.7+
42
+ # dict iteration is insertion-ordered), which makes report output stable.
43
+ _REGISTRY: dict[str, CheckSpec] = {}
44
+
45
+
46
+ def register(
47
+ check_id: str,
48
+ pillar: Pillar,
49
+ title: str,
50
+ explanation: str,
51
+ weight: float = 1.0,
52
+ ) -> Callable[[CheckFn], CheckFn]:
53
+ """Decorator: register a check function under `check_id`.
54
+
55
+ Raises if the same check_id is registered twice — that would silently
56
+ drop one of the checks at import time, which is a bug we'd rather
57
+ surface loudly.
58
+ """
59
+ def deco(fn: CheckFn) -> CheckFn:
60
+ if check_id in _REGISTRY:
61
+ raise ValueError(f"duplicate check registration: {check_id!r}")
62
+ _REGISTRY[check_id] = CheckSpec(
63
+ check_id=check_id,
64
+ pillar=pillar,
65
+ title=title,
66
+ explanation=explanation.strip(),
67
+ fn=fn,
68
+ weight=weight,
69
+ )
70
+ return fn
71
+ return deco
72
+
73
+
74
+ def all_checks() -> list[CheckSpec]:
75
+ """Return all registered check specs in registration order."""
76
+ return list(_REGISTRY.values())
77
+
78
+
79
+ def get_check(check_id: str) -> CheckSpec | None:
80
+ return _REGISTRY.get(check_id)
81
+
82
+
83
+ def _ensure_loaded() -> None:
84
+ """Force-import the check modules so their @register decorators fire.
85
+
86
+ Called by the CLI before scoring. Adding a new check is: drop a module
87
+ in agent_readiness.checks, import it from here, done.
88
+ """
89
+ # Imported for side effect: each module's @register calls populate
90
+ # _REGISTRY at import time.
91
+ from agent_readiness.checks import ( # noqa: F401
92
+ readme,
93
+ agent_docs,
94
+ test_command,
95
+ headless,
96
+ secrets,
97
+ manifest,
98
+ git_history,
99
+ repo_shape,
100
+ entry_points,
101
+ env_parity,
102
+ ci_check,
103
+ setup_steps,
104
+ naming,
105
+ typecheck,
106
+ lint_check,
107
+ gitignore,
108
+ churn,
109
+ # New checks ported from agent-ready
110
+ devcontainer,
111
+ repo_templates,
112
+ hooks,
113
+ security,
114
+ branch_rulesets,
115
+ )
@@ -0,0 +1,117 @@
1
+ """Check: agent_docs.present
2
+
3
+ A repo that explicitly speaks to AI agents — via AGENTS.md, CLAUDE.md,
4
+ .cursorrules, or .github/copilot-instructions.md — is meaningfully more
5
+ ready than one that doesn't. These files communicate conventions
6
+ (branch naming, code style, commit message format, do-not-touch dirs)
7
+ that an agent would otherwise have to infer from observation.
8
+
9
+ Scoring:
10
+ - No agent-targeted docs: 0
11
+ - One present: 70
12
+ - Two or more present: 100
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from agent_readiness.checks import register
20
+ from agent_readiness.context import RepoContext
21
+ from agent_readiness.models import CheckResult, Finding, Pillar, Severity
22
+
23
+
24
+ # Files we recognise. Some live at root, some in .github/.
25
+ # Entries are (parts, is_dir_glob) where is_dir_glob=True means we check
26
+ # for any file matching a glob inside a directory.
27
+ _AGENT_DOC_FILES: tuple[tuple[str, ...], ...] = (
28
+ ("AGENTS.md",),
29
+ ("CLAUDE.md",),
30
+ (".cursorrules",),
31
+ (".github", "copilot-instructions.md"),
32
+ ("copilot-setup-steps.yml",),
33
+ (".github", "copilot-setup-steps.yml"),
34
+ )
35
+
36
+ # Directories where any matching file counts as a hit
37
+ _AGENT_DOC_DIRS: tuple[tuple[tuple[str, ...], str], ...] = (
38
+ ((".cursor", "rules"), "*.mdc"),
39
+ )
40
+
41
+
42
+ def _resolve(ctx: RepoContext, parts: tuple[str, ...]) -> Path | None:
43
+ """Return the relative path if it exists as a file, else None."""
44
+ candidate = Path(*parts)
45
+ if (ctx.root / candidate).is_file():
46
+ return candidate
47
+ return None
48
+
49
+
50
+ def _resolve_dir_glob(ctx: RepoContext, parts: tuple[str, ...], pattern: str) -> Path | None:
51
+ """Return the relative dir path if it exists and contains files matching pattern."""
52
+ candidate = Path(*parts)
53
+ dir_path = ctx.root / candidate
54
+ if dir_path.is_dir() and any(dir_path.glob(pattern)):
55
+ return candidate
56
+ return None
57
+
58
+
59
+ @register(
60
+ check_id="agent_docs.present",
61
+ pillar=Pillar.COGNITIVE_LOAD,
62
+ title="Repo includes agent-targeted documentation",
63
+ explanation="""
64
+ Agent-targeted docs (AGENTS.md, CLAUDE.md, .cursorrules,
65
+ .cursor/rules/*.mdc, .github/copilot-instructions.md,
66
+ copilot-setup-steps.yml) encode conventions an agent would otherwise
67
+ have to infer: branch naming, do-not-touch directories, commit message
68
+ style, preferred libraries, and tool-specific configuration. A short
69
+ doc here is one of the highest-leverage edits a maintainer can make.
70
+ """,
71
+ )
72
+ def check(ctx: RepoContext) -> CheckResult:
73
+ found: list[Path] = []
74
+ for parts in _AGENT_DOC_FILES:
75
+ rel = _resolve(ctx, parts)
76
+ if rel is not None:
77
+ found.append(rel)
78
+ for parts, pattern in _AGENT_DOC_DIRS:
79
+ rel = _resolve_dir_glob(ctx, parts, pattern)
80
+ if rel is not None:
81
+ found.append(rel)
82
+
83
+ if not found:
84
+ return CheckResult(
85
+ check_id="agent_docs.present",
86
+ pillar=Pillar.COGNITIVE_LOAD,
87
+ score=0.0,
88
+ findings=[Finding(
89
+ check_id="agent_docs.present",
90
+ pillar=Pillar.COGNITIVE_LOAD,
91
+ severity=Severity.WARN,
92
+ message=(
93
+ "No agent-targeted docs found "
94
+ "(AGENTS.md / CLAUDE.md / .cursorrules / "
95
+ ".github/copilot-instructions.md / .cursor/rules/*.mdc)."
96
+ ),
97
+ fix_hint=(
98
+ "Add an AGENTS.md at the repo root with conventions, "
99
+ "do-not-touch paths, and the canonical test command."
100
+ ),
101
+ )],
102
+ )
103
+
104
+ score = 100.0 if len(found) >= 2 else 70.0
105
+ info_finding = Finding(
106
+ check_id="agent_docs.present",
107
+ pillar=Pillar.COGNITIVE_LOAD,
108
+ severity=Severity.INFO,
109
+ file=found[0],
110
+ message=f"Found agent-targeted docs: {', '.join(str(p) for p in found)}.",
111
+ )
112
+ return CheckResult(
113
+ check_id="agent_docs.present",
114
+ pillar=Pillar.COGNITIVE_LOAD,
115
+ score=score,
116
+ findings=[info_finding],
117
+ )
@@ -0,0 +1,139 @@
1
+ """Check: branch_rulesets.configured
2
+
3
+ GitHub branch rulesets (formerly branch protection rules) enforce review
4
+ requirements, status checks, and merge policies. Without them an agent
5
+ could push directly to main, bypass required reviews, or merge a PR that
6
+ failed CI. The check shells out to `gh` — it returns not_measured if `gh`
7
+ is unavailable or not authenticated.
8
+
9
+ Scoring:
10
+ - At least one ruleset configured: 100
11
+ - No rulesets found: 0
12
+ - gh unavailable / not authed: not_measured
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+
22
+ from agent_readiness.checks import register
23
+ from agent_readiness.context import RepoContext
24
+ from agent_readiness.models import CheckResult, Finding, Pillar, Severity
25
+
26
+
27
+ def _get_remote_owner_repo(ctx: RepoContext) -> tuple[str, str] | None:
28
+ """Parse owner/repo from the git remote URL. Returns None on failure."""
29
+ result = subprocess.run(
30
+ ["git", "remote", "get-url", "origin"],
31
+ cwd=ctx.root, capture_output=True, text=True, check=False,
32
+ )
33
+ if result.returncode != 0:
34
+ return None
35
+ url = result.stdout.strip()
36
+ # HTTPS: https://github.com/owner/repo.git
37
+ https_match = re.search(r"github\.com[/:]([^/]+)/([^/\s]+?)(?:\.git)?$", url)
38
+ if https_match:
39
+ return https_match.group(1), https_match.group(2)
40
+ return None
41
+
42
+
43
+ @register(
44
+ check_id="branch_rulesets.configured",
45
+ pillar=Pillar.SAFETY,
46
+ title="GitHub branch rulesets configured",
47
+ explanation="""
48
+ GitHub branch rulesets (Settings → Rules → Rulesets) enforce required
49
+ reviewers, passing status checks, and linear history before merges.
50
+ Without them an agent with push access can bypass CI, skip reviews,
51
+ and push broken code directly to the default branch. This check calls
52
+ `gh api repos/{owner}/{repo}/rulesets` — it is marked not_measured when
53
+ the gh CLI is absent or not authenticated.
54
+ """,
55
+ weight=0.8,
56
+ )
57
+ def check(ctx: RepoContext) -> CheckResult:
58
+ _not_measured = CheckResult(
59
+ check_id="branch_rulesets.configured",
60
+ pillar=Pillar.SAFETY,
61
+ score=0.0,
62
+ weight=0.8,
63
+ not_measured=True,
64
+ findings=[Finding(
65
+ check_id="branch_rulesets.configured",
66
+ pillar=Pillar.SAFETY,
67
+ severity=Severity.INFO,
68
+ message=(
69
+ "Branch ruleset check skipped: requires the gh CLI with "
70
+ "a GitHub remote and valid authentication."
71
+ ),
72
+ )],
73
+ )
74
+
75
+ # Require gh CLI
76
+ if shutil.which("gh") is None:
77
+ return _not_measured
78
+
79
+ # Require a GitHub remote
80
+ if not ctx.is_git_repo:
81
+ return _not_measured
82
+ owner_repo = _get_remote_owner_repo(ctx)
83
+ if owner_repo is None:
84
+ return _not_measured
85
+ owner, repo = owner_repo
86
+
87
+ # Require gh auth
88
+ auth_check = subprocess.run(
89
+ ["gh", "auth", "status"],
90
+ capture_output=True, text=True, check=False,
91
+ )
92
+ if auth_check.returncode != 0:
93
+ return _not_measured
94
+
95
+ # Query rulesets
96
+ result = subprocess.run(
97
+ ["gh", "api", f"repos/{owner}/{repo}/rulesets"],
98
+ capture_output=True, text=True, check=False,
99
+ timeout=15,
100
+ )
101
+ if result.returncode != 0:
102
+ return _not_measured
103
+
104
+ try:
105
+ rulesets = json.loads(result.stdout)
106
+ except (json.JSONDecodeError, ValueError):
107
+ return _not_measured
108
+
109
+ if not isinstance(rulesets, list) or len(rulesets) == 0:
110
+ return CheckResult(
111
+ check_id="branch_rulesets.configured",
112
+ pillar=Pillar.SAFETY,
113
+ score=0.0,
114
+ weight=0.8,
115
+ findings=[Finding(
116
+ check_id="branch_rulesets.configured",
117
+ pillar=Pillar.SAFETY,
118
+ severity=Severity.INFO,
119
+ message=f"No branch rulesets configured for {owner}/{repo}.",
120
+ fix_hint=(
121
+ "Add a branch ruleset under Settings → Rules → Rulesets "
122
+ "to require passing CI and code review before merging."
123
+ ),
124
+ )],
125
+ )
126
+
127
+ names = [r.get("name", "unnamed") for r in rulesets if isinstance(r, dict)]
128
+ return CheckResult(
129
+ check_id="branch_rulesets.configured",
130
+ pillar=Pillar.SAFETY,
131
+ score=100.0,
132
+ weight=0.8,
133
+ findings=[Finding(
134
+ check_id="branch_rulesets.configured",
135
+ pillar=Pillar.SAFETY,
136
+ severity=Severity.INFO,
137
+ message=f"Branch rulesets found: {', '.join(names)}.",
138
+ )],
139
+ )
@@ -0,0 +1,238 @@
1
+ """Checks: git.churn_hotspots and code.complexity
2
+
3
+ git.churn_hotspots: Files that change frequently AND are large are
4
+ "hotspots" — they're hard to understand and modify correctly. An agent
5
+ working in a hotspot has a higher chance of introducing regressions.
6
+
7
+ code.complexity: High cyclomatic complexity means more paths through the
8
+ code, harder-to-predict behaviour, and more test cases needed. An agent
9
+ generating code with high complexity is harder to test and review.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+
16
+ from agent_readiness.checks import register
17
+ from agent_readiness.context import RepoContext
18
+ from agent_readiness.models import CheckResult, Finding, Pillar, Severity
19
+
20
+
21
+ @register(
22
+ check_id="git.churn_hotspots",
23
+ pillar=Pillar.COGNITIVE_LOAD,
24
+ title="No high-churn large files (hotspots)",
25
+ explanation="""
26
+ Files that are both frequently changed (>10 commits) and large (>200
27
+ lines) are "hotspots" — they concentrate risk. When an agent modifies
28
+ a hotspot, it's more likely to introduce a regression because the file
29
+ has many existing behaviours to preserve. Hotspots also tend to have
30
+ unclear ownership and intertwined concerns.
31
+ """,
32
+ weight=0.6,
33
+ )
34
+ def check_churn_hotspots(ctx: RepoContext) -> CheckResult:
35
+ # Skip if fewer than 5 commits (not enough history to measure churn)
36
+ if ctx.commit_count < 5:
37
+ return CheckResult(
38
+ check_id="git.churn_hotspots",
39
+ pillar=Pillar.COGNITIVE_LOAD,
40
+ score=0.0,
41
+ weight=0.6,
42
+ not_measured=True,
43
+ findings=[Finding(
44
+ check_id="git.churn_hotspots",
45
+ pillar=Pillar.COGNITIVE_LOAD,
46
+ severity=Severity.INFO,
47
+ message=(
48
+ f"Only {ctx.commit_count} commits — not enough history "
49
+ "to measure churn hotspots."
50
+ ),
51
+ )],
52
+ )
53
+
54
+ # Run git log --numstat
55
+ try:
56
+ result = subprocess.run(
57
+ ["git", "log", "--numstat", "--pretty=format:", "--", "."],
58
+ cwd=ctx.root,
59
+ capture_output=True,
60
+ text=True,
61
+ check=False,
62
+ timeout=30,
63
+ )
64
+ except (subprocess.TimeoutExpired, OSError):
65
+ return CheckResult(
66
+ check_id="git.churn_hotspots",
67
+ pillar=Pillar.COGNITIVE_LOAD,
68
+ score=0.0,
69
+ weight=0.6,
70
+ not_measured=True,
71
+ findings=[Finding(
72
+ check_id="git.churn_hotspots",
73
+ pillar=Pillar.COGNITIVE_LOAD,
74
+ severity=Severity.INFO,
75
+ message="Could not run git log for churn analysis.",
76
+ )],
77
+ )
78
+
79
+ # Parse numstat output: "<additions>\t<deletions>\t<filename>"
80
+ change_count: dict[str, int] = {}
81
+ for line in result.stdout.splitlines():
82
+ parts = line.split("\t")
83
+ if len(parts) < 3:
84
+ continue
85
+ additions, deletions, filename = parts[0], parts[1], parts[2]
86
+ # Binary files show "-"
87
+ if additions == "-" or deletions == "-":
88
+ continue
89
+ # Ignore renames (contain " => ")
90
+ if " => " in filename:
91
+ continue
92
+ change_count[filename] = change_count.get(filename, 0) + 1
93
+
94
+ # Identify hotspots: changed >10 times AND file has >200 lines
95
+ hotspots: list[str] = []
96
+ for filename, count in change_count.items():
97
+ if count <= 10:
98
+ continue
99
+ full_path = ctx.root / filename
100
+ if not full_path.is_file():
101
+ continue
102
+ text = ctx.read_text(filename, max_bytes=256_000)
103
+ if text is not None and text.count("\n") > 200:
104
+ hotspots.append((filename, count))
105
+
106
+ hotspots.sort(key=lambda x: x[1], reverse=True)
107
+
108
+ n = len(hotspots)
109
+ if n == 0:
110
+ score = 100.0
111
+ elif n <= 2:
112
+ # Mild (10-20 changes) vs severe (>20 changes)
113
+ max_churn = max(c for _, c in hotspots)
114
+ score = 80.0 if max_churn <= 20 else 60.0
115
+ else:
116
+ score = 0.0
117
+
118
+ findings: list[Finding] = []
119
+ for filename, count in hotspots[:5]:
120
+ findings.append(Finding(
121
+ check_id="git.churn_hotspots",
122
+ pillar=Pillar.COGNITIVE_LOAD,
123
+ severity=Severity.WARN,
124
+ file=filename,
125
+ message=f"Hotspot: {filename} changed {count} times and is >200 lines.",
126
+ fix_hint="Consider splitting this file into smaller, focused modules.",
127
+ ))
128
+
129
+ return CheckResult(
130
+ check_id="git.churn_hotspots",
131
+ pillar=Pillar.COGNITIVE_LOAD,
132
+ score=score,
133
+ weight=0.6,
134
+ findings=findings,
135
+ )
136
+
137
+
138
+ @register(
139
+ check_id="code.complexity",
140
+ pillar=Pillar.COGNITIVE_LOAD,
141
+ title="Code cyclomatic complexity is low",
142
+ explanation="""
143
+ High cyclomatic complexity (many branches, loops, exception handlers)
144
+ in a function means more paths through the code. An agent generating
145
+ changes to a complex function has more edge cases to reason about and
146
+ more ways to introduce a bug. Keeping functions simple (complexity < 5)
147
+ makes agent-generated diffs safer and easier to review.
148
+ """,
149
+ weight=0.7,
150
+ )
151
+ def check_code_complexity(ctx: RepoContext) -> CheckResult:
152
+ try:
153
+ import lizard # type: ignore[import]
154
+ except ImportError:
155
+ return CheckResult(
156
+ check_id="code.complexity",
157
+ pillar=Pillar.COGNITIVE_LOAD,
158
+ score=0.0,
159
+ weight=0.7,
160
+ not_measured=True,
161
+ findings=[Finding(
162
+ check_id="code.complexity",
163
+ pillar=Pillar.COGNITIVE_LOAD,
164
+ severity=Severity.INFO,
165
+ message="Install lizard for complexity analysis: pip install lizard",
166
+ )],
167
+ )
168
+
169
+ # Scan Python/JS/TS/Go/Java files
170
+ _EXT = {".py", ".js", ".ts", ".go", ".java"}
171
+ files_to_scan = [
172
+ str(ctx.root / f) for f in ctx._files if f.suffix in _EXT
173
+ ]
174
+
175
+ if not files_to_scan:
176
+ return CheckResult(
177
+ check_id="code.complexity",
178
+ pillar=Pillar.COGNITIVE_LOAD,
179
+ score=100.0,
180
+ weight=0.7,
181
+ not_measured=True,
182
+ )
183
+
184
+ total_complexity = 0.0
185
+ total_functions = 0
186
+ high_complexity: list[tuple[str, str, int]] = [] # (file, func, cc)
187
+
188
+ for filepath in files_to_scan:
189
+ try:
190
+ file_info = lizard.analyze_file(filepath)
191
+ except Exception: # noqa: BLE001
192
+ continue
193
+ for func in file_info.function_list:
194
+ cc = func.cyclomatic_complexity
195
+ total_complexity += cc
196
+ total_functions += 1
197
+ if cc > 15:
198
+ rel = filepath.replace(str(ctx.root) + "/", "")
199
+ high_complexity.append((rel, func.name, cc))
200
+
201
+ if total_functions == 0:
202
+ return CheckResult(
203
+ check_id="code.complexity",
204
+ pillar=Pillar.COGNITIVE_LOAD,
205
+ score=100.0,
206
+ weight=0.7,
207
+ )
208
+
209
+ avg = total_complexity / total_functions
210
+
211
+ if avg < 5:
212
+ score = 100.0
213
+ elif avg < 8:
214
+ score = 80.0
215
+ elif avg < 12:
216
+ score = 60.0
217
+ else:
218
+ score = 0.0
219
+
220
+ high_complexity.sort(key=lambda x: x[2], reverse=True)
221
+ findings: list[Finding] = []
222
+ for rel_file, func_name, cc in high_complexity[:5]:
223
+ findings.append(Finding(
224
+ check_id="code.complexity",
225
+ pillar=Pillar.COGNITIVE_LOAD,
226
+ severity=Severity.WARN,
227
+ file=rel_file,
228
+ message=f"Function '{func_name}' has cyclomatic complexity {cc}.",
229
+ fix_hint="Refactor into smaller functions with a single responsibility.",
230
+ ))
231
+
232
+ return CheckResult(
233
+ check_id="code.complexity",
234
+ pillar=Pillar.COGNITIVE_LOAD,
235
+ score=score,
236
+ weight=0.7,
237
+ findings=findings,
238
+ )