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.
@@ -0,0 +1,162 @@
1
+ """Error quality component.
2
+
3
+ Scores how actionable a repo's exceptions are for an AI agent. Agents fail
4
+ fastest on unactionable errors — a cryptic ``InvalidOperationException("bad
5
+ state")`` gives an agent nothing to work with.
6
+
7
+ - 50 pts: % of throw sites whose message contains an actionable fix hint.
8
+ - 30 pts: % of throws that use a user-defined (domain) exception subclass.
9
+ - 20 pts: language-stdlib generic exceptions do NOT dominate (bonus if rare).
10
+
11
+ All exception classification goes through the active language adapter.
12
+ Zero hard-coded exception type names in this file — every domain exception
13
+ name comes from config or adapter auto-discovery.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..adapters import LanguageAdapter, ThrowSite
21
+ from ..config import Config
22
+ from ..scoring import scale_linear
23
+
24
+ _HINT_WEIGHT = 50
25
+ _SUBCLASS_WEIGHT = 30
26
+ _GENERIC_WEIGHT = 20
27
+ _HINT_FULL_PCT = 50.0
28
+ _SUBCLASS_FULL_RATIO = 0.50
29
+ _GENERIC_LOW_PCT = 20.0
30
+ _GENERIC_HIGH_PCT = 40.0
31
+
32
+
33
+ def compute_error_quality(repo_root: Path, config: Config, adapter: LanguageAdapter) -> dict[str, Any]:
34
+ """Score error-message quality: hint coverage + exception typing."""
35
+ production_files = adapter.find_production_files(repo_root)
36
+ domain_types = _resolve_domain_exception_types(config, adapter, production_files)
37
+ sites = adapter.scan_throw_sites(
38
+ production_files,
39
+ hint_marker=config.error_quality.hint_marker,
40
+ domain_exception_types=domain_types,
41
+ )
42
+
43
+ hint = _score_hint_coverage(sites)
44
+ subclass = _score_domain_subclass_ratio(sites)
45
+ generic = _score_generic_dominance(sites, adapter.generic_exception_names())
46
+
47
+ total = hint["score"] + subclass["score"] + generic["score"]
48
+ return {
49
+ "score": round(total, 2),
50
+ "total": 100,
51
+ "breakdown": {
52
+ "hint_coverage": hint,
53
+ "exception_subclass_ratio": subclass,
54
+ "generic_exception_dominance": generic,
55
+ },
56
+ }
57
+
58
+
59
+ def _resolve_domain_exception_types(
60
+ config: Config,
61
+ adapter: LanguageAdapter,
62
+ production_files: list[Path],
63
+ ) -> set[str]:
64
+ """Build the set of 'user-defined' exception type names.
65
+
66
+ Priority:
67
+ 1. Explicit config ``error_quality.domain_exception_types`` list.
68
+ 2. Auto-discovery from the repo's own source (scan declarations whose
69
+ name ends in 'Exception' or 'Error' — language-neutral heuristic).
70
+ """
71
+ explicit = set(config.error_quality.domain_exception_types)
72
+ if explicit:
73
+ return explicit
74
+
75
+ # Auto-discover: scan declarations and keep any ending in Exception/Error.
76
+ declarations = adapter.scan_declarations(production_files)
77
+ discovered: set[str] = set()
78
+ for decl in declarations:
79
+ if decl.name.endswith("Exception") or decl.name.endswith("Error"):
80
+ discovered.add(decl.name)
81
+ return discovered
82
+
83
+
84
+ def _score_hint_coverage(sites: list[ThrowSite]) -> dict[str, Any]:
85
+ """50 pts: % of throws with an actionable fix hint, scaled 0% -> 50%."""
86
+ total = len(sites)
87
+ if total == 0:
88
+ return {
89
+ "score": _HINT_WEIGHT,
90
+ "max": _HINT_WEIGHT,
91
+ "coverage_pct": 100.0,
92
+ "total_sites": 0,
93
+ "with_hint": 0,
94
+ "note": "no throw sites",
95
+ }
96
+ with_hint = sum(1 for s in sites if s.has_fix_hint)
97
+ pct = 100.0 * with_hint / total
98
+ score = scale_linear(pct, zero_at=0.0, full_at=_HINT_FULL_PCT, max_pts=_HINT_WEIGHT)
99
+ return {
100
+ "score": round(score, 2),
101
+ "max": _HINT_WEIGHT,
102
+ "coverage_pct": round(pct, 2),
103
+ "total_sites": total,
104
+ "with_hint": with_hint,
105
+ }
106
+
107
+
108
+ def _score_domain_subclass_ratio(sites: list[ThrowSite]) -> dict[str, Any]:
109
+ """30 pts: % of throws using a user-defined (domain) exception class."""
110
+ total = len(sites)
111
+ if total == 0:
112
+ return {
113
+ "score": _SUBCLASS_WEIGHT,
114
+ "max": _SUBCLASS_WEIGHT,
115
+ "ratio": 1.0,
116
+ "note": "no throw sites",
117
+ }
118
+ subclass_count = sum(1 for s in sites if s.is_user_defined)
119
+ ratio = subclass_count / total
120
+ score = scale_linear(
121
+ ratio,
122
+ zero_at=0.0,
123
+ full_at=_SUBCLASS_FULL_RATIO,
124
+ max_pts=_SUBCLASS_WEIGHT,
125
+ )
126
+ return {
127
+ "score": round(score, 2),
128
+ "max": _SUBCLASS_WEIGHT,
129
+ "ratio": round(ratio, 3),
130
+ "subclass_count": subclass_count,
131
+ "total_throws": total,
132
+ }
133
+
134
+
135
+ def _score_generic_dominance(
136
+ sites: list[ThrowSite],
137
+ generic_names: set[str],
138
+ ) -> dict[str, Any]:
139
+ """20 pts: generic stdlib exceptions should not dominate. Lower is better."""
140
+ total = len(sites)
141
+ if total == 0:
142
+ return {
143
+ "score": _GENERIC_WEIGHT,
144
+ "max": _GENERIC_WEIGHT,
145
+ "pct": 0.0,
146
+ "note": "no throw sites",
147
+ }
148
+ generic_count = sum(1 for s in sites if s.exception_type in generic_names)
149
+ pct = 100.0 * generic_count / total
150
+ score = scale_linear(
151
+ pct,
152
+ zero_at=_GENERIC_HIGH_PCT,
153
+ full_at=_GENERIC_LOW_PCT,
154
+ max_pts=_GENERIC_WEIGHT,
155
+ )
156
+ return {
157
+ "score": round(score, 2),
158
+ "max": _GENERIC_WEIGHT,
159
+ "pct": round(pct, 2),
160
+ "generic_count": generic_count,
161
+ "total_throws": total,
162
+ }
@@ -0,0 +1,175 @@
1
+ """Module hygiene component.
2
+
3
+ Scores how neatly a codebase's production modules are organized:
4
+
5
+ - 30 pts: enough files declare internal / non-public types (visibility hygiene).
6
+ - 30 pts: god files (files over a size threshold) are rare.
7
+ - 20 pts: public declarations have doc comments.
8
+ - 20 pts: architecture doc is fresh.
9
+
10
+ Every file scan goes through the active language adapter — the component
11
+ never looks at file suffixes directly.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..adapters import LanguageAdapter
19
+ from ..adapters.base import count_file_loc
20
+ from ..config import Config
21
+ from ..scoring import file_mtime_age_days, scale_linear
22
+
23
+ _INTERNAL_WEIGHT = 30
24
+ _GOD_FILE_WEIGHT = 30
25
+ _DOC_COMMENT_WEIGHT = 20
26
+ _ARCH_WEIGHT = 20
27
+ _GOD_FILE_FULL_COUNT = 5
28
+ _GOD_FILE_ZERO_COUNT = 15
29
+
30
+
31
+ def compute_module_hygiene(repo_root: Path, config: Config, adapter: LanguageAdapter) -> dict[str, Any]:
32
+ """Score internal visibility + god files + doc coverage + arch doc freshness."""
33
+ production_files = adapter.find_production_files(repo_root)
34
+ declarations = adapter.scan_declarations(production_files)
35
+
36
+ internal = _score_internal_visibility(declarations, production_files, config)
37
+ god = _score_god_files(production_files, config)
38
+ docs = _score_doc_coverage(declarations, config)
39
+ arch = _score_architecture_doc(repo_root, config)
40
+
41
+ total = internal["score"] + god["score"] + docs["score"] + arch["score"]
42
+ return {
43
+ "score": round(total, 2),
44
+ "total": 100,
45
+ "breakdown": {
46
+ "internal_visibility": internal,
47
+ "god_files": god,
48
+ "doc_comment_coverage": docs,
49
+ "architecture_doc": arch,
50
+ },
51
+ }
52
+
53
+
54
+ def _score_internal_visibility(
55
+ declarations: list[Any],
56
+ production_files: list[Path],
57
+ config: Config,
58
+ ) -> dict[str, Any]:
59
+ """30 pts: proportion of production files that declare a non-public type."""
60
+ if not production_files:
61
+ return {
62
+ "score": 0,
63
+ "max": _INTERNAL_WEIGHT,
64
+ "internal_files": 0,
65
+ "total_files": 0,
66
+ "ratio": 0.0,
67
+ }
68
+ files_with_internal: set[Path] = set()
69
+ for decl in declarations:
70
+ if decl.visibility in ("internal", "private"):
71
+ files_with_internal.add(decl.file)
72
+ ratio = len(files_with_internal) / len(production_files)
73
+ score = scale_linear(
74
+ ratio,
75
+ zero_at=0.0,
76
+ full_at=config.module_hygiene.internal_visibility_full_ratio,
77
+ max_pts=_INTERNAL_WEIGHT,
78
+ )
79
+ return {
80
+ "score": round(score, 2),
81
+ "max": _INTERNAL_WEIGHT,
82
+ "internal_files": len(files_with_internal),
83
+ "total_files": len(production_files),
84
+ "ratio": round(ratio, 3),
85
+ }
86
+
87
+
88
+ def _score_god_files(production_files: list[Path], config: Config) -> dict[str, Any]:
89
+ """30 pts: count of production files over the god-file LOC threshold."""
90
+ threshold = config.thresholds.god_file_loc
91
+ max_bytes = config.thresholds.max_file_bytes
92
+ god: list[dict[str, Any]] = []
93
+ for path in production_files:
94
+ loc = count_file_loc(path, max_bytes=max_bytes)
95
+ if loc > threshold:
96
+ god.append({"path": str(path), "loc": loc})
97
+
98
+ count = len(god)
99
+ score = scale_linear(
100
+ count,
101
+ zero_at=_GOD_FILE_ZERO_COUNT,
102
+ full_at=_GOD_FILE_FULL_COUNT,
103
+ max_pts=_GOD_FILE_WEIGHT,
104
+ )
105
+ god.sort(key=lambda d: d["loc"], reverse=True)
106
+ return {
107
+ "score": round(score, 2),
108
+ "max": _GOD_FILE_WEIGHT,
109
+ "god_file_count": count,
110
+ "top_5": [_relative_god_entry(d, production_files) for d in god[:5]],
111
+ }
112
+
113
+
114
+ def _relative_god_entry(entry: dict[str, Any], production_files: list[Path]) -> dict[str, Any]:
115
+ """Format a god-file entry with repo-relative path (no string splits)."""
116
+ path = Path(entry["path"])
117
+ # Find the closest common ancestor among production files (language-neutral).
118
+ try:
119
+ common = Path(*path.parts[:-1])
120
+ rel = path.name if str(common) == "." else str(path)
121
+ except (ValueError, IndexError):
122
+ rel = str(path)
123
+ return {"path": rel, "loc": entry["loc"]}
124
+
125
+
126
+ def _score_doc_coverage(declarations: list[Any], config: Config) -> dict[str, Any]:
127
+ """20 pts: % of public declarations with a doc comment. Full at 90%."""
128
+ public = [d for d in declarations if d.visibility == "public"]
129
+ total = len(public)
130
+ if total == 0:
131
+ return {
132
+ "score": _DOC_COMMENT_WEIGHT,
133
+ "max": _DOC_COMMENT_WEIGHT,
134
+ "total_public_declarations": 0,
135
+ "documented": 0,
136
+ "pct": 100.0,
137
+ }
138
+ documented = sum(1 for d in public if d.has_doc_comment)
139
+ pct = 100.0 * documented / total
140
+ score = scale_linear(
141
+ pct,
142
+ zero_at=0.0,
143
+ full_at=config.thresholds.doc_comment_min_coverage_pct,
144
+ max_pts=_DOC_COMMENT_WEIGHT,
145
+ )
146
+ return {
147
+ "score": round(score, 2),
148
+ "max": _DOC_COMMENT_WEIGHT,
149
+ "total_public_declarations": total,
150
+ "documented": documented,
151
+ "pct": round(pct, 2),
152
+ }
153
+
154
+
155
+ def _score_architecture_doc(repo_root: Path, config: Config) -> dict[str, Any]:
156
+ """20 pts: architecture doc exists AND was touched recently."""
157
+ path = repo_root / config.paths.architecture_doc
158
+ if not path.is_file():
159
+ return {"score": 0, "max": _ARCH_WEIGHT, "exists": False, "age_days": None}
160
+ age = file_mtime_age_days(path)
161
+ fresh_days = config.module_hygiene.architecture_doc_fresh_days
162
+ if age <= fresh_days:
163
+ return {
164
+ "score": _ARCH_WEIGHT,
165
+ "max": _ARCH_WEIGHT,
166
+ "exists": True,
167
+ "age_days": round(age, 2),
168
+ }
169
+ return {
170
+ "score": _ARCH_WEIGHT / 2,
171
+ "max": _ARCH_WEIGHT,
172
+ "exists": True,
173
+ "age_days": round(age, 2),
174
+ "stale": True,
175
+ }
@@ -0,0 +1,179 @@
1
+ """Test quality component.
2
+
3
+ Scores test readability and fixture hygiene — not test coverage. Coverage is
4
+ a solved problem (codecov etc.); this component measures whether the test
5
+ suite tells an agent what each test does without running it.
6
+
7
+ - 40 pts: % of test methods that match the idiomatic naming convention.
8
+ - 30 pts: enough reusable helper files to discourage copy-paste fixtures.
9
+ - 30 pts: configured fixture-duplication patterns appear sparingly.
10
+
11
+ ``fixture_duplication_patterns`` is empty by default — the sub-score gives
12
+ full credit unless the user opts in by listing project-specific patterns.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..adapters import LanguageAdapter
21
+ from ..adapters.base import iter_source_files
22
+ from ..config import Config
23
+ from ..scoring import scale_linear
24
+
25
+ _NAMING_WEIGHT = 40
26
+ _HELPERS_WEIGHT = 30
27
+ _DUPLICATION_WEIGHT = 30
28
+ _DUP_FULL_MAX = 50
29
+ _DUP_ZERO_MAX = 200
30
+
31
+
32
+ def compute_test_quality(repo_root: Path, config: Config, adapter: LanguageAdapter) -> dict[str, Any]:
33
+ """Score test naming convention + helper count + fixture duplication."""
34
+ test_files = adapter.find_test_files(repo_root)
35
+
36
+ naming = _score_test_naming(test_files, adapter)
37
+ helpers = _score_test_helpers(repo_root, config, test_files)
38
+ duplication = _score_fixture_duplication(test_files, config)
39
+
40
+ total = naming["score"] + helpers["score"] + duplication["score"]
41
+ return {
42
+ "score": round(total, 2),
43
+ "total": 100,
44
+ "breakdown": {
45
+ "naming_convention": naming,
46
+ "helper_files": helpers,
47
+ "fixture_duplication": duplication,
48
+ },
49
+ }
50
+
51
+
52
+ def _score_test_naming(
53
+ test_files: list[Path],
54
+ adapter: LanguageAdapter,
55
+ ) -> dict[str, Any]:
56
+ """40 pts: % of test methods matching the adapter's naming convention."""
57
+ methods = adapter.find_test_methods(test_files)
58
+ pattern = adapter.test_naming_pattern()
59
+
60
+ total = len(methods)
61
+ if total == 0:
62
+ return {
63
+ "score": 0,
64
+ "max": _NAMING_WEIGHT,
65
+ "total_methods": 0,
66
+ "matching_methods": 0,
67
+ "pct": 0.0,
68
+ }
69
+
70
+ matching = sum(1 for _, name in methods if pattern.match(name))
71
+ pct = 100.0 * matching / total
72
+ score = scale_linear(pct, zero_at=0.0, full_at=100.0, max_pts=_NAMING_WEIGHT)
73
+ return {
74
+ "score": round(score, 2),
75
+ "max": _NAMING_WEIGHT,
76
+ "total_methods": total,
77
+ "matching_methods": matching,
78
+ "pct": round(pct, 2),
79
+ }
80
+
81
+
82
+ def _score_test_helpers(
83
+ repo_root: Path,
84
+ config: Config,
85
+ test_files: list[Path],
86
+ ) -> dict[str, Any]:
87
+ """30 pts: count of helper files under the configured helpers directory."""
88
+ helpers_dir = _resolve_helpers_dir(repo_root, config, test_files)
89
+ if helpers_dir is None or not helpers_dir.is_dir():
90
+ return {"score": 0, "max": _HELPERS_WEIGHT, "helper_count": 0}
91
+
92
+ # Count helpers using a neutral suffix list: any source file under the
93
+ # helpers dir. The active adapter's production-file suffix set is a fair
94
+ # proxy; we reuse iter_source_files to respect symlink/size guards.
95
+ helpers = iter_source_files(
96
+ helpers_dir,
97
+ suffixes=(".cs", ".py", ".ts", ".tsx", ".js", ".jsx", ".rs", ".go"),
98
+ )
99
+ count = len(helpers)
100
+ score = scale_linear(
101
+ count,
102
+ zero_at=0,
103
+ full_at=config.test_quality.helpers_full_count,
104
+ max_pts=_HELPERS_WEIGHT,
105
+ )
106
+ return {
107
+ "score": round(score, 2),
108
+ "max": _HELPERS_WEIGHT,
109
+ "helper_count": count,
110
+ }
111
+
112
+
113
+ def _resolve_helpers_dir(
114
+ repo_root: Path,
115
+ config: Config,
116
+ test_files: list[Path],
117
+ ) -> Path | None:
118
+ """Resolve the helpers directory from config ('auto' means guess)."""
119
+ configured = config.paths.test_helpers_dir
120
+ if configured and configured != "auto":
121
+ return repo_root / configured
122
+
123
+ # Auto-discovery: look for a TestHelpers / fixtures / helpers directory
124
+ # under any test file's parent chain.
125
+ candidates = ("TestHelpers", "test_helpers", "helpers", "fixtures", "conftest")
126
+ seen: set[Path] = set()
127
+ for test_file in test_files:
128
+ for parent in test_file.parents:
129
+ if parent == repo_root or parent == repo_root.parent:
130
+ break
131
+ for name in candidates:
132
+ candidate = parent / name
133
+ if candidate.is_dir() and candidate not in seen:
134
+ seen.add(candidate)
135
+ return candidate
136
+ return None
137
+
138
+
139
+ def _score_fixture_duplication(
140
+ test_files: list[Path],
141
+ config: Config,
142
+ ) -> dict[str, Any]:
143
+ """30 pts: configured fixture-duplication patterns are rare."""
144
+ patterns = config.test_quality.fixture_duplication_patterns
145
+ if not patterns:
146
+ return {
147
+ "score": _DUPLICATION_WEIGHT,
148
+ "max": _DUPLICATION_WEIGHT,
149
+ "duplicate_builder_count": 0,
150
+ "note": "no fixture_duplication_patterns configured",
151
+ }
152
+
153
+ compiled: list[re.Pattern[str]] = []
154
+ for raw in patterns:
155
+ try:
156
+ compiled.append(re.compile(raw))
157
+ except re.error:
158
+ continue
159
+
160
+ total = 0
161
+ for path in test_files:
162
+ try:
163
+ text = path.read_text(encoding="utf-8", errors="ignore")
164
+ except OSError:
165
+ continue
166
+ for pattern in compiled:
167
+ total += len(pattern.findall(text))
168
+
169
+ score = scale_linear(
170
+ total,
171
+ zero_at=_DUP_ZERO_MAX,
172
+ full_at=_DUP_FULL_MAX,
173
+ max_pts=_DUPLICATION_WEIGHT,
174
+ )
175
+ return {
176
+ "score": round(score, 2),
177
+ "max": _DUPLICATION_WEIGHT,
178
+ "duplicate_builder_count": total,
179
+ }
@@ -0,0 +1,84 @@
1
+ """Composite orchestrator — combines the 5 components into the CAH score."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .adapters import LanguageAdapter, detect_primary, get_adapter_by_name
8
+ from .components import (
9
+ compute_decision_queryability,
10
+ compute_error_quality,
11
+ compute_module_hygiene,
12
+ compute_navigability,
13
+ compute_test_quality,
14
+ )
15
+ from .config import Config, load_config
16
+
17
+ _GENERATOR_NAME = "agentrepocoach"
18
+
19
+
20
+ def compute_cah(repo_root: Path, config: Config | None = None, adapter: LanguageAdapter | None = None) -> dict[str, Any]:
21
+ """Compute every component and assemble the weighted composite.
22
+
23
+ Args:
24
+ repo_root: Path to the repository to score.
25
+ config: Optional explicit config. If None, loads from
26
+ ``<repo_root>/.agentrepocoach.toml`` with defaults.
27
+ adapter: Optional explicit language adapter. If None, auto-detects.
28
+
29
+ Returns:
30
+ A dict with ``schema_version``, ``generator``, ``total``, ``weights``,
31
+ ``components``, and ``language``.
32
+ """
33
+ from . import VERSION # local import to avoid circular reference
34
+
35
+ repo_root = repo_root.resolve()
36
+ if config is None:
37
+ config = load_config(repo_root)
38
+ if adapter is None:
39
+ adapter = _pick_adapter(repo_root, config)
40
+
41
+ components = {
42
+ "navigability": compute_navigability(repo_root, config, adapter),
43
+ "error_quality": compute_error_quality(repo_root, config, adapter),
44
+ "decision_queryability": compute_decision_queryability(repo_root, config, adapter),
45
+ "test_quality": compute_test_quality(repo_root, config, adapter),
46
+ "module_hygiene": compute_module_hygiene(repo_root, config, adapter),
47
+ }
48
+
49
+ total = 0.0
50
+ for name, weight in config.weights.items():
51
+ total += weight * components[name]["score"]
52
+
53
+ result = {
54
+ "schema_version": config.schema_version,
55
+ "generator": f"{_GENERATOR_NAME} {VERSION}",
56
+ "total": round(total, 2),
57
+ "weights": dict(config.weights),
58
+ "language": adapter.name,
59
+ "components": components,
60
+ }
61
+
62
+ # Generate coaching recommendations from the scored result.
63
+ from .output import generate_coaching
64
+ tips = generate_coaching(result)
65
+ if tips:
66
+ result["coaching"] = [
67
+ {
68
+ "component": t["component"],
69
+ "sub_component": t["sub_component"],
70
+ "label": t["label"],
71
+ "tip": t["tip"],
72
+ "gap": round(t["gap"], 2),
73
+ }
74
+ for t in tips
75
+ ]
76
+
77
+ return result
78
+
79
+
80
+ def _pick_adapter(repo_root: Path, config: Config) -> LanguageAdapter:
81
+ """Pick the adapter either by explicit config or auto-detection."""
82
+ if config.language and config.language != "auto":
83
+ return get_adapter_by_name(config.language)
84
+ return detect_primary(repo_root)