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.
- agent_readiness/__init__.py +3 -0
- agent_readiness/__main__.py +5 -0
- agent_readiness/checks/__init__.py +115 -0
- agent_readiness/checks/agent_docs.py +117 -0
- agent_readiness/checks/branch_rulesets.py +139 -0
- agent_readiness/checks/churn.py +238 -0
- agent_readiness/checks/ci_check.py +105 -0
- agent_readiness/checks/devcontainer.py +70 -0
- agent_readiness/checks/entry_points.py +90 -0
- agent_readiness/checks/env_parity.py +120 -0
- agent_readiness/checks/git_history.py +85 -0
- agent_readiness/checks/gitignore.py +114 -0
- agent_readiness/checks/headless.py +169 -0
- agent_readiness/checks/hooks.py +81 -0
- agent_readiness/checks/lint_check.py +94 -0
- agent_readiness/checks/manifest.py +136 -0
- agent_readiness/checks/naming.py +82 -0
- agent_readiness/checks/readme.py +97 -0
- agent_readiness/checks/repo_shape.py +175 -0
- agent_readiness/checks/repo_templates.py +101 -0
- agent_readiness/checks/secrets.py +123 -0
- agent_readiness/checks/security.py +140 -0
- agent_readiness/checks/setup_steps.py +126 -0
- agent_readiness/checks/test_command.py +187 -0
- agent_readiness/checks/typecheck.py +91 -0
- agent_readiness/cli.py +366 -0
- agent_readiness/config.py +35 -0
- agent_readiness/context.py +217 -0
- agent_readiness/mcp_server.py +132 -0
- agent_readiness/models.py +129 -0
- agent_readiness/plugins.py +48 -0
- agent_readiness/renderers/__init__.py +6 -0
- agent_readiness/renderers/html_renderer.py +76 -0
- agent_readiness/renderers/json_renderer.py +12 -0
- agent_readiness/renderers/progress.py +143 -0
- agent_readiness/renderers/sarif.py +55 -0
- agent_readiness/renderers/terminal.py +143 -0
- agent_readiness/sandbox.py +334 -0
- agent_readiness/scaffold.py +145 -0
- agent_readiness/scorer.py +95 -0
- agent_readiness/templates/AGENTS.md +35 -0
- agent_readiness/templates/CODEOWNERS +10 -0
- agent_readiness/templates/SECURITY.md +31 -0
- agent_readiness/templates/dependabot.yml +21 -0
- agent_readiness/templates/devcontainer.json +11 -0
- agent_readiness/templates/gitignore +34 -0
- agent_readiness/templates/issue_template_bug.md +29 -0
- agent_readiness/templates/issue_template_feature.md +19 -0
- agent_readiness/templates/pre-commit-config.yaml +25 -0
- agent_readiness/templates/pull_request_template.md +17 -0
- agent_readiness-1.0.0.dist-info/METADATA +148 -0
- agent_readiness-1.0.0.dist-info/RECORD +54 -0
- agent_readiness-1.0.0.dist-info/WHEEL +4 -0
- agent_readiness-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
)
|