python-code-quality 0.1.16__py3-none-any.whl → 0.2.2__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.
@@ -7,52 +7,288 @@ per-file docstring coverage on stdout::
7
7
  | TOTAL | 5 | 2 | 3 | 60.0% |
8
8
 
9
9
  The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
10
- as the ``doc_coverage`` metric (0.01.0).
10
+ as the ``doc_coverage`` metric (0.0-1.0).
11
11
  """
12
12
 
13
+ import ast
13
14
  import re
15
+ import tomllib
16
+ from pathlib import Path
14
17
 
15
18
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
19
+ from py_cq.parsers.common import (
20
+ extract_first_issue,
21
+ format_issue_header,
22
+ format_source_context,
23
+ )
16
24
 
17
- _ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|")
25
+ _SKIP_PARAMS = {"self", "cls"}
26
+
27
+
28
+ def _format_missing_docstring(file_str: str, line: int, code: str, message: str) -> str:
29
+ base = format_issue_header(file_str, line, code, message) + format_source_context(
30
+ file_str, line
31
+ )
32
+
33
+ if code == "D100":
34
+ return base + "\n\nInsert a module-level docstring as the very first statement in the file."
35
+
36
+ try:
37
+ source = Path(file_str).read_text(encoding="utf-8")
38
+ tree = ast.parse(source)
39
+ except (OSError, SyntaxError):
40
+ return base + "\n\nInsert a docstring as the first statement in the body."
41
+
42
+ for node in ast.walk(tree):
43
+ if isinstance(node, ast.ClassDef) and node.lineno == line:
44
+ insert_line = node.body[0].lineno
45
+ return (
46
+ base
47
+ + f"\n\nInsert a docstring on line {insert_line} (first line of the class body)."
48
+ + "\n\nA good class docstring describes the class purpose in one sentence."
49
+ )
50
+ if (
51
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
52
+ and node.lineno == line
53
+ ):
54
+ insert_line = node.body[0].lineno
55
+ params = [a.arg for a in node.args.args if a.arg not in _SKIP_PARAMS]
56
+ returns = ast.unparse(node.returns) if node.returns else None
57
+
58
+ hint = f"\n\nInsert a docstring on line {insert_line} (first line of the function body)."
59
+ hint += "\n\nA good one-line docstring describes what the function does (not how)."
60
+
61
+ indent = " "
62
+ if returns:
63
+ hint += f' Return annotation is `{returns}` - start the docstring with a verb like "Return ...".'
64
+ hint += f'\n\nExample:\n```python\n{indent}"""Return the <value> for <reason>."""\n```'
65
+ else:
66
+ hint += f'\n\nExample:\n```python\n{indent}"""Do <action> and return <result>."""\n```'
67
+
68
+ if params:
69
+ hint += f"\n\nDocument non-obvious parameters: {', '.join(f'`{p}`' for p in params)}."
70
+
71
+ return base + hint
72
+
73
+ return base + "\n\nInsert a docstring as the first statement in the body."
74
+
75
+ _ROW_RE = re.compile(
76
+ r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|"
77
+ )
78
+ _CONTEXT_PATH_RE = re.compile(r'interrogate\s+"([^"]+)"')
79
+ _COVERAGE_FOR_RE = re.compile(r"Coverage for\s+(.+?)[\s=]*$")
80
+
81
+
82
+ def _load_interrogate_cfg(context_path: str) -> dict:
83
+ """Read [tool.interrogate] from the nearest pyproject.toml."""
84
+ p = Path(context_path).resolve()
85
+ for candidate in [p, *p.parents]:
86
+ pyproject = (candidate if candidate.is_dir() else candidate.parent) / "pyproject.toml"
87
+ if pyproject.exists():
88
+ try:
89
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
90
+ return data.get("tool", {}).get("interrogate", {})
91
+ except Exception:
92
+ return {}
93
+ return {}
94
+
95
+
96
+ def _skip_node(name: str, cfg: dict) -> bool:
97
+ """Return True if interrogate would skip this name given cfg."""
98
+ is_magic = name.startswith("__") and name.endswith("__")
99
+ is_private = name.startswith("__") and not name.endswith("__")
100
+ is_semiprivate = name.startswith("_") and not name.startswith("__")
101
+ if name == "__init__" and cfg.get("ignore-init-method"):
102
+ return True
103
+ if is_magic and cfg.get("ignore-magic"):
104
+ return True
105
+ if is_private and cfg.get("ignore-private"):
106
+ return True
107
+ if is_semiprivate and cfg.get("ignore-semiprivate"):
108
+ return True
109
+ return False
110
+
111
+
112
+ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[int, str, str]]:
113
+ """Return (line, kind, source_line) for each node missing a docstring.
114
+
115
+ kind is 'module', 'def', or 'class'. source_line is the verbatim text of
116
+ the def/class line (for searching), or the first non-empty source line for
117
+ the module.
118
+ """
119
+ try:
120
+ source = file_path.read_text(encoding="utf-8")
121
+ tree = ast.parse(source, filename=str(file_path))
122
+ except (OSError, SyntaxError):
123
+ return []
124
+ src_lines = source.splitlines()
125
+
126
+ def src_line(lineno: int) -> str:
127
+ """Return the stripped content of the source line at the given line number."""
128
+ return src_lines[lineno - 1].strip() if 0 < lineno <= len(src_lines) else ""
129
+
130
+ def first_code_line() -> str:
131
+ """Return the first non-empty, non-comment line of the source."""
132
+ for ln in src_lines:
133
+ stripped = ln.strip()
134
+ if stripped and not stripped.startswith("#"):
135
+ return stripped
136
+ return ""
137
+
138
+ effective_cfg = cfg or {}
139
+ results = []
140
+ for node in ast.walk(tree):
141
+ if isinstance(node, ast.Module):
142
+ if not ast.get_docstring(node):
143
+ results.append((0, "module", first_code_line()))
144
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
145
+ if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
146
+ results.append((node.lineno, "def", src_line(node.lineno)))
147
+ elif isinstance(node, ast.ClassDef):
148
+ if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
149
+ results.append((node.lineno, "class", src_line(node.lineno)))
150
+ results.sort(key=lambda x: x[0])
151
+ return results
152
+
153
+
154
+ def _is_file_empty(path: Path | None) -> bool:
155
+ """Check if the file is empty."""
156
+ if path is None:
157
+ return False
158
+ try:
159
+ return not path.read_text(encoding="utf-8").strip()
160
+ except OSError:
161
+ return False
162
+
163
+
164
+ def _resolve(context_path: str, rel_file: str) -> Path | None:
165
+ """Return the first existing Path for rel_file, trying context_path then cwd."""
166
+ via_context = Path(context_path) / rel_file
167
+ if via_context.exists():
168
+ return via_context
169
+ # When context_path is a file, interrogate reports only the basename.
170
+ # Try the parent directory so "dig.py" resolves against "D:/.../dig.py".
171
+ via_parent = Path(context_path).parent / rel_file
172
+ if via_parent.exists():
173
+ return via_parent
174
+ direct = Path(rel_file)
175
+ if direct.exists():
176
+ return direct
177
+ return None
18
178
 
19
179
 
20
180
  class InterrogateParser(AbstractParser):
21
181
  """Parses raw output from ``interrogate -v`` into a ToolResult."""
22
182
 
23
183
  def parse(self, raw_result: RawResult) -> ToolResult:
24
- files: dict[str, dict] = {}
25
- total_coverage: float | None = None
184
+ cm = _CONTEXT_PATH_RE.search(raw_result.command)
185
+ context_path = cm.group(1) if cm else "."
186
+ interrogate_cfg = _load_interrogate_cfg(context_path)
187
+
188
+ # Interrogate reports paths relative to the package root it discovers,
189
+ # not relative to context_path. Parse the "Coverage for <dir>" header
190
+ # to compute the prefix needed to make paths project-relative.
191
+ prefix = ""
192
+ for line in (raw_result.stdout or "").splitlines():
193
+ hm = _COVERAGE_FOR_RE.search(line)
194
+ if hm:
195
+ coverage_root = hm.group(1).strip().rstrip("\\/")
196
+ try:
197
+ rel = (
198
+ Path(coverage_root)
199
+ .resolve()
200
+ .relative_to(Path(context_path).resolve())
201
+ )
202
+ prefix = rel.as_posix()
203
+ except ValueError:
204
+ pass
205
+ break
206
+
207
+ summaries: dict[str, dict] = {}
26
208
  for line in (raw_result.stdout or "").splitlines():
27
209
  m = _ROW_RE.match(line)
28
210
  if not m:
29
211
  continue
30
- name = m.group(1).strip()
212
+ name = m.group(1).strip().replace("\\", "/")
31
213
  total = int(m.group(2))
32
214
  miss = int(m.group(3))
33
215
  cover = float(m.group(4))
34
- if name == "TOTAL":
35
- total_coverage = cover / 100.0
36
- elif total > 0:
37
- files[name.replace("\\", "/")] = {
216
+ if name != "TOTAL" and total > 0 and ".venv" not in name:
217
+ file_key = f"{prefix}/{name}" if prefix else name
218
+ summaries[file_key] = {
38
219
  "total": total,
39
220
  "missing": miss,
40
221
  "coverage": cover / 100.0,
41
222
  }
42
- score = total_coverage if total_coverage is not None else 1.0
43
- return ToolResult(raw=raw_result, metrics={"doc_coverage": score}, details=files)
44
-
45
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
46
- score = tr.metrics.get("doc_coverage", 0)
47
- uncovered = sorted(
48
- [(f, d) for f, d in tr.details.items() if isinstance(d, dict) and d.get("missing", 0) > 0],
49
- key=lambda x: x[1].get("coverage", 0.0),
50
- )[:5]
51
- if not uncovered:
223
+
224
+ if self.parser_config.get("skip_empty_init", True):
225
+ summaries = {
226
+ f: d
227
+ for f, d in summaries.items()
228
+ if not (
229
+ Path(f).name == "__init__.py"
230
+ and _is_file_empty(_resolve(context_path, f))
231
+ )
232
+ }
233
+
234
+ total_docs = sum(d["total"] for d in summaries.values())
235
+ missing_docs = sum(d["missing"] for d in summaries.values())
236
+ score = (total_docs - missing_docs) / total_docs if total_docs > 0 else 1.0
237
+
238
+ # Build per-issue list details (sorted worst-first) so _fingerprint_from_slice
239
+ # can produce a specific line+code fingerprint for is_fixed checks.
240
+ files: dict[str, list] = {}
241
+ for rel_file, summary in sorted(
242
+ summaries.items(), key=lambda x: x[1]["coverage"]
243
+ ):
244
+ if summary["missing"] == 0:
245
+ continue
246
+ resolved = _resolve(context_path, rel_file)
247
+ nodes = _missing_docstrings(resolved, interrogate_cfg) if resolved else []
248
+ issues = []
249
+ for lineno, kind, source_line in nodes:
250
+ if kind == "module":
251
+ code, message = "D100", "missing module docstring"
252
+ lineno = 1
253
+ elif kind == "class":
254
+ nm = re.search(r"class\s+(\w+)", source_line)
255
+ name = nm.group(1) if nm else source_line
256
+ code, message = "D101", f"missing docstring in class `{name}`"
257
+ else:
258
+ nm = re.search(r"def\s+(\w+)", source_line)
259
+ name = nm.group(1) if nm else source_line
260
+ code, message = "D103", f"missing docstring in function `{name}`"
261
+ issues.append(
262
+ {
263
+ "line": lineno if lineno > 0 else 1,
264
+ "code": code,
265
+ "message": message,
266
+ }
267
+ )
268
+ if issues:
269
+ files[rel_file] = issues
270
+
271
+ return ToolResult(
272
+ raw=raw_result, metrics={"doc_coverage": score}, details=files
273
+ )
274
+
275
+ def format_llm_message(
276
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
277
+ ) -> str:
278
+ result = extract_first_issue(tr.details)
279
+ if result is None:
280
+ score = tr.metrics.get("doc_coverage", 0)
52
281
  return f"**doc_coverage** score: {score:.3f}"
53
- lines = [f"**doc coverage** {score:.1%} — files with most missing docstrings:"]
54
- for path, data in uncovered:
55
- miss = data.get("missing", 0)
56
- pct = data.get("coverage", 0.0)
57
- lines.append(f"- `{path}`: {pct:.0%} ({miss} undocumented)")
58
- return "\n".join(lines)
282
+
283
+ m = _CONTEXT_PATH_RE.search(tr.raw.command)
284
+ context_path = m.group(1) if m else "."
285
+
286
+ rel_file, issue = result
287
+ line = issue.get("line", 1)
288
+ code = issue.get("code", "D100")
289
+ message = issue.get("message", "missing docstring")
290
+
291
+ resolved = _resolve(context_path, rel_file)
292
+ file_str = str(resolved) if resolved else rel_file
293
+
294
+ return _format_missing_docstring(file_str, line, code, message)
@@ -12,13 +12,21 @@ class LineCountParser(AbstractParser):
12
12
  """
