agentrepocoach 0.3.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 (42) hide show
  1. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/PKG-INFO +1 -4
  2. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/README.md +0 -3
  3. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/pyproject.toml +1 -1
  4. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/__init__.py +1 -1
  5. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/__init__.py +43 -12
  6. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/cli.py +94 -9
  7. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/compute.py +57 -1
  8. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/PKG-INFO +1 -4
  9. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/SOURCES.txt +2 -0
  10. agentrepocoach-0.3.1/tests/test_cli.py +108 -0
  11. agentrepocoach-0.3.1/tests/test_multi_language.py +197 -0
  12. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/LICENSE +0 -0
  13. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/setup.cfg +0 -0
  14. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/__main__.py +0 -0
  15. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/base.py +0 -0
  16. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/csharp.py +0 -0
  17. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/go.py +0 -0
  18. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/python.py +0 -0
  19. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/rust.py +0 -0
  20. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/adapters/typescript.py +0 -0
  21. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/__init__.py +0 -0
  22. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/decision_queryability.py +0 -0
  23. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/documentation.py +0 -0
  24. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/error_quality.py +0 -0
  25. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/module_hygiene.py +0 -0
  26. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/components/test_quality.py +0 -0
  27. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/config.py +0 -0
  28. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/output.py +0 -0
  29. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/pr_bot.py +0 -0
  30. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/regex_safety.py +0 -0
  31. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach/scoring.py +0 -0
  32. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/dependency_links.txt +0 -0
  33. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/entry_points.txt +0 -0
  34. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/requires.txt +0 -0
  35. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/src/agentrepocoach.egg-info/top_level.txt +0 -0
  36. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_adapters.py +0 -0
  37. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_cli_compare.py +0 -0
  38. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_components.py +0 -0
  39. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_config.py +0 -0
  40. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_output.py +0 -0
  41. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_pr_bot.py +0 -0
  42. {agentrepocoach-0.3.0 → agentrepocoach-0.3.1}/tests/test_regex_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentrepocoach
3
- Version: 0.3.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
@@ -203,6 +203,3 @@ are safe to publish as CI artifacts.
203
203
  ## License
204
204
 
205
205
  Apache 2.0. See [LICENSE](LICENSE).
