python-code-quality 0.1.14__tar.gz → 0.1.16__tar.gz

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.
Files changed (33) hide show
  1. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/PKG-INFO +6 -2
  2. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/README.md +5 -1
  3. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/pyproject.toml +10 -1
  4. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/cli.py +35 -43
  5. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/main.py +1 -1
  6. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/banditparser.py +6 -0
  7. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/common.py +104 -7
  8. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/compileparser.py +20 -6
  9. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/complexityparser.py +8 -1
  10. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/coverageparser.py +3 -3
  11. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/halsteadparser.py +8 -1
  12. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/interrogateparser.py +4 -4
  13. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/maintainabilityparser.py +8 -1
  14. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/pytestparser.py +91 -4
  15. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/ruffparser.py +4 -0
  16. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/typarser.py +23 -1
  17. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/vultureparser.py +4 -0
  18. python_code_quality-0.1.16/src/py_cq/table_formatter.py +29 -0
  19. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/__init__.py +0 -0
  20. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/config/__init__.py +0 -0
  21. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/config/config.yaml +0 -0
  22. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/context_hash.py +0 -0
  23. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/execution_engine.py +0 -0
  24. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/language_detector.py +0 -0
  25. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/llm_formatter.py +0 -0
  26. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/localtypes.py +0 -0
  27. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/metric_aggregator.py +0 -0
  28. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/__init__.py +0 -0
  29. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/exitcodeparser.py +0 -0
  30. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/linecountparser.py +0 -0
  31. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/parsers/regexcountparser.py +0 -0
  32. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/py.typed +0 -0
  33. {python_code_quality-0.1.14 → python_code_quality-0.1.16}/src/py_cq/tool_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.1.14
3
+ Version: 0.1.16
4
4
  Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
5
5
  Author: Chris Kilner
6
6
  Author-email: Chris Kilner <chris@rhiza.fr>
@@ -37,6 +37,10 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  Run 11+ code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
39
39
 
40
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
41
+
42
+ Note: It never edits your files. This is a job for you or an LLM. You may wish to run `ruff check --fix` and `ruff format` first.
43
+
40
44
  ```bash
41
45
  cq check . -o llm # top defect as markdown, pipe to an LLM
42
46
  cq check . # table overview of all scores
@@ -175,7 +179,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
175
179
  ```bash
176
180
  > cq check . -o score
177
181
  ```
178
- ```python
182
+ ```
179
183
  0.9662730667181059 # this is designed to approach but not reach 1.0
180
184
  ```
181
185
 
@@ -8,6 +8,10 @@
8
8
 
9
9
  Run 11+ code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
10
10
 
11
+ This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
12
+
13
+ Note: It never edits your files. This is a job for you or an LLM. You may wish to run `ruff check --fix` and `ruff format` first.
14
+
11
15
  ```bash
12
16
  cq check . -o llm # top defect as markdown, pipe to an LLM
13
17
  cq check . # table overview of all scores
@@ -146,7 +150,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
146
150
  ```bash
147
151
  > cq check . -o score
148
152
  ```
149
- ```python
153
+ ```
150
154
  0.9662730667181059 # this is designed to approach but not reach 1.0
151
155
  ```
152
156
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-code-quality"
3
- version = "0.1.14"
3
+ version = "0.1.16"
4
4
  description = "Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -47,5 +47,14 @@ cq = "py_cq.main:main"
47
47
  testpaths = ["tests"]
48
48
  norecursedirs = [".venv", "dist"]
49
49
 
50
+ [[tool.ty.overrides]]
51
+ include = ["demo/**"]
52
+ rules = {unresolved-import = "ignore"}
53
+
50
54
  [tool.cq]
51
55
  exclude = ["demo"]
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "hypothesis>=6.151.10",
60
+ ]
@@ -10,13 +10,14 @@ analysis.
10
10
  Helper functions such as `format_as_table` convert the aggregated tool
11
11
  results into a Rich Table for convenient console display.
