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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""AgentRepoCoach — Codebase Agent Health (CAH) composite score.
|
|
2
|
+
|
|
3
|
+
Public entry points:
|
|
4
|
+
|
|
5
|
+
from agentrepocoach import compute_cah, VERSION
|
|
6
|
+
result = compute_cah(Path("/path/to/repo"))
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .compute import compute_cah
|
|
11
|
+
|
|
12
|
+
VERSION = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["compute_cah", "VERSION"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Adapter registry and language detection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .base import Declaration, LanguageAdapter, NotSupportedError, ThrowSite
|
|
7
|
+
from .csharp import CSharpAdapter
|
|
8
|
+
from .go import GoAdapter
|
|
9
|
+
from .python import PythonAdapter
|
|
10
|
+
from .rust import RustAdapter
|
|
11
|
+
from .typescript import TypeScriptAdapter
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, type[LanguageAdapter]] = {
|
|
14
|
+
"csharp": CSharpAdapter,
|
|
15
|
+
"python": PythonAdapter,
|
|
16
|
+
"typescript": TypeScriptAdapter,
|
|
17
|
+
"rust": RustAdapter,
|
|
18
|
+
"go": GoAdapter,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoAdapterError(RuntimeError):
|
|
23
|
+
"""Raised when no adapter can handle the repository."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_adapter_by_name(name: str) -> LanguageAdapter:
|
|
27
|
+
"""Instantiate an adapter by its registered name."""
|
|
28
|
+
if name not in _REGISTRY:
|
|
29
|
+
supported = ", ".join(sorted(_REGISTRY))
|
|
30
|
+
msg = f"Unknown adapter '{name}'. Supported: {supported}."
|
|
31
|
+
raise NoAdapterError(f"{msg} Check spelling or use --language to specify one of: {supported}.")
|
|
32
|
+
return _REGISTRY[name]()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_primary(repo_path: Path) -> LanguageAdapter:
|
|
36
|
+
"""Try every adapter and return the one with the highest detect() confidence."""
|
|
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
|
+
if not candidates:
|
|
44
|
+
supported = ", ".join(sorted(_REGISTRY))
|
|
45
|
+
msg = f"No supported language detected in {repo_path}. Supported: {supported}."
|
|
46
|
+
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
|
+
return candidates[0][1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"CSharpAdapter",
|
|
53
|
+
"Declaration",
|
|
54
|
+
"GoAdapter",
|
|
55
|
+
"LanguageAdapter",
|
|
56
|
+
"NoAdapterError",
|
|
57
|
+
"NotSupportedError",
|
|
58
|
+
"PythonAdapter",
|
|
59
|
+
"RustAdapter",
|
|
60
|
+
"ThrowSite",
|
|
61
|
+
"TypeScriptAdapter",
|
|
62
|
+
"detect_primary",
|
|
63
|
+
"get_adapter_by_name",
|
|
64
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Language adapter abstract base class.
|
|
2
|
+
|
|
3
|
+
Every supported language contributes one concrete adapter subclass with a
|
|
4
|
+
single-file footprint. The base class declares the 9 methods components need
|
|
5
|
+
to compute the Codebase Agent Health (CAH) score.
|
|
6
|
+
|
|
7
|
+
Adapters are language-neutral contracts. No component should contain a
|
|
8
|
+
language-specific regex or file-extension check; that belongs here.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ThrowSite:
|
|
22
|
+
"""A language-neutral throw/raise site descriptor."""
|
|
23
|
+
file: Path
|
|
24
|
+
line: int
|
|
25
|
+
exception_type: str
|
|
26
|
+
has_fix_hint: bool
|
|
27
|
+
is_user_defined: bool
|
|
28
|
+
is_generic: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Declaration:
|
|
33
|
+
"""A top-level declaration (class/struct/function) with visibility info."""
|
|
34
|
+
file: Path
|
|
35
|
+
line: int
|
|
36
|
+
name: str
|
|
37
|
+
visibility: str # "public" | "internal" | "private"
|
|
38
|
+
has_doc_comment: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotSupportedError(NotImplementedError):
|
|
42
|
+
"""Raised by stub adapters that detect the language but cannot analyze it."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LanguageAdapter(ABC):
|
|
46
|
+
"""Abstract language adapter. One concrete implementation per language."""
|
|
47
|
+
|
|
48
|
+
name: str = "base"
|
|
49
|
+
|
|
50
|
+
# ------- Detection -------
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def detect(self, repo_path: Path) -> float:
|
|
54
|
+
"""Return a 0.0-1.0 confidence score that this adapter applies."""
|
|
55
|
+
|
|
56
|
+
# ------- File discovery -------
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def find_production_files(self, repo_path: Path) -> list[Path]:
|
|
60
|
+
"""All source files for production modules, filtered for generated/build artifacts."""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
64
|
+
"""All source files under the repo's test directory convention."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
68
|
+
"""Logical module names (projects/packages) used by navigability's codebase_map check."""
|
|
69
|
+
|
|
70
|
+
# ------- Throw-site analysis (error_quality) -------
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def scan_throw_sites(
|
|
74
|
+
self,
|
|
75
|
+
files: Iterable[Path],
|
|
76
|
+
hint_marker: str,
|
|
77
|
+
domain_exception_types: set[str],
|
|
78
|
+
) -> list[ThrowSite]:
|
|
79
|
+
"""Find every throw/raise and classify it."""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def generic_exception_names(self) -> set[str]:
|
|
83
|
+
"""Language-stdlib exception types considered 'too generic'."""
|
|
84
|
+
|
|
85
|
+
# ------- Declarations (module_hygiene) -------
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
89
|
+
"""Find every top-level declaration with visibility and doc-comment flag."""
|
|
90
|
+
|
|
91
|
+
# ------- Test-method analysis (test_quality) -------
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
95
|
+
"""Return list of (file, method_name) for every test method."""
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
99
|
+
"""Regex matching the idiomatic test-method naming convention."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Safe file iteration helpers — shared by all adapters.
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def iter_source_files(root: Path, suffixes: tuple[str, ...], exclude_substrings: tuple[str, ...] = (), exclude_suffixes: tuple[str, ...] = (), follow_symlinks: bool = False, max_file_bytes: int = 10_485_760) -> list[Path]:
|
|
108
|
+
"""Walk ``root`` and return files matching ``suffixes``.
|
|
109
|
+
|
|
110
|
+
Hardened against three threat-model risks:
|
|
111
|
+
|
|
112
|
+
1. Symlink traversal — ``follow_symlinks=False`` by default.
|
|
113
|
+
2. Large-file OOM — files over ``max_file_bytes`` are skipped.
|
|
114
|
+
3. Path injection via resolved-outside-root — only entries under ``root``
|
|
115
|
+
after ``os.walk`` are returned.
|
|
116
|
+
"""
|
|
117
|
+
results: list[Path] = []
|
|
118
|
+
if not root.is_dir():
|
|
119
|
+
return results
|
|
120
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
|
|
121
|
+
# Prune excluded directories in-place so os.walk does not descend.
|
|
122
|
+
dirnames[:] = [d for d in dirnames if not _is_excluded_segment(d)]
|
|
123
|
+
for filename in filenames:
|
|
124
|
+
if not any(filename.endswith(sfx) for sfx in suffixes):
|
|
125
|
+
continue
|
|
126
|
+
if exclude_suffixes and any(filename.endswith(sfx) for sfx in exclude_suffixes):
|
|
127
|
+
continue
|
|
128
|
+
path_str = os.path.join(dirpath, filename)
|
|
129
|
+
if exclude_substrings and any(needle in path_str for needle in exclude_substrings):
|
|
130
|
+
continue
|
|
131
|
+
path = Path(path_str)
|
|
132
|
+
if not follow_symlinks and path.is_symlink():
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
if path.stat().st_size > max_file_bytes:
|
|
136
|
+
continue
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
results.append(path)
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Default directories to prune during iteration. Covers common build / cache
|
|
144
|
+
# directories across all languages.
|
|
145
|
+
_EXCLUDED_SEGMENTS: frozenset[str] = frozenset({
|
|
146
|
+
".git",
|
|
147
|
+
".hg",
|
|
148
|
+
".svn",
|
|
149
|
+
"node_modules",
|
|
150
|
+
"vendor",
|
|
151
|
+
"third_party",
|
|
152
|
+
"bin",
|
|
153
|
+
"obj",
|
|
154
|
+
"__pycache__",
|
|
155
|
+
".venv",
|
|
156
|
+
"venv",
|
|
157
|
+
".tox",
|
|
158
|
+
".mypy_cache",
|
|
159
|
+
".pytest_cache",
|
|
160
|
+
".ruff_cache",
|
|
161
|
+
"dist",
|
|
162
|
+
"build",
|
|
163
|
+
"target",
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_excluded_segment(name: str) -> bool:
|
|
168
|
+
return name in _EXCLUDED_SEGMENTS
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_text_safely(path: Path, max_bytes: int = 10_485_760) -> str:
|
|
172
|
+
"""Read a file as UTF-8 with errors='ignore'. Returns '' on failure."""
|
|
173
|
+
try:
|
|
174
|
+
if path.stat().st_size > max_bytes:
|
|
175
|
+
return ""
|
|
176
|
+
except OSError:
|
|
177
|
+
return ""
|
|
178
|
+
try:
|
|
179
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
180
|
+
except OSError:
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def count_file_loc(path: Path, max_bytes: int = 10_485_760) -> int:
|
|
185
|
+
"""Count lines in ``path`` safely, returning 0 on error."""
|
|
186
|
+
try:
|
|
187
|
+
if path.stat().st_size > max_bytes:
|
|
188
|
+
return 0
|
|
189
|
+
except OSError:
|
|
190
|
+
return 0
|
|
191
|
+
try:
|
|
192
|
+
with path.open(encoding="utf-8", errors="ignore") as handle:
|
|
193
|
+
return sum(1 for _ in handle)
|
|
194
|
+
except OSError:
|
|
195
|
+
return 0
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""C# language adapter.
|
|
2
|
+
|
|
3
|
+
Auto-discovers production modules by scanning ``*.csproj`` files at the repo
|
|
4
|
+
root and mapping each project's containing directory to a production source
|
|
5
|
+
tree. Tests directories are detected by conventional naming (``*.Tests``,
|
|
6
|
+
``*Test``) and by ``*Tests.csproj`` filename patterns.
|
|
7
|
+
|
|
8
|
+
Throw-site scanning is ported from the methodology research throw-site
|
|
9
|
+
extractor — a minimal C# tokenizer that walks ``throw new <Name>Exception(``
|
|
10
|
+
sites, extracts the message argument text, and classifies each site by:
|
|
11
|
+
|
|
12
|
+
- ``exception_type`` — the C# type name raised
|
|
13
|
+
- ``has_fix_hint`` — does the message contain the configured hint marker
|
|
14
|
+
- ``is_user_defined`` — is the type declared inside the repo (user exception)
|
|
15
|
+
- ``is_generic`` — is the type one of the language-stdlib "too generic" names
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable
|
|
22
|
+
|
|
23
|
+
from .base import (
|
|
24
|
+
Declaration,
|
|
25
|
+
LanguageAdapter,
|
|
26
|
+
ThrowSite,
|
|
27
|
+
count_file_loc,
|
|
28
|
+
iter_source_files,
|
|
29
|
+
read_text_safely,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Source-file patterns.
|
|
33
|
+
_CSHARP_SUFFIX: tuple[str, ...] = (".cs",)
|
|
34
|
+
_CSHARP_EXCLUDE_SUFFIXES: tuple[str, ...] = (".Designer.cs", ".g.cs", ".g.i.cs")
|
|
35
|
+
_CSHARP_EXCLUDE_PATH_SUBSTRINGS: tuple[str, ...] = ("/bin/", "/obj/")
|
|
36
|
+
|
|
37
|
+
# Throw-site scanning.
|
|
38
|
+
_THROW_PATTERN = re.compile(r"\bthrow\s+new\s+([A-Za-z_][A-Za-z0-9_]*Exception)\s*\(")
|
|
39
|
+
|
|
40
|
+
# Declaration scanning.
|
|
41
|
+
_PUBLIC_DECL_PATTERN = re.compile(
|
|
42
|
+
r"\bpublic\s+(?:sealed\s+|abstract\s+|static\s+|partial\s+)*"
|
|
43
|
+
r"(?:class|interface|record|enum|struct)\s+(\w+)",
|
|
44
|
+
)
|
|
45
|
+
_INTERNAL_DECL_PATTERN = re.compile(
|
|
46
|
+
r"\binternal\s+(?:sealed\s+|abstract\s+|static\s+|partial\s+)*"
|
|
47
|
+
r"(?:class|interface|record|enum|struct)\s+(\w+)",
|
|
48
|
+
)
|
|
49
|
+
_PRIVATE_DECL_PATTERN = re.compile(
|
|
50
|
+
r"\bprivate\s+(?:sealed\s+|abstract\s+|static\s+|partial\s+)*"
|
|
51
|
+
r"(?:class|interface|record|enum|struct)\s+(\w+)",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Test-method naming conventions (xUnit / NUnit / MSTest Method_Scenario_Expected).
|
|
55
|
+
_TEST_METHOD_PATTERN = re.compile(r"\bpublic\s+(?:async\s+)?(?:Task|void)\s+(\w+)\s*\(")
|
|
56
|
+
_MSE_NAMING_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9]+_[A-Za-z0-9]+")
|
|
57
|
+
|
|
58
|
+
# Keywords that indicate an actionable fix hint in an error message. These are
|
|
59
|
+
# intentionally simple substrings (case-insensitive) so adapters do not need a
|
|
60
|
+
# full natural-language parser.
|
|
61
|
+
_FIX_HINT_WORD_KEYWORDS: tuple[str, ...] = (
|
|
62
|
+
"run",
|
|
63
|
+
"use",
|
|
64
|
+
"try",
|
|
65
|
+
"check",
|
|
66
|
+
"see",
|
|
67
|
+
"set",
|
|
68
|
+
"add",
|
|
69
|
+
"install",
|
|
70
|
+
"provide",
|
|
71
|
+
"ensure",
|
|
72
|
+
"verify",
|
|
73
|
+
"enable",
|
|
74
|
+
"configure",
|
|
75
|
+
"register",
|
|
76
|
+
"retry",
|
|
77
|
+
"rerun",
|
|
78
|
+
)
|
|
79
|
+
_FIX_HINT_SUBSTRING_KEYWORDS: tuple[str, ...] = (
|
|
80
|
+
"did you mean",
|
|
81
|
+
"available:",
|
|
82
|
+
"expected:",
|
|
83
|
+
"allowed:",
|
|
84
|
+
"supported:",
|
|
85
|
+
"valid:",
|
|
86
|
+
"valid values:",
|
|
87
|
+
"matches:",
|
|
88
|
+
"hint:",
|
|
89
|
+
"fix:",
|
|
90
|
+
"example:",
|
|
91
|
+
"to fix",
|
|
92
|
+
"to resolve",
|
|
93
|
+
"suggested fix",
|
|
94
|
+
"environment variable",
|
|
95
|
+
".md",
|
|
96
|
+
".json",
|
|
97
|
+
".cs",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Language-stdlib exception types considered "too generic" for good agent UX.
|
|
101
|
+
_GENERIC_EXCEPTION_NAMES: frozenset[str] = frozenset({
|
|
102
|
+
"Exception",
|
|
103
|
+
"SystemException",
|
|
104
|
+
"InvalidOperationException",
|
|
105
|
+
"ApplicationException",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CSharpAdapter(LanguageAdapter):
|
|
110
|
+
"""C# / .NET adapter. MVP implementation."""
|
|
111
|
+
|
|
112
|
+
name = "csharp"
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Detection
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
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")):
|
|
121
|
+
return 1.0
|
|
122
|
+
if any(repo_path.rglob("*.csproj")):
|
|
123
|
+
return 0.8
|
|
124
|
+
return 0.0
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# File discovery
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def find_production_files(self, repo_path: Path) -> list[Path]:
|
|
131
|
+
"""Return all production *.cs files, skipping bin/obj/generated files."""
|
|
132
|
+
project_dirs = self._find_production_project_dirs(repo_path)
|
|
133
|
+
results: list[Path] = []
|
|
134
|
+
for proj_dir in project_dirs:
|
|
135
|
+
results.extend(self._iter_cs_files(proj_dir))
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
139
|
+
"""Return all test *.cs files (projects whose name matches test conventions)."""
|
|
140
|
+
project_dirs = self._find_test_project_dirs(repo_path)
|
|
141
|
+
results: list[Path] = []
|
|
142
|
+
for proj_dir in project_dirs:
|
|
143
|
+
results.extend(self._iter_cs_files(proj_dir))
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
147
|
+
"""Return logical project names for every production *.csproj."""
|
|
148
|
+
names: list[str] = []
|
|
149
|
+
for proj_path in self._iter_csproj_files(repo_path):
|
|
150
|
+
if self._looks_like_test_project(proj_path):
|
|
151
|
+
continue
|
|
152
|
+
names.append(proj_path.stem)
|
|
153
|
+
return sorted(set(names))
|
|
154
|
+
|
|
155
|
+
def _iter_csproj_files(self, repo_path: Path) -> list[Path]:
|
|
156
|
+
return [
|
|
157
|
+
p for p in repo_path.rglob("*.csproj")
|
|
158
|
+
if "/bin/" not in str(p) and "/obj/" not in str(p)
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
def _find_production_project_dirs(self, repo_path: Path) -> list[Path]:
|
|
162
|
+
return [
|
|
163
|
+
proj.parent for proj in self._iter_csproj_files(repo_path)
|
|
164
|
+
if not self._looks_like_test_project(proj)
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def _find_test_project_dirs(self, repo_path: Path) -> list[Path]:
|
|
168
|
+
return [
|
|
169
|
+
proj.parent for proj in self._iter_csproj_files(repo_path)
|
|
170
|
+
if self._looks_like_test_project(proj)
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _looks_like_test_project(csproj_path: Path) -> bool:
|
|
175
|
+
name = csproj_path.stem.lower()
|
|
176
|
+
return name.endswith(".tests") or name.endswith("tests") or name.endswith(".test")
|
|
177
|
+
|
|
178
|
+
def _iter_cs_files(self, dir_path: Path) -> list[Path]:
|
|
179
|
+
return iter_source_files(
|
|
180
|
+
dir_path,
|
|
181
|
+
suffixes=_CSHARP_SUFFIX,
|
|
182
|
+
exclude_substrings=_CSHARP_EXCLUDE_PATH_SUBSTRINGS,
|
|
183
|
+
exclude_suffixes=_CSHARP_EXCLUDE_SUFFIXES,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
# Throw-site analysis
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def scan_throw_sites(
|
|
191
|
+
self,
|
|
192
|
+
files: Iterable[Path],
|
|
193
|
+
hint_marker: str,
|
|
194
|
+
domain_exception_types: set[str],
|
|
195
|
+
) -> list[ThrowSite]:
|
|
196
|
+
"""Scan every file for ``throw new X(...)`` sites."""
|
|
197
|
+
sites: list[ThrowSite] = []
|
|
198
|
+
for path in files:
|
|
199
|
+
text = read_text_safely(path)
|
|
200
|
+
if not text:
|
|
201
|
+
continue
|
|
202
|
+
sites.extend(
|
|
203
|
+
self._scan_throw_sites_in_text(
|
|
204
|
+
path,
|
|
205
|
+
text,
|
|
206
|
+
hint_marker,
|
|
207
|
+
domain_exception_types,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return sites
|
|
211
|
+
|
|
212
|
+
def _scan_throw_sites_in_text(
|
|
213
|
+
self,
|
|
214
|
+
path: Path,
|
|
215
|
+
text: str,
|
|
216
|
+
hint_marker: str,
|
|
217
|
+
domain_exception_types: set[str],
|
|
218
|
+
) -> list[ThrowSite]:
|
|
219
|
+
results: list[ThrowSite] = []
|
|
220
|
+
for match in _THROW_PATTERN.finditer(text):
|
|
221
|
+
exception_type = match.group(1)
|
|
222
|
+
paren_start = match.end() - 1
|
|
223
|
+
args_text, _ = _extract_throw_message(text, paren_start)
|
|
224
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
225
|
+
results.append(
|
|
226
|
+
ThrowSite(
|
|
227
|
+
file=path,
|
|
228
|
+
line=line_no,
|
|
229
|
+
exception_type=exception_type,
|
|
230
|
+
has_fix_hint=_has_fix_hint(args_text, hint_marker),
|
|
231
|
+
is_user_defined=exception_type in domain_exception_types,
|
|
232
|
+
is_generic=exception_type in _GENERIC_EXCEPTION_NAMES,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
def generic_exception_names(self) -> set[str]:
|
|
238
|
+
return set(_GENERIC_EXCEPTION_NAMES)
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Declarations
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
245
|
+
declarations: list[Declaration] = []
|
|
246
|
+
for path in files:
|
|
247
|
+
text = read_text_safely(path)
|
|
248
|
+
if not text:
|
|
249
|
+
continue
|
|
250
|
+
declarations.extend(self._scan_declarations_in_text(path, text))
|
|
251
|
+
return declarations
|
|
252
|
+
|
|
253
|
+
def _scan_declarations_in_text(self, path: Path, text: str) -> list[Declaration]:
|
|
254
|
+
lines = text.splitlines()
|
|
255
|
+
results: list[Declaration] = []
|
|
256
|
+
for i, line in enumerate(lines):
|
|
257
|
+
visibility = _declaration_visibility(line)
|
|
258
|
+
if visibility is None:
|
|
259
|
+
continue
|
|
260
|
+
name_match = _declaration_name(line, visibility)
|
|
261
|
+
if name_match is None:
|
|
262
|
+
continue
|
|
263
|
+
has_doc = _has_preceding_xml_doc(lines, i)
|
|
264
|
+
results.append(
|
|
265
|
+
Declaration(
|
|
266
|
+
file=path,
|
|
267
|
+
line=i + 1,
|
|
268
|
+
name=name_match,
|
|
269
|
+
visibility=visibility,
|
|
270
|
+
has_doc_comment=has_doc,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return results
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
# Test methods
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
280
|
+
results: list[tuple[Path, str]] = []
|
|
281
|
+
for path in files:
|
|
282
|
+
text = read_text_safely(path)
|
|
283
|
+
if not text:
|
|
284
|
+
continue
|
|
285
|
+
for match in _TEST_METHOD_PATTERN.finditer(text):
|
|
286
|
+
results.append((path, match.group(1)))
|
|
287
|
+
return results
|
|
288
|
+
|
|
289
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
290
|
+
return _MSE_NAMING_PATTERN
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Module-level helpers (kept outside the class so they can be unit-tested
|
|
295
|
+
# without instantiating the adapter).
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _extract_throw_message(source: str, start: int) -> tuple[str, int]:
|
|
300
|
+
"""Walk from the opening paren at ``start`` to the matching close-paren.
|
|
301
|
+
|
|
302
|
+
Handles nested parens, string literals (including verbatim @"..." and
|
|
303
|
+
escaped), and line comments. Returns the argument text and the index
|
|
304
|
+
just past the closing paren. This is a minimal C# tokenizer good enough
|
|
305
|
+
for throw-site message extraction.
|
|
306
|
+
"""
|
|
307
|
+
depth = 1
|
|
308
|
+
i = start + 1
|
|
309
|
+
n = len(source)
|
|
310
|
+
buf: list[str] = []
|
|
311
|
+
|
|
312
|
+
while i < n and depth > 0:
|
|
313
|
+
ch = source[i]
|
|
314
|
+
|
|
315
|
+
# String literals.
|
|
316
|
+
if ch == '"':
|
|
317
|
+
buf.append(ch)
|
|
318
|
+
i += 1
|
|
319
|
+
is_verbatim = i >= 2 and source[i - 2] == "@"
|
|
320
|
+
while i < n:
|
|
321
|
+
cur = source[i]
|
|
322
|
+
buf.append(cur)
|
|
323
|
+
if is_verbatim:
|
|
324
|
+
if cur == '"':
|
|
325
|
+
if i + 1 < n and source[i + 1] == '"':
|
|
326
|
+
buf.append(source[i + 1])
|
|
327
|
+
i += 2
|
|
328
|
+
continue
|
|
329
|
+
i += 1
|
|
330
|
+
break
|
|
331
|
+
i += 1
|
|
332
|
+
else:
|
|
333
|
+
if cur == "\\" and i + 1 < n:
|
|
334
|
+
buf.append(source[i + 1])
|
|
335
|
+
i += 2
|
|
336
|
+
continue
|
|
337
|
+
if cur == '"':
|
|
338
|
+
i += 1
|
|
339
|
+
break
|
|
340
|
+
i += 1
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
# Line comments.
|
|
344
|
+
if ch == "/" and i + 1 < n and source[i + 1] == "/":
|
|
345
|
+
while i < n and source[i] != "\n":
|
|
346
|
+
i += 1
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if ch == "(":
|
|
350
|
+
depth += 1
|
|
351
|
+
elif ch == ")":
|
|
352
|
+
depth -= 1
|
|
353
|
+
if depth == 0:
|
|
354
|
+
return "".join(buf), i + 1
|
|
355
|
+
buf.append(ch)
|
|
356
|
+
i += 1
|
|
357
|
+
|
|
358
|
+
return "".join(buf), i
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _has_fix_hint(message_text: str, hint_marker: str) -> bool:
|
|
362
|
+
"""Return True if the message text contains any fix-hint signal.
|
|
363
|
+
|
|
364
|
+
The configurable ``hint_marker`` (e.g. "Suggested fix:") is matched first.
|
|
365
|
+
If absent, a small set of generic action-verb and substring keywords is
|
|
366
|
+
checked.
|
|
367
|
+
"""
|
|
368
|
+
lower = message_text.lower()
|
|
369
|
+
if hint_marker and hint_marker.lower() in lower:
|
|
370
|
+
return True
|
|
371
|
+
for substring in _FIX_HINT_SUBSTRING_KEYWORDS:
|
|
372
|
+
if substring in lower:
|
|
373
|
+
return True
|
|
374
|
+
# Word-bounded action verbs.
|
|
375
|
+
for verb in _FIX_HINT_WORD_KEYWORDS:
|
|
376
|
+
if re.search(rf"\b{re.escape(verb)}\b", lower):
|
|
377
|
+
return True
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _declaration_visibility(line: str) -> str | None:
|
|
382
|
+
if _PUBLIC_DECL_PATTERN.search(line):
|
|
383
|
+
return "public"
|
|
384
|
+
if _INTERNAL_DECL_PATTERN.search(line):
|
|
385
|
+
return "internal"
|
|
386
|
+
if _PRIVATE_DECL_PATTERN.search(line):
|
|
387
|
+
return "private"
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _declaration_name(line: str, visibility: str) -> str | None:
|
|
392
|
+
patterns = {
|
|
393
|
+
"public": _PUBLIC_DECL_PATTERN,
|
|
394
|
+
"internal": _INTERNAL_DECL_PATTERN,
|
|
395
|
+
"private": _PRIVATE_DECL_PATTERN,
|
|
396
|
+
}
|
|
397
|
+
match = patterns[visibility].search(line)
|
|
398
|
+
return match.group(1) if match else None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _has_preceding_xml_doc(lines: list[str], index: int) -> bool:
|
|
402
|
+
"""Walk backwards past attributes / blank lines. Return True if the first
|
|
403
|
+
non-attribute / non-blank line above ``index`` starts with ``///``.
|
|
404
|
+
"""
|
|
405
|
+
j = index - 1
|
|
406
|
+
while j >= 0:
|
|
407
|
+
stripped = lines[j].strip()
|
|
408
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
409
|
+
j -= 1
|
|
410
|
+
continue
|
|
411
|
+
if stripped == "":
|
|
412
|
+
j -= 1
|
|
413
|
+
continue
|
|
414
|
+
return stripped.startswith("///")
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# Expose count_file_loc for components to use via adapter.
|
|
419
|
+
CSharpAdapter.count_file_loc = staticmethod(count_file_loc) # type: ignore[attr-defined]
|