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,304 @@
|
|
|
1
|
+
"""Rust language adapter.
|
|
2
|
+
|
|
3
|
+
Detects Rust repos via ``Cargo.toml`` presence. Walks ``src/`` for production
|
|
4
|
+
``.rs`` files and scans for error-creation patterns. Rust uses ``Result<T, E>``
|
|
5
|
+
rather than exceptions — this adapter maps error-creation sites (``panic!``,
|
|
6
|
+
custom error types, ``anyhow!``, ``bail!``) to the ``ThrowSite`` model.
|
|
7
|
+
|
|
8
|
+
Declarations are detected via ``pub fn``, ``pub struct``, ``pub enum``, etc.
|
|
9
|
+
Doc comments use ``///`` (outer) or ``//!`` (inner) prefixes.
|
|
10
|
+
|
|
11
|
+
All analysis is regex-based — no AST parsing, stdlib only.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from .base import (
|
|
20
|
+
Declaration,
|
|
21
|
+
LanguageAdapter,
|
|
22
|
+
ThrowSite,
|
|
23
|
+
count_file_loc,
|
|
24
|
+
iter_source_files,
|
|
25
|
+
read_text_safely,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_RUST_SUFFIX: tuple[str, ...] = (".rs",)
|
|
29
|
+
|
|
30
|
+
# Error-creation patterns.
|
|
31
|
+
_PANIC_PATTERN = re.compile(r"\bpanic!\s*\(")
|
|
32
|
+
_ANYHOW_PATTERN = re.compile(r"\b(?:anyhow!|bail!)\s*\(")
|
|
33
|
+
_CUSTOM_ERROR_RETURN = re.compile(r"\bErr\s*\(\s*([A-Z][A-Za-z0-9_]*(?:::[A-Z]\w*)?)\s*[({]")
|
|
34
|
+
|
|
35
|
+
# Declaration patterns.
|
|
36
|
+
_PUB_DECL_PATTERN = re.compile(
|
|
37
|
+
r"^pub(?:\s*\(crate\))?\s+(?:async\s+)?(?:fn|struct|enum|trait|type|const|static|mod)\s+([A-Za-z_]\w*)"
|
|
38
|
+
)
|
|
39
|
+
_PRIVATE_DECL_PATTERN = re.compile(
|
|
40
|
+
r"^(?:async\s+)?(?:fn|struct|enum|trait|type|const|static)\s+([A-Za-z_]\w*)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Test function pattern.
|
|
44
|
+
_TEST_FN_PATTERN = re.compile(r"^\s*(?:async\s+)?fn\s+(test_\w+)\s*\(")
|
|
45
|
+
_TEST_ATTR_PATTERN = re.compile(r"^\s*#\[test\]")
|
|
46
|
+
# Rust test naming: test_something_does_something.
|
|
47
|
+
_TEST_NAMING_PATTERN = re.compile(r"^test_[a-z][a-z0-9_]*_[a-z0-9_]+$")
|
|
48
|
+
|
|
49
|
+
# Generic panic types.
|
|
50
|
+
_GENERIC_ERROR_NAMES: frozenset[str] = frozenset({
|
|
51
|
+
"panic!",
|
|
52
|
+
"anyhow!",
|
|
53
|
+
"bail!",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
_FIX_HINT_KEYWORDS: tuple[str, ...] = (
|
|
57
|
+
"hint:", "fix:", "see ", "try ", "use ", "check ", "did you mean",
|
|
58
|
+
"suggested fix", "to fix", "to resolve", "example:", "ensure",
|
|
59
|
+
"expected", "must be", "should be",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RustAdapter(LanguageAdapter):
|
|
64
|
+
"""Rust adapter."""
|
|
65
|
+
|
|
66
|
+
name = "rust"
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Detection
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def detect(self, repo_path: Path) -> float:
|
|
73
|
+
if (repo_path / "Cargo.toml").is_file():
|
|
74
|
+
return 1.0
|
|
75
|
+
if any(repo_path.rglob("*.rs")):
|
|
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
|
+
src_dir = repo_path / "src"
|
|
85
|
+
if src_dir.is_dir():
|
|
86
|
+
return iter_source_files(src_dir, suffixes=_RUST_SUFFIX)
|
|
87
|
+
return iter_source_files(repo_path, suffixes=_RUST_SUFFIX)
|
|
88
|
+
|
|
89
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
90
|
+
results: list[Path] = []
|
|
91
|
+
tests_dir = repo_path / "tests"
|
|
92
|
+
if tests_dir.is_dir():
|
|
93
|
+
results.extend(iter_source_files(tests_dir, suffixes=_RUST_SUFFIX))
|
|
94
|
+
# In Rust, test modules are often inline (#[cfg(test)]) in prod files.
|
|
95
|
+
# We also look for dedicated test files in src/.
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
99
|
+
"""Return crate name from Cargo.toml and top-level module names."""
|
|
100
|
+
modules: set[str] = set()
|
|
101
|
+
cargo = repo_path / "Cargo.toml"
|
|
102
|
+
if cargo.is_file():
|
|
103
|
+
text = read_text_safely(cargo)
|
|
104
|
+
m = re.search(r'name\s*=\s*"([^"]+)"', text)
|
|
105
|
+
if m:
|
|
106
|
+
modules.add(m.group(1))
|
|
107
|
+
src_dir = repo_path / "src"
|
|
108
|
+
if src_dir.is_dir():
|
|
109
|
+
for entry in src_dir.iterdir():
|
|
110
|
+
if entry.is_dir() and any(entry.rglob("*.rs")):
|
|
111
|
+
modules.add(entry.name)
|
|
112
|
+
elif entry.is_file() and entry.suffix == ".rs" and entry.name not in ("main.rs", "lib.rs"):
|
|
113
|
+
modules.add(entry.stem)
|
|
114
|
+
return sorted(modules)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Throw-site analysis (Rust: panic!, bail!, Err(CustomError))
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def scan_throw_sites(
|
|
121
|
+
self,
|
|
122
|
+
files: Iterable[Path],
|
|
123
|
+
hint_marker: str,
|
|
124
|
+
domain_exception_types: set[str],
|
|
125
|
+
) -> list[ThrowSite]:
|
|
126
|
+
sites: list[ThrowSite] = []
|
|
127
|
+
for path in files:
|
|
128
|
+
text = read_text_safely(path)
|
|
129
|
+
if not text:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# panic!() sites
|
|
133
|
+
for match in _PANIC_PATTERN.finditer(text):
|
|
134
|
+
context = _extract_context(text, match.start())
|
|
135
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
136
|
+
sites.append(
|
|
137
|
+
ThrowSite(
|
|
138
|
+
file=path,
|
|
139
|
+
line=line_no,
|
|
140
|
+
exception_type="panic!",
|
|
141
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
142
|
+
is_user_defined=False,
|
|
143
|
+
is_generic=True,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# anyhow!/bail! sites
|
|
148
|
+
for match in _ANYHOW_PATTERN.finditer(text):
|
|
149
|
+
context = _extract_context(text, match.start())
|
|
150
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
151
|
+
err_type = "anyhow!" if "anyhow!" in match.group() else "bail!"
|
|
152
|
+
sites.append(
|
|
153
|
+
ThrowSite(
|
|
154
|
+
file=path,
|
|
155
|
+
line=line_no,
|
|
156
|
+
exception_type=err_type,
|
|
157
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
158
|
+
is_user_defined=False,
|
|
159
|
+
is_generic=True,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Err(CustomError{}) or Err(CustomError(...))
|
|
164
|
+
for match in _CUSTOM_ERROR_RETURN.finditer(text):
|
|
165
|
+
error_type = match.group(1)
|
|
166
|
+
context = _extract_context(text, match.start())
|
|
167
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
168
|
+
sites.append(
|
|
169
|
+
ThrowSite(
|
|
170
|
+
file=path,
|
|
171
|
+
line=line_no,
|
|
172
|
+
exception_type=error_type,
|
|
173
|
+
has_fix_hint=_has_fix_hint(context, hint_marker),
|
|
174
|
+
is_user_defined=error_type in domain_exception_types,
|
|
175
|
+
is_generic=False,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return sites
|
|
179
|
+
|
|
180
|
+
def generic_exception_names(self) -> set[str]:
|
|
181
|
+
return set(_GENERIC_ERROR_NAMES)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Declarations
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
188
|
+
declarations: list[Declaration] = []
|
|
189
|
+
for path in files:
|
|
190
|
+
text = read_text_safely(path)
|
|
191
|
+
if not text:
|
|
192
|
+
continue
|
|
193
|
+
declarations.extend(self._scan_declarations_in_text(path, text))
|
|
194
|
+
return declarations
|
|
195
|
+
|
|
196
|
+
def _scan_declarations_in_text(self, path: Path, text: str) -> list[Declaration]:
|
|
197
|
+
lines = text.splitlines()
|
|
198
|
+
results: list[Declaration] = []
|
|
199
|
+
for i, line in enumerate(lines):
|
|
200
|
+
# Try pub declarations first.
|
|
201
|
+
m = _PUB_DECL_PATTERN.match(line)
|
|
202
|
+
if m:
|
|
203
|
+
visibility = "internal" if "pub(crate)" in line else "public"
|
|
204
|
+
results.append(
|
|
205
|
+
Declaration(
|
|
206
|
+
file=path,
|
|
207
|
+
line=i + 1,
|
|
208
|
+
name=m.group(1),
|
|
209
|
+
visibility=visibility,
|
|
210
|
+
has_doc_comment=_has_preceding_doc_comment(lines, i),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
214
|
+
# Try private (no pub) declarations — only at column 0.
|
|
215
|
+
if not line.startswith(" ") and not line.startswith("\t"):
|
|
216
|
+
m = _PRIVATE_DECL_PATTERN.match(line)
|
|
217
|
+
if m:
|
|
218
|
+
results.append(
|
|
219
|
+
Declaration(
|
|
220
|
+
file=path,
|
|
221
|
+
line=i + 1,
|
|
222
|
+
name=m.group(1),
|
|
223
|
+
visibility="private",
|
|
224
|
+
has_doc_comment=_has_preceding_doc_comment(lines, i),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return results
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Test methods
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
234
|
+
"""Find test functions in both dedicated test files and inline #[test] modules."""
|
|
235
|
+
results: list[tuple[Path, str]] = []
|
|
236
|
+
all_files = list(files)
|
|
237
|
+
# Also scan production files for inline tests.
|
|
238
|
+
for path in all_files:
|
|
239
|
+
text = read_text_safely(path)
|
|
240
|
+
if not text:
|
|
241
|
+
continue
|
|
242
|
+
lines = text.splitlines()
|
|
243
|
+
for i, line in enumerate(lines):
|
|
244
|
+
m = _TEST_FN_PATTERN.match(line)
|
|
245
|
+
if m:
|
|
246
|
+
results.append((path, m.group(1)))
|
|
247
|
+
continue
|
|
248
|
+
# Also detect functions preceded by #[test] attribute.
|
|
249
|
+
if _TEST_ATTR_PATTERN.match(line):
|
|
250
|
+
# Next non-blank, non-attribute line should be the fn.
|
|
251
|
+
for j in range(i + 1, min(i + 5, len(lines))):
|
|
252
|
+
fn_match = re.match(r"^\s*(?:async\s+)?fn\s+(\w+)\s*\(", lines[j])
|
|
253
|
+
if fn_match:
|
|
254
|
+
results.append((path, fn_match.group(1)))
|
|
255
|
+
break
|
|
256
|
+
return results
|
|
257
|
+
|
|
258
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
259
|
+
return _TEST_NAMING_PATTERN
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Helpers
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _extract_context(text: str, start: int, max_chars: int = 500) -> str:
|
|
268
|
+
"""Extract text from start through the next closing paren/brace."""
|
|
269
|
+
end = min(start + max_chars, len(text))
|
|
270
|
+
depth = 0
|
|
271
|
+
for i in range(start, end):
|
|
272
|
+
ch = text[i]
|
|
273
|
+
if ch in ("(", "{"):
|
|
274
|
+
depth += 1
|
|
275
|
+
elif ch in (")", "}"):
|
|
276
|
+
depth -= 1
|
|
277
|
+
if depth == 0:
|
|
278
|
+
return text[start:i + 1]
|
|
279
|
+
return text[start:end]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _has_fix_hint(text: str, hint_marker: str) -> bool:
|
|
283
|
+
lower = text.lower()
|
|
284
|
+
if hint_marker and hint_marker.lower() in lower:
|
|
285
|
+
return True
|
|
286
|
+
for keyword in _FIX_HINT_KEYWORDS:
|
|
287
|
+
if keyword in lower:
|
|
288
|
+
return True
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _has_preceding_doc_comment(lines: list[str], index: int) -> bool:
|
|
293
|
+
"""Return True if the line above is a Rust doc comment (``///`` or ``//!``)."""
|
|
294
|
+
j = index - 1
|
|
295
|
+
while j >= 0:
|
|
296
|
+
stripped = lines[j].strip()
|
|
297
|
+
if stripped == "":
|
|
298
|
+
j -= 1
|
|
299
|
+
continue
|
|
300
|
+
return stripped.startswith("///") or stripped.startswith("//!")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
RustAdapter.count_file_loc = staticmethod(count_file_loc) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""TypeScript language adapter.
|
|
2
|
+
|
|
3
|
+
Detects TypeScript repos via ``tsconfig.json`` / ``package.json`` presence.
|
|
4
|
+
Walks ``src/`` for production files and ``tests/`` / ``test/`` / ``__tests__/``
|
|
5
|
+
for test files. Scans ``throw new`` statements, classifies error types, and
|
|
6
|
+
detects JSDoc presence above top-level declarations.
|
|
7
|
+
|
|
8
|
+
All analysis is regex-based against file text — no AST parsing, no runtime
|
|
9
|
+
dependencies beyond the Python 3.11+ standard library.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
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
|
+
_TS_SUFFIX: tuple[str, ...] = (".ts", ".tsx")
|
|
28
|
+
_TS_EXCLUDE_SUFFIXES: tuple[str, ...] = (".d.ts",)
|
|
29
|
+
_TS_TEST_SUFFIXES: tuple[str, ...] = (".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")
|
|
30
|
+
|
|
31
|
+
# Throw-site scanning.
|
|
32
|
+
_THROW_PATTERN = re.compile(r"\bthrow\s+new\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
33
|
+
|
|
34
|
+
# Declaration scanning — exported (public).
|
|
35
|
+
_EXPORT_CLASS_PATTERN = re.compile(
|
|
36
|
+
r"^export\s+(?:default\s+|abstract\s+)*(?:class|interface)\s+([A-Za-z_]\w*)"
|
|
37
|
+
)
|
|
38
|
+
_EXPORT_FUNCTION_PATTERN = re.compile(
|
|
39
|
+
r"^export\s+(?:default\s+|async\s+)*function\s+([A-Za-z_]\w*)"
|
|
40
|
+
)
|
|
41
|
+
_EXPORT_ENUM_PATTERN = re.compile(
|
|
42
|
+
r"^export\s+(?:const\s+)?enum\s+([A-Za-z_]\w*)"
|
|
43
|
+
)
|
|
44
|
+
_EXPORT_TYPE_PATTERN = re.compile(
|
|
45
|
+
r"^export\s+type\s+([A-Za-z_]\w*)\s*[=<{]"
|
|
46
|
+
)
|
|
47
|
+
_EXPORT_CONST_PATTERN = re.compile(
|
|
48
|
+
r"^export\s+const\s+([A-Za-z_]\w*)\s*[=:]"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Non-exported (internal) declarations.
|
|
52
|
+
_INTERNAL_CLASS_PATTERN = re.compile(
|
|
53
|
+
r"^(?:abstract\s+)?class\s+([A-Za-z_]\w*)"
|
|
54
|
+
)
|
|
55
|
+
_INTERNAL_FUNCTION_PATTERN = re.compile(
|
|
56
|
+
r"^(?:async\s+)?function\s+([A-Za-z_]\w*)"
|
|
57
|
+
)
|
|
58
|
+
_INTERNAL_CONST_PATTERN = re.compile(
|
|
59
|
+
r"^const\s+([A-Za-z_]\w*)\s*[=:]"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Test method patterns (Jest/Vitest/Mocha).
|
|
63
|
+
_TEST_IT_PATTERN = re.compile(r"""\bit\s*\(\s*(['"`])(.+?)\1""")
|
|
64
|
+
_TEST_TEST_PATTERN = re.compile(r"""\btest\s*\(\s*(['"`])(.+?)\1""")
|
|
65
|
+
|
|
66
|
+
# Test naming: descriptive string — we accept anything with at least 3 words.
|
|
67
|
+
_TEST_DESCRIPTIVE_PATTERN = re.compile(r"^.+\s.+\s.+")
|
|
68
|
+
|
|
69
|
+
# Language-stdlib error types considered "too generic" for good agent UX.
|
|
70
|
+
_GENERIC_EXCEPTION_NAMES: frozenset[str] = frozenset({
|
|
71
|
+
"Error",
|
|
72
|
+
"TypeError",
|
|
73
|
+
"RangeError",
|
|
74
|
+
"ReferenceError",
|
|
75
|
+
"SyntaxError",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
_FIX_HINT_KEYWORDS: tuple[str, ...] = (
|
|
79
|
+
"hint:", "fix:", "see ", "try ", "use ", "check ", "did you mean",
|
|
80
|
+
"suggested fix", "to fix", "to resolve", "example:", "ensure",
|
|
81
|
+
"install", "provide", "verify", "configure",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
_PROD_DIR_CANDIDATES: tuple[str, ...] = ("src", "lib")
|
|
85
|
+
_TEST_DIR_CANDIDATES: tuple[str, ...] = ("tests", "test", "__tests__")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TypeScriptAdapter(LanguageAdapter):
|
|
89
|
+
"""TypeScript / JavaScript adapter."""
|
|
90
|
+
|
|
91
|
+
name = "typescript"
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Detection
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def detect(self, repo_path: Path) -> float:
|
|
98
|
+
if (repo_path / "tsconfig.json").is_file():
|
|
99
|
+
return 1.0
|
|
100
|
+
pkg_json = repo_path / "package.json"
|
|
101
|
+
if pkg_json.is_file():
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(read_text_safely(pkg_json))
|
|
104
|
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
105
|
+
if "typescript" in deps:
|
|
106
|
+
return 0.8
|
|
107
|
+
except (json.JSONDecodeError, TypeError):
|
|
108
|
+
pass
|
|
109
|
+
return 0.3
|
|
110
|
+
return 0.0
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# File discovery
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def find_production_files(self, repo_path: Path) -> list[Path]:
|
|
117
|
+
production_roots = self._production_roots(repo_path)
|
|
118
|
+
results: list[Path] = []
|
|
119
|
+
for root in production_roots:
|
|
120
|
+
for f in iter_source_files(
|
|
121
|
+
root,
|
|
122
|
+
suffixes=_TS_SUFFIX,
|
|
123
|
+
exclude_suffixes=_TS_EXCLUDE_SUFFIXES,
|
|
124
|
+
):
|
|
125
|
+
if not _is_test_file(f):
|
|
126
|
+
results.append(f)
|
|
127
|
+
return results
|
|
128
|
+
|
|
129
|
+
def find_test_files(self, repo_path: Path) -> list[Path]:
|
|
130
|
+
results: list[Path] = []
|
|
131
|
+
# Conventional test directories.
|
|
132
|
+
for name in _TEST_DIR_CANDIDATES:
|
|
133
|
+
candidate = repo_path / name
|
|
134
|
+
if candidate.is_dir():
|
|
135
|
+
results.extend(
|
|
136
|
+
iter_source_files(candidate, suffixes=_TS_SUFFIX, exclude_suffixes=_TS_EXCLUDE_SUFFIXES)
|
|
137
|
+
)
|
|
138
|
+
# Also pick up co-located test files in production directories.
|
|
139
|
+
for root in self._production_roots(repo_path):
|
|
140
|
+
for f in iter_source_files(root, suffixes=_TS_SUFFIX, exclude_suffixes=_TS_EXCLUDE_SUFFIXES):
|
|
141
|
+
if _is_test_file(f) and f not in results:
|
|
142
|
+
results.append(f)
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
def find_production_modules(self, repo_path: Path) -> list[str]:
|
|
146
|
+
"""Top-level directories under src/ that contain .ts files."""
|
|
147
|
+
modules: set[str] = set()
|
|
148
|
+
for root in self._production_roots(repo_path):
|
|
149
|
+
for entry in root.iterdir():
|
|
150
|
+
if entry.is_dir() and any(entry.rglob("*.ts")):
|
|
151
|
+
modules.add(entry.name)
|
|
152
|
+
# If src/ has direct .ts files but no subdirectories, use the root name.
|
|
153
|
+
if not modules:
|
|
154
|
+
ts_files = list(root.glob("*.ts"))
|
|
155
|
+
if ts_files:
|
|
156
|
+
modules.add(root.name)
|
|
157
|
+
return sorted(modules)
|
|
158
|
+
|
|
159
|
+
def _production_roots(self, repo_path: Path) -> list[Path]:
|
|
160
|
+
roots: list[Path] = []
|
|
161
|
+
for name in _PROD_DIR_CANDIDATES:
|
|
162
|
+
candidate = repo_path / name
|
|
163
|
+
if candidate.is_dir():
|
|
164
|
+
roots.append(candidate)
|
|
165
|
+
return roots
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Throw-site analysis
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def scan_throw_sites(
|
|
172
|
+
self,
|
|
173
|
+
files: Iterable[Path],
|
|
174
|
+
hint_marker: str,
|
|
175
|
+
domain_exception_types: set[str],
|
|
176
|
+
) -> list[ThrowSite]:
|
|
177
|
+
sites: list[ThrowSite] = []
|
|
178
|
+
for path in files:
|
|
179
|
+
text = read_text_safely(path)
|
|
180
|
+
if not text:
|
|
181
|
+
continue
|
|
182
|
+
for match in _THROW_PATTERN.finditer(text):
|
|
183
|
+
exception_type = match.group(1)
|
|
184
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
185
|
+
# Extract text from throw to closing paren/semicolon for
|
|
186
|
+
# fix-hint detection (message often spans multiple lines).
|
|
187
|
+
context_text = _extract_throw_context(text, match.start())
|
|
188
|
+
sites.append(
|
|
189
|
+
ThrowSite(
|
|
190
|
+
file=path,
|
|
191
|
+
line=line_no,
|
|
192
|
+
exception_type=exception_type,
|
|
193
|
+
has_fix_hint=_has_fix_hint(context_text, hint_marker),
|
|
194
|
+
is_user_defined=exception_type in domain_exception_types,
|
|
195
|
+
is_generic=exception_type in _GENERIC_EXCEPTION_NAMES,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return sites
|
|
199
|
+
|
|
200
|
+
def generic_exception_names(self) -> set[str]:
|
|
201
|
+
return set(_GENERIC_EXCEPTION_NAMES)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Declarations
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def scan_declarations(self, files: Iterable[Path]) -> list[Declaration]:
|
|
208
|
+
declarations: list[Declaration] = []
|
|
209
|
+
for path in files:
|
|
210
|
+
text = read_text_safely(path)
|
|
211
|
+
if not text:
|
|
212
|
+
continue
|
|
213
|
+
declarations.extend(self._scan_declarations_in_text(path, text))
|
|
214
|
+
return declarations
|
|
215
|
+
|
|
216
|
+
def _scan_declarations_in_text(self, path: Path, text: str) -> list[Declaration]:
|
|
217
|
+
lines = text.splitlines()
|
|
218
|
+
results: list[Declaration] = []
|
|
219
|
+
for i, line in enumerate(lines):
|
|
220
|
+
stripped = line.strip()
|
|
221
|
+
|
|
222
|
+
# Try exported (public) patterns first.
|
|
223
|
+
name = _match_exported(stripped)
|
|
224
|
+
if name is not None:
|
|
225
|
+
results.append(
|
|
226
|
+
Declaration(
|
|
227
|
+
file=path,
|
|
228
|
+
line=i + 1,
|
|
229
|
+
name=name,
|
|
230
|
+
visibility="public",
|
|
231
|
+
has_doc_comment=_has_preceding_jsdoc(lines, i),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Try non-exported (internal) patterns — only at column 0.
|
|
237
|
+
if line and not line[0].isspace():
|
|
238
|
+
name = _match_internal(stripped)
|
|
239
|
+
if name is not None:
|
|
240
|
+
results.append(
|
|
241
|
+
Declaration(
|
|
242
|
+
file=path,
|
|
243
|
+
line=i + 1,
|
|
244
|
+
name=name,
|
|
245
|
+
visibility="internal",
|
|
246
|
+
has_doc_comment=_has_preceding_jsdoc(lines, i),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
return results
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Test methods
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def find_test_methods(self, files: Iterable[Path]) -> list[tuple[Path, str]]:
|
|
256
|
+
results: list[tuple[Path, str]] = []
|
|
257
|
+
for path in files:
|
|
258
|
+
text = read_text_safely(path)
|
|
259
|
+
if not text:
|
|
260
|
+
continue
|
|
261
|
+
for match in _TEST_IT_PATTERN.finditer(text):
|
|
262
|
+
results.append((path, match.group(2)))
|
|
263
|
+
for match in _TEST_TEST_PATTERN.finditer(text):
|
|
264
|
+
results.append((path, match.group(2)))
|
|
265
|
+
return results
|
|
266
|
+
|
|
267
|
+
def test_naming_pattern(self) -> re.Pattern[str]:
|
|
268
|
+
return _TEST_DESCRIPTIVE_PATTERN
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Helpers
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _extract_throw_context(text: str, start: int, max_chars: int = 500) -> str:
|
|
277
|
+
"""Extract text from a throw statement through the closing paren.
|
|
278
|
+
|
|
279
|
+
Walks forward from ``start`` up to ``max_chars`` or the matching close
|
|
280
|
+
paren, whichever comes first. This captures multi-line throw messages.
|
|
281
|
+
"""
|
|
282
|
+
end = min(start + max_chars, len(text))
|
|
283
|
+
depth = 0
|
|
284
|
+
for i in range(start, end):
|
|
285
|
+
if text[i] == "(":
|
|
286
|
+
depth += 1
|
|
287
|
+
elif text[i] == ")":
|
|
288
|
+
depth -= 1
|
|
289
|
+
if depth == 0:
|
|
290
|
+
return text[start:i + 1]
|
|
291
|
+
return text[start:end]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _is_test_file(path: Path) -> bool:
|
|
295
|
+
name = path.name
|
|
296
|
+
return any(name.endswith(sfx) for sfx in _TS_TEST_SUFFIXES)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _has_fix_hint(text: str, hint_marker: str) -> bool:
|
|
300
|
+
lower = text.lower()
|
|
301
|
+
if hint_marker and hint_marker.lower() in lower:
|
|
302
|
+
return True
|
|
303
|
+
for keyword in _FIX_HINT_KEYWORDS:
|
|
304
|
+
if keyword in lower:
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _match_exported(line: str) -> str | None:
|
|
310
|
+
"""Return the declaration name if line is an export declaration, else None."""
|
|
311
|
+
for pattern in (
|
|
312
|
+
_EXPORT_CLASS_PATTERN,
|
|
313
|
+
_EXPORT_FUNCTION_PATTERN,
|
|
314
|
+
_EXPORT_ENUM_PATTERN,
|
|
315
|
+
_EXPORT_TYPE_PATTERN,
|
|
316
|
+
_EXPORT_CONST_PATTERN,
|
|
317
|
+
):
|
|
318
|
+
m = pattern.match(line)
|
|
319
|
+
if m:
|
|
320
|
+
return m.group(1)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _match_internal(line: str) -> str | None:
|
|
325
|
+
"""Return the declaration name if line is a non-exported top-level declaration."""
|
|
326
|
+
for pattern in (
|
|
327
|
+
_INTERNAL_CLASS_PATTERN,
|
|
328
|
+
_INTERNAL_FUNCTION_PATTERN,
|
|
329
|
+
_INTERNAL_CONST_PATTERN,
|
|
330
|
+
):
|
|
331
|
+
m = pattern.match(line)
|
|
332
|
+
if m:
|
|
333
|
+
return m.group(1)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _has_preceding_jsdoc(lines: list[str], index: int) -> bool:
|
|
338
|
+
"""Walk backwards from ``index``. Return True if the first non-blank line
|
|
339
|
+
above ends with ``*/`` (closing a JSDoc block).
|
|
340
|
+
"""
|
|
341
|
+
j = index - 1
|
|
342
|
+
while j >= 0:
|
|
343
|
+
stripped = lines[j].strip()
|
|
344
|
+
if stripped == "":
|
|
345
|
+
j -= 1
|
|
346
|
+
continue
|
|
347
|
+
return stripped.endswith("*/")
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
TypeScriptAdapter.count_file_loc = staticmethod(count_file_loc) # type: ignore[attr-defined]
|