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/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """AgentRepoCoach CLI entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import VERSION
9
+ from .adapters import NoAdapterError
10
+ from .compute import compute_cah
11
+ from .config import ConfigError, load_config
12
+ from .output import (
13
+ format_summary,
14
+ format_verbose,
15
+ write_json,
16
+ write_markdown_comment,
17
+ write_prometheus,
18
+ )
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ """Build the argument parser for the ``agentrepocoach`` CLI."""
23
+ parser = argparse.ArgumentParser(
24
+ prog="agentrepocoach",
25
+ description="Compute the Codebase Agent Health (CAH) composite score for a repository.",
26
+ )
27
+ parser.add_argument(
28
+ "--repo",
29
+ type=Path,
30
+ default=Path.cwd(),
31
+ help="Path to the repository to score (default: current directory).",
32
+ )
33
+ parser.add_argument(
34
+ "--config",
35
+ type=Path,
36
+ default=None,
37
+ help="Explicit config file path (default: <repo>/.agentrepocoach.toml).",
38
+ )
39
+ parser.add_argument(
40
+ "--language",
41
+ type=str,
42
+ default=None,
43
+ help="Override language detection (csharp|python|auto).",
44
+ )
45
+ parser.add_argument("--json", type=Path, help="Write full JSON result to this path.")
46
+ parser.add_argument("--prometheus", type=Path, help="Write Prometheus metrics to this path.")
47
+ parser.add_argument("--comment", type=Path, help="Write a PR-comment markdown file to this path.")
48
+ parser.add_argument(
49
+ "--format",
50
+ choices=["json", "markdown", "both"],
51
+ default=None,
52
+ help="Output format when using --output. 'json' writes the full report, "
53
+ "'markdown' writes a PR-comment summary, 'both' writes both (markdown "
54
+ "path derived from --output by swapping the extension to .md).",
55
+ )
56
+ parser.add_argument(
57
+ "--output",
58
+ type=Path,
59
+ default=None,
60
+ help="Output path for --format. Ignored if --format is not set.",
61
+ )
62
+ parser.add_argument("--verbose", action="store_true", help="Print per-sub-component breakdown.")
63
+ parser.add_argument("--quiet", action="store_true", help="Print only the total score.")
64
+ parser.add_argument("--version", action="version", version=f"agentrepocoach {VERSION}")
65
+ return parser
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ """Run the CLI, parse arguments, compute the CAH score, and write outputs."""
70
+ parser = build_parser()
71
+ args = parser.parse_args(argv)
72
+
73
+ repo_root = args.repo.resolve()
74
+ if not repo_root.is_dir():
75
+ print(f"error: repo path is not a directory: {repo_root}", file=sys.stderr)
76
+ return 2
77
+
78
+ try:
79
+ config = load_config(repo_root, config_path=args.config)
80
+ except ConfigError as exc:
81
+ print(f"error: {exc}", file=sys.stderr)
82
+ return 2
83
+
84
+ if args.language:
85
+ # Replace the config's language field. Dataclass is frozen -> rebuild.
86
+ from dataclasses import replace as _replace
87
+ config = _replace(config, language=args.language)
88
+
89
+ try:
90
+ result = compute_cah(repo_root, config=config)
91
+ except NoAdapterError as exc:
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return 2
94
+
95
+ if args.quiet:
96
+ print(f"{result['total']:.2f}")
97
+ elif args.verbose:
98
+ print(format_verbose(result))
99
+ else:
100
+ print(format_summary(result))
101
+
102
+ if args.json:
103
+ write_json(result, args.json)
104
+ if not args.quiet:
105
+ print(f"\nJSON report written to {args.json}")
106
+
107
+ if args.prometheus:
108
+ write_prometheus(result, args.prometheus)
109
+ if not args.quiet:
110
+ print(f"Prometheus metrics written to {args.prometheus}")
111
+
112
+ if args.comment:
113
+ write_markdown_comment(result, args.comment)
114
+ if not args.quiet:
115
+ print(f"PR comment written to {args.comment}")
116
+
117
+ if args.format and args.output:
118
+ _write_formatted(result, args.format, args.output, quiet=args.quiet)
119
+ elif args.format and not args.output:
120
+ print("error: --format requires --output", file=sys.stderr)
121
+ return 2
122
+
123
+ return 0
124
+
125
+
126
+ def _write_formatted(
127
+ result: dict,
128
+ fmt: str,
129
+ output: Path,
130
+ *,
131
+ quiet: bool,
132
+ ) -> None:
133
+ """Dispatch --format/--output combinations to the underlying writers."""
134
+ if fmt == "json":
135
+ write_json(result, output)
136
+ if not quiet:
137
+ print(f"\nJSON report written to {output}")
138
+ return
139
+ if fmt == "markdown":
140
+ write_markdown_comment(result, output)
141
+ if not quiet:
142
+ print(f"\nMarkdown report written to {output}")
143
+ return
144
+ # fmt == "both"
145
+ json_path = output
146
+ markdown_path = output.with_suffix(".md")
147
+ write_json(result, json_path)
148
+ write_markdown_comment(result, markdown_path)
149
+ if not quiet:
150
+ print(f"\nJSON report written to {json_path}")
151
+ print(f"Markdown report written to {markdown_path}")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())
@@ -0,0 +1,27 @@
1
+ """AgentRepoCoach scoring components.
2
+
3
+ Each component returns a dict with ``{"score": float, "total": 100,
4
+ "breakdown": {...}}``. The orchestrator in :mod:`agentrepocoach.compute` combines
5
+ them with weights from config to produce the final composite score.
6
+
7
+ File-to-component mapping:
8
+
9
+ - ``documentation.py`` -> ``navigability`` (AGENTS.md, codebase map, CLI manifest, root hygiene)
10
+ - ``error_quality.py`` -> ``error_quality``
11
+ - ``decision_queryability.py`` -> ``decision_queryability``
12
+ - ``test_quality.py`` -> ``test_quality``
13
+ - ``module_hygiene.py`` -> ``module_hygiene``
14
+ """
15
+ from .decision_queryability import compute_decision_queryability
16
+ from .documentation import compute_navigability
17
+ from .error_quality import compute_error_quality
18
+ from .module_hygiene import compute_module_hygiene
19
+ from .test_quality import compute_test_quality
20
+
21
+ __all__ = [
22
+ "compute_decision_queryability",
23
+ "compute_error_quality",
24
+ "compute_module_hygiene",
25
+ "compute_navigability",
26
+ "compute_test_quality",
27
+ ]
@@ -0,0 +1,192 @@
1
+ """Decision queryability component.
2
+
3
+ Scores how easily an AI agent can discover *why* the code is the way it is:
4
+
5
+ - 60 pts: ADR catalog has enough entries with valid frontmatter.
6
+ - 40 pts: inline references in source code resolve to an ADR body or filename.
7
+
8
+ The original research included a third sub-score (MCP tool availability)
9
+ worth 30 pts, but that sub-score required importing a proprietary internal
10
+ MCP server module at score-compute time. It has been **dropped** for the
11
+ public tool; the 30 pts were reallocated: adr_catalog 40 -> 60, and
12
+ inline_ref_resolution 30 -> 40. Total still sums to 100.
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 ..config import Config
22
+ from ..scoring import scale_linear
23
+
24
+ _ADR_COUNT_WEIGHT = 60
25
+ _REF_RESOLVE_WEIGHT = 40
26
+ _REF_FULL_PCT = 90.0
27
+
28
+
29
+ def compute_decision_queryability(repo_root: Path, config: Config, adapter: LanguageAdapter) -> dict[str, Any]:
30
+ """Score ADR catalog health + inline-ref resolution."""
31
+ adr = _score_adr_catalog(repo_root, config)
32
+ refs = _score_inline_ref_resolution(repo_root, config, adapter)
33
+
34
+ total = adr["score"] + refs["score"]
35
+ return {
36
+ "score": round(total, 2),
37
+ "total": 100,
38
+ "breakdown": {
39
+ "adr_catalog": adr,
40
+ "inline_ref_resolution": refs,
41
+ },
42
+ }
43
+
44
+
45
+ def _score_adr_catalog(repo_root: Path, config: Config) -> dict[str, Any]:
46
+ """60 pts: enough ADRs under the configured ADR dir, with valid frontmatter."""
47
+ adr_dir = repo_root / config.paths.adr_dir
48
+ if not adr_dir.is_dir():
49
+ return {
50
+ "score": 0,
51
+ "max": _ADR_COUNT_WEIGHT,
52
+ "count": 0,
53
+ "valid_count": 0,
54
+ }
55
+
56
+ files = [p for p in sorted(adr_dir.glob("*.md")) if p.name.lower() != "readme.md"]
57
+ valid = 0
58
+ for path in files:
59
+ try:
60
+ text = path.read_text(encoding="utf-8")
61
+ except OSError:
62
+ continue
63
+ if _has_valid_frontmatter(text):
64
+ valid += 1
65
+
66
+ score = scale_linear(
67
+ valid,
68
+ zero_at=0,
69
+ full_at=config.thresholds.adr_min_count,
70
+ max_pts=_ADR_COUNT_WEIGHT,
71
+ )
72
+ return {
73
+ "score": round(score, 2),
74
+ "max": _ADR_COUNT_WEIGHT,
75
+ "count": len(files),
76
+ "valid_count": valid,
77
+ }
78
+
79
+
80
+ def _has_valid_frontmatter(text: str) -> bool:
81
+ """Return True if ``text`` begins with a --- fence and parses an id: key."""
82
+ if not text.startswith("---"):
83
+ return False
84
+ lines = text.splitlines()
85
+ if len(lines) < 2 or lines[0] != "---":
86
+ return False
87
+ for i in range(1, min(len(lines), 40)):
88
+ if lines[i] == "---":
89
+ break
90
+ if lines[i].strip().lower().startswith("id:"):
91
+ return True
92
+ return False
93
+
94
+
95
+ def _score_inline_ref_resolution(
96
+ repo_root: Path,
97
+ config: Config,
98
+ adapter: LanguageAdapter,
99
+ ) -> dict[str, Any]:
100
+ """40 pts: % of unique inline refs in production code that resolve to an ADR."""
101
+ patterns = _compile_inline_ref_patterns(config.decision_queryability.inline_ref_patterns)
102
+ if not patterns:
103
+ return {
104
+ "score": _REF_RESOLVE_WEIGHT,
105
+ "max": _REF_RESOLVE_WEIGHT,
106
+ "note": "no inline ref patterns configured",
107
+ }
108
+
109
+ production_files = adapter.find_production_files(repo_root)
110
+ refs = _extract_refs(production_files, patterns)
111
+
112
+ if not refs:
113
+ return {
114
+ "score": _REF_RESOLVE_WEIGHT,
115
+ "max": _REF_RESOLVE_WEIGHT,
116
+ "total_refs": 0,
117
+ "resolved_refs": 0,
118
+ "resolve_pct": 100.0,
119
+ "note": "no inline refs found",
120
+ }
121
+
122
+ resolved, unresolved = _resolve_refs_against_adrs(refs, repo_root, config)
123
+ pct = 100.0 * resolved / len(refs)
124
+ score = scale_linear(pct, zero_at=0.0, full_at=_REF_FULL_PCT, max_pts=_REF_RESOLVE_WEIGHT)
125
+ return {
126
+ "score": round(score, 2),
127
+ "max": _REF_RESOLVE_WEIGHT,
128
+ "total_refs": len(refs),
129
+ "resolved_refs": resolved,
130
+ "resolve_pct": round(pct, 2),
131
+ "unresolved_sample": unresolved[:10],
132
+ }
133
+
134
+
135
+ def _compile_inline_ref_patterns(patterns: tuple[str, ...]) -> list[re.Pattern[str]]:
136
+ """Compile config-provided pattern strings with word-boundary anchoring."""
137
+ compiled: list[re.Pattern[str]] = []
138
+ for raw in patterns:
139
+ # Wrap in word boundaries if the user did not already supply them.
140
+ anchored = raw if raw.startswith("\\b") else rf"\b{raw}\b"
141
+ try:
142
+ compiled.append(re.compile(anchored, re.IGNORECASE))
143
+ except re.error:
144
+ # Malformed regex -> skip silently; the user sees it in --verbose.
145
+ continue
146
+ return compiled
147
+
148
+
149
+ def _extract_refs(files: list[Path], patterns: list[re.Pattern[str]]) -> set[str]:
150
+ refs: set[str] = set()
151
+ for path in files:
152
+ try:
153
+ text = path.read_text(encoding="utf-8", errors="ignore")
154
+ except OSError:
155
+ continue
156
+ for pattern in patterns:
157
+ for match in pattern.finditer(text):
158
+ token = re.sub(r"\s+", " ", match.group(0)).upper()
159
+ refs.add(token)
160
+ return refs
161
+
162
+
163
+ def _resolve_refs_against_adrs(
164
+ refs: set[str],
165
+ repo_root: Path,
166
+ config: Config,
167
+ ) -> tuple[int, list[str]]:
168
+ adr_dir = repo_root / config.paths.adr_dir
169
+ adr_bodies: list[str] = []
170
+ adr_filenames: list[str] = []
171
+ if adr_dir.is_dir():
172
+ for path in adr_dir.glob("*.md"):
173
+ if path.name.lower() == "readme.md":
174
+ continue
175
+ try:
176
+ adr_bodies.append(path.read_text(encoding="utf-8", errors="ignore").lower())
177
+ adr_filenames.append(path.name.lower())
178
+ except OSError:
179
+ continue
180
+
181
+ resolved = 0
182
+ unresolved: list[str] = []
183
+ for ref in sorted(refs):
184
+ needle = ref.lower()
185
+ if any(needle in body for body in adr_bodies):
186
+ resolved += 1
187
+ continue
188
+ if any(needle in name for name in adr_filenames):
189
+ resolved += 1
190
+ continue
191
+ unresolved.append(ref)
192
+ return resolved, unresolved
@@ -0,0 +1,205 @@
1
+ """Navigability component ('documentation' file in the package).
2
+
3
+ Scores the agent navigability layer — the docs and entry points an AI agent
4
+ reads first when opening an unfamiliar repo:
5
+
6
+ - 30 pts: ``AGENTS.md`` exists and links to the codebase map, CLI manifest, and ADR dir.
7
+ - 30 pts: ``docs/codebase-map.md`` exists and mentions every production module.
8
+ - 20 pts: ``docs/cli-manifest.json`` exists, is fresh, and has enough commands.
9
+ - 20 pts: Root directory is free of stale artifacts.
10
+
11
+ All paths and thresholds are configurable via ``.agentrepocoach.toml``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..adapters import LanguageAdapter
21
+ from ..config import Config
22
+ from ..scoring import file_mtime_age_days, scale_linear
23
+
24
+ _AGENTS_MD_WEIGHT = 30
25
+ _CODEBASE_MAP_WEIGHT = 30
26
+ _CLI_MANIFEST_WEIGHT = 20
27
+ _ROOT_CLEAN_WEIGHT = 20
28
+
29
+ _STALE_ARTIFACT_PATTERNS = (
30
+ re.compile(r".*\.json$"),
31
+ re.compile(r".*-results\..*"),
32
+ re.compile(r".*-backup\..*"),
33
+ re.compile(r".*\.bak$"),
34
+ )
35
+
36
+
37
+ def compute_navigability(repo_root: Path, config: Config, adapter: LanguageAdapter) -> dict[str, Any]:
38
+ """Score the agent navigability layer."""
39
+ agents = _score_agents_md(repo_root, config)
40
+ codebase_map = _score_codebase_map(repo_root, config, adapter)
41
+ cli_manifest = _score_cli_manifest(repo_root, config)
42
+ root_cleanliness = _score_root_cleanliness(repo_root, config)
43
+
44
+ total = (
45
+ agents["score"]
46
+ + codebase_map["score"]
47
+ + cli_manifest["score"]
48
+ + root_cleanliness["score"]
49
+ )
50
+ return {
51
+ "score": round(total, 2),
52
+ "total": 100,
53
+ "breakdown": {
54
+ "agents_md": agents,
55
+ "codebase_map": codebase_map,
56
+ "cli_manifest": cli_manifest,
57
+ "root_cleanliness": root_cleanliness,
58
+ },
59
+ }
60
+
61
+
62
+ def _score_agents_md(repo_root: Path, config: Config) -> dict[str, Any]:
63
+ """30 pts: AGENTS.md exists AND links to map, manifest, and ADR dir."""
64
+ path = repo_root / config.paths.agents_md
65
+ required = [
66
+ config.paths.codebase_map,
67
+ config.paths.cli_manifest,
68
+ config.paths.adr_dir.rstrip("/"),
69
+ ]
70
+ if not path.is_file():
71
+ return {
72
+ "score": 0,
73
+ "max": _AGENTS_MD_WEIGHT,
74
+ "exists": False,
75
+ "missing_links": required,
76
+ }
77
+
78
+ text = path.read_text(encoding="utf-8", errors="ignore")
79
+ missing = [link for link in required if link not in text]
80
+ if missing:
81
+ partial = 10 + (len(required) - len(missing)) / len(required) * 20
82
+ return {
83
+ "score": round(partial, 2),
84
+ "max": _AGENTS_MD_WEIGHT,
85
+ "exists": True,
86
+ "missing_links": missing,
87
+ }
88
+ return {
89
+ "score": _AGENTS_MD_WEIGHT,
90
+ "max": _AGENTS_MD_WEIGHT,
91
+ "exists": True,
92
+ "missing_links": [],
93
+ }
94
+
95
+
96
+ def _score_codebase_map(
97
+ repo_root: Path,
98
+ config: Config,
99
+ adapter: LanguageAdapter,
100
+ ) -> dict[str, Any]:
101
+ """30 pts: codebase map exists AND mentions every production module."""
102
+ path = repo_root / config.paths.codebase_map
103
+ required_modules = adapter.find_production_modules(repo_root)
104
+ total_modules = len(required_modules)
105
+
106
+ if not path.is_file():
107
+ return {
108
+ "score": 0,
109
+ "max": _CODEBASE_MAP_WEIGHT,
110
+ "exists": False,
111
+ "matched_projects": 0,
112
+ "total_projects": total_modules,
113
+ }
114
+ if total_modules == 0:
115
+ # Nothing to check -> give full credit, noting the adapter found no
116
+ # modules (which the module_hygiene component will also reflect).
117
+ return {
118
+ "score": _CODEBASE_MAP_WEIGHT,
119
+ "max": _CODEBASE_MAP_WEIGHT,
120
+ "exists": True,
121
+ "matched_projects": 0,
122
+ "total_projects": 0,
123
+ "note": "no production modules discovered",
124
+ }
125
+
126
+ text = path.read_text(encoding="utf-8", errors="ignore")
127
+ matched = sum(1 for name in required_modules if name in text)
128
+ ratio = matched / total_modules
129
+ score = round(ratio * _CODEBASE_MAP_WEIGHT, 2)
130
+ return {
131
+ "score": score,
132
+ "max": _CODEBASE_MAP_WEIGHT,
133
+ "exists": True,
134
+ "matched_projects": matched,
135
+ "total_projects": total_modules,
136
+ }
137
+
138
+
139
+ def _score_cli_manifest(repo_root: Path, config: Config) -> dict[str, Any]:
140
+ """20 pts: manifest exists, is fresh, and has enough commands."""
141
+ path = repo_root / config.paths.cli_manifest
142
+ if not path.is_file():
143
+ return {"score": 0, "max": _CLI_MANIFEST_WEIGHT, "exists": False}
144
+
145
+ try:
146
+ data = json.loads(path.read_text(encoding="utf-8"))
147
+ except (OSError, json.JSONDecodeError) as exc:
148
+ return {
149
+ "score": 0,
150
+ "max": _CLI_MANIFEST_WEIGHT,
151
+ "exists": True,
152
+ "parse_error": str(exc),
153
+ }
154
+
155
+ command_count = len(data.get("commands", []) or [])
156
+ age_days = file_mtime_age_days(path)
157
+
158
+ thresholds = config.thresholds
159
+ if age_days <= thresholds.cli_manifest_fresh_days:
160
+ freshness_pts = float(_CLI_MANIFEST_WEIGHT)
161
+ elif age_days <= thresholds.cli_manifest_stale_days:
162
+ freshness_pts = _CLI_MANIFEST_WEIGHT / 2.0
163
+ else:
164
+ freshness_pts = 0.0
165
+
166
+ if command_count < thresholds.cli_manifest_min_commands:
167
+ freshness_pts /= 2.0
168
+
169
+ return {
170
+ "score": round(freshness_pts, 2),
171
+ "max": _CLI_MANIFEST_WEIGHT,
172
+ "exists": True,
173
+ "age_days": round(age_days, 2),
174
+ "command_count": command_count,
175
+ }
176
+
177
+
178
+ def _score_root_cleanliness(repo_root: Path, config: Config) -> dict[str, Any]:
179
+ """20 pts: no stale artifacts in the repo root."""
180
+ allowlist = set(config.root_allowlist)
181
+ violations: list[str] = []
182
+ for entry in sorted(repo_root.iterdir()):
183
+ if entry.is_dir():
184
+ continue
185
+ name = entry.name
186
+ if name in allowlist:
187
+ continue
188
+ for pattern in _STALE_ARTIFACT_PATTERNS:
189
+ if pattern.match(name):
190
+ violations.append(name)
191
+ break
192
+
193
+ count = len(violations)
194
+ score = scale_linear(
195
+ count,
196
+ zero_at=config.thresholds.root_stale_max_penalty_count,
197
+ full_at=0,
198
+ max_pts=_ROOT_CLEAN_WEIGHT,
199
+ )
200
+ return {
201
+ "score": round(score, 2),
202
+ "max": _ROOT_CLEAN_WEIGHT,
203
+ "violation_count": count,
204
+ "violations": violations[:10],
205
+ }