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.
@@ -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,4 @@
1
+ """Enable ``python -m agentrepocoach``."""
2
+ from .cli import main
3
+
4
+ raise SystemExit(main()) # See cli.py:main() for argument parsing
@@ -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]