agentrepocoach 0.2.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.
- agentrepocoach/__init__.py +14 -0
- agentrepocoach/__main__.py +4 -0
- agentrepocoach/adapters/__init__.py +64 -0
- agentrepocoach/adapters/base.py +195 -0
- agentrepocoach/adapters/csharp.py +419 -0
- agentrepocoach/adapters/go.py +283 -0
- agentrepocoach/adapters/python.py +244 -0
- agentrepocoach/adapters/rust.py +304 -0
- agentrepocoach/adapters/typescript.py +351 -0
- agentrepocoach/cli.py +155 -0
- agentrepocoach/components/__init__.py +27 -0
- agentrepocoach/components/decision_queryability.py +192 -0
- agentrepocoach/components/documentation.py +205 -0
- agentrepocoach/components/error_quality.py +162 -0
- agentrepocoach/components/module_hygiene.py +175 -0
- agentrepocoach/components/test_quality.py +179 -0
- agentrepocoach/compute.py +84 -0
- agentrepocoach/config.py +263 -0
- agentrepocoach/output.py +267 -0
- agentrepocoach/scoring.py +34 -0
- agentrepocoach-0.2.0.dist-info/METADATA +202 -0
- agentrepocoach-0.2.0.dist-info/RECORD +26 -0
- agentrepocoach-0.2.0.dist-info/WHEEL +5 -0
- agentrepocoach-0.2.0.dist-info/entry_points.txt +2 -0
- agentrepocoach-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentrepocoach-0.2.0.dist-info/top_level.txt +1 -0
agentrepocoach/config.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Configuration loader and schema for AgentRepoCoach.
|
|
2
|
+
|
|
3
|
+
Uses the stdlib ``tomllib`` (Python 3.11+) to parse ``.agentrepocoach.toml``. No
|
|
4
|
+
PyYAML dependency: zero runtime deps is a hard supply-chain-trust constraint.
|
|
5
|
+
|
|
6
|
+
All fields in the config file are optional. ``load_config(repo_root)`` returns
|
|
7
|
+
a populated ``Config`` object with defaults filled in. If the file is missing
|
|
8
|
+
or unreadable, defaults are used.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Schema version — bump on breaking config changes.
|
|
18
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
# Default component weights. Derived from methodology research: navigability
|
|
21
|
+
# and error quality dominate because agents fail fastest on missing entry
|
|
22
|
+
# points and unactionable errors.
|
|
23
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
24
|
+
"navigability": 0.25,
|
|
25
|
+
"error_quality": 0.25,
|
|
26
|
+
"decision_queryability": 0.20,
|
|
27
|
+
"test_quality": 0.15,
|
|
28
|
+
"module_hygiene": 0.15,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DEFAULT_EXCLUDES: tuple[str, ...] = (
|
|
32
|
+
"node_modules/**",
|
|
33
|
+
"vendor/**",
|
|
34
|
+
"third_party/**",
|
|
35
|
+
"**/bin/**",
|
|
36
|
+
"**/obj/**",
|
|
37
|
+
"**/__pycache__/**",
|
|
38
|
+
"**/.venv/**",
|
|
39
|
+
"**/.tox/**",
|
|
40
|
+
"**/*.Designer.*",
|
|
41
|
+
"**/*.g.*",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Language-neutral files allowed at repo root without counting as "stale".
|
|
45
|
+
DEFAULT_ROOT_ALLOWLIST: tuple[str, ...] = (
|
|
46
|
+
".gitignore",
|
|
47
|
+
".dockerignore",
|
|
48
|
+
".editorconfig",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"NOTICE",
|
|
51
|
+
"README.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
53
|
+
"CONTRIBUTING.md",
|
|
54
|
+
"CODE_OF_CONDUCT.md",
|
|
55
|
+
"SECURITY.md",
|
|
56
|
+
"pyproject.toml",
|
|
57
|
+
"setup.py",
|
|
58
|
+
"setup.cfg",
|
|
59
|
+
"requirements.txt",
|
|
60
|
+
"package.json",
|
|
61
|
+
"package-lock.json",
|
|
62
|
+
"Cargo.toml",
|
|
63
|
+
"Cargo.lock",
|
|
64
|
+
"go.mod",
|
|
65
|
+
"go.sum",
|
|
66
|
+
"Dockerfile",
|
|
67
|
+
"docker-compose.yml",
|
|
68
|
+
"Makefile",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ThresholdConfig:
|
|
74
|
+
"""Numeric thresholds shared across components."""
|
|
75
|
+
god_file_loc: int = 800
|
|
76
|
+
adr_min_count: int = 20
|
|
77
|
+
doc_comment_min_coverage_pct: float = 90.0
|
|
78
|
+
cli_manifest_min_commands: int = 20
|
|
79
|
+
cli_manifest_fresh_days: int = 7
|
|
80
|
+
cli_manifest_stale_days: int = 14
|
|
81
|
+
root_stale_max_penalty_count: int = 5
|
|
82
|
+
# Threat-model hardening constants (see SECURITY.md).
|
|
83
|
+
max_file_bytes: int = 10_485_760 # 10 MB
|
|
84
|
+
follow_symlinks: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class DecisionQueryabilityConfig:
|
|
89
|
+
"""Settings for the decision_queryability component."""
|
|
90
|
+
inline_ref_patterns: tuple[str, ...] = ("ADR-\\d+",)
|
|
91
|
+
adr_index: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ErrorQualityConfig:
|
|
96
|
+
"""Settings for the error_quality component."""
|
|
97
|
+
domain_exception_base: str = ""
|
|
98
|
+
domain_exception_types: tuple[str, ...] = ()
|
|
99
|
+
hint_marker: str = "Suggested fix:"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class TestQualityConfig:
|
|
104
|
+
"""Settings for the test_quality component."""
|
|
105
|
+
fixture_duplication_patterns: tuple[str, ...] = ()
|
|
106
|
+
helpers_full_count: int = 10
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ModuleHygieneConfig:
|
|
111
|
+
"""Settings for the module_hygiene component."""
|
|
112
|
+
architecture_doc_fresh_days: int = 60
|
|
113
|
+
internal_visibility_full_ratio: float = 0.10
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class PathConfig:
|
|
118
|
+
"""File and directory paths used by scoring components."""
|
|
119
|
+
agents_md: str = "AGENTS.md"
|
|
120
|
+
codebase_map: str = "docs/codebase-map.md"
|
|
121
|
+
cli_manifest: str = "docs/cli-manifest.json"
|
|
122
|
+
adr_dir: str = "docs/adr/"
|
|
123
|
+
architecture_doc: str = "docs/architecture.md"
|
|
124
|
+
test_helpers_dir: str = "auto"
|
|
125
|
+
production_modules: tuple[str, ...] = ("auto",)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Config:
|
|
130
|
+
"""Fully-populated AgentRepoCoach configuration."""
|
|
131
|
+
schema_version: int = CURRENT_SCHEMA_VERSION
|
|
132
|
+
language: str = "auto"
|
|
133
|
+
weights: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_WEIGHTS))
|
|
134
|
+
paths: PathConfig = field(default_factory=PathConfig)
|
|
135
|
+
exclude: tuple[str, ...] = DEFAULT_EXCLUDES
|
|
136
|
+
root_allowlist: tuple[str, ...] = DEFAULT_ROOT_ALLOWLIST
|
|
137
|
+
thresholds: ThresholdConfig = field(default_factory=ThresholdConfig)
|
|
138
|
+
decision_queryability: DecisionQueryabilityConfig = field(default_factory=DecisionQueryabilityConfig)
|
|
139
|
+
error_quality: ErrorQualityConfig = field(default_factory=ErrorQualityConfig)
|
|
140
|
+
test_quality: TestQualityConfig = field(default_factory=TestQualityConfig)
|
|
141
|
+
module_hygiene: ModuleHygieneConfig = field(default_factory=ModuleHygieneConfig)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ConfigError(ValueError):
|
|
145
|
+
"""Raised when a config file exists but fails validation."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config(repo_root: Path, config_path: Path | None = None) -> Config:
|
|
149
|
+
"""Load ``.agentrepocoach.toml`` from ``repo_root`` (or an explicit path).
|
|
150
|
+
|
|
151
|
+
Missing file -> returns the default Config.
|
|
152
|
+
Malformed file -> raises ConfigError.
|
|
153
|
+
"""
|
|
154
|
+
path = config_path if config_path is not None else (repo_root / ".agentrepocoach.toml")
|
|
155
|
+
if not path.is_file():
|
|
156
|
+
return Config()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
with path.open("rb") as handle:
|
|
160
|
+
raw = tomllib.load(handle)
|
|
161
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
162
|
+
msg = f"Failed to parse {path}: {exc}."
|
|
163
|
+
raise ConfigError(f"{msg} Check that the file is valid TOML. See docs/configuration.md for syntax examples.") from exc
|
|
164
|
+
|
|
165
|
+
return _build_config_from_dict(raw)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_config_from_dict(raw: dict[str, Any]) -> Config:
|
|
169
|
+
"""Merge a parsed TOML dict into a Config with defaults applied."""
|
|
170
|
+
schema_version = int(raw.get("schema_version", CURRENT_SCHEMA_VERSION))
|
|
171
|
+
if schema_version != CURRENT_SCHEMA_VERSION:
|
|
172
|
+
msg = f"Unsupported schema_version {schema_version}. This tool supports schema_version {CURRENT_SCHEMA_VERSION}."
|
|
173
|
+
raise ConfigError(f"{msg} Try updating agentrepocoach or check the config file format at docs/configuration.md.")
|
|
174
|
+
|
|
175
|
+
weights = dict(DEFAULT_WEIGHTS)
|
|
176
|
+
weights.update(raw.get("weights", {}))
|
|
177
|
+
_validate_weights(weights)
|
|
178
|
+
|
|
179
|
+
return Config(
|
|
180
|
+
schema_version=schema_version,
|
|
181
|
+
language=str(raw.get("language", "auto")),
|
|
182
|
+
weights=weights,
|
|
183
|
+
paths=_build_path_config(raw.get("paths", {})),
|
|
184
|
+
exclude=tuple(raw.get("exclude", DEFAULT_EXCLUDES)),
|
|
185
|
+
root_allowlist=tuple(raw.get("root_allowlist", DEFAULT_ROOT_ALLOWLIST)),
|
|
186
|
+
thresholds=_build_threshold_config(raw.get("thresholds", {})),
|
|
187
|
+
decision_queryability=_build_decision_queryability_config(
|
|
188
|
+
raw.get("decision_queryability", {}),
|
|
189
|
+
),
|
|
190
|
+
error_quality=_build_error_quality_config(raw.get("error_quality", {})),
|
|
191
|
+
test_quality=_build_test_quality_config(raw.get("test_quality", {})),
|
|
192
|
+
module_hygiene=_build_module_hygiene_config(raw.get("module_hygiene", {})),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_weights(weights: dict[str, float]) -> None:
|
|
197
|
+
"""Ensure every component has a weight and they sum to ~1.0."""
|
|
198
|
+
missing = set(DEFAULT_WEIGHTS) - set(weights)
|
|
199
|
+
if missing:
|
|
200
|
+
msg = f"Missing component weights: {sorted(missing)}."
|
|
201
|
+
raise ConfigError(f"{msg} Check that [weights] in .agentrepocoach.toml includes all five components. See docs/configuration.md.")
|
|
202
|
+
total = sum(weights[name] for name in DEFAULT_WEIGHTS)
|
|
203
|
+
if abs(total - 1.0) > 0.01:
|
|
204
|
+
msg = f"Component weights must sum to 1.0 (got {total:.3f})."
|
|
205
|
+
raise ConfigError(f"{msg} Check the [weights] section in .agentrepocoach.toml and ensure the five values add up to exactly 1.0.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _build_path_config(raw: dict[str, Any]) -> PathConfig:
|
|
209
|
+
production = raw.get("production_modules", ("auto",))
|
|
210
|
+
if isinstance(production, str):
|
|
211
|
+
production = (production,)
|
|
212
|
+
return PathConfig(
|
|
213
|
+
agents_md=str(raw.get("agents_md", "AGENTS.md")),
|
|
214
|
+
codebase_map=str(raw.get("codebase_map", "docs/codebase-map.md")),
|
|
215
|
+
cli_manifest=str(raw.get("cli_manifest", "docs/cli-manifest.json")),
|
|
216
|
+
adr_dir=str(raw.get("adr_dir", "docs/adr/")),
|
|
217
|
+
architecture_doc=str(raw.get("architecture_doc", "docs/architecture.md")),
|
|
218
|
+
test_helpers_dir=str(raw.get("test_helpers_dir", "auto")),
|
|
219
|
+
production_modules=tuple(production),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_threshold_config(raw: dict[str, Any]) -> ThresholdConfig:
|
|
224
|
+
return ThresholdConfig(
|
|
225
|
+
god_file_loc=int(raw.get("god_file_loc", 800)),
|
|
226
|
+
adr_min_count=int(raw.get("adr_min_count", 20)),
|
|
227
|
+
doc_comment_min_coverage_pct=float(raw.get("doc_comment_min_coverage_pct", 90.0)),
|
|
228
|
+
cli_manifest_min_commands=int(raw.get("cli_manifest_min_commands", 20)),
|
|
229
|
+
cli_manifest_fresh_days=int(raw.get("cli_manifest_fresh_days", 7)),
|
|
230
|
+
cli_manifest_stale_days=int(raw.get("cli_manifest_stale_days", 14)),
|
|
231
|
+
root_stale_max_penalty_count=int(raw.get("root_stale_max_penalty_count", 5)),
|
|
232
|
+
max_file_bytes=int(raw.get("max_file_bytes", 10_485_760)),
|
|
233
|
+
follow_symlinks=bool(raw.get("follow_symlinks", False)),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_decision_queryability_config(raw: dict[str, Any]) -> DecisionQueryabilityConfig:
|
|
238
|
+
return DecisionQueryabilityConfig(
|
|
239
|
+
inline_ref_patterns=tuple(raw.get("inline_ref_patterns", ("ADR-\\d+",))),
|
|
240
|
+
adr_index=str(raw.get("adr_index", "")),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _build_error_quality_config(raw: dict[str, Any]) -> ErrorQualityConfig:
|
|
245
|
+
return ErrorQualityConfig(
|
|
246
|
+
domain_exception_base=str(raw.get("domain_exception_base", "")),
|
|
247
|
+
domain_exception_types=tuple(raw.get("domain_exception_types", ())),
|
|
248
|
+
hint_marker=str(raw.get("hint_marker", "Suggested fix:")),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _build_test_quality_config(raw: dict[str, Any]) -> TestQualityConfig:
|
|
253
|
+
return TestQualityConfig(
|
|
254
|
+
fixture_duplication_patterns=tuple(raw.get("fixture_duplication_patterns", ())),
|
|
255
|
+
helpers_full_count=int(raw.get("helpers_full_count", 10)),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _build_module_hygiene_config(raw: dict[str, Any]) -> ModuleHygieneConfig:
|
|
260
|
+
return ModuleHygieneConfig(
|
|
261
|
+
architecture_doc_fresh_days=int(raw.get("architecture_doc_fresh_days", 60)),
|
|
262
|
+
internal_visibility_full_ratio=float(raw.get("internal_visibility_full_ratio", 0.10)),
|
|
263
|
+
)
|
agentrepocoach/output.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Output writers for AgentRepoCoach.
|
|
2
|
+
|
|
3
|
+
Three supported formats:
|
|
4
|
+
|
|
5
|
+
- JSON: full score breakdown, suitable for CI artifacts.
|
|
6
|
+
- Prometheus: exposition format for metric scraping.
|
|
7
|
+
- Markdown: short summary suitable for a PR comment.
|
|
8
|
+
|
|
9
|
+
Threat-model constraint: the JSON output must NEVER contain code snippets or
|
|
10
|
+
raw message bodies from scanned files — only counts, percentages, exception
|
|
11
|
+
type names, and file paths. The current component implementations already
|
|
12
|
+
honor this contract.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_METRIC_HELP = "AgentRepoCoach composite codebase agent health score (0-100)."
|
|
21
|
+
_METRIC_NAME = "agentrepocoach_codebase_health_score"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_json(result: dict[str, Any], path: Path) -> None:
|
|
25
|
+
"""Write the full score breakdown as JSON."""
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
path.write_text(json.dumps(result, indent=2, default=str) + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_prometheus(result: dict[str, Any], path: Path) -> None:
|
|
31
|
+
"""Write the score in Prometheus exposition format."""
|
|
32
|
+
lines = [
|
|
33
|
+
f"# HELP {_METRIC_NAME} {_METRIC_HELP}",
|
|
34
|
+
f"# TYPE {_METRIC_NAME} gauge",
|
|
35
|
+
f'{_METRIC_NAME}{{component="total"}} {result["total"]}',
|
|
36
|
+
]
|
|
37
|
+
for name, component in result.get("components", {}).items():
|
|
38
|
+
score = component.get("score", 0)
|
|
39
|
+
lines.append(f'{_METRIC_NAME}{{component="{name}"}} {score}')
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text("\n".join(lines) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_markdown_comment(result: dict[str, Any], path: Path) -> None:
|
|
45
|
+
"""Write a short summary suitable for a GitHub PR comment."""
|
|
46
|
+
lines = [
|
|
47
|
+
"### AgentRepoCoach — Codebase Agent Health",
|
|
48
|
+
"",
|
|
49
|
+
f"**Total score:** {result['total']:.2f} / 100",
|
|
50
|
+
f"**Language:** `{result.get('language', 'unknown')}`",
|
|
51
|
+
"",
|
|
52
|
+
"| Component | Score | Weight |",
|
|
53
|
+
"|---|---:|---:|",
|
|
54
|
+
]
|
|
55
|
+
weights = result.get("weights", {})
|
|
56
|
+
for name, component in result.get("components", {}).items():
|
|
57
|
+
weight = weights.get(name, 0.0)
|
|
58
|
+
lines.append(f"| {name} | {component['score']:.2f} / 100 | {weight:.2f} |")
|
|
59
|
+
tips = generate_coaching(result)
|
|
60
|
+
coaching = format_coaching_markdown(tips)
|
|
61
|
+
if coaching:
|
|
62
|
+
lines.append(coaching)
|
|
63
|
+
lines.append("<!-- agentrepocoach -->")
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
path.write_text("\n".join(lines) + "\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Coaching recommendations engine
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# Maps (component, sub_component) to (short label, actionable fix suggestion).
|
|
73
|
+
# Only sub-components with known coaching text are included.
|
|
74
|
+
_COACHING_MAP: dict[tuple[str, str], tuple[str, str]] = {
|
|
75
|
+
("navigability", "agents_md"): (
|
|
76
|
+
"Missing AGENTS.md",
|
|
77
|
+
"Create an AGENTS.md at the repo root that links to your codebase map, "
|
|
78
|
+
"CLI manifest, and ADR directory. This is the first file AI agents read.",
|
|
79
|
+
),
|
|
80
|
+
("navigability", "codebase_map"): (
|
|
81
|
+
"Incomplete codebase map",
|
|
82
|
+
"Create or update docs/codebase-map.md to list every production module "
|
|
83
|
+
"with a one-line description of what it does.",
|
|
84
|
+
),
|
|
85
|
+
("navigability", "cli_manifest"): (
|
|
86
|
+
"Missing or stale CLI manifest",
|
|
87
|
+
"Create docs/cli-manifest.json listing your CLI commands and their flags. "
|
|
88
|
+
"Keep it fresh — agents use it to discover available operations.",
|
|
89
|
+
),
|
|
90
|
+
("navigability", "root_cleanliness"): (
|
|
91
|
+
"Root directory clutter",
|
|
92
|
+
"Remove stale artifacts (backup files, old reports) from the repo root. "
|
|
93
|
+
"A clean root helps agents orient faster.",
|
|
94
|
+
),
|
|
95
|
+
("error_quality", "hint_coverage"): (
|
|
96
|
+
"Low fix-hint coverage in errors",
|
|
97
|
+
"Add actionable fix hints to your error messages (e.g., 'Try ...', "
|
|
98
|
+
"'See docs/...', 'Did you mean ...'). Agents recover faster from errors "
|
|
99
|
+
"that explain what to do next.",
|
|
100
|
+
),
|
|
101
|
+
("error_quality", "exception_subclass_ratio"): (
|
|
102
|
+
"Too few domain-specific error types",
|
|
103
|
+
"Replace generic exceptions with domain-specific subtypes. Agents can "
|
|
104
|
+
"handle a ValidationError differently from a ConnectionError — but only "
|
|
105
|
+
"if you distinguish them in your type hierarchy.",
|
|
106
|
+
),
|
|
107
|
+
("error_quality", "generic_exception_dominance"): (
|
|
108
|
+
"Generic exceptions dominate",
|
|
109
|
+
"Reduce use of bare Exception/Error throws. Wrap them in domain types "
|
|
110
|
+
"so agents can match on specific error categories.",
|
|
111
|
+
),
|
|
112
|
+
("decision_queryability", "adr_catalog"): (
|
|
113
|
+
"Few or no ADRs",
|
|
114
|
+
"Create Architecture Decision Records in docs/adr/ (e.g., ADR-001-*.md). "
|
|
115
|
+
"Agents consult ADRs to understand why the codebase is shaped the way it is.",
|
|
116
|
+
),
|
|
117
|
+
("decision_queryability", "inline_ref_resolution"): (
|
|
118
|
+
"Missing inline ADR references",
|
|
119
|
+
"Add ADR-NNN references in code comments near decisions. This lets agents "
|
|
120
|
+
"trace from code back to the rationale without searching.",
|
|
121
|
+
),
|
|
122
|
+
("test_quality", "naming_convention"): (
|
|
123
|
+
"Test names lack structure",
|
|
124
|
+
"Use descriptive test names that encode the scenario and expectation "
|
|
125
|
+
"(e.g., test_doWork_negativeInput_returnsError). Agents use test names "
|
|
126
|
+
"to understand intended behavior.",
|
|
127
|
+
),
|
|
128
|
+
("test_quality", "helper_files"): (
|
|
129
|
+
"No test helpers or builders",
|
|
130
|
+
"Add shared test helpers (builders, factories) to reduce fixture duplication "
|
|
131
|
+
"and make test setup readable for agents.",
|
|
132
|
+
),
|
|
133
|
+
("test_quality", "fixture_duplication"): (
|
|
134
|
+
"Fixture code is duplicated across tests",
|
|
135
|
+
"Extract shared test fixtures into conftest/setUp helpers. Duplicated setup "
|
|
136
|
+
"wastes agent context and increases mutation surface.",
|
|
137
|
+
),
|
|
138
|
+
("module_hygiene", "internal_visibility"): (
|
|
139
|
+
"Low internal visibility usage",
|
|
140
|
+
"Mark implementation-detail types as internal/private. Public-by-default "
|
|
141
|
+
"forces agents to consider your entire surface area as API.",
|
|
142
|
+
),
|
|
143
|
+
("module_hygiene", "god_files"): (
|
|
144
|
+
"God files detected",
|
|
145
|
+
"Split files over 500 lines into focused modules. Large files overflow "
|
|
146
|
+
"agent context windows and slow down navigation.",
|
|
147
|
+
),
|
|
148
|
+
("module_hygiene", "doc_comment_coverage"): (
|
|
149
|
+
"Low doc comment coverage",
|
|
150
|
+
"Add doc comments (JSDoc, docstrings, XML docs, /// comments) to public "
|
|
151
|
+
"declarations. Agents read these before reading function bodies.",
|
|
152
|
+
),
|
|
153
|
+
("module_hygiene", "architecture_doc"): (
|
|
154
|
+
"Missing or stale architecture doc",
|
|
155
|
+
"Create or update docs/architecture.md with a high-level overview of your "
|
|
156
|
+
"system's modules and data flow.",
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def generate_coaching(result: dict[str, Any], max_tips: int = 3) -> list[dict[str, Any]]:
|
|
162
|
+
"""Return the top coaching recommendations sorted by impact (biggest gap first).
|
|
163
|
+
|
|
164
|
+
Each recommendation is a dict with: component, sub_component, label, tip,
|
|
165
|
+
current_score, max_score, gap.
|
|
166
|
+
"""
|
|
167
|
+
gaps: list[dict[str, Any]] = []
|
|
168
|
+
for comp_name, component in result.get("components", {}).items():
|
|
169
|
+
weight = result.get("weights", {}).get(comp_name, 0.0)
|
|
170
|
+
for sub_name, sub in component.get("breakdown", {}).items():
|
|
171
|
+
score = sub.get("score", 0)
|
|
172
|
+
maximum = sub.get("max", 0)
|
|
173
|
+
if maximum <= 0:
|
|
174
|
+
continue
|
|
175
|
+
gap = maximum - score
|
|
176
|
+
if gap <= 0:
|
|
177
|
+
continue
|
|
178
|
+
key = (comp_name, sub_name)
|
|
179
|
+
if key not in _COACHING_MAP:
|
|
180
|
+
continue
|
|
181
|
+
label, tip = _COACHING_MAP[key]
|
|
182
|
+
# Weighted gap: how much this sub-component could improve the total.
|
|
183
|
+
weighted_gap = gap * weight
|
|
184
|
+
gaps.append({
|
|
185
|
+
"component": comp_name,
|
|
186
|
+
"sub_component": sub_name,
|
|
187
|
+
"label": label,
|
|
188
|
+
"tip": tip,
|
|
189
|
+
"current_score": score,
|
|
190
|
+
"max_score": maximum,
|
|
191
|
+
"gap": gap,
|
|
192
|
+
"weighted_gap": weighted_gap,
|
|
193
|
+
})
|
|
194
|
+
gaps.sort(key=lambda g: g["weighted_gap"], reverse=True)
|
|
195
|
+
return gaps[:max_tips]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def format_coaching(tips: list[dict[str, Any]]) -> str:
|
|
199
|
+
"""Format coaching tips for terminal output."""
|
|
200
|
+
if not tips:
|
|
201
|
+
return ""
|
|
202
|
+
lines = ["", "Top recommendations to improve your score:", ""]
|
|
203
|
+
for i, tip in enumerate(tips, 1):
|
|
204
|
+
lines.append(
|
|
205
|
+
f" {i}. [{tip['component']}] {tip['label']} "
|
|
206
|
+
f"({tip['current_score']:.0f}/{tip['max_score']:.0f} pts)"
|
|
207
|
+
)
|
|
208
|
+
lines.append(f" {tip['tip']}")
|
|
209
|
+
lines.append("")
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def format_coaching_markdown(tips: list[dict[str, Any]]) -> str:
|
|
214
|
+
"""Format coaching tips as markdown."""
|
|
215
|
+
if not tips:
|
|
216
|
+
return ""
|
|
217
|
+
lines = [
|
|
218
|
+
"",
|
|
219
|
+
"#### Top recommendations",
|
|
220
|
+
"",
|
|
221
|
+
]
|
|
222
|
+
for i, tip in enumerate(tips, 1):
|
|
223
|
+
lines.append(
|
|
224
|
+
f"{i}. **{tip['label']}** "
|
|
225
|
+
f"(`{tip['component']}` — {tip['current_score']:.0f}/{tip['max_score']:.0f} pts)"
|
|
226
|
+
)
|
|
227
|
+
lines.append(f" {tip['tip']}")
|
|
228
|
+
lines.append("")
|
|
229
|
+
return "\n".join(lines)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def format_summary(result: dict[str, Any]) -> str:
|
|
233
|
+
"""Return a terminal-friendly summary with coaching tips."""
|
|
234
|
+
lines = [
|
|
235
|
+
"AgentRepoCoach — Codebase Agent Health",
|
|
236
|
+
"=================================",
|
|
237
|
+
f"Total score: {result['total']:.2f} / 100",
|
|
238
|
+
f"Language: {result.get('language', 'unknown')}",
|
|
239
|
+
"",
|
|
240
|
+
"Components:",
|
|
241
|
+
]
|
|
242
|
+
weights = result.get("weights", {})
|
|
243
|
+
for name, component in result.get("components", {}).items():
|
|
244
|
+
weight = weights.get(name, 0.0)
|
|
245
|
+
contribution = weight * component["score"]
|
|
246
|
+
lines.append(
|
|
247
|
+
f" {name:25s} {component['score']:6.2f} / 100 "
|
|
248
|
+
f"weight={weight:.2f} contribution={contribution:6.2f}"
|
|
249
|
+
)
|
|
250
|
+
tips = generate_coaching(result)
|
|
251
|
+
coaching = format_coaching(tips)
|
|
252
|
+
if coaching:
|
|
253
|
+
lines.append(coaching)
|
|
254
|
+
return "\n".join(lines)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def format_verbose(result: dict[str, Any]) -> str:
|
|
258
|
+
"""Return the summary plus a per-sub-component breakdown."""
|
|
259
|
+
lines = [format_summary(result), "", "Sub-component breakdown:"]
|
|
260
|
+
for name, component in result.get("components", {}).items():
|
|
261
|
+
lines.append(f"\n[{name}] {component['score']:.2f} / 100")
|
|
262
|
+
for sub_name, sub in component.get("breakdown", {}).items():
|
|
263
|
+
score = sub.get("score", 0)
|
|
264
|
+
maximum = sub.get("max", 0)
|
|
265
|
+
extras = {k: v for k, v in sub.items() if k not in {"score", "max"}}
|
|
266
|
+
lines.append(f" - {sub_name:30s} {score:6.2f} / {maximum:<3} {extras}")
|
|
267
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared scoring primitives used by every component."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def scale_linear(value: float, zero_at: float, full_at: float, max_pts: float) -> float:
|
|
9
|
+
"""Linear interpolation from ``zero_at`` -> 0 to ``full_at`` -> ``max_pts``.
|
|
10
|
+
|
|
11
|
+
Clamps outside the range. Handles the inverted case (``zero_at`` > ``full_at``)
|
|
12
|
+
for "lower is better" metrics.
|
|
13
|
+
"""
|
|
14
|
+
if zero_at == full_at:
|
|
15
|
+
return float(max_pts) if value == full_at else 0.0
|
|
16
|
+
if zero_at < full_at:
|
|
17
|
+
if value <= zero_at:
|
|
18
|
+
return 0.0
|
|
19
|
+
if value >= full_at:
|
|
20
|
+
return float(max_pts)
|
|
21
|
+
return max_pts * (value - zero_at) / (full_at - zero_at)
|
|
22
|
+
# Inverted: lower is better.
|
|
23
|
+
if value >= zero_at:
|
|
24
|
+
return 0.0
|
|
25
|
+
if value <= full_at:
|
|
26
|
+
return float(max_pts)
|
|
27
|
+
return max_pts * (zero_at - value) / (zero_at - full_at)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def file_mtime_age_days(path: Path) -> float:
|
|
31
|
+
"""Return the age of ``path`` in days (fractional). Returns +inf if missing."""
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return float("inf")
|
|
34
|
+
return (time.time() - path.stat().st_mtime) / 86400.0
|