agentrepocoach 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (40) hide show
  1. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/PKG-INFO +8 -2
  2. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/README.md +7 -1
  3. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/pyproject.toml +1 -1
  4. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/__init__.py +1 -1
  5. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/__init__.py +12 -2
  6. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/csharp.py +7 -3
  7. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/go.py +3 -1
  8. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/rust.py +3 -1
  9. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/cli.py +112 -6
  10. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/decision_queryability.py +9 -3
  11. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/test_quality.py +9 -2
  12. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/output.py +103 -4
  13. agentrepocoach-0.3.0/src/agentrepocoach/pr_bot.py +174 -0
  14. agentrepocoach-0.3.0/src/agentrepocoach/regex_safety.py +98 -0
  15. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/PKG-INFO +8 -2
  16. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/SOURCES.txt +7 -1
  17. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/tests/test_adapters.py +17 -0
  18. agentrepocoach-0.3.0/tests/test_cli_compare.py +342 -0
  19. agentrepocoach-0.3.0/tests/test_output.py +109 -0
  20. agentrepocoach-0.3.0/tests/test_pr_bot.py +207 -0
  21. agentrepocoach-0.3.0/tests/test_regex_safety.py +82 -0
  22. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/LICENSE +0 -0
  23. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/setup.cfg +0 -0
  24. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/__main__.py +0 -0
  25. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/base.py +0 -0
  26. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/python.py +0 -0
  27. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/adapters/typescript.py +0 -0
  28. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/__init__.py +0 -0
  29. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/documentation.py +0 -0
  30. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/error_quality.py +0 -0
  31. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/components/module_hygiene.py +0 -0
  32. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/compute.py +0 -0
  33. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/config.py +0 -0
  34. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach/scoring.py +0 -0
  35. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/dependency_links.txt +0 -0
  36. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/entry_points.txt +0 -0
  37. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/requires.txt +0 -0
  38. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/src/agentrepocoach.egg-info/top_level.txt +0 -0
  39. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/tests/test_components.py +0 -0
  40. {agentrepocoach-0.2.0 → agentrepocoach-0.3.0}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentrepocoach
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Score your codebase on how ready it is for AI agents — and coach you through the fixes.
5
5
  Author: WouterDeBot