12
12
  """
13
-
14
13
  import copy
14
+ import io
15
15
  import json
16
16
  import logging
17
17
  import tomllib
18
18
  from enum import Enum
19
19
  from importlib import import_module
20
+ from importlib.metadata import requires, version
20
21
  from pathlib import Path
21
22
 
22
23
  import typer
@@ -28,8 +29,9 @@ from py_cq.config import load_user_config
28
29
  from py_cq.execution_engine import _cache as tool_cache
29
30
  from py_cq.execution_engine import run_tools
30
31
  from py_cq.language_detector import detect_language
31
- from py_cq.localtypes import CombinedToolResults, ToolConfig
32
+ from py_cq.localtypes import ToolConfig
32
33
  from py_cq.metric_aggregator import aggregate_metrics
34
+ from py_cq.table_formatter import format_as_table
33
35
  from py_cq.tool_registry import tool_registry
34
36
 
35
37
  logging.basicConfig(
@@ -100,8 +102,36 @@ class OutputMode(str, Enum):
100
102
  RAW = "raw"
101
103
 
102
104
 
105
+ def _version_callback(value: bool) -> None:
106
+ if not value:
107
+ return
108
+ import re
109
+ import sys
110
+ if isinstance(sys.stdout, io.TextIOWrapper): # pragma: no branch
111
+ sys.stdout.reconfigure(encoding="utf-8")
112
+ pkg = "python-code-quality"
113
+ pkg_version = version(pkg)
114
+ dep_versions: list[tuple[str, str]] = []
115
+ for req in (requires(pkg) or []):
116
+ if "; extra ==" in req:
117
+ continue
118
+ dep_name = re.split(r"[>=<!;\s\[]", req)[0]
119
+ try:
120
+ dep_versions.append((dep_name, version(dep_name)))
121
+ except Exception:
122
+ pass
123
+ typer.echo(f"{pkg} v{pkg_version}")
124
+ for dep_name, dep_ver in sorted(dep_versions):
125
+ typer.echo(f"\u251c\u2500\u2500 {dep_name} v{dep_ver}")
126
+ raise typer.Exit()
127
+
128
+
103
129
  @app.callback()
104
- def callback():
130
+ def callback(
131
+ _: bool = typer.Option(
132
+ False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and dependencies"
133
+ ),
134
+ ) -> None:
105
135
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
106
136
  console = Console()
107
137
 
@@ -156,7 +186,7 @@ def check(
156
186
  if path_obj.is_file():
157
187
  if path_obj.suffix != ".py":
158
188
  raise typer.BadParameter(f"File must be a Python file (.py): {path}")
159
- elif path_obj.is_dir():
189
+ elif path_obj.is_dir(): # pragma: no branch
160
190
  if not (path_obj / "pyproject.toml").exists():
161
191
  raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
162
192
  log.setLevel(log_level)
@@ -187,7 +217,7 @@ def check(
187
217
  elif output == OutputMode.LLM:
188
218
  # log.setLevel("CRITICAL")
189
219
  from py_cq.llm_formatter import format_for_llm
190
- console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
220
+ print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
191
221
  else:
192
222
  console.print(f"[bold green]{path_obj.resolve()}[/]")
193
223
  console.print(format_as_table(combined_metrics, effective_registry))
@@ -255,41 +285,3 @@ def config(
255
285
  console.print(table)
256
286
 
257
287
 
258
- def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
259
- """Format combined tool results into a Rich Table.
260
-
261
- Args:
262
- data (CombinedToolResults): Aggregated tool results, including the path,
263
- individual tool results, and the overall score.
264
-
265
- Returns:
266
- rich.table.Table: A Rich table with columns ``Tool``, ``Metric``, ``Score`` and
267
- ``Status``. Each metric row displays a status icon based on thresholds from
268
- the tool's configuration. The table is titled with the data path and ends
269
- with a row showing the overall score.
270
-
271
- Example:
272
- >>> table = format_as_table(combined_results)
273
- >>> console.print(table)
274
- """
275
- table = Table(width=80)
276
- table.add_column("Tool", justify="left", no_wrap=True)
277
- table.add_column("Time", justify="right", style="dim")
278
- table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
279
- table.add_column("Score", style="magenta")
280
- table.add_column("Status")
281
- for tr in data.tool_results:
282
- tool_name = tr.raw.tool_name
283
- config = next((t for t in registry.values() if t.name == tool_name))
284
- for i, (name, value) in enumerate(tr.metrics.items()):
285
- status = ""
286
- if value < config.error_threshold:
287
- status = "[bold red]Error[/]"
288
- elif value < config.warning_threshold:
289
- status = "[yellow]Warning[/]"
290
- else:
291
- status = "[green]OK[/]"
292
- time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
293
- table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
294
- table.add_row("", "", "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", "")
295
- return table
@@ -8,5 +8,5 @@ def main():
8
8
  app()
9
9
 
10
10
 
11
- if __name__ == "__main__":
11
+ if __name__ == "__main__": # pragma: no cover
12
12
  main()
@@ -22,6 +22,8 @@ class BanditParser(AbstractParser):
22
22
  data = json.loads(raw_result.stdout)
23
23
  except (json.JSONDecodeError, ValueError):
24
24
  return ToolResult(raw=raw_result, metrics={"security": 1.0})
25
+ if not isinstance(data, dict):
26
+ return ToolResult(raw=raw_result, metrics={"security": 1.0})
25
27
 
26
28
  files: dict[str, list] = {}
27
29
  weighted = 0
@@ -46,7 +48,11 @@ class BanditParser(AbstractParser):
46
48
  if not tr.details:
47
49
  return "bandit reported issues (no details available)"
48
50
  file, issues = next(iter(tr.details.items()))
51
+ if not isinstance(issues, list) or not issues:
52
+ return "bandit reported issues (no details available)"
49
53
  issue = issues[0]
54
+ if not isinstance(issue, dict):
55
+ return "bandit reported issues (no details available)"
50
56
  line = issue.get("line", "?")
51
57
  code = issue.get("code", "")
52
58
  severity = issue.get("severity", "")
@@ -12,39 +12,134 @@ performance metrics or error scores:
12
12
  Both functions return a float and can be used directly in downstream analytics,
13
13
  visualisation or decision-making pipelines."""
