agentrepocoach 0.2.0__tar.gz → 0.3.1__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 (43) hide show
  1. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/PKG-INFO +8 -5
  2. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/README.md +7 -4
  3. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/pyproject.toml +1 -1
  4. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/__init__.py +1 -1
  5. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/__init__.py +44 -3
  6. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/csharp.py +7 -3
  7. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/go.py +3 -1
  8. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/rust.py +3 -1
  9. agentrepocoach-0.3.1/src/agentrepocoach/cli.py +346 -0
  10. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/decision_queryability.py +9 -3
  11. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/test_quality.py +9 -2
  12. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/compute.py +57 -1
  13. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/output.py +103 -4
  14. agentrepocoach-0.3.1/src/agentrepocoach/pr_bot.py +174 -0
  15. agentrepocoach-0.3.1/src/agentrepocoach/regex_safety.py +98 -0
  16. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/PKG-INFO +8 -5
  17. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/SOURCES.txt +9 -1
  18. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/tests/test_adapters.py +17 -0
  19. agentrepocoach-0.3.1/tests/test_cli.py +108 -0
  20. agentrepocoach-0.3.1/tests/test_cli_compare.py +342 -0
  21. agentrepocoach-0.3.1/tests/test_multi_language.py +197 -0
  22. agentrepocoach-0.3.1/tests/test_output.py +109 -0
  23. agentrepocoach-0.3.1/tests/test_pr_bot.py +207 -0
  24. agentrepocoach-0.3.1/tests/test_regex_safety.py +82 -0
  25. agentrepocoach-0.2.0/src/agentrepocoach/cli.py +0 -155
  26. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/LICENSE +0 -0
  27. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/setup.cfg +0 -0
  28. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/__main__.py +0 -0
  29. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/base.py +0 -0
  30. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/python.py +0 -0
  31. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/typescript.py +0 -0
  32. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/__init__.py +0 -0
  33. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/documentation.py +0 -0
  34. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/error_quality.py +0 -0
  35. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/module_hygiene.py +0 -0
  36. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/config.py +0 -0
  37. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach/scoring.py +0 -0
  38. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/dependency_links.txt +0 -0
  39. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/entry_points.txt +0 -0
  40. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/requires.txt +0 -0
  41. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/top_level.txt +0 -0
  42. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/tests/test_components.py +0 -0
  43. {agentrepocoach-0.2.0 → agentrepocoach-0.3.1}/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.1
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
  ```
@@ -197,6 +203,3 @@ are safe to publish as CI artifacts.
197
203
  ## License
198
204
 
199
205
  Apache 2.0. See [LICENSE](LICENSE).
200
-
201
- Built using the [GSD](https://github.com/gsd-build/get-shit-done) workflow
202
- methodology (MIT).
@@ -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
  ```
@@ -163,6 +169,3 @@ are safe to publish as CI artifacts.
163
169
  ## License
164
170
 
165
171
  Apache 2.0. See [LICENSE](LICENSE).
166
-
167
- Built using the [GSD](https://github.com/gsd-build/get-shit-done) workflow
168
- methodology (MIT).
@@ -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.1"
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.1"
13
13
 
14
14
  __all__ = ["compute_cah", "VERSION"]
@@ -32,22 +32,62 @@ def get_adapter_by_name(name: str) -> LanguageAdapter:
32
32
  return _REGISTRY[name]()
33
33
 
34
34
 
35
- def detect_primary(repo_path: Path) -> LanguageAdapter:
36
- """Try every adapter and return the one with the highest detect() confidence."""
35
+ def _collect_candidates(repo_path: Path) -> list[tuple[float, LanguageAdapter]]:
36
+ """Collect all adapters with confidence > 0.0, sorted descending by (confidence, file_count)."""
37
37
  candidates: list[tuple[float, LanguageAdapter]] = []
38
38
  for cls in _REGISTRY.values():
39
39
  adapter = cls()
40
40
  confidence = adapter.detect(repo_path)
41
41
  if confidence > 0.0:
42
42
  candidates.append((confidence, adapter))
43
+ candidates.sort(
44
+ key=lambda pair: (pair[0], len(pair[1].find_production_files(repo_path))),
45
+ reverse=True,
46
+ )
47
+ return candidates
48
+
49
+
50
+ def detect_primary(repo_path: Path) -> LanguageAdapter:
51
+ """Try every adapter and return the one with the highest detect() confidence.
52
+
53
+ When multiple adapters tie on confidence, the adapter whose
54
+ ``find_production_files`` returns more files wins — a repo with 20 .py
55
+ files and a single .sln fixture is almost certainly a Python project.
56
+ """
57
+ candidates = _collect_candidates(repo_path)
43
58
  if not candidates:
44
59
  supported = ", ".join(sorted(_REGISTRY))
45
60
  msg = f"No supported language detected in {repo_path}. Supported: {supported}."
46
61
  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)
48
62
  return candidates[0][1]
49
63
 
50
64
 
65
+ def detect_all(repo_path: Path) -> list[tuple[float, LanguageAdapter]]:
66
+ """Return all adapters that meet the detection threshold.
67
+
68
+ An adapter is included when ``confidence >= 0.5`` AND it has
69
+ ``file_count >= 3`` production files under ``repo_path``. This filters
70
+ out repos where a language appears only as tooling sprinkles (e.g. a
71
+ single Makefile helper script in an otherwise Go repo).
72
+
73
+ Returns:
74
+ A list of ``(confidence, adapter)`` tuples, sorted descending by
75
+ ``(confidence, file_count)``. Returns an empty list when no adapter
76
+ meets the threshold.
77
+ """
78
+ _CONFIDENCE_FLOOR = 0.5
79
+ _FILE_COUNT_FLOOR = 3
80
+
81
+ result: list[tuple[float, LanguageAdapter]] = []
82
+ for confidence, adapter in _collect_candidates(repo_path):
83
+ if confidence < _CONFIDENCE_FLOOR:
84
+ continue
85
+ if len(adapter.find_production_files(repo_path)) < _FILE_COUNT_FLOOR:
86
+ continue
87
+ result.append((confidence, adapter))
88
+ return result
89
+
90
+
51
91
  __all__ = [
52
92
  "CSharpAdapter",
53
93
  "Declaration",
@@ -59,6 +99,7 @@ __all__ = [
59
99
  "RustAdapter",
60
100
  "ThrowSite",
61
101
  "TypeScriptAdapter",
102
+ "detect_all",
62
103
  "detect_primary",
63
104
  "get_adapter_by_name",
64
105
  ]
@@ -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
 
@@ -0,0 +1,346 @@
1
+ """AgentRepoCoach CLI entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json as _json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import VERSION
10
+ from .adapters import NoAdapterError, _REGISTRY
11
+ from .compute import compute_cah, compute_cah_all
12
+ from .config import Config, ConfigError, load_config
13
+ from .output import (
14
+ format_comparison,
15
+ format_comparison_markdown,
16
+ format_summary,
17
+ format_verbose,
18
+ render_json,
19
+ render_markdown_comment,
20
+ write_json,
21
+ write_markdown_comment,
22
+ write_prometheus,
23
+ )
24
+ from .pr_bot import compare_scores, format_pr_comment, parse_score_output
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ """Build the argument parser for the ``agentrepocoach`` CLI."""
29
+ parser = argparse.ArgumentParser(
30
+ prog="agentrepocoach",
31
+ description="Compute the Codebase Agent Health (CAH) composite score for a repository.",
32
+ )
33
+ parser.add_argument(
34
+ "--repo",
35
+ type=Path,
36
+ default=None,
37
+ help="Path to the repository to score (default: current directory). "
38
+ "You may also pass the path as a positional argument: agentrepocoach [PATH].",
39
+ )
40
+ parser.add_argument(
41
+ "--config",
42
+ type=Path,
43
+ default=None,
44
+ help="Explicit config file path (default: <repo>/.agentrepocoach.toml).",
45
+ )
46
+ _adapter_names = "|".join(sorted(_REGISTRY)) + "|auto"
47
+ lang_group = parser.add_mutually_exclusive_group()
48
+ lang_group.add_argument(
49
+ "--language",
50
+ type=str,
51
+ default=None,
52
+ help=f"Override language detection ({_adapter_names}). Mutually exclusive with --all-languages.",
53
+ )
54
+ lang_group.add_argument(
55
+ "--all-languages",
56
+ action="store_true",
57
+ default=False,
58
+ dest="all_languages",
59
+ help="Score every detected language above threshold. Mutually exclusive with --language.",
60
+ )
61
+ parser.add_argument("--json", type=Path, help="Write full JSON result to this path.")
62
+ parser.add_argument("--prometheus", type=Path, help="Write Prometheus metrics to this path.")
63
+ parser.add_argument("--comment", type=Path, help="Write a PR-comment markdown file to this path.")
64
+ parser.add_argument(
65
+ "--format",
66
+ choices=["json", "markdown", "both"],
67
+ default=None,
68
+ help="Output format. 'json' prints the full report (to stdout or --output), "
69
+ "'markdown' prints a PR-comment summary (to stdout or --output), "
70
+ "'both' writes both to --output (markdown path derived by swapping "
71
+ "the extension to .md; requires --output).",
72
+ )
73
+ parser.add_argument(
74
+ "--output",
75
+ type=Path,
76
+ default=None,
77
+ help="Output path for --format. Ignored if --format is not set.",
78
+ )
79
+ parser.add_argument(
80
+ "--compare",
81
+ type=Path,
82
+ default=None,
83
+ help="Path to a baseline JSON report. Prints a delta comparison instead of "
84
+ "the normal summary.",
85
+ )
86
+ parser.add_argument("--verbose", action="store_true", help="Print per-sub-component breakdown.")
87
+ parser.add_argument("--quiet", action="store_true", help="Print only the total score.")
88
+ parser.add_argument("--version", action="version", version=f"agentrepocoach {VERSION}")
89
+
90
+ # Subcommands
91
+ subparsers = parser.add_subparsers(dest="subcommand")
92
+
93
+ # compare subcommand
94
+ compare_parser = subparsers.add_parser(
95
+ "compare",
96
+ help="Compare two JSON score files and display deltas.",
97
+ )
98
+ compare_parser.add_argument(
99
+ "base_file",
100
+ type=Path,
101
+ help="Path to the base (target branch) JSON score file.",
102
+ )
103
+ compare_parser.add_argument(
104
+ "pr_file",
105
+ type=Path,
106
+ help="Path to the PR (source branch) JSON score file.",
107
+ )
108
+ compare_parser.add_argument(
109
+ "--json",
110
+ action="store_true",
111
+ dest="json_output",
112
+ help="Output raw comparison dict as JSON instead of markdown.",
113
+ )
114
+
115
+ return parser
116
+
117
+
118
+ def _run_compare(args: argparse.Namespace) -> int:
119
+ """Execute the ``compare`` subcommand."""
120
+ base_path = args.base_file.resolve()
121
+ pr_path = args.pr_file.resolve()
122
+
123
+ if not base_path.is_file():
124
+ print(f"error: base file does not exist: {base_path}", file=sys.stderr)
125
+ return 2
126
+ if not pr_path.is_file():
127
+ print(f"error: pr file does not exist: {pr_path}", file=sys.stderr)
128
+ return 2
129
+
130
+ try:
131
+ base_scores = parse_score_output(base_path.read_text())
132
+ except ValueError as exc:
133
+ print(f"error: failed to parse base file: {exc}", file=sys.stderr)
134
+ return 2
135
+
136
+ try:
137
+ pr_scores = parse_score_output(pr_path.read_text())
138
+ except ValueError as exc:
139
+ print(f"error: failed to parse pr file: {exc}", file=sys.stderr)
140
+ return 2
141
+
142
+ comparison = compare_scores(base_scores, pr_scores)
143
+
144
+ if args.json_output:
145
+ print(_json.dumps(comparison, indent=2))
146
+ else:
147
+ print(format_pr_comment(comparison))
148
+
149
+ return 0
150
+
151
+
152
+ def main(argv: list[str] | None = None) -> int:
153
+ """Run the CLI, parse arguments, compute the CAH score, and write outputs."""
154
+ parser = build_parser()
155
+
156
+ # Pre-process argv to support positional path: `agentrepocoach .`
157
+ # If the first non-option argument is not a known subcommand and looks like
158
+ # a path (not starting with '-'), inject it as `--repo` so argparse handles
159
+ # the rest normally. This preserves full backward-compat with `--repo` and
160
+ # with subcommands like `compare`.
161
+ _argv = list(argv) if argv is not None else sys.argv[1:]
162
+ _known_subcommands = {"compare"}
163
+ _positional_path: Path | None = None
164
+ if _argv and not _argv[0].startswith("-") and _argv[0] not in _known_subcommands:
165
+ _positional_path = Path(_argv[0])
166
+ _argv = _argv[1:]
167
+
168
+ args = parser.parse_args(_argv)
169
+
170
+ # Dispatch subcommands
171
+ if args.subcommand == "compare":
172
+ return _run_compare(args)
173
+
174
+ # Resolve repo path: --repo wins over positional; default is cwd.
175
+ if args.repo is not None and _positional_path is not None:
176
+ print(
177
+ "notice: both --repo and positional PATH given; --repo takes precedence.",
178
+ file=sys.stderr,
179
+ )
180
+ resolved_repo = args.repo
181
+ elif args.repo is not None:
182
+ resolved_repo = args.repo
183
+ elif _positional_path is not None:
184
+ resolved_repo = _positional_path
185
+ else:
186
+ resolved_repo = Path.cwd()
187
+
188
+ repo_root = resolved_repo.resolve()
189
+ if not repo_root.is_dir():
190
+ print(f"error: repo path is not a directory: {repo_root}", file=sys.stderr)
191
+ return 2
192
+
193
+ try:
194
+ config = load_config(repo_root, config_path=args.config)
195
+ except ConfigError as exc:
196
+ print(f"error: {exc}", file=sys.stderr)
197
+ return 2
198
+
199
+ if args.language:
200
+ # Replace the config's language field. Dataclass is frozen -> rebuild.
201
+ from dataclasses import replace as _replace
202
+ config = _replace(config, language=args.language)
203
+
204
+ if args.all_languages:
205
+ return _run_all_languages(repo_root, config, args)
206
+
207
+ try:
208
+ result = compute_cah(repo_root, config=config)
209
+ except NoAdapterError as exc:
210
+ print(f"error: {exc}", file=sys.stderr)
211
+ return 2
212
+
213
+ # When --format is used without --output, the formatted content replaces
214
+ # the default terminal summary on stdout.
215
+ stdout_replaced_by_format = args.format and not args.output
216
+
217
+ if args.compare:
218
+ baseline_path = args.compare.resolve()
219
+ if not baseline_path.is_file():
220
+ print(f"error: baseline report not found: {baseline_path}", file=sys.stderr)
221
+ return 2
222
+ import json as _json
223
+ baseline = _json.loads(baseline_path.read_text())
224
+ if args.quiet:
225
+ delta = result["total"] - baseline["total"]
226
+ print(f"{delta:+.2f}")
227
+ else:
228
+ print(format_comparison(result, baseline))
229
+ elif stdout_replaced_by_format:
230
+ pass # handled below in the --format block
231
+ elif args.quiet:
232
+ print(f"{result['total']:.2f}")
233
+ elif args.verbose:
234
+ print(format_verbose(result))
235
+ else:
236
+ print(format_summary(result))
237
+
238
+ if args.json:
239
+ write_json(result, args.json)
240
+ if not args.quiet:
241
+ print(f"\nJSON report written to {args.json}")
242
+
243
+ if args.prometheus:
244
+ write_prometheus(result, args.prometheus)
245
+ if not args.quiet:
246
+ print(f"Prometheus metrics written to {args.prometheus}")
247
+
248
+ if args.comment:
249
+ write_markdown_comment(result, args.comment)
250
+ if not args.quiet:
251
+ print(f"PR comment written to {args.comment}")
252
+
253
+ if args.format and args.output:
254
+ _write_formatted(result, args.format, args.output, quiet=args.quiet)
255
+ elif args.format and not args.output:
256
+ if args.format == "both":
257
+ print("error: --format both requires --output", file=sys.stderr)
258
+ return 2
259
+ _print_formatted(result, args.format)
260
+
261
+ return 0
262
+
263
+
264
+ def _run_all_languages(
265
+ repo_root: Path,
266
+ config: Config,
267
+ args: argparse.Namespace,
268
+ ) -> int:
269
+ """Handle the --all-languages code path."""
270
+ try:
271
+ result = compute_cah_all(repo_root, config=config)
272
+ except (NoAdapterError, RuntimeError) as exc:
273
+ print(f"error: {exc}", file=sys.stderr)
274
+ return 2
275
+
276
+ languages: dict = result.get("languages", {})
277
+
278
+ if not languages:
279
+ print("No language met the detection threshold (confidence >= 0.5, file count >= 3).", file=sys.stderr)
280
+ return 2
281
+
282
+ # Text output: one summary block per language, separated by a header line.
283
+ if not args.quiet:
284
+ first = True
285
+ for lang_name, lang_result in languages.items():
286
+ if not first:
287
+ print()
288
+ print(f"=== Language: {lang_name} ===")
289
+ print(format_summary(lang_result))
290
+ first = False
291
+
292
+ # JSON file output: write the nested multi-language shape.
293
+ if args.json:
294
+ write_json(result, args.json)
295
+ if not args.quiet:
296
+ print(f"\nJSON report written to {args.json}")
297
+
298
+ # --format json to stdout.
299
+ if args.format == "json" and not args.output:
300
+ print(render_json(result))
301
+ elif args.format == "json" and args.output:
302
+ write_json(result, args.output)
303
+ if not args.quiet:
304
+ print(f"\nJSON report written to {args.output}")
305
+
306
+ return 0
307
+
308
+
309
+ def _print_formatted(result: dict, fmt: str) -> None:
310
+ """Print formatted output to stdout when --output is not provided."""
311
+ if fmt == "json":
312
+ print(render_json(result))
313
+ elif fmt == "markdown":
314
+ print(render_markdown_comment(result))
315
+
316
+
317
+ def _write_formatted(
318
+ result: dict,
319
+ fmt: str,
320
+ output: Path,
321
+ *,
322
+ quiet: bool,
323
+ ) -> None:
324
+ """Dispatch --format/--output combinations to the underlying writers."""
325
+ if fmt == "json":
326
+ write_json(result, output)
327
+ if not quiet:
328
+ print(f"\nJSON report written to {output}")
329
+ return
330
+ if fmt == "markdown":
331
+ write_markdown_comment(result, output)
332
+ if not quiet:
333
+ print(f"\nMarkdown report written to {output}")
334
+ return
335
+ # fmt == "both"
336
+ json_path = output
337
+ markdown_path = output.with_suffix(".md")
338
+ write_json(result, json_path)
339
+ write_markdown_comment(result, markdown_path)
340
+ if not quiet:
341
+ print(f"\nJSON report written to {json_path}")
342
+ print(f"Markdown report written to {markdown_path}")
343
+
344
+
345
+ if __name__ == "__main__":
346
+ sys.exit(main())
@@ -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
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
- from .adapters import LanguageAdapter, detect_primary, get_adapter_by_name
7
+ from .adapters import LanguageAdapter, detect_all, detect_primary, get_adapter_by_name
8
8
  from .components import (
9
9
  compute_decision_queryability,
10
10
  compute_error_quality,
@@ -82,3 +82,59 @@ def _pick_adapter(repo_root: Path, config: Config) -> LanguageAdapter:
82
82
  if config.language and config.language != "auto":
83
83
  return get_adapter_by_name(config.language)
84
84
  return detect_primary(repo_root)
85
+
86
+
87
+ # Schema version for the multi-language output shape.
88
+ _MULTI_LANGUAGE_SCHEMA_VERSION = 2
89
+
90
+
91
+ def compute_cah_all(repo_root: Path, config: Config | None = None) -> dict[str, Any]:
92
+ """Compute CAH scores for every language that meets the detection threshold.
93
+
94
+ Uses ``detect_all()`` to find adapters with ``confidence >= 0.5`` AND
95
+ ``file_count >= 3``, then calls ``compute_cah()`` once per adapter.
96
+
97
+ The returned dict uses a nested shape distinct from the single-language
98
+ shape so downstream consumers can distinguish the two without ambiguity:
99
+
100
+ .. code-block:: json
101
+
102
+ {
103
+ "schema_version": 2,
104
+ "generator": "agentrepocoach <version>",
105
+ "languages": {
106
+ "python": {"total": 72.4, "components": {...}, "language": "python"},
107
+ "typescript": {"total": 61.2, "components": {...}, "language": "typescript"}
108
+ }
109
+ }
110
+
111
+ Note: the top-level ``"total"`` and ``"language"`` keys are intentionally
112
+ **absent** — use the per-language sub-dicts for those values.
113
+
114
+ Args:
115
+ repo_root: Path to the repository to score.
116
+ config: Optional explicit config. If None, loads from
117
+ ``<repo_root>/.agentrepocoach.toml`` with defaults.
118
+
119
+ Returns:
120
+ Multi-language result dict as described above. ``"languages"`` is
121
+ empty when no adapter meets the threshold.
122
+ """
123
+ from . import VERSION # local import to avoid circular reference
124
+
125
+ repo_root = repo_root.resolve()
126
+ if config is None:
127
+ config = load_config(repo_root)
128
+
129
+ adapters = detect_all(repo_root)
130
+
131
+ per_language: dict[str, Any] = {}
132
+ for _confidence, adapter in adapters:
133
+ lang_result = compute_cah(repo_root, config=config, adapter=adapter)
134
+ per_language[adapter.name] = lang_result
135
+
136
+ return {
137
+ "schema_version": _MULTI_LANGUAGE_SCHEMA_VERSION,
138
+ "generator": f"{_GENERATOR_NAME} {VERSION}",
139
+ "languages": per_language,
140
+ }