13
13
 
14
14
  def parse(self, raw_result: RawResult) -> ToolResult:
15
+ """
16
+ Parse the raw result to count non-empty lines and calculate the violation score.
17
+ """
15
18
  lines = [ln for ln in (raw_result.stdout or "").splitlines() if ln.strip()]
16
19
  count = len(lines)
17
20
  scale = self.parser_config.get("scale_factor", 15)
18
21
  score = score_logistic_variant(count, scale_factor=scale)
19
- return ToolResult(raw=raw_result, metrics={"violations": score}, details={"lines": lines})
22
+ return ToolResult(
23
+ raw=raw_result, metrics={"violations": score}, details={"lines": lines}
24
+ )
20
25
 
21
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
26
+ def format_llm_message(
27
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
28
+ ) -> str:
29
+ """Formats the ToolResult lines into a string for the LLM message."""
22
30
  lines = tr.details.get("lines", [])
23
31
  if not lines:
24
32
  return "No violations found"
@@ -4,10 +4,8 @@ The `MaintainabilityParser` implements the `AbstractParser` interface to
4
4
  convert raw JSON output from a maintainability tool into a
5
5
  `ToolResult` structure that other components of the framework can consume."""
6
6
 
7
- import json
8
-
9
7
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
10
- from py_cq.parsers.common import score_logistic_variant
8
+ from py_cq.parsers.common import parse_json_dict, score_logistic_variant
11
9
 
12
10
 
13
11
  class MaintainabilityParser(AbstractParser):
@@ -41,12 +39,8 @@ class MaintainabilityParser(AbstractParser):
41
39
  * ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
42
40
  * ``details['return_code']`` - the tool's exit code."""