14
14
 
15
+ import re
16
+ from pathlib import Path
15
17
 
16
18
 
17
19
  def read_source_lines(file_path: str, line: int, count: int = 5) -> str:
18
20
  """Return up to `count` source lines starting at the given 1-based line number."""
19
- from pathlib import Path
20
21
  try:
21
22
  all_lines = Path(file_path).read_text(encoding="utf-8").splitlines()
22
23
  start = max(0, line - 1)
23
24
  return "\n".join(all_lines[start : start + count])
24
- except OSError:
25
+ except (OSError, ValueError):
25
26
  return ""
26
27
 
27
28
 
28
29
  def format_source_context(file: str, line: int | str, context: int = 3, count: int = 8) -> str:
29
- """Return a fenced python code block for the source around `line`, or '' if unavailable."""
30
+ """Return a fenced python code block for the source around `line`, or '' if unavailable.
31
+
32
+ Stops before spilling into the next top-level ``def`` or ``class`` definition.
33
+ """
30
34
  if not isinstance(line, int):
31
35
  return ""
32
36
  context_start = max(1, line - context)
33
37
  raw_lines = read_source_lines(file, context_start, count=count).splitlines()
34
38
  if not raw_lines:
35
39
  return ""
36
- src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines))
40
+ error_offset = line - context_start # 0-based index of the error line in raw_lines
41
+ collected = []
42
+ for i, rline in enumerate(raw_lines):
43
+ if i > error_offset and (
44
+ rline.startswith("def ")
45
+ or rline.startswith("async def ")
46
+ or rline.startswith("class ")
47
+ ):
48
+ break
49
+ collected.append(f"{context_start + i}: {rline}")
50
+ src = "\n".join(collected)
37
51
  return f"\n```python\n{src}\n```"
38
52
 
39
53
 