6
6
  License: Apache-2.0
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  [![PyPI version](https://img.shields.io/pypi/v/agentrepocoach.svg)](https://pypi.org/project/agentrepocoach/)
40
40
  [![License](https://img.shields.io/github/license/WouterDeBot/agentrepocoach.svg)](./LICENSE)
41
- [![CI](https://github.com/WouterDeBot/agentrepocoach/workflows/dogfood/badge.svg)](https://github.com/WouterDeBot/agentrepocoach/actions)
41
+ [![CI](https://github.com/WouterDeBot/agentrepocoach/actions/workflows/ci.yml/badge.svg)](https://github.com/WouterDeBot/agentrepocoach/actions/workflows/ci.yml)
42
42
  [![Python versions](https://img.shields.io/pypi/pyversions/agentrepocoach.svg)](https://pypi.org/project/agentrepocoach/)
43
43
 
44
44
  AgentRepoCoach computes the **Codebase Agent Health (CAH)** score: a single 0-100
@@ -123,6 +123,12 @@ python -m agentrepocoach.cli --repo . --format json --output ./report.json
123
123
  # Per-sub-component breakdown
124
124
  python -m agentrepocoach.cli --repo . --verbose
125
125
 
126
+ # Compare against a baseline report (inline delta)
127
+ python -m agentrepocoach.cli --repo . --format json --output ./pr.json --compare ./baseline.json
128
+
129
+ # Compare two saved score files
130
+ python -m agentrepocoach.cli compare ./baseline.json ./pr.json
131
+
126
132
  # Show the installed version
127
133
  python -m agentrepocoach.cli --version
128
134
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![PyPI version](https://img.shields.io/pypi/v/agentrepocoach.svg)](https://pypi.org/project/agentrepocoach/)
6
6
  [![License](https://img.shields.io/github/license/WouterDeBot/agentrepocoach.svg)](./LICENSE)
7
- [![CI](https://github.com/WouterDeBot/agentrepocoach/workflows/dogfood/badge.svg)](https://github.com/WouterDeBot/agentrepocoach/actions)
7
+ [![CI](https://github.com/WouterDeBot/agentrepocoach/actions/workflows/ci.yml/badge.svg)](https://github.com/WouterDeBot/agentrepocoach/actions/workflows/ci.yml)
8
8
  [![Python versions](https://img.shields.io/pypi/pyversions/agentrepocoach.svg)](https://pypi.org/project/agentrepocoach/)
9
9
 
10
10
  AgentRepoCoach computes the **Codebase Agent Health (CAH)** score: a single 0-100
@@ -89,6 +89,12 @@ python -m agentrepocoach.cli --repo . --format json --output ./report.json
89
89
  # Per-sub-component breakdown
90
90
  python -m agentrepocoach.cli --repo . --verbose
91
91
 
92
+ # Compare against a baseline report (inline delta)
93
+ python -m agentrepocoach.cli --repo . --format json --output ./pr.json --compare ./baseline.json
94
+
95
+ # Compare two saved score files
96
+ python -m agentrepocoach.cli compare ./baseline.json ./pr.json
97
+
92
98
  # Show the installed version
93
99
  python -m agentrepocoach.cli --version
94
100
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentrepocoach"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Score your codebase on how ready it is for AI agents — and coach you through the fixes."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -9,6 +9,6 @@ from __future__ import annotations
9
9
 
10
10
  from .compute import compute_cah
11
11
 
12
- VERSION = "0.2.0"
12
+ VERSION = "0.3.0"
13
13
 
14
14
  __all__ = ["compute_cah", "VERSION"]
@@ -33,7 +33,12 @@ def get_adapter_by_name(name: str) -> LanguageAdapter:
33
33
 
34
34
 
35
35
  def detect_primary(repo_path: Path) -> LanguageAdapter:
36
- """Try every adapter and return the one with the highest detect() confidence."""
36
+ """Try every adapter and return the one with the highest detect() confidence.
37
+
38
+ When multiple adapters tie on confidence, the adapter whose
39
+ ``find_production_files`` returns more files wins — a repo with 20 .py
40
+ files and a single .sln fixture is almost certainly a Python project.
41
+ """
37
42
  candidates: list[tuple[float, LanguageAdapter]] = []
38
43
  for cls in _REGISTRY.values():
39
44
  adapter = cls()
@@ -44,7 +49,12 @@ def detect_primary(repo_path: Path) -> LanguageAdapter:
44
49
  supported = ", ".join(sorted(_REGISTRY))
45
50
  msg = f"No supported language detected in {repo_path}. Supported: {supported}."
46
51
  raise NoAdapterError(f"{msg} Try using --language to force an adapter, or check that the repo contains a recognized project file.")
47
- candidates.sort(key=lambda pair: pair[0], reverse=True)
52
+ # Sort by confidence (desc), then by production file count (desc) to
53
+ # break ties deterministically in favour of the dominant language.
54
+ candidates.sort(
55
+ key=lambda pair: (pair[0], len(pair[1].find_production_files(repo_path))),
56
+ reverse=True,
57
+ )
48
58
  return candidates[0][1]
49
59
 
50
60
 
@@ -116,10 +116,14 @@ class CSharpAdapter(LanguageAdapter):
116
116
  # ------------------------------------------------------------------
117
117
 
118
118
  def detect(self, repo_path: Path) -> float:
119
- """1.0 if any *.sln, 0.8 if any *.csproj, else 0.0."""
120
- if any(repo_path.rglob("*.sln")):
119
+ """1.0 if any *.sln, 0.8 if any *.csproj, else 0.0.
120
+
121
+ Only checks the repo root and one level deep to avoid false
122
+ positives from test fixtures or vendored dependencies.
123
+ """
124
+ if any(repo_path.glob("*.sln")) or any(repo_path.glob("*/*.sln")):
121
125
  return 1.0
122
- if any(repo_path.rglob("*.csproj")):
126
+ if any(repo_path.glob("*.csproj")) or any(repo_path.glob("*/*.csproj")):
123
127
  return 0.8
124
128
  return 0.0
125
129
 
@@ -72,7 +72,9 @@ class GoAdapter(LanguageAdapter):
72
72
  def detect(self, repo_path: Path) -> float:
73
73
  if (repo_path / "go.mod").is_file():
74
74
  return 1.0
75
- if any(repo_path.rglob("*.go")):
75
+ # Shallow search (root + one level) to avoid false positives from
76
+ # test fixtures or vendored dependencies.
77
+ if any(repo_path.glob("*.go")) or any(repo_path.glob("*/*.go")):
76
78
  return 0.5
77
79
  return 0.0
78
80
 
@@ -72,7 +72,9 @@ class RustAdapter(LanguageAdapter):
72
72
  def detect(self, repo_path: Path) -> float:
73
73
  if (repo_path / "Cargo.toml").is_file():
74
74
  return 1.0
75
- if any(repo_path.rglob("*.rs")):
75
+ # Shallow search (root + one level) to avoid false positives from
76
+ # test fixtures or vendored dependencies.
77
+ if any(repo_path.glob("*.rs")) or any(repo_path.glob("*/*.rs")):
76
78
  return 0.5
77
79
  return 0.0
78
80
 
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
+ import json as _json
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
@@ -10,12 +11,17 @@ from .adapters import NoAdapterError
10
11
  from .compute import compute_cah
11
12
  from .config import ConfigError, load_config
12
13
  from .output import (
14
+ format_comparison,
15
+ format_comparison_markdown,
13
16
  format_summary,
14
17
  format_verbose,
18
+ render_json,
19
+ render_markdown_comment,
15
20
  write_json,
16
21
  write_markdown_comment,
17
22
  write_prometheus,
18
23
  )
24
+ from .pr_bot import compare_scores, format_pr_comment, parse_score_output
19
25
 
20
26
 
21
27
  def build_parser() -> argparse.ArgumentParser:
@@ -49,9 +55,10 @@ def build_parser() -> argparse.ArgumentParser:
49
55
  "--format",
50
56
  choices=["json", "markdown", "both"],
51
57
  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).",
58
+ help="Output format. 'json' prints the full report (to stdout or --output), "
59
+ "'markdown' prints a PR-comment summary (to stdout or --output), "
60
+ "'both' writes both to --output (markdown path derived by swapping "
61
+ "the extension to .md; requires --output).",
55
62
  )
56
63
  parser.add_argument(
57
64
  "--output",
@@ -59,17 +66,88 @@ def build_parser() -> argparse.ArgumentParser:
59
66
  default=None,
60
67
  help="Output path for --format. Ignored if --format is not set.",
61
68
  )
69
+ parser.add_argument(
70
+ "--compare",
71
+ type=Path,
72
+ default=None,
73
+ help="Path to a baseline JSON report. Prints a delta comparison instead of "
74
+ "the normal summary.",
75
+ )
62
76
  parser.add_argument("--verbose", action="store_true", help="Print per-sub-component breakdown.")
63
77
  parser.add_argument("--quiet", action="store_true", help="Print only the total score.")
64
78
  parser.add_argument("--version", action="version", version=f"agentrepocoach {VERSION}")
79
+
80
+ # Subcommands
81
+ subparsers = parser.add_subparsers(dest="subcommand")
82
+
83
+ # compare subcommand
84
+ compare_parser = subparsers.add_parser(
85
+ "compare",
86
+ help="Compare two JSON score files and display deltas.",
87
+ )
88
+ compare_parser.add_argument(
89
+ "base_file",
90
+ type=Path,
91
+ help="Path to the base (target branch) JSON score file.",
92
+ )
93
+ compare_parser.add_argument(
94
+ "pr_file",
95
+ type=Path,
96
+ help="Path to the PR (source branch) JSON score file.",
97
+ )
98
+ compare_parser.add_argument(
99
+ "--json",
100
+ action="store_true",
101
+ dest="json_output",
102
+ help="Output raw comparison dict as JSON instead of markdown.",
103
+ )
104
+
65
105
  return parser
66
106
 
67
107
 
108
+ def _run_compare(args: argparse.Namespace) -> int:
109
+ """Execute the ``compare`` subcommand."""
110
+ base_path = args.base_file.resolve()
111
+ pr_path = args.pr_file.resolve()
112
+
113
+ if not base_path.is_file():
114
+ print(f"error: base file does not exist: {base_path}", file=sys.stderr)
115
+ return 2
116
+ if not pr_path.is_file():
117
+ print(f"error: pr file does not exist: {pr_path}", file=sys.stderr)
118
+ return 2
119
+
120
+ try:
121
+ base_scores = parse_score_output(base_path.read_text())
122
+ except ValueError as exc:
123
+ print(f"error: failed to parse base file: {exc}", file=sys.stderr)
124
+ return 2
125
+
126
+ try:
127
+ pr_scores = parse_score_output(pr_path.read_text())
128
+ except ValueError as exc:
129
+ print(f"error: failed to parse pr file: {exc}", file=sys.stderr)
130
+ return 2
131
+
132
+ comparison = compare_scores(base_scores, pr_scores)
133
+
134
+ if args.json_output:
135
+ print(_json.dumps(comparison, indent=2))
136
+ else:
137
+ print(format_pr_comment(comparison))
138
+
139
+ return 0
140
+
141
+
68
142
  def main(argv: list[str] | None = None) -> int:
69
143
  """Run the CLI, parse arguments, compute the CAH score, and write outputs."""
70
144
  parser = build_parser()
71
145
  args = parser.parse_args(argv)
72
146
 
147
+ # Dispatch subcommands
148
+ if args.subcommand == "compare":
149
+ return _run_compare(args)
150
+
73
151
  repo_root = args.repo.resolve()
74
152
  if not repo_root.is_dir():
75
153
  print(f"error: repo path is not a directory: {repo_root}", file=sys.stderr)
@@ -92,7 +170,25 @@ def main(argv: list[str] | None = None) -> int:
92
170
  print(f"error: {exc}", file=sys.stderr)
93
171
  return 2
94
172
 
95
- if args.quiet:
173
+ # When --format is used without --output, the formatted content replaces
174
+ # the default terminal summary on stdout.
175
+ stdout_replaced_by_format = args.format and not args.output
176
+
177
+ if args.compare:
178
+ baseline_path = args.compare.resolve()
179
+ if not baseline_path.is_file():
180
+ print(f"error: baseline report not found: {baseline_path}", file=sys.stderr)
181
+ return 2
182
+ import json as _json
183
+ baseline = _json.loads(baseline_path.read_text())
184
+ if args.quiet:
185
+ delta = result["total"] - baseline["total"]
186
+ print(f"{delta:+.2f}")
187
+ else:
188
+ print(format_comparison(result, baseline))
189
+ elif stdout_replaced_by_format:
190
+ pass # handled below in the --format block
191
+ elif args.quiet:
96
192
  print(f"{result['total']:.2f}")
97
193
  elif args.verbose:
98
194
  print(format_verbose(result))
@@ -117,12 +213,22 @@ def main(argv: list[str] | None = None) -> int:
117
213
  if args.format and args.output:
118
214
  _write_formatted(result, args.format, args.output, quiet=args.quiet)
119
215
  elif args.format and not args.output:
120
- print("error: --format requires --output", file=sys.stderr)
121
- return 2
216
+ if args.format == "both":
217
+ print("error: --format both requires --output", file=sys.stderr)
218
+ return 2
219
+ _print_formatted(result, args.format)
122
220
 
123
221
  return 0
124
222
 
125
223
 
224
+ def _print_formatted(result: dict, fmt: str) -> None:
225
+ """Print formatted output to stdout when --output is not provided."""
226
+ if fmt == "json":
227
+ print(render_json(result))
228
+ elif fmt == "markdown":
229
+ print(render_markdown_comment(result))
230
+
231
+
126
232
  def _write_formatted(
127
233
  result: dict,
128
234
  fmt: str,
@@ -14,11 +14,13 @@ inline_ref_resolution 30 -> 40. Total still sums to 100.
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
+ import warnings
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
20
21
  from ..adapters import LanguageAdapter
21
22
  from ..config import Config
23
+ from ..regex_safety import safe_compile_pattern
22
24
  from ..scoring import scale_linear
23
25
 
24
26
  _ADR_COUNT_WEIGHT = 60
@@ -139,9 +141,13 @@ def _compile_inline_ref_patterns(patterns: tuple[str, ...]) -> list[re.Pattern[s
139
141
  # Wrap in word boundaries if the user did not already supply them.
140
142
  anchored = raw if raw.startswith("\\b") else rf"\b{raw}\b"
141
143
  try:
142
- compiled.append(re.compile(anchored, re.IGNORECASE))
143
- except re.error:
144
- # Malformed regex -> skip silently; the user sees it in --verbose.
144
+ compiled.append(safe_compile_pattern(anchored, flags=re.IGNORECASE))
145
+ except ValueError as exc:
146
+ warnings.warn(
147
+ f"Skipping inline_ref_pattern {raw!r}: {exc}",
148
+ UserWarning,
149
+ stacklevel=2,
150
+ )
145
151
  continue
146
152
  return compiled
147
153
 
@@ -14,12 +14,14 @@ full credit unless the user opts in by listing project-specific patterns.
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
+ import warnings
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
20
21
  from ..adapters import LanguageAdapter
21
22
  from ..adapters.base import iter_source_files
22
23
  from ..config import Config
24
+ from ..regex_safety import safe_compile_pattern
23
25
  from ..scoring import scale_linear
24
26
 
25
27
  _NAMING_WEIGHT = 40
@@ -153,8 +155,13 @@ def _score_fixture_duplication(
153
155
  compiled: list[re.Pattern[str]] = []
154
156
  for raw in patterns:
155
157
  try:
156
- compiled.append(re.compile(raw))
157
- except re.error:
158
+ compiled.append(safe_compile_pattern(raw))
159
+ except ValueError as exc:
160
+ warnings.warn(
161
+ f"Skipping fixture_duplication_pattern {raw!r}: {exc}",
162
+ UserWarning,
163
+ stacklevel=2,
164
+ )
158
165
  continue
159
166
 
160
167
  total = 0
@@ -21,14 +21,19 @@ _METRIC_HELP = "AgentRepoCoach composite codebase agent health score (0-100)."
21
21
  _METRIC_NAME = "agentrepocoach_codebase_health_score"
22
22
 
23
23
 
24
+ def render_json(result: dict[str, Any]) -> str:
25
+ """Return the full score breakdown as a JSON string."""
26
+ return json.dumps(result, indent=2, default=str)
27
+
28
+
24
29
  def write_json(result: dict[str, Any], path: Path) -> None:
25
30
  """Write the full score breakdown as JSON."""
26
31
  path.parent.mkdir(parents=True, exist_ok=True)
27
- path.write_text(json.dumps(result, indent=2, default=str) + "\n")
32
+ path.write_text(render_json(result) + "\n")
28
33
 
29
34
 
30
- def write_prometheus(result: dict[str, Any], path: Path) -> None:
31
- """Write the score in Prometheus exposition format."""
35
+ def render_prometheus(result: dict[str, Any]) -> str:
36
+ """Return the score in Prometheus exposition format as a string."""
32
37
  lines = [
33
38
  f"# HELP {_METRIC_NAME} {_METRIC_HELP}",
34
39
  f"# TYPE {_METRIC_NAME} gauge",
@@ -37,8 +42,36 @@ def write_prometheus(result: dict[str, Any], path: Path) -> None:
37
42
  for name, component in result.get("components", {}).items():
38
43
  score = component.get("score", 0)
39
44
  lines.append(f'{_METRIC_NAME}{{component="{name}"}} {score}')
45
+ return "\n".join(lines)
46
+
47
+
48
+ def write_prometheus(result: dict[str, Any], path: Path) -> None:
49
+ """Write the score in Prometheus exposition format."""
40
50
  path.parent.mkdir(parents=True, exist_ok=True)
41
- path.write_text("\n".join(lines) + "\n")
51
+ path.write_text(render_prometheus(result) + "\n")
52
+
53
+
54
+ def render_markdown_comment(result: dict[str, Any]) -> str:
55
+ """Return a short summary suitable for a GitHub PR comment as a string."""
56
+ lines = [
57
+ "### AgentRepoCoach — Codebase Agent Health",
58
+ "",
59
+ f"**Total score:** {result['total']:.2f} / 100",
60
+ f"**Language:** `{result.get('language', 'unknown')}`",
61
+ "",
62
+ "| Component | Score | Weight |",
63
+ "|---|---:|---:|",
64
+ ]
65
+ weights = result.get("weights", {})
66
+ for name, component in result.get("components", {}).items():
67
+ weight = weights.get(name, 0.0)
68
+ lines.append(f"| {name} | {component['score']:.2f} / 100 | {weight:.2f} |")
69
+ tips = generate_coaching(result)
70
+ coaching = format_coaching_markdown(tips)
71
+ if coaching:
72
+ lines.append(coaching)
73
+ lines.append("<!-- agentrepocoach -->")
74
+ return "\n".join(lines)
42
75
 
43
76
 
44
77
  def write_markdown_comment(result: dict[str, Any], path: Path) -> None:
@@ -254,6 +287,72 @@ def format_summary(result: dict[str, Any]) -> str:
254
287
  return "\n".join(lines)
255
288
 
256
289
 
290
+ def format_comparison(current: dict[str, Any], baseline: dict[str, Any]) -> str:
291
+ """Return a terminal-friendly delta table comparing *current* against *baseline*."""
292
+ cur_total = current["total"]
293
+ base_total = baseline["total"]
294
+ delta_total = cur_total - base_total
295
+
296
+ lines = [
297
+ "AgentRepoCoach — Score Comparison",
298
+ "=================================",
299
+ f"Total: {base_total:.2f} -> {cur_total:.2f} ({delta_total:+.2f})",
300
+ "",
301
+ f" {'Component':25s} {'Baseline':>10s} {'Current':>10s} {'Delta':>10s}",
302
+ f" {'-' * 25} {'-' * 10} {'-' * 10} {'-' * 10}",
303
+ ]
304
+
305
+ base_components = baseline.get("components", {})
306
+ cur_components = current.get("components", {})
307
+ all_names = list(dict.fromkeys(list(base_components) + list(cur_components)))
308
+
309
+ for name in all_names:
310
+ base_score = base_components.get(name, {}).get("score", 0.0)
311
+ cur_score = cur_components.get(name, {}).get("score", 0.0)
312
+ delta = cur_score - base_score
313
+ lines.append(
314
+ f" {name:25s} {base_score:10.2f} {cur_score:10.2f} {delta:+10.2f}"
315
+ )
316
+
317
+ return "\n".join(lines)
318
+
319
+
320
+ def format_comparison_markdown(current: dict[str, Any], baseline: dict[str, Any]) -> str:
321
+ """Return a markdown delta table suitable for a GitHub PR comment."""
322
+ cur_total = current["total"]
323
+ base_total = baseline["total"]
324
+ delta_total = cur_total - base_total
325
+
326
+ lines = [
327
+ "### AgentRepoCoach — Score Comparison",
328
+ "",
329
+ f"**Total score:** {base_total:.2f} -> {cur_total:.2f} ({delta_total:+.2f})",
330
+ "",
331
+ "| Component | Baseline | Current | Delta |",
332
+ "|---|---:|---:|---:|",
333
+ ]
334
+
335
+ base_components = baseline.get("components", {})
336
+ cur_components = current.get("components", {})
337
+ all_names = list(dict.fromkeys(list(base_components) + list(cur_components)))
338
+
339
+ for name in all_names:
340
+ base_score = base_components.get(name, {}).get("score", 0.0)
341
+ cur_score = cur_components.get(name, {}).get("score", 0.0)
342
+ delta = cur_score - base_score
343
+ lines.append(f"| {name} | {base_score:.2f} | {cur_score:.2f} | {delta:+.2f} |")
344
+
345
+ # Append coaching tips from the current result
346
+ tips = generate_coaching(current)
347
+ coaching = format_coaching_markdown(tips)
348
+ if coaching:
349
+ lines.append(coaching)
350
+
351
+ lines.append("<!-- agentrepocoach -->")
352
+
353
+ return "\n".join(lines)
354
+
355
+
257
356
  def format_verbose(result: dict[str, Any]) -> str:
258
357
  """Return the summary plus a per-sub-component breakdown."""
259
358
  lines = [format_summary(result), "", "Sub-component breakdown:"]