43
41
  tr = ToolResult(raw=raw_result)
44
- try:
45
- data = json.loads(raw_result.stdout)
46
- except (json.JSONDecodeError, ValueError):
47
- tr.metrics["maintainability"] = 0.0
48
- return tr
49
- if not isinstance(data, dict):
42
+ data = parse_json_dict(raw_result.stdout)
43
+ if data is None:
50
44
  tr.metrics["maintainability"] = 0.0
51
45
  return tr
52
46
  num_items = 0
@@ -68,3 +62,32 @@ class MaintainabilityParser(AbstractParser):
68
62
  }
69
63
  tr.metrics["maintainability"] = score / num_items if num_items > 0 else 0.0
70
64
  return tr
65
+
66
+ def format_llm_message(
67
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
68
+ ) -> str:
69
+ """Format the LLM message with details about the worst maintainability scores."""
70
+ worst_file = worst_rank = None
71
+ worst_score = 1.0
72
+ for file, data in tr.details.items():
73
+ if not isinstance(data, dict):
74
+ continue
75
+ error = data.get("error")
76
+ score = data.get("mi", 1.0)
77
+ if score < worst_score:
78
+ worst_score = score
79
+ worst_file = file
80
+ worst_rank = data.get("rank", "F")
81
+ if error:
82
+ worst_rank = f"F (error: {error})"
83
+ if worst_file is None:
84
+ if tr.metrics:
85
+ metric_name, value = next(iter(tr.metrics.items()))
86
+ return f"**{metric_name}** score: {value:.3f}"
87
+ return "No maintainability details available"
88
+ return (
89
+ f"`{worst_file}` - maintainability rank **{worst_rank}**\n\n"
90
+ "The maintainability index is low. Common causes: long functions, high complexity, "
91
+ "deeply nested logic, or lack of comments. Refactor by extracting helpers and "
92
+ "simplifying control flow."
93
+ )
@@ -8,11 +8,24 @@ process return code so downstream components can uniformly consume results
8
8
  from multiple test tools. It is part of the test-collection framework and