54
+ _PYTHON_KEYWORDS = frozenset([
55
+ "if", "elif", "else", "for", "while", "with", "assert", "return",
56
+ "raise", "import", "from", "class", "def", "lambda", "yield",
57
+ "del", "pass", "break", "continue", "not", "and", "or", "in", "is",
58
+ "print", "super", "type", "len", "range",
59
+ ])
60
+
61
+
62
+ def extract_callee_name(source_line: str) -> str | None:
63
+ """Extract the primary callee function name from a source line, or None.
64
+
65
+ Prefers the RHS of an assignment so that ``result = func(...)`` returns
66
+ ``func`` rather than the variable on the left. Python keywords and
67
+ built-ins listed in ``_PYTHON_KEYWORDS`` are excluded.
68
+ """
69
+ stripped = source_line.strip()
70
+ rhs = stripped
71
+ if "=" in stripped and not stripped.startswith(("assert", "return")):
72
+ rhs = stripped.split("=", 1)[1].strip()
73
+ m = re.search(r"\b([a-zA-Z_]\w*)\s*\(", rhs)
74
+ if m and m.group(1) not in _PYTHON_KEYWORDS:
75
+ return m.group(1)
76
+ return None
77
+
78
+
79
+ def _find_project_root(hint_file: str) -> Path:
80
+ root = Path(hint_file).resolve().parent
81
+ current = root
82
+ for _ in range(8):
83
+ if (current / "pyproject.toml").exists() or (current / "setup.py").exists():
84
+ return current
85
+ parent = current.parent
86
+ if parent == current:
87
+ break
88
+ current = parent
89
+ return root
90
+
91
+
92
+ def find_in_project(func_name: str, hint_file: str, max_lines: int = 10) -> tuple[str, str]:
93
+ """Find func_name definition in project files; same file first, then project-wide.
94
+
95
+ Returns ``(file_path, code_block)`` for the first match, or ``("", "")`` if not found.
96
+ """
97
+ result = find_function_source(hint_file, func_name, max_lines=max_lines)
98
+ if result:
99
+ return hint_file, result
100
+ root = _find_project_root(hint_file)
101
+ for py_file in sorted(root.rglob("*.py")):
102
+ if py_file.resolve() == Path(hint_file).resolve():
103
+ continue
104
+ r = find_function_source(str(py_file), func_name, max_lines=max_lines)
105
+ if r:
106
+ return str(py_file), r
107
+ return "", ""
108
+
109
+
110
+ def _relative_path(path: str) -> str:
111
+ """Return path relative to cwd, normalised to forward slashes."""
112
+ try:
113
+ return str(Path(path).relative_to(Path.cwd())).replace("\\", "/")
114
+ except ValueError:
115
+ return path.replace("\\", "/")
116
+
117
+
118
+ def format_callee_context(func_name: str, hint_file: str, max_lines: int = 10) -> str:
119
+ """Return a labelled callee definition block, or '' if not found in project.
120
+
121
+ Output format::
122
+
123
+ Callee `func_name` — `relative/path/to/file.py`
124
+ ```python
125
+ N: def func_name(...):
126
+ ...
127
+ ```
128
+ """
129
+ callee_file, code_block = find_in_project(func_name, hint_file, max_lines=max_lines)
130
+ if not code_block:
131
+ return ""
132
+ m = re.search(r"```python\n(\d+):", code_block)
133
+ line_ref = f":{m.group(1)}" if m else ""
134
+ return f"\n`{func_name}` is defined at: `{_relative_path(callee_file)}{line_ref}`{code_block}"
135
+
136
+
40
137
  def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
41
138
  """Return a fenced python block for the body of func_name, or '' if unavailable."""
42
- from pathlib import Path
43
139
  try:
44
140
  all_lines = Path(file).read_text(encoding="utf-8").splitlines()
45
- except OSError:
141
+ except (OSError, ValueError):
46
142
  return ""
47
- import re
48
143
  pattern = re.compile(rf"^(\s*)(?:async\s+)?def\s+{re.escape(func_name)}\s*\(")
49
144
  match_result: tuple[int, int] | None = None
50
145
  for i, line in enumerate(all_lines):
@@ -64,6 +159,8 @@ def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
64
159
  collected.append(line)
65
160
  if len(collected) >= max_lines:
66
161
  break
162
+ while collected and not collected[-1].strip():
163
+ collected.pop()
67
164
  numbered = "\n".join(f"{start_idx + 1 + i}: {ln}" for i, ln in enumerate(collected))
68
165
  return f"\n```python\n{numbered}\n```"
69
166
 
@@ -64,7 +64,10 @@ class CompileParser(AbstractParser):
64
64
  compilations += 1
65
65
  elif line.startswith("*** File "):
66
66
  # This indicates a compilation error
67
- file_path = line.split('"')[1]
67
+ parts = line.split('"')
68
+ if len(parts) < 2:
69
+ continue
70
+ file_path = parts[1]
68
71
  current_error = {"file": file_path, "error": line}
69
72
  elif current_error and line.strip():
