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/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
|
+
}
|