9
9
  enables consistent handling of pytest output across the system."""
10
10
 
11
+ import functools
11
12
  import re as _re
13
+ from pathlib import Path as _Path
12
14
 
13
15
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
14
16
 
15
17
 
18
+ @functools.lru_cache(maxsize=64)
19
+ def _section_pattern(test_name: str) -> _re.Pattern:
20
+ return _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
21
+
22
+
23
+ def _target_dir(command: str) -> str:
24
+ """Extract the --directory value from a uv run command, or ''."""
25
+ m = _re.search(r'--directory\s+"?([^"\s]+)"?', command)
26
+ return m.group(1) if m else ""
27
+
28
+
16
29
  def _last_call_line_for_test(stdout: str, test_name: str) -> str:
17
30
  """Return the last source line before E-lines in a test's failure section.
18
31
 
@@ -20,7 +33,7 @@ def _last_call_line_for_test(stdout: str, test_name: str) -> str:
20
33
  current-executing-line marker.
21
34
  """
22
35
  lines = stdout.splitlines()
23
- pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
36
+ pattern = _section_pattern(test_name)
24
37
  in_section = False
25
38
  last_src = ""
26
39
  for line in lines:
@@ -68,7 +81,7 @@ def _extract_collection_error(stdout: str) -> dict | None:
68
81
  def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
69
82
  """Extract the failure section for test_name from pytest stdout."""
70
83
  lines = stdout.splitlines()
71
- pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
84
+ pattern = _section_pattern(test_name)
72
85
  start = None
73
86
  for i, line in enumerate(lines):
74
87
  if pattern.search(line):
@@ -76,14 +89,37 @@ def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
76
89
  break
77
90
  if start is None:
78
91
  return ""
79
- collected = []
92
+ # Collect the full block - skip sub-section dividers ("_ _ _") but stop at
93
+ # the next test header ("____") or summary line ("====").
94
+ section = []
80
95
  for line in lines[start:]:
81
- if line.strip().startswith("_") or line.strip().startswith("="):
82
- break
83
- collected.append(line)
84
- if len(collected) >= max_lines:
96
+ stripped = line.strip()
97
+ if _re.match(r"_{4,}", stripped) or stripped.startswith("="):
85
98
  break
86
- text = "\n".join(collected).strip()
99
+ section.append(line)
100
+ # Prefer E-lines (the actual assertion / exception messages).
101
+ e_lines = [ln for ln in section if ln.startswith("E ") or ln.strip() == "E"]
102
+ if e_lines:
103
+ arrow_lines = [
104
+ ln.lstrip("> \t")
105
+ for ln in section
106
+ if ln.startswith(">") and ln.lstrip("> \t")
107
+ ]
108
+ cleaned = [_re.sub(r"^E\s*", "", ln) for ln in e_lines]
109
+ # Drop "At index N diff:" lines - always redundant with the first E-line.
110
+ cleaned = [ln for ln in cleaned if not _re.match(r"At index \d+ diff:", ln)]
111
+ parts = ([f"> {arrow_lines[-1]}"] if arrow_lines else []) + cleaned
112
+ text = "\n".join(parts[:max_lines]).strip()
113
+ else:
114
+ # Fall back: show the traceback up to max_lines, stopping before E-lines.
115
+ sub = []
116
+ for line in section:
117
+ if line.startswith("E ") or line.strip() == "E":
118
+ break
119
+ sub.append(line)
120
+ if len(sub) >= max_lines:
121
+ break
122
+ text = "\n".join(sub).strip()
87
123
  return f"\n```\n{text}\n```" if text else ""
88
124
 
89
125
 
@@ -132,7 +168,10 @@ class PytestParser(AbstractParser):
132
168
  passed_tests = 0
133
169
  for line in lines:
134
170
  # tests/test_common.py::test_name[param] PASSED [ 8%]
135
- tests_match = _re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
171
+ tests_match = _re.search(
172
+ r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)",
173
+ line,
174
+ )
136
175
  if tests_match:
137
176
  test_file = tests_match.group(1)
138
177
  test_name = tests_match.group(2).strip()
@@ -144,7 +183,9 @@ class PytestParser(AbstractParser):
144
183
  if num_tests == 0:
145
184
  # No individual test lines found (e.g. non-verbose output);
146
185
  # fall back to parsing the pytest summary line.
147
- summary = _re.search(r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout)
186
+ summary = _re.search(
187
+ r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout
188
+ )
148
189
  if summary:
149
190
  passed_tests = int(summary.group(1))
150
191
  failed_tests = int(summary.group(2)) if summary.group(2) else 0
@@ -153,29 +194,40 @@ class PytestParser(AbstractParser):
153
194
  tr.details = tests_found
154
195
  return tr
155
196
 
156
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
197
+ def format_llm_message(
198
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
199
+ ) -> str:
157
200
  """Return the first failing test with function body, failure output, and callee signature."""
158
201
  from py_cq.parsers.common import (
159
202
  extract_callee_name,
160
203
  find_function_source,
161
204
  format_callee_context,
162
205
  )
206
+
163
207
  for file, tests in tr.details.items():
164
208
  if not isinstance(tests, dict):
165
209
  continue
166
210
  for test_name, status in tests.items():
167
211
  if status != "FAILED":
168
212
  continue
169
- header = f"`{file}::{test_name}` test **FAILED**"
213
+ header = f"`{file}::{test_name}` - test **FAILED**"
170
214
  bare_name = test_name.split("[")[0]
171
- body = find_function_source(file, bare_name, max_lines=context_lines)
172
- failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
215
+ tdir = _target_dir(tr.raw.command)
216
+ resolved = (
217
+ (_Path(tdir) / file).as_posix()
218
+ if tdir and not _Path(file).is_absolute()
219
+ else file
220
+ )
221
+ body = find_function_source(
222
+ resolved, bare_name, max_lines=context_lines
223
+ )
224
+ failure = _extract_failure(tr.raw.stdout, test_name, max_lines=50)
173
225
  callee = ""
174
226
  call_line = _last_call_line_for_test(tr.raw.stdout, test_name)
175
227
  if call_line:
176
228
  func_name = extract_callee_name(call_line)
177
229
  if func_name and func_name != bare_name:
178
- callee = format_callee_context(func_name, file)
230
+ callee = format_callee_context(func_name, resolved)
179
231
  parts = [header]
180
232
  if body:
181
233
  parts.append(body)
@@ -186,19 +238,24 @@ class PytestParser(AbstractParser):
186
238
  return "\n".join(parts)
187
239
  if "no tests ran" in tr.raw.stdout:
188
240
  return (
189
- "**No tests found.** This project has no pytest test suite.\n\n"
190
- "Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
191
- "and write a first test covering a core function."
241
+ "**No tests found.** pytest ran but collected nothing.\n\n"
242
+ "Create `tests/test_basic.py` and write a first test covering a core function."
192
243
  )
193
244
  from py_cq.parsers.common import (
194
245
  extract_callee_name,
195
246
  format_callee_context,
196
247
  format_source_context,
197
248
  )
249
+
198
250
  combined = tr.raw.stdout + tr.raw.stderr
199
251
  err = _extract_collection_error(combined)
200
252
  if err:
201
- file, line, typ, help_msg = err["file"], err["line"], err["type"], err["help"]
253
+ file, line, typ, help_msg = (
254
+ err["file"],
255
+ err["line"],
256
+ err["type"],
257
+ err["help"],
258
+ )
202
259
  code_block = format_source_context(file, line, count=context_lines) or ""
203
260
  callee = ""
204
261
  # try to find callee from the offending source line via format_source_context result
@@ -211,7 +268,7 @@ class PytestParser(AbstractParser):
211
268
  func_name = extract_callee_name(src_line)
212
269
  if func_name:
213
270
  callee = format_callee_context(func_name, file)
214
- return f"`{file}:{line}` **{typ}**: {help_msg}{code_block}{callee}"
271
+ return f"`{file}:{line}` - **{typ}**: {help_msg}{code_block}{callee}"
215
272
  output = combined.strip()
216
273
  if output:
217
274
  tail = "\n".join(output.splitlines()[-30:])
@@ -1,5 +1,6 @@
1
1
  """Parser that counts stdout lines matching a regex pattern."""
2
2
 
3
+ import functools
3
4
  import re
4
5
 
5
6
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
@@ -14,11 +15,17 @@ class RegexCountParser(AbstractParser):
14
15
  scale_factor (int, default 15): passed to score_logistic_variant.
15
16
  """
16
17
 
18
+ @functools.cached_property
19
+ def _pattern(self) -> re.Pattern:
20
+ return re.compile(self.parser_config["pattern"])
21
+
17
22
  def parse(self, raw_result: RawResult) -> ToolResult:
18
- pattern = re.compile(self.parser_config["pattern"])
23
+ """
24
+ Parses the raw result using a regex pattern and computes a score.
25
+ """
19
26
  scale = self.parser_config.get("scale_factor", 15)
20
27
  lines = (raw_result.stdout or "").splitlines()
21
- matches = [ln for ln in lines if pattern.search(ln)]
28
+ matches = [ln for ln in lines if self._pattern.search(ln)]
22
29
  count = len(matches)
23
30
  score = score_logistic_variant(count, scale_factor=scale)
24
31
  return ToolResult(
@@ -27,7 +34,10 @@ class RegexCountParser(AbstractParser):
27
34
  details={"count": count, "matches": matches},
28
35
  )
29
36
 
30
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
37
+ def format_llm_message(
38
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
39
+ ) -> str:
40
+ """Formats the LLM message with match details."""
31
41
  matches = tr.details.get("matches", [])
32
42
  if not matches:
33
43
  return "No violations found"