70
73
  # Append additional error context
@@ -80,17 +83,21 @@ class CompileParser(AbstractParser):
80
83
  error_info = {}
81
84
  # Extract line number if present
82
85
  if "line " in error_lines[0]:
83
- error_info["line"] = int(
84
- error_lines[0].split("line ")[1].split(",")[0]
85
- )
86
+ try:
87
+ error_info["line"] = int(
88
+ error_lines[0].split("line ")[1].split(",")[0]
89
+ )
90
+ except ValueError:
91
+ pass
86
92
  # Get source code context if available
87
93
  if len(error_lines) > 1:
88
94
  error_info["src"] = error_lines[1].strip()
89
95
  if len(error_lines) > 3:
90
96
  if "Error:" in error_lines[3]:
91
97
  error_parts = error_lines[3].split(":")
98
+ type_tokens = error_parts[0].strip().split()
92
99
  error_info["type"] = (
93
- error_parts[0].strip().split()[-1]
100
+ type_tokens[-1] if type_tokens else "Unknown"
94
101
  ) # Gets "SyntaxError"
95
102
  error_info["help"] = ",".join(
96
103
  error_parts[1:]
@@ -125,4 +132,11 @@ class CompileParser(AbstractParser):
125
132
  typ = info.get("type", "Error")
126
133
  help_msg = info.get("help", "")
127
134
  code_block = format_source_context(file, line, count=context_lines) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
128
- return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
135
+ callee = ""
136
+ src_line = info.get("src", "")
137
+ if src_line:
138
+ from py_cq.parsers.common import extract_callee_name, format_callee_context
139
+ func_name = extract_callee_name(src_line)
140
+ if func_name:
141
+ callee = format_callee_context(func_name, file)
142
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
@@ -64,7 +64,14 @@ class ComplexityParser(AbstractParser):
64
64
  >>> result.metrics["simplicity"]
65
65
  0.4"""
66
66
  tr = ToolResult(raw=raw_result)
67
- data = json.loads(raw_result.stdout)
67
+ try:
68
+ data = json.loads(raw_result.stdout)
69
+ except (json.JSONDecodeError, ValueError):
70
+ tr.metrics["simplicity"] = 0.0
71
+ return tr
72
+ if not isinstance(data, dict):
73
+ tr.metrics["simplicity"] = 0.0
74
+ return tr
68
75
  score = 0
69
76
  num_items = 0
70
77
  max_complexity = 30
@@ -76,13 +76,13 @@ class CoverageParser(AbstractParser):
76
76
  score = tr.metrics.get("coverage", 0)
77
77
  uncovered = sorted(
78
78
  [(f, d) for f, d in tr.details.items() if isinstance(d, dict) and d.get("missing")],
79
- key=lambda x: x[1]["coverage"],
79
+ key=lambda x: x[1].get("coverage", 0.0),
80
80
  )[:5]
81
81
  if not uncovered:
82
82
  return f"**coverage** score: {score:.3f}"
83
83
  lines = [f"**coverage** score: {score:.3f} — files with lowest coverage:"]
84
84
  for path, data in uncovered:
85
- pct = data["coverage"]
86
- miss = data["missing"]
85
+ pct = data.get("coverage", 0.0)
86
+ miss = data.get("missing", 0)
87
87
  lines.append(f"- `{path}`: {pct:.0%} ({miss} uncovered statements)")
88
88
  return "\n".join(lines)
@@ -59,6 +59,14 @@ class HalsteadParser(AbstractParser):
59
59
  # {"total": {"h1": 6, "h2": 18, "N1": 13, "N2": 22, "vocabulary": 24, "length": 35, "calculated_length": 90.56842503028855, "volume": 160.4736875252405, "difficulty": 3.6666666666666665, "effort": 588.4035209258818, "time": 32.68908449588233, "bugs": 0.05349122917508017},
60
60
  # "functions": {"calc_dist": {"h1": 3, "h2": 9, "N1": 5, "N2": 10, "vocabulary": 12, "length": 15, "calculated_length": 33.28421251514428, "volume": 53.77443751081735, "difficulty": 1.6666666666666667, "effort": 89.62406251802892, "time": 4.9791145843349405, "bugs": 0.017924812503605784}, "find_nearest_city": {"h1": 1, "h2": 2, "N1": 1, "N2": 2, "vocabulary": 3, "length": 3, "calculated_length": 2.0, "volume": 4.754887502163469, "difficulty": 0.5, "effort": 2.3774437510817346, "time": 0.1320802083934297, "bugs": 0.0015849625007211565}, "generate_tour": {"h1": 2, "h2": 5, "N1": 6, "N2": 8, "vocabulary": 7, "length": 14, "calculated_length": 13.60964047443681, "volume": 39.302968908806456, "difficulty": 1.6, "effort": 62.884750254090335, "time": 3.493597236338352, "bugs": 0.01310098963626882}, "main": {"h1": 0, "h2": 0, "N1": 0, "N2": 0, "vocabulary": 0, "length": 0, "calculated_length": 0, "volume": 0, "difficulty": 0, "effort": 0, "time": 0.0, "bugs": 0.0}}}
61
61
  tr = ToolResult(raw=raw_result)
62
+ try:
63
+ data = json.loads(raw_result.stdout)
64
+ except (json.JSONDecodeError, ValueError):
65
+ tr.metrics = {"file_bug_free": 1.0, "file_smallness": 1.0, "functions_bug_free": 1.0, "functions_smallness": 1.0}
66
+ return tr
67
+ if not isinstance(data, dict):
68
+ tr.metrics = {"file_bug_free": 1.0, "file_smallness": 1.0, "functions_bug_free": 1.0, "functions_smallness": 1.0}
69
+ return tr
62
70
  MAX_FILE_BUGS = 1
63
71
  MAX_FILE_VOLUME = 2000
64
72
  MAX_FUNCTION_BUGS = 0.2
@@ -67,7 +75,6 @@ class HalsteadParser(AbstractParser):
67
75
  min_file_sm = 1.0
68
76
  min_function_nb = 1.0
69
77
  min_function_sm = 1.0
70
- data = json.loads(raw_result.stdout)
71
78
  for file, values in data.items():
72
79
  file_name = file.replace("\\", "/")
73
80
  if file_name not in tr.details:
@@ -45,14 +45,14 @@ class InterrogateParser(AbstractParser):
45
45
  def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
46
46
  score = tr.metrics.get("doc_coverage", 0)
47
47
  uncovered = sorted(
48
- [(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
49
- key=lambda x: x[1]["coverage"],
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
50
  )[:5]
51
51
  if not uncovered:
52
52
  return f"**doc_coverage** score: {score:.3f}"
53
53
  lines = [f"**doc coverage** {score:.1%} — files with most missing docstrings:"]
54
54
  for path, data in uncovered:
55
- miss = data["missing"]
56
- pct = data["coverage"]
55
+ miss = data.get("missing", 0)
56
+ pct = data.get("coverage", 0.0)
57
57
  lines.append(f"- `{path}`: {pct:.0%} ({miss} undocumented)")
58
58
  return "\n".join(lines)
@@ -41,7 +41,14 @@ class MaintainabilityParser(AbstractParser):
41
41
  * ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
42
42
  * ``details['return_code']`` - the tool's exit code."""
43
43
  tr = ToolResult(raw=raw_result)
44
- data = json.loads(raw_result.stdout)
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):
50
+ tr.metrics["maintainability"] = 0.0
51
+ return tr
45
52
  num_items = 0
46
53
  score = 0
47
54
  for file, values in data.items():
@@ -13,6 +13,58 @@ import re as _re
13
13
  from py_cq.localtypes import AbstractParser, RawResult, ToolResult
14
14
 
15
15
 
16
+ def _last_call_line_for_test(stdout: str, test_name: str) -> str:
17
+ """Return the last source line before E-lines in a test's failure section.
18
+
19
+ Captures both indented context lines and pytest's ``>``-prefixed
20
+ current-executing-line marker.
21
+ """
22
+ lines = stdout.splitlines()
23
+ pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
24
+ in_section = False
25
+ last_src = ""
26
+ for line in lines:
27
+ if not in_section:
28
+ if pattern.search(line):
29
+ in_section = True
30
+ else:
31
+ stripped = line.strip()
32
+ if stripped.startswith(("_", "=")):
33
+ break
34
+ if stripped.startswith("E ") or stripped == "E":
35
+ break
36
+ if line.startswith((" ", "\t", ">")):
37
+ src = line.lstrip("> \t")
38
+ if src:
39
+ last_src = src
40
+ return last_src
41
+
42
+
43
+ _COLLECTION_FILE_RE = _re.compile(r'E\s+File "([^"]+)", line (\d+)')
44
+ _COLLECTION_ERROR_RE = _re.compile(r"E\s+(\w+(?:Error|Warning|Exception)):\s*(.*)")
45
+
46
+
47
+ def _extract_collection_error(stdout: str) -> dict | None:
48
+ """Return {file, line, type, help} if pytest stdout contains a collection error."""
49
+ file_match = None
50
+ error_match = None
51
+ for line in stdout.splitlines():
52
+ m = _COLLECTION_FILE_RE.search(line)
53
+ if m:
54
+ file_match = m
55
+ m = _COLLECTION_ERROR_RE.search(line)
56
+ if m:
57
+ error_match = m
58
+ if file_match and error_match:
59
+ return {
60
+ "file": file_match.group(1).replace("\\", "/"),
61
+ "line": int(file_match.group(2)),
62
+ "type": error_match.group(1),
63
+ "help": error_match.group(2).strip(),
64
+ }
65
+ return None
66
+
67
+
16
68
  def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
17
69
  """Extract the failure section for test_name from pytest stdout."""
18
70
  lines = stdout.splitlines()
@@ -102,8 +154,12 @@ class PytestParser(AbstractParser):
102
154
  return tr
103
155
 
104
156
  def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
105
- """Return the first failing test with function body and failure output."""
106
- from py_cq.parsers.common import find_function_source
157
+ """Return the first failing test with function body, failure output, and callee signature."""
158
+ from py_cq.parsers.common import (
159
+ extract_callee_name,
160
+ find_function_source,
161
+ format_callee_context,
162
+ )
107
163
  for file, tests in tr.details.items():
108
164
  if not isinstance(tests, dict):
109
165
  continue
@@ -111,13 +167,22 @@ class PytestParser(AbstractParser):
111
167
  if status != "FAILED":
112
168
  continue
113
169
  header = f"`{file}::{test_name}` — test **FAILED**"
114
- body = find_function_source(file, test_name, max_lines=context_lines)
170
+ bare_name = test_name.split("[")[0]
171
+ body = find_function_source(file, bare_name, max_lines=context_lines)
115
172
  failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
173
+ callee = ""
174
+ call_line = _last_call_line_for_test(tr.raw.stdout, test_name)
175
+ if call_line:
176
+ func_name = extract_callee_name(call_line)
177
+ if func_name and func_name != bare_name:
178
+ callee = format_callee_context(func_name, file)
116
179
  parts = [header]
117
180
  if body:
118
181
  parts.append(body)
119
182
  if failure:
120
183
  parts.append(failure)
184
+ if callee:
185
+ parts.append(callee)
121
186
  return "\n".join(parts)
122
187
  if "no tests ran" in tr.raw.stdout:
123
188
  return (
@@ -125,7 +190,29 @@ class PytestParser(AbstractParser):
125
190
  "Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
126
191
  "and write a first test covering a core function."
127
192
  )
128
- output = (tr.raw.stdout + tr.raw.stderr).strip()
193
+ from py_cq.parsers.common import (
194
+ extract_callee_name,
195
+ format_callee_context,
196
+ format_source_context,
197
+ )
198
+ combined = tr.raw.stdout + tr.raw.stderr
199
+ err = _extract_collection_error(combined)
200
+ if err:
201
+ file, line, typ, help_msg = err["file"], err["line"], err["type"], err["help"]
202
+ code_block = format_source_context(file, line, count=context_lines) or ""
203
+ callee = ""
204
+ # try to find callee from the offending source line via format_source_context result
205
+ src_line = ""
206
+ for ln in (tr.raw.stdout + tr.raw.stderr).splitlines():
207
+ m = _re.match(r"E\s{6,}(\S.*)", ln)
208
+ if m:
209
+ src_line = m.group(1)
210
+ if src_line:
211
+ func_name = extract_callee_name(src_line)
212
+ if func_name:
213
+ callee = format_callee_context(func_name, file)
214
+ return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}{callee}"
215
+ output = combined.strip()
129
216
  if output:
130
217
  tail = "\n".join(output.splitlines()[-30:])
131
218
  return f"pytest reported failures:\n\n```\n{tail}\n```"
@@ -50,7 +50,11 @@ class RuffParser(AbstractParser):
50
50
  if not tr.details:
51
51
  return "ruff reported issues (no details available)"
52
52
  file, issues = next(iter(tr.details.items()))
53
+ if not isinstance(issues, list) or not issues:
54
+ return "ruff reported issues (no details available)"
53
55
  issue = issues[0]
56
+ if not isinstance(issue, dict):
57
+ return "ruff reported issues (no details available)"
54
58
  line = issue.get("line", "?")
55
59
  code = issue.get("code", "")
56
60
  message = issue.get("message", "")
@@ -18,6 +18,16 @@ from py_cq.parsers.common import format_source_context, score_logistic_variant
18
18
 
19
19
  _DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
20
20
 
21
+ _CALL_CODES = frozenset([
22
+ "call-non-callable",
23
+ "missing-argument",
24
+ "unexpected-keyword",
25
+ "argument-type",
26
+ "too-many-positional-arguments",
27
+ "invalid-argument-type",
28
+ "no-matching-overload",
29
+ ])
30
+
21
31
 
22
32
  class TyParser(AbstractParser):
23
33
  """Parses raw output from ``ty check`` into a structured ToolResult."""
@@ -54,8 +64,20 @@ class TyParser(AbstractParser):
54
64
  if not tr.details:
55
65
  return "ty reported issues (no details available)"
56
66
  file, issues = next(iter(tr.details.items()))
67
+ if not isinstance(issues, list) or not issues:
68
+ return "ty reported issues (no details available)"
57
69
  issue = issues[0]
70
+ if not isinstance(issue, dict):
71
+ return "ty reported issues (no details available)"
58
72
  line = issue.get("line", "?")
59
73
  code = issue.get("code", "")
60
74
  message = issue.get("message", "")
61
- return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
75
+ src_ctx = format_source_context(file, line, count=context_lines)
76
+ callee = ""
77
+ if code in _CALL_CODES and isinstance(line, int):
78
+ from py_cq.parsers.common import extract_callee_name, format_callee_context, read_source_lines
79
+ src_line = read_source_lines(file, line, count=1)
80
+ func_name = extract_callee_name(src_line)
81
+ if func_name:
82
+ callee = format_callee_context(func_name, file)
83
+ return f"`{file}:{line}` — **{code}**: {message}{src_ctx}{callee}"
@@ -40,7 +40,11 @@ class VultureParser(AbstractParser):
40
40
  if not tr.details:
41
41
  return "vulture reported issues (no details available)"
42
42
  file, issues = next(iter(tr.details.items()))
43
+ if not isinstance(issues, list) or not issues:
44
+ return "vulture reported issues (no details available)"
43
45
  issue = issues[0]
46
+ if not isinstance(issue, dict):
47
+ return "vulture reported issues (no details available)"
44
48
  line = issue.get("line", "?")
45
49
  kind = issue.get("type", "unused")
46
50
  name = issue.get("name", "")
@@ -0,0 +1,29 @@
1
+ """Rich table formatter for combined tool results."""
2
+
3
+ from rich.table import Table
4
+
5
+ from py_cq.localtypes import CombinedToolResults, ToolConfig
6
+
7
+
8
+ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]) -> Table:
9
+ """Format combined tool results into a Rich Table."""
10
+ table = Table(width=80)
11
+ table.add_column("Tool", justify="left", no_wrap=True)
12
+ table.add_column("Time", justify="right", style="dim")
13
+ table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
14
+ table.add_column("Score", style="magenta")
15
+ table.add_column("Status")
16
+ for tr in data.tool_results:
17
+ tool_name = tr.raw.tool_name
18
+ config = next((t for t in registry.values() if t.name == tool_name))
19
+ for i, (name, value) in enumerate(tr.metrics.items()):
20
+ if value < config.error_threshold:
21
+ status = "[bold red]Error[/]"
22
+ elif value < config.warning_threshold:
23
+ status = "[yellow]Warning[/]"
24
+ else:
25
+ status = "[green]OK[/]"
26
+ time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
27
+ table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
28
+ table.add_row("", "", "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", "")
29
+ return table