206
-
207
- Built using the [GSD](https://github.com/gsd-build/get-shit-done) workflow
208
- methodology (MIT).
@@ -169,6 +169,3 @@ are safe to publish as CI artifacts.
169
169
  ## License
170
170
 
171
171
  Apache 2.0. See [LICENSE](LICENSE).
172
-
173
- Built using the [GSD](https://github.com/gsd-build/get-shit-done) workflow
174
- methodology (MIT).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentrepocoach"
7
- version = "0.3.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.3.0"
12
+ VERSION = "0.3.1"
13
13
 
14
14
  __all__ = ["compute_cah", "VERSION"]
@@ -32,6 +32,21 @@ def get_adapter_by_name(name: str) -> LanguageAdapter:
32
32
  return _REGISTRY[name]()
33
33
 
34
34
 
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
+ candidates: list[tuple[float, LanguageAdapter]] = []
38
+ for cls in _REGISTRY.values():
39
+ adapter = cls()
40
+ confidence = adapter.detect(repo_path)
41
+ if confidence > 0.0:
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
+
35
50
  def detect_primary(repo_path: Path) -> LanguageAdapter:
36
51
  """Try every adapter and return the one with the highest detect() confidence.
37
52
 
@@ -39,25 +54,40 @@ def detect_primary(repo_path: Path) -> LanguageAdapter:
39
54
  ``find_production_files`` returns more files wins — a repo with 20 .py
40
55
  files and a single .sln fixture is almost certainly a Python project.
41
56
  """
42
- candidates: list[tuple[float, LanguageAdapter]] = []
43
- for cls in _REGISTRY.values():
44
- adapter = cls()
45
- confidence = adapter.detect(repo_path)
46
- if confidence > 0.0:
47
- candidates.append((confidence, adapter))
57
+ candidates = _collect_candidates(repo_path)
48
58
  if not candidates:
49
59
  supported = ", ".join(sorted(_REGISTRY))
50
60
  msg = f"No supported language detected in {repo_path}. Supported: {supported}."
51
61
  raise NoAdapterError(f"{msg} Try using --language to force an adapter, or check that the repo contains a recognized project file.")
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
- )
58
62
  return candidates[0][1]
59
63
 
60
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
+
61
91
  __all__ = [
62
92
  "CSharpAdapter",
63
93
  "Declaration",
@@ -69,6 +99,7 @@ __all__ = [
69
99
  "RustAdapter",
70
100
  "ThrowSite",
71
101
  "TypeScriptAdapter",
102
+ "detect_all",
72
103
  "detect_primary",
73
104
  "get_adapter_by_name",
74
105
  ]
@@ -7,9 +7,9 @@ import sys
7
7
  from pathlib import Path
8
8
 
9
9
  from . import VERSION
10
- from .adapters import NoAdapterError
11
- from .compute import compute_cah
12
- from .config import ConfigError, load_config
10
+ from .adapters import NoAdapterError, _REGISTRY
11
+ from .compute import compute_cah, compute_cah_all
12
+ from .config import Config, ConfigError, load_config
13
13
  from .output import (
14
14
  format_comparison,
15
15
  format_comparison_markdown,
@@ -33,8 +33,9 @@ def build_parser() -> argparse.ArgumentParser:
33
33
  parser.add_argument(
34
34
  "--repo",
35
35
  type=Path,
36
- default=Path.cwd(),
37
- help="Path to the repository to score (default: current directory).",
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].",
38
39
  )
39
40
  parser.add_argument(
40
41
  "--config",
@@ -42,11 +43,20 @@ def build_parser() -> argparse.ArgumentParser:
42
43
  default=None,
43
44
  help="Explicit config file path (default: <repo>/.agentrepocoach.toml).",
44
45
  )
45
- parser.add_argument(
46
+ _adapter_names = "|".join(sorted(_REGISTRY)) + "|auto"
47
+ lang_group = parser.add_mutually_exclusive_group()
48
+ lang_group.add_argument(
46
49
  "--language",
47
50
  type=str,
48
51
  default=None,
49
- help="Override language detection (csharp|python|auto).",
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.",
50
60
  )
51
61
  parser.add_argument("--json", type=Path, help="Write full JSON result to this path.")
52
62
  parser.add_argument("--prometheus", type=Path, help="Write Prometheus metrics to this path.")
@@ -142,13 +152,40 @@ def _run_compare(args: argparse.Namespace) -> int:
142
152
  def main(argv: list[str] | None = None) -> int:
143
153
  """Run the CLI, parse arguments, compute the CAH score, and write outputs."""
144
154
  parser = build_parser()
145
- args = parser.parse_args(argv)
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)
146
169
 
147
170
  # Dispatch subcommands
148
171
  if args.subcommand == "compare":
149
172
  return _run_compare(args)
150
173
 
151
- repo_root = args.repo.resolve()
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()
152
189
  if not repo_root.is_dir():
153
190
  print(f"error: repo path is not a directory: {repo_root}", file=sys.stderr)
154
191
  return 2
@@ -164,6 +201,9 @@ def main(argv: list[str] | None = None) -> int:
164
201
  from dataclasses import replace as _replace
165
202
  config = _replace(config, language=args.language)
166
203
 
204
+ if args.all_languages:
205
+ return _run_all_languages(repo_root, config, args)
206
+
167
207
  try:
168
208
  result = compute_cah(repo_root, config=config)
169
209
  except NoAdapterError as exc:
@@ -221,6 +261,51 @@ def main(argv: list[str] | None = None) -> int:
221
261
  return 0
222
262
 
223
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
+
224
309
  def _print_formatted(result: dict, fmt: str) -> None:
225
310
  """Print formatted output to stdout when --output is not provided."""
226
311
  if fmt == "json":
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentrepocoach
3
- Version: 0.3.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
@@ -203,6 +203,3 @@ are safe to publish as CI artifacts.
203
203
  ## License
204
204
 
205
205
  Apache 2.0. See [LICENSE](LICENSE).
206
-
207
- Built using the [GSD](https://github.com/gsd-build/get-shit-done) workflow
208
- methodology (MIT).
@@ -30,9 +30,11 @@ src/agentrepocoach/components/error_quality.py
30
30
  src/agentrepocoach/components/module_hygiene.py
31
31
  src/agentrepocoach/components/test_quality.py
32
32
  tests/test_adapters.py
33
+ tests/test_cli.py
33
34
  tests/test_cli_compare.py
34
35
  tests/test_components.py
35
36
  tests/test_config.py
37
+ tests/test_multi_language.py
36
38
  tests/test_output.py
37
39
  tests/test_pr_bot.py
38
40
  tests/test_regex_safety.py
@@ -0,0 +1,108 @@
1
+ """Tests for v0.3.1 release-integrity fixes.
2
+
3
+ AC-01: --all-languages flag exists in --help output
4
+ AC-02: --language help text lists all registered adapters
5
+ AC-03: agentrepocoach <positional-path> works without --repo
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from unittest.mock import patch
13
+
14
+ import pytest
15
+
16
+ from agentrepocoach.cli import main
17
+
18
+
19
+ FIXTURES_ROOT = Path(__file__).parent / "fixtures"
20
+
21
+
22
+ class TestAllLanguagesFlagExists:
23
+ """AC-01: --all-languages flag must appear in --help output."""
24
+
25
+ def test_all_languages_flag_exists(self) -> None:
26
+ """Invoke --help via subprocess and assert --all-languages is listed."""
27
+ result = subprocess.run(
28
+ [sys.executable, "-m", "agentrepocoach", "--help"],
29
+ capture_output=True,
30
+ text=True,
31
+ )
32
+ assert result.returncode == 0
33
+ assert "--all-languages" in result.stdout
34
+
35
+
36
+ class TestLanguageHelpListsAllAdapters:
37
+ """AC-02: --language help text must list all registered adapter names."""
38
+
39
+ def test_language_help_lists_all_adapters(self) -> None:
40
+ """Invoke --help, assert each adapter name appears in --language help text."""
41
+ result = subprocess.run(
42
+ [sys.executable, "-m", "agentrepocoach", "--help"],
43
+ capture_output=True,
44
+ text=True,
45
+ )
46
+ assert result.returncode == 0
47
+ stdout = result.stdout
48
+ # All five adapter names must appear in the help output
49
+ for adapter_name in ("csharp", "go", "python", "rust", "typescript"):
50
+ assert adapter_name in stdout, (
51
+ f"Adapter '{adapter_name}' missing from --help output. "
52
+ f"Full stdout:\n{stdout}"
53
+ )
54
+
55
+
56
+ class TestPositionalPathArgument:
57
+ """AC-03: agentrepocoach <path> (positional) must succeed and produce a score."""
58
+
59
+ def test_positional_path_argument(self, capsys: pytest.CaptureFixture[str]) -> None:
60
+ """Invoke main with positional path (no --repo), assert exit 0 and score produced."""
61
+ python_repo = FIXTURES_ROOT / "sample-python-repo"
62
+ mock_result = {
63
+ "total": 72.00,
64
+ "language": "python",
65
+ "weights": {"navigability": 0.20},
66
+ "components": {
67
+ "navigability": {"score": 72.0, "breakdown": {}},
68
+ },
69
+ }
70
+ with patch("agentrepocoach.cli.compute_cah", return_value=mock_result):
71
+ rc = main([str(python_repo)])
72
+ assert rc == 0
73
+ out = capsys.readouterr().out
74
+ assert "72.00" in out
75
+
76
+ def test_positional_path_dot_uses_cwd(self, capsys: pytest.CaptureFixture[str]) -> None:
77
+ """agentrepocoach . should resolve to cwd without error."""
78
+ mock_result = {
79
+ "total": 65.00,
80
+ "language": "python",
81
+ "weights": {"navigability": 0.20},
82
+ "components": {
83
+ "navigability": {"score": 65.0, "breakdown": {}},
84
+ },
85
+ }
86
+ with patch("agentrepocoach.cli.compute_cah", return_value=mock_result):
87
+ rc = main(["."])
88
+ assert rc == 0
89
+ out = capsys.readouterr().out
90
+ assert "65.00" in out
91
+
92
+ def test_repo_flag_wins_over_positional(self, capsys: pytest.CaptureFixture[str]) -> None:
93
+ """When both --repo and positional are given, --repo wins and a notice is printed."""
94
+ python_repo = FIXTURES_ROOT / "sample-python-repo"
95
+ mock_result = {
96
+ "total": 80.00,
97
+ "language": "python",
98
+ "weights": {"navigability": 0.20},
99
+ "components": {
100
+ "navigability": {"score": 80.0, "breakdown": {}},
101
+ },
102
+ }
103
+ with patch("agentrepocoach.cli.compute_cah", return_value=mock_result):
104
+ rc = main([str(python_repo), "--repo", str(python_repo)])
105
+ assert rc == 0
106
+ captured = capsys.readouterr()
107
+ # --repo takes precedence; notice on stderr
108
+ assert "takes precedence" in captured.err or "notice" in captured.err
@@ -0,0 +1,197 @@
1
+ """Tests for multi-language scoring (detect_all, compute_cah_all, --all-languages flag).
2
+
3
+ Coverage maps to XPL-003-MVP acceptance criteria:
4
+ - AC-1: Threshold logic edge cases (confidence and file_count boundaries)
5
+ - AC-2: detect_all() positive — mixed-language fixture
6
+ - AC-3: detect_all() empty — nothing meets threshold
7
+ - AC-4: compute_cah_all() JSON shape contract
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Generator
13
+ from unittest.mock import patch
14
+
15
+ import pytest
16
+
17
+ from agentrepocoach.adapters import LanguageAdapter, PythonAdapter, detect_all
18
+ from agentrepocoach.compute import compute_cah_all, _MULTI_LANGUAGE_SCHEMA_VERSION
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Helpers / mini-fixtures
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _make_python_repo(root: Path, file_count: int) -> Path:
27
+ """Scaffold a minimal Python repo with ``file_count`` .py source files."""
28
+ src = root / "src"
29
+ src.mkdir(parents=True)
30
+ (root / "pyproject.toml").write_text("[project]\nname = 'test'\n")
31
+ for i in range(file_count):
32
+ (src / f"module_{i}.py").write_text(f"def func_{i}(): pass\n")
33
+ return root
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # AC-1: Threshold logic edge cases
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class TestDetectAllThresholdEdgeCases:
42
+ """Confidence and file_count boundary conditions (AC-1)."""
43
+
44
+ def test_confidence_below_floor_excluded(self, tmp_path: Path) -> None:
45
+ """Adapter with confidence=0.49 must be excluded."""
46
+ repo = _make_python_repo(tmp_path / "repo", file_count=5)
47
+ adapter = PythonAdapter()
48
+ with patch.object(type(adapter), "detect", return_value=0.49):
49
+ with patch("agentrepocoach.adapters._REGISTRY", {"python": lambda: adapter}):
50
+ result = detect_all(repo)
51
+ assert result == []
52
+
53
+ def test_confidence_at_floor_included(self, tmp_path: Path) -> None:
54
+ """Adapter with confidence=0.50 and file_count>=3 must be included."""
55
+ repo = _make_python_repo(tmp_path / "repo", file_count=5)
56
+ adapter = PythonAdapter()
57
+ with patch.object(type(adapter), "detect", return_value=0.50):
58
+ with patch("agentrepocoach.adapters._REGISTRY", {"python": lambda: adapter}):
59
+ result = detect_all(repo)
60
+ assert len(result) == 1
61
+ assert result[0][0] == pytest.approx(0.50)
62
+
63
+ def test_file_count_below_floor_excluded(self, tmp_path: Path) -> None:
64
+ """Adapter with file_count=2 (< 3) must be excluded even at confidence=1.0."""
65
+ repo = _make_python_repo(tmp_path / "repo", file_count=2)
66
+ # PythonAdapter.detect() will return 1.0 (pyproject.toml present)
67
+ # but only 2 production files exist → excluded
68
+ result = detect_all(repo)
69
+ assert result == []
70
+
71
+ def test_file_count_at_floor_included(self, tmp_path: Path) -> None:
72
+ """Adapter with file_count=3 and confidence>=0.5 must be included."""
73
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
74
+ result = detect_all(repo)
75
+ assert len(result) == 1
76
+ assert result[0][1].name == "python"
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # AC-2: detect_all() positive — multiple languages above threshold
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ class TestDetectAllPositive:
85
+ """detect_all() returns multiple adapters when the repo is multi-language (AC-2)."""
86
+
87
+ def test_detect_all_returns_multiple_adapters(self, tmp_path: Path) -> None:
88
+ """A repo with Python AND Go source files above threshold returns both adapters.
89
+
90
+ PythonAdapter.find_production_files() looks in ``src/`` or ``lib/`` dirs.
91
+ GoAdapter.find_production_files() looks for ``*.go`` files.
92
+ """
93
+ repo = tmp_path / "mixed"
94
+ repo.mkdir()
95
+
96
+ # Python side: pyproject.toml + 3 .py files under src/
97
+ (repo / "pyproject.toml").write_text("[project]\nname = 'mixed'\n")
98
+ py_src = repo / "src"
99
+ py_src.mkdir()
100
+ for i in range(3):
101
+ (py_src / f"module_{i}.py").write_text(f"def func_{i}(): pass\n")
102
+
103
+ # Go side: go.mod + 3 .go files
104
+ (repo / "go.mod").write_text("module example.com/mixed\ngo 1.21\n")
105
+ go_src = repo / "go_src"
106
+ go_src.mkdir()
107
+ for i in range(3):
108
+ (go_src / f"pkg{i}.go").write_text(f"package main\nfunc F{i}() {{}}\n")
109
+
110
+ result = detect_all(repo)
111
+ adapter_names = {adapter.name for _, adapter in result}
112
+ assert "python" in adapter_names
113
+ assert "go" in adapter_names
114
+
115
+ def test_detect_all_sorted_by_confidence_descending(self, tmp_path: Path) -> None:
116
+ """Results are sorted confidence-descending."""
117
+ repo = _make_python_repo(tmp_path / "repo", file_count=5)
118
+ result = detect_all(repo)
119
+ # At least one result (python) must be present
120
+ assert result
121
+ confidences = [c for c, _ in result]
122
+ assert confidences == sorted(confidences, reverse=True)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # AC-3: detect_all() empty — no language meets threshold
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class TestDetectAllEmpty:
131
+ """detect_all() returns [] when nothing meets the detection threshold (AC-3)."""
132
+
133
+ def test_empty_repo_returns_empty_list(self, tmp_path: Path) -> None:
134
+ """A repo with no recognised language files returns []."""
135
+ empty = tmp_path / "empty"
136
+ empty.mkdir()
137
+ result = detect_all(empty)
138
+ assert result == []
139
+
140
+ def test_below_file_count_threshold_returns_empty_list(self, tmp_path: Path) -> None:
141
+ """A Python repo with only 2 production files (< 3) returns []."""
142
+ repo = _make_python_repo(tmp_path / "repo", file_count=2)
143
+ result = detect_all(repo)
144
+ assert result == []
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # AC-4: compute_cah_all() JSON shape contract
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ class TestComputeCahAllShape:
153
+ """compute_cah_all() output shape: no top-level total/language, nested languages dict (AC-4)."""
154
+
155
+ def test_no_top_level_total(self, tmp_path: Path) -> None:
156
+ """Top-level 'total' key must be absent from multi-language output."""
157
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
158
+ result = compute_cah_all(repo)
159
+ assert "total" not in result
160
+
161
+ def test_no_top_level_language(self, tmp_path: Path) -> None:
162
+ """Top-level 'language' key must be absent from multi-language output."""
163
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
164
+ result = compute_cah_all(repo)
165
+ assert "language" not in result
166
+
167
+ def test_languages_dict_present(self, tmp_path: Path) -> None:
168
+ """Top-level 'languages' dict is present and non-empty for a qualifying repo."""
169
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
170
+ result = compute_cah_all(repo)
171
+ assert "languages" in result
172
+ assert isinstance(result["languages"], dict)
173
+ assert len(result["languages"]) >= 1
174
+
175
+ def test_languages_sub_shape(self, tmp_path: Path) -> None:
176
+ """Each language entry has 'total' and 'language' sub-keys."""
177
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
178
+ result = compute_cah_all(repo)
179
+ for lang_name, lang_result in result["languages"].items():
180
+ assert "total" in lang_result, f"{lang_name} missing 'total'"
181
+ assert "language" in lang_result, f"{lang_name} missing 'language'"
182
+ assert lang_result["language"] == lang_name
183
+
184
+ def test_schema_version_is_bumped(self, tmp_path: Path) -> None:
185
+ """schema_version must be the multi-language version (2), not the v0.3 value (1)."""
186
+ repo = _make_python_repo(tmp_path / "repo", file_count=3)
187
+ result = compute_cah_all(repo)
188
+ assert result["schema_version"] == _MULTI_LANGUAGE_SCHEMA_VERSION
189
+ assert result["schema_version"] == 2
190
+
191
+ def test_empty_repo_returns_empty_languages(self, tmp_path: Path) -> None:
192
+ """When no language meets threshold, 'languages' is an empty dict."""
193
+ empty = tmp_path / "empty"
194
+ empty.mkdir()
195
+ result = compute_cah_all(empty)
196
+ assert result["languages"] == {}
197
+ assert "total" not in result
File without changes
File without changes