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,283 @@
|
|
|
1
|
+
"""Go language adapter.
|
|
2
|
+
|
|
3
|
+
Detects Go repos via ``go.mod`` presence. Walks the repo for production
|
|
4
|
+
``.go`` files (excluding ``_test.go``), scans ``return fmt.Errorf(`` /
|
|
5
|
+
``errors.New(`` / custom error returns, and detects Go doc comments above
|
|
6
|
+
exported declarations.
|
|
7
|
+
|
|
8
|
+
Go's error model differs from throw-based languages: errors are returned
|
|
9
|
+
values, not thrown exceptions. This adapter maps Go error-creation sites
|
|
10
|
+
(``fmt.Errorf``, ``errors.New``, ``&CustomError{}``) to the ``ThrowSite``
|
|
11
|
+
model. The "exception type" is the error constructor or custom type name.
|
|
12
|
+
|
|
13
|
+
All analysis is regex-based — no AST parsing, stdlib only.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
from .base import (
|
|
22
|
+
Declaration,
|
|
23
|
+
LanguageAdapter,
|
|
24
|
+
ThrowSite,
|
|
25
|
+
count_file_loc,
|
|
26
|
+
iter_source_files,
|
|
27
|
+
read_text_safely,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_GO_SUFFIX: tuple[str, ...] = (".go",)
|
|
31
|
+
_GO_TEST_SUFFIX: tuple[str, ...] = ("_test.go",)
|
|
32
|
+
|
|
33
|
+
# Error-creation patterns (Go returns errors, doesn't throw them).
|
|
34
|
+
_ERRORS_NEW_PATTERN = re.compile(r"\berrors\.New\s*\(")
|
|
35
|
+
_FMT_ERRORF_PATTERN = re.compile(r"\bfmt\.Errorf\s*\(")
|
|
36
|
+
_CUSTOM_ERROR_PATTERN = re.compile(r"&([A-Z][A-Za-z0-9_]*Error)\s*\{")
|
|
37
|
+
|
|
38
|
+
# Declaration patterns — Go uses capitalization for visibility.
|
|
39
|
+
_FUNC_PATTERN = re.compile(r"^func\s+(?:\([^)]+\)\s+)?([A-Za-z_]\w*)\s*\(")
|
|
40
|
+
_TYPE_PATTERN = re.compile(r"^type\s+([A-Za-z_]\w*)\s+(?:struct|interface|int|string)")
|
|
41
|
+
_CONST_VAR_PATTERN = re.compile(r"^(?:var|const)\s+([A-Za-z_]\w*)\s")
|
|
42
|
+
|
|
43
|
+
# Test function pattern.
|
|
44
|
+
_TEST_FUNC_PATTERN = re.compile(r"^func\s+(Test[A-Z]\w*)\s*\(")
|
|
45
|
+
# Go convention: TestFoo_Bar_Baz or TestFooBar.
|
|
46
|
+
_TEST_NAMING_PATTERN = re.compile(r"^Test[A-Z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+$")
|
|
47
|
+
|
|
48
|
+
# Generic error constructors (too generic for good agent UX).
|
|
49
|
+
_GENERIC_ERROR_NAMES: frozenset[str] = frozenset({
|
|
50
|
+
"errors.New",
|
|
51
|
+
"fmt.Errorf",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
_FIX_HINT_KEYWORDS: tuple[str, ...] = (
|
|
55
|
+
"hint:", "fix:", "see ", "try ", "use ", "check ", "did you mean",
|
|
56
|
+
"suggested fix", "to fix", "to resolve", "example:", "ensure",
|
|
57
|
+
"install", "provide", "verify", "configure", "expected",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_TEST_DIR_CANDIDATES: tuple[str, ...] = ("test", "tests", "internal/test")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GoAdapter(LanguageAdapter):
|
|
64
|
+
"""Go adapter."""
|
|
65
|
+
|
|
66
|
+
name = "go"
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Detection
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def detect(self, repo_path: Path) -> float:
|
|
73
|
+
if (repo_path / "go.mod").is_file():
|
|
74
|
+
return 1.0
|
|
75
|
+
if any(repo_path.rglob("*.go")):
|
|
76
|
+
return 0.5
|
|
77
|
+
return 0.0
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# File discovery
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def find_production_files(self, repo_path: Path) -> list[Path]:
|
|
84
|
+
all_go = iter_source_files(repo_path, suffixes=_GO_SUFFIX)
|
|
85
|
+
return [f for f in all_go if not _is_test_file(f)]
|
|
86
|
+
|
|
87
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
88
|
+
all_go = iter_source_files(repo_path, suffixes=_GO_SUFFIX)
|
|
89
|
+
return [f for f in all_go if _is_test_file(f)]
|
|
90
|
+
|
|
91
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
92
|
+
"""Return Go package directory names containing production .go files."""
|
|
93
|
+
modules: set[str] = set()
|
|
94
|
+
for f in self.find_production_files(repo_path):
|
|
95
|
+
# Use the parent directory name as the module/package name.
|
|
96
|
+
pkg = f.parent.name
|
|
97
|
+
if pkg and pkg != repo_path.name:
|
|
98
|
+
modules.add(pkg)
|
|
99
|
+
# If all files are at repo root, use the module name from go.mod.
|
|
100
|
+
if not modules:
|
|
101
|
+
mod_file = repo_path / "go.mod"
|
|
102
|
+
if mod_file.is_file():
|
|
103
|
+
text = read_text_safely(mod_file)
|
|
104
|
+
m = re.search(r"^module\s+(\S+)", text, re.MULTILINE)
|
|
105
|
+
if m:
|
|
106
|
+
modules.add(m.group(1).rsplit("/", 1)[-1])
|
|
107
|
+
return sorted(modules)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Throw-site analysis (Go: error-creation sites)
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def scan_throw_sites(
|
|
114
|
+
self,
|
|
115
|
+
files: Iterable[Path],
|
|
116
|
+
hint_marker: str,
|
|
117
|
+
domain_exception_types: set[str],
|
|
118
|
+
) -> list[ThrowSite]:
|
|
119
|
+
sites: list[ThrowSite] = []
|
|
120
|
+
for path in files:
|
|
121
|
+
text = read_text_safely(path)
|
|
122
|
+
if not text:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# errors.New() sites
|
|
126
|
+
for match in _ERRORS_NEW_PATTERN.finditer(text):
|
|
127
|
+
context = _extract_context(text, match.start())
|
|
128
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
129
|
+
sites.append(
|
|
130
|
+
ThrowSite(
|
|
131
|
+
file=path,
|
|
132
|
+
line=line_no,
|
|
133
|
+
exception_type="errors.New",
|
|
134
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
135
|
+
is_user_defined=False,
|
|
136
|
+
is_generic=True,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# fmt.Errorf() sites
|
|
141
|
+
for match in _FMT_ERRORF_PATTERN.finditer(text):
|
|
142
|
+
context = _extract_context(text, match.start())
|
|
143
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
144
|
+
sites.append(
|
|
145
|
+
ThrowSite(
|
|
146
|
+
file=path,
|
|
147
|
+
line=line_no,
|
|
148
|
+
exception_type="fmt.Errorf",
|
|
149
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
150
|
+
is_user_defined=False,
|
|
151
|
+
is_generic=True,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Custom error types: &CustomError{}
|
|
156
|
+
for match in _CUSTOM_ERROR_PATTERN.finditer(text):
|
|
157
|
+
error_type = match.group(1)
|
|
158
|
+
context = _extract_context(text, match.start())
|
|
159
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
160
|
+
sites.append(
|
|
161
|
+
ThrowSite(
|
|
162
|
+
file=path,
|
|
163
|
+
line=line_no,
|
|
164
|
+
exception_type=error_type,
|
|
165
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
166
|
+
is_user_defined=error_type in domain_exception_types,
|
|
167
|
+
is_generic=False,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return sites
|
|
171
|
+
|
|
172
|
+
def generic_exception_names(self) -> set[str]:
|
|
173
|
+
return set(_GENERIC_ERROR_NAMES)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Declarations
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
180
|
+
declarations: list[Declaration] = []
|
|
181
|
+
for path in files:
|
|
182
|
+
text = read_text_safely(path)
|
|
183
|
+
if not text:
|
|
184
|
+
continue
|
|
185
|
+
declarations.extend(self._scan_declarations_in_text(path, text))
|
|
186
|
+
return declarations
|
|
187
|
+
|
|
188
|
+
def _scan_declarations_in_text(self, path: Path, text: str) -> list[Declaration]:
|
|
189
|
+
lines = text.splitlines()
|
|
190
|
+
results: list[Declaration] = []
|
|
191
|
+
for i, line in enumerate(lines):
|
|
192
|
+
name = _match_declaration(line)
|
|
193
|
+
if name is None:
|
|
194
|
+
continue
|
|
195
|
+
# Go visibility: uppercase first letter = exported (public).
|
|
196
|
+
visibility = "public" if name[0].isupper() else "internal"
|
|
197
|
+
results.append(
|
|
198
|
+
Declaration(
|
|
199
|
+
file=path,
|
|
200
|
+
line=i + 1,
|
|
201
|
+
name=name,
|
|
202
|
+
visibility=visibility,
|
|
203
|
+
has_doc_comment=_has_preceding_go_doc(lines, i),
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Test methods
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
213
|
+
results: list[tuple[Path, str]] = []
|
|
214
|
+
for path in files:
|
|
215
|
+
text = read_text_safely(path)
|
|
216
|
+
if not text:
|
|
217
|
+
continue
|
|
218
|
+
for line in text.splitlines():
|
|
219
|
+
m = _TEST_FUNC_PATTERN.match(line)
|
|
220
|
+
if m:
|
|
221
|
+
results.append((path, m.group(1)))
|
|
222
|
+
return results
|
|
223
|
+
|
|
224
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
225
|
+
return _TEST_NAMING_PATTERN
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_test_file(path: Path) -> bool:
|
|
234
|
+
return path.name.endswith("_test.go")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _extract_context(text: str, start: int, max_chars: int = 500) -> str:
|
|
238
|
+
"""Extract text from start through the next closing paren or brace."""
|
|
239
|
+
end = min(start + max_chars, len(text))
|
|
240
|
+
depth = 0
|
|
241
|
+
for i in range(start, end):
|
|
242
|
+
ch = text[i]
|
|
243
|
+
if ch in ("(", "{"):
|
|
244
|
+
depth += 1
|
|
245
|
+
elif ch in (")", "}"):
|
|
246
|
+
depth -= 1
|
|
247
|
+
if depth == 0:
|
|
248
|
+
return text[start:i + 1]
|
|
249
|
+
return text[start:end]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _has_fix_hint(text: str, hint_marker: str) -> bool:
|
|
253
|
+
lower = text.lower()
|
|
254
|
+
if hint_marker and hint_marker.lower() in lower:
|
|
255
|
+
return True
|
|
256
|
+
for keyword in _FIX_HINT_KEYWORDS:
|
|
257
|
+
if keyword in lower:
|
|
258
|
+
return True
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _match_declaration(line: str) -> str | None:
|
|
263
|
+
"""Return the declaration name if the line declares a func, type, or var/const."""
|
|
264
|
+
for pattern in (_FUNC_PATTERN, _TYPE_PATTERN, _CONST_VAR_PATTERN):
|
|
265
|
+
m = pattern.match(line)
|
|
266
|
+
if m:
|
|
267
|
+
return m.group(1)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _has_preceding_go_doc(lines: list[str], index: int) -> bool:
|
|
272
|
+
"""Return True if the line above is a Go doc comment (``//`` comment block)."""
|
|
273
|
+
j = index - 1
|
|
274
|
+
while j >= 0:
|
|
275
|
+
stripped = lines[j].strip()
|
|
276
|
+
if stripped == "":
|
|
277
|
+
j -= 1
|
|
278
|
+
continue
|
|
279
|
+
return stripped.startswith("//")
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
GoAdapter.count_file_loc = staticmethod(count_file_loc) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Python language adapter.
|
|
2
|
+
|
|
3
|
+
Detects Python repos via ``pyproject.toml`` / ``setup.py`` presence. Walks
|
|
4
|
+
``src/`` or top-level packages for production files and ``tests/`` / ``test/``
|
|
5
|
+
for test files. Scans ``raise`` statements, classifies exception types, and
|
|
6
|
+
detects docstring presence above top-level declarations.
|
|
7
|
+
|
|
8
|
+
Deliberately minimal: this adapter is new at launch and not research-backed
|
|
9
|
+
to the same degree as the C# adapter. A "Python adapter validation" doc
|
|
10
|
+
published post-launch will compare scores on 3 popular Python repos.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterable
|
|
17
|
+
|
|
18
|
+
from .base import (
|
|
19
|
+
Declaration,
|
|
20
|
+
LanguageAdapter,
|
|
21
|
+
ThrowSite,
|
|
22
|
+
count_file_loc,
|
|
23
|
+
iter_source_files,
|
|
24
|
+
read_text_safely,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_PYTHON_SUFFIX: tuple[str, ...] = (".py",)
|
|
28
|
+
|
|
29
|
+
_RAISE_PATTERN = re.compile(r"\braise\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|$)")
|
|
30
|
+
|
|
31
|
+
_PUBLIC_DECL_PATTERN = re.compile(r"^(class|def)\s+([A-Za-z][A-Za-z0-9_]*)\s*[\(:]")
|
|
32
|
+
_PRIVATE_DECL_PATTERN = re.compile(r"^(class|def)\s+(_[A-Za-z0-9_]*)\s*[\(:]")
|
|
33
|
+
|
|
34
|
+
_TEST_METHOD_PATTERN = re.compile(r"^\s*def\s+(test_[A-Za-z0-9_]+)\s*\(", re.MULTILINE)
|
|
35
|
+
_TEST_SNAKE_PATTERN = re.compile(r"^test_[a-z][a-z0-9_]*_[a-z0-9_]+_[a-z0-9_]+$")
|
|
36
|
+
|
|
37
|
+
# Stdlib exception types considered "too generic" for good agent UX.
|
|
38
|
+
_GENERIC_EXCEPTION_NAMES: frozenset[str] = frozenset({
|
|
39
|
+
"Exception",
|
|
40
|
+
"BaseException",
|
|
41
|
+
"RuntimeError",
|
|
42
|
+
"ValueError",
|
|
43
|
+
"TypeError",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
_PROD_DIR_CANDIDATES: tuple[str, ...] = ("src", "lib")
|
|
47
|
+
_TEST_DIR_CANDIDATES: tuple[str, ...] = ("tests", "test")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PythonAdapter(LanguageAdapter):
|
|
51
|
+
"""Python adapter. MVP implementation."""
|
|
52
|
+
|
|
53
|
+
name = "python"
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Detection
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def detect(self, repo_path: Path) -> float:
|
|
60
|
+
if (repo_path / "pyproject.toml").is_file():
|
|
61
|
+
return 1.0
|
|
62
|
+
if (repo_path / "setup.py").is_file():
|
|
63
|
+
return 0.9
|
|
64
|
+
if any(repo_path.rglob("*.py")):
|
|
65
|
+
return 0.6
|
|
66
|
+
return 0.0
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# File discovery
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def find_production_files(self, repo_path: Path) -> list[Path]:
|
|
73
|
+
production_roots = self._production_roots(repo_path)
|
|
74
|
+
results: list[Path] = []
|
|
75
|
+
for root in production_roots:
|
|
76
|
+
results.extend(iter_source_files(root, suffixes=_PYTHON_SUFFIX))
|
|
77
|
+
return results
|
|
78
|
+
|
|
79
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
80
|
+
results: list[Path] = []
|
|
81
|
+
for name in _TEST_DIR_CANDIDATES:
|
|
82
|
+
candidate = repo_path / name
|
|
83
|
+
if candidate.is_dir():
|
|
84
|
+
results.extend(iter_source_files(candidate, suffixes=_PYTHON_SUFFIX))
|
|
85
|
+
return results
|
|
86
|
+
|
|
87
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
88
|
+
"""Top-level package directories under src/ or the repo root."""
|
|
89
|
+
modules: set[str] = set()
|
|
90
|
+
for root in self._production_roots(repo_path):
|
|
91
|
+
for entry in root.iterdir():
|
|
92
|
+
if entry.is_dir() and (entry / "__init__.py").is_file():
|
|
93
|
+
modules.add(entry.name)
|
|
94
|
+
return sorted(modules)
|
|
95
|
+
|
|
96
|
+
def _production_roots(self, repo_path: Path) -> list[Path]:
|
|
97
|
+
roots: list[Path] = []
|
|
98
|
+
for name in _PROD_DIR_CANDIDATES:
|
|
99
|
+
candidate = repo_path / name
|
|
100
|
+
if candidate.is_dir():
|
|
101
|
+
roots.append(candidate)
|
|
102
|
+
if not roots:
|
|
103
|
+
# Fall back: top-level packages in the repo root that are NOT tests.
|
|
104
|
+
for entry in repo_path.iterdir():
|
|
105
|
+
if not entry.is_dir():
|
|
106
|
+
continue
|
|
107
|
+
if entry.name in _TEST_DIR_CANDIDATES:
|
|
108
|
+
continue
|
|
109
|
+
if entry.name.startswith("."):
|
|
110
|
+
continue
|
|
111
|
+
if (entry / "__init__.py").is_file():
|
|
112
|
+
roots.append(entry)
|
|
113
|
+
return roots
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Throw-site analysis (Python: raise sites)
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def scan_throw_sites(
|
|
120
|
+
self,
|
|
121
|
+
files: Iterable[Path],
|
|
122
|
+
hint_marker: str,
|
|
123
|
+
domain_exception_types: set[str],
|
|
124
|
+
) -> list[ThrowSite]:
|
|
125
|
+
sites: list[ThrowSite] = []
|
|
126
|
+
for path in files:
|
|
127
|
+
text = read_text_safely(path)
|
|
128
|
+
if not text:
|
|
129
|
+
continue
|
|
130
|
+
for match in _RAISE_PATTERN.finditer(text):
|
|
131
|
+
exception_type = match.group(1)
|
|
132
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
133
|
+
# Lightweight message extraction: take the rest of the line.
|
|
134
|
+
line_start = text.rfind("\n", 0, match.start()) + 1
|
|
135
|
+
line_end = text.find("\n", match.start())
|
|
136
|
+
if line_end == -1:
|
|
137
|
+
line_end = len(text)
|
|
138
|
+
line_text = text[line_start:line_end]
|
|
139
|
+
sites.append(
|
|
140
|
+
ThrowSite(
|
|
141
|
+
file=path,
|
|
142
|
+
line=line_no,
|
|
143
|
+
exception_type=exception_type,
|
|
144
|
+
has_fix_hint=_has_fix_hint(line_text, hint_marker),
|
|
145
|
+
is_user_defined=exception_type in domain_exception_types,
|
|
146
|
+
is_generic=exception_type in _GENERIC_EXCEPTION_NAMES,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return sites
|
|
150
|
+
|
|
151
|
+
def generic_exception_names(self) -> set[str]:
|
|
152
|
+
return set(_GENERIC_EXCEPTION_NAMES)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Declarations
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
159
|
+
declarations: list[Declaration] = []
|
|
160
|
+
for path in files:
|
|
161
|
+
text = read_text_safely(path)
|
|
162
|
+
if not text:
|
|
163
|
+
continue
|
|
164
|
+
declarations.extend(self._scan_declarations_in_text(path, text))
|
|
165
|
+
return declarations
|
|
166
|
+
|
|
167
|
+
def _scan_declarations_in_text(self, path: Path, text: str) -> list[Declaration]:
|
|
168
|
+
lines = text.splitlines()
|
|
169
|
+
results: list[Declaration] = []
|
|
170
|
+
for i, line in enumerate(lines):
|
|
171
|
+
if line.startswith(" ") or line.startswith("\t"):
|
|
172
|
+
# Only top-level declarations.
|
|
173
|
+
continue
|
|
174
|
+
public_match = _PUBLIC_DECL_PATTERN.match(line)
|
|
175
|
+
if public_match and not public_match.group(2).startswith("_"):
|
|
176
|
+
results.append(
|
|
177
|
+
Declaration(
|
|
178
|
+
file=path,
|
|
179
|
+
line=i + 1,
|
|
180
|
+
name=public_match.group(2),
|
|
181
|
+
visibility="public",
|
|
182
|
+
has_doc_comment=_has_following_docstring(lines, i),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
private_match = _PRIVATE_DECL_PATTERN.match(line)
|
|
187
|
+
if private_match:
|
|
188
|
+
results.append(
|
|
189
|
+
Declaration(
|
|
190
|
+
file=path,
|
|
191
|
+
line=i + 1,
|
|
192
|
+
name=private_match.group(2),
|
|
193
|
+
visibility="private",
|
|
194
|
+
has_doc_comment=_has_following_docstring(lines, i),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return results
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Test methods
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
204
|
+
results: list[tuple[Path, str]] = []
|
|
205
|
+
for path in files:
|
|
206
|
+
text = read_text_safely(path)
|
|
207
|
+
if not text:
|
|
208
|
+
continue
|
|
209
|
+
for match in _TEST_METHOD_PATTERN.finditer(text):
|
|
210
|
+
results.append((path, match.group(1)))
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
214
|
+
return _TEST_SNAKE_PATTERN
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Helpers
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _has_fix_hint(text: str, hint_marker: str) -> bool:
|
|
223
|
+
lower = text.lower()
|
|
224
|
+
if hint_marker and hint_marker.lower() in lower:
|
|
225
|
+
return True
|
|
226
|
+
for phrase in ("hint:", "fix:", "see ", "try ", "use ", "check ", "did you mean"):
|
|
227
|
+
if phrase in lower:
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _has_following_docstring(lines: list[str], index: int) -> bool:
|
|
233
|
+
"""Return True if the next non-blank line after ``index`` starts a docstring."""
|
|
234
|
+
j = index + 1
|
|
235
|
+
while j < len(lines):
|
|
236
|
+
stripped = lines[j].strip()
|
|
237
|
+
if stripped == "":
|
|
238
|
+
j += 1
|
|
239
|
+
continue
|
|
240
|
+
return stripped.startswith('"""') or stripped.startswith("'''")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
PythonAdapter.count_file_loc = staticmethod(count_file_loc) # type: ignore[attr-defined]
|