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
|
@@ -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)
|