python-code-quality 0.1.7__py3-none-any.whl → 0.1.9__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.
py_cq/cli.py CHANGED
@@ -16,6 +16,7 @@ import json
16
16
  import logging
17
17
  import tomllib
18
18
  from enum import Enum
19
+ from importlib import import_module
19
20
  from pathlib import Path
20
21
 
21
22
  import typer
@@ -26,6 +27,7 @@ from rich.table import Table
26
27
  from py_cq.config import load_user_config
27
28
  from py_cq.execution_engine import _cache as tool_cache
28
29
  from py_cq.execution_engine import run_tools
30
+ from py_cq.language_detector import detect_language
29
31
  from py_cq.localtypes import CombinedToolResults, ToolConfig
30
32
  from py_cq.metric_aggregator import aggregate_metrics
31
33
  from py_cq.tool_registry import tool_registry
@@ -43,7 +45,8 @@ app = typer.Typer(
43
45
  " cq check . # full table with all metrics (default)\n\n"
44
46
  " cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
45
47
  " cq check . -o score # numeric score only\n\n"
46
- " cq check . -o json # full raw json of all tools"
48
+ " cq check . -o json # parsed metrics as json\n\n"
49
+ " cq check . -o raw # unprocessed tool output as json\n\n"
47
50
  " cq config . # show effective tool configuration"
48
51
  ),
49
52
  )
@@ -55,6 +58,7 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
55
58
  Supports:
56
59
  - ``disable``: list of tool IDs to remove
57
60
  - ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
61
+ - ``tools.<tool_id>``: declare new tools (or override built-ins)
58
62
  """
59
63
  registry = {k: copy.copy(v) for k, v in base.items()}
60
64
  for tool_id in user_cfg.get("disable", []):
@@ -65,6 +69,24 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
65
69
  registry[tool_id].warning_threshold = float(thresholds["warning"])
66
70
  if "error" in thresholds:
67
71
  registry[tool_id].error_threshold = float(thresholds["error"])
72
+ for tool_id, tool_data in user_cfg.get("tools", {}).items():
73
+ try:
74
+ parser_name = tool_data["parser"]
75
+ module = import_module(f"py_cq.parsers.{parser_name.lower()}")
76
+ parser_class = getattr(module, parser_name)
77
+ registry[tool_id] = ToolConfig(
78
+ name=tool_id,
79
+ command=tool_data["command"],
80
+ parser_class=parser_class,
81
+ order=tool_data["order"],
82
+ warning_threshold=tool_data["warning_threshold"],
83
+ error_threshold=tool_data["error_threshold"],
84
+ run_in_target_env=tool_data.get("run_in_target_env", False),
85
+ extra_deps=tool_data.get("extra_deps", []),
86
+ parser_config=tool_data.get("parser_config", {}),
87
+ )
88
+ except KeyError as e:
89
+ raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
68
90
  return registry
69
91
 
70
92
 
@@ -74,6 +96,7 @@ class OutputMode(str, Enum):
74
96
  SCORE = "score"
75
97
  JSON = "json"
76
98
  LLM = "llm"
99
+ RAW = "raw"
77
100
 
78
101
 
79
102
  @app.callback()
@@ -99,11 +122,27 @@ def check(
99
122
  workers: int = typer.Option(
100
123
  0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
101
124
  ),
125
+ language: str | None = typer.Option(
126
+ None, "--language", "-l", help="Override language detection (e.g. python, typescript, rust)"
127
+ ),
102
128
  ):
103
129
  """Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
104
130
  path_obj = Path(path)
105
131
  if not path_obj.exists():
106
132
  raise typer.BadParameter(f"Path does not exist: {path}")
133
+
134
+ resolved_language = language or detect_language(path_obj)
135
+
136
+ if resolved_language is not None and resolved_language != "python":
137
+ console.print(
138
+ f"[yellow]{resolved_language.capitalize()} project detected. "
139
+ "Non-Python language support is not yet available.[/yellow]"
140
+ )
141
+ raise typer.Exit(0)
142
+
143
+ # Python path (or unknown — fall through to existing validation).
144
+ # Note: --language python still requires pyproject.toml; the flag selects
145
+ # the tool set, not the input validation rules.
107
146
  if path_obj.is_file():
108
147
  if path_obj.suffix != ".py":
109
148
  raise typer.BadParameter(f"File must be a Python file (.py): {path}")
@@ -111,24 +150,37 @@ def check(
111
150
  if not (path_obj / "pyproject.toml").exists():
112
151
  raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
113
152
  log.setLevel(log_level)
114
- effective_registry = _apply_user_config(tool_registry, load_user_config(path_obj))
153
+ user_cfg = load_user_config(path_obj)
154
+ context_lines: int = int(user_cfg.get("context_lines", 15))
155
+ effective_registry = _apply_user_config(tool_registry, user_cfg)
115
156
  if clear_cache:
116
157
  tool_cache.clear()
117
- tool_results = run_tools(effective_registry.values(), path, workers)
118
- for tr in tool_results:
119
- log.debug(json.dumps(tr.to_dict(), indent=2))
158
+ tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
159
+ # for tr in tool_results:
160
+ # log.debug(json.dumps(tr.to_dict(), indent=2))
120
161
  combined_metrics = aggregate_metrics(path=path, metrics=tool_results)
121
162
  if output == OutputMode.SCORE:
122
163
  console.print(combined_metrics.score)
123
164
  elif output == OutputMode.JSON:
124
- console.print(json.dumps(combined_metrics.to_dict(), indent=2))
165
+ console.print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
166
+ elif output == OutputMode.RAW:
167
+ console.print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
125
168
  elif output == OutputMode.LLM:
126
- log.setLevel("CRITICAL")
169
+ # log.setLevel("CRITICAL")
127
170
  from py_cq.llm_formatter import format_for_llm
128
- console.print(format_for_llm(effective_registry, combined_metrics))
171
+ console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
129
172
  else:
173
+ console.print(f"[bold green]{path_obj.resolve()}[/]")
130
174
  console.print(format_as_table(combined_metrics, effective_registry))
131
175
 
176
+ tool_by_name = {tc.name: tc for tc in effective_registry.values()}
177
+ if any(
178
+ min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
179
+ for tr in tool_results
180
+ if tr.metrics and tr.raw.tool_name in tool_by_name
181
+ ):
182
+ raise typer.Exit(code=1)
183
+
132
184
 
133
185
  @app.command()
134
186
  def config(
@@ -163,18 +215,19 @@ def config(
163
215
 
164
216
  table = Table()
165
217
  table.add_column("Tool", style="cyan")
166
- table.add_column("Priority", justify="right")
218
+ table.add_column("Order", justify="right")
167
219
  table.add_column("Warning", justify="right")
168
220
  table.add_column("Error", justify="right")
169
221
  table.add_column("Status", justify="center")
170
222
 
171
- for tool_id in sorted(tool_registry, key=lambda t: tool_registry[t].priority):
172
- tc = effective_registry.get(tool_id, tool_registry[tool_id])
223
+ all_tool_ids = set(tool_registry) | set(effective_registry)
224
+ for tool_id in sorted(all_tool_ids, key=lambda t: (effective_registry.get(t) or tool_registry[t]).order):
225
+ tc = effective_registry.get(tool_id) or tool_registry[tool_id]
173
226
  is_disabled = tool_id in disabled_ids
174
227
  status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
175
228
  table.add_row(
176
229
  tc.name,
177
- str(tc.priority),
230
+ str(tc.order),
178
231
  f"{tc.warning_threshold:.2f}",
179
232
  f"{tc.error_threshold:.2f}",
180
233
  status,
@@ -200,7 +253,7 @@ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
200
253
  >>> table = format_as_table(combined_results)
201
254
  >>> console.print(table)
202
255
  """
203
- table = Table(title=f"[bold green]{data.path}[/]", width=80)
256
+ table = Table(width=80)
204
257
  table.add_column("Tool", justify="left", no_wrap=True)
205
258
  table.add_column("Time", justify="right", style="dim")
206
259
  table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
@@ -0,0 +1,88 @@
1
+ python:
2
+
3
+ compile:
4
+ command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
5
+ parser: "CompileParser"
6
+ order: 1
7
+ warning_threshold: 0.9999
8
+ error_threshold: 0.9999
9
+
10
+ ruff:
11
+ command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\""
12
+ parser: "RuffParser"
13
+ order: 2
14
+ warning_threshold: 0.9999
15
+ error_threshold: 0.9
16
+
17
+ ty:
18
+ command: "{python} -m ty check --output-format concise --color never \"{context_path}\""
19
+ parser: "TyParser"
20
+ order: 3
21
+ warning_threshold: 0.9999
22
+ error_threshold: 0.8
23
+ run_in_target_env: true
24
+ extra_deps:
25
+ - ty
26
+
27
+ bandit:
28
+ command: "{python} -m bandit -r \"{context_path}\" -f json -q -s B101 --severity-level medium --exclude \"{input_path_posix}/.venv,{input_path_posix}/tests\""
29
+ parser: "BanditParser"
30
+ order: 4
31
+ warning_threshold: 0.9999
32
+ error_threshold: 0.8
33
+
34
+ pytest:
35
+ command: "{python} -m pytest -v \"{context_path}\""
36
+ parser: "PytestParser"
37
+ order: 5
38
+ warning_threshold: 1.0
39
+ error_threshold: 1.0
40
+ run_in_target_env: true
41
+ extra_deps:
42
+ - pytest
43
+
44
+ coverage:
45
+ command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
46
+ parser: "CoverageParser"
47
+ order: 6
48
+ warning_threshold: 0.9
49
+ error_threshold: 0.5
50
+ run_in_target_env: true
51
+ extra_deps:
52
+ - coverage
53
+ - pytest
54
+
55
+ radon-cc:
56
+ command: "{python} -m radon cc --json \"{context_path}\""
57
+ parser: "ComplexityParser"
58
+ order: 7
59
+ warning_threshold: 0.6
60
+ error_threshold: 0.4
61
+
62
+ radon-mi:
63
+ command: "{python} -m radon mi -s --json \"{context_path}\""
64
+ parser: "MaintainabilityParser"
65
+ order: 8
66
+ warning_threshold: 0.6
67
+ error_threshold: 0.4
68
+
69
+ radon-hal:
70
+ command: "{python} -m radon hal -f --json \"{context_path}\""
71
+ parser: "HalsteadParser"
72
+ order: 9
73
+ warning_threshold: 0.5
74
+ error_threshold: 0.3
75
+
76
+ vulture:
77
+ command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
78
+ parser: "VultureParser"
79
+ order: 10
80
+ warning_threshold: 0.9999
81
+ error_threshold: 0.8
82
+
83
+ interrogate:
84
+ command: "{python} -m interrogate \"{context_path}\" -v --fail-under 0"
85
+ parser: "InterrogateParser"
86
+ order: 11
87
+ warning_threshold: 0.8
88
+ error_threshold: 0.3
py_cq/execution_engine.py CHANGED
@@ -82,7 +82,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
82
82
  log.info(f"Cache hit: {command}")
83
83
  return RawResult(**cast(dict[str, Any], _cache[cache_key]))
84
84
  log.info(f"Running: {command}")
85
- result = subprocess.run(command, capture_output=True, text=True, shell=True) # nosec
85
+ result = subprocess.run(command, capture_output=True, text=True, shell=True, encoding="utf-8", errors="replace") # nosec
86
86
  timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
87
87
  raw_result = RawResult(
88
88
  tool_name=tool_config.name,
@@ -96,7 +96,7 @@ def run_tool(tool_config: ToolConfig, context_path: str) -> RawResult:
96
96
  return raw_result
97
97
 
98
98
 
99
- def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0) -> list[ToolResult]:
99
+ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False) -> list[ToolResult]:
100
100
  """Run multiple tools and return their parsed results.
101
101
 
102
102
  Runs each tool specified in *tool_configs* on the file or directory at
@@ -139,14 +139,26 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
139
139
  def _run_and_parse(tool_config: ToolConfig) -> tuple[int, ToolResult]:
140
140
  t0 = time.perf_counter()
141
141
  raw_result = run_tool(tool_config, path)
142
- tr = tool_config.parser_class().parse(raw_result)
142
+ tr = tool_config.parser_class(tool_config.parser_config).parse(raw_result)
143
143
  tr.duration_s = time.perf_counter() - t0
144
- return tool_config.priority, tr
144
+ return tool_config.order, tr
145
145
 
146
146
  if not tool_configs:
147
147
  return []
148
148
  t_start = time.perf_counter()
149
149
  prioritized: list[tuple[int, ToolResult]] = []
150
+ if early_exit:
151
+ for tool_config in sorted(tool_configs, key=lambda tc: tc.order):
152
+ try:
153
+ prioritized.append(_run_and_parse(tool_config))
154
+ except Exception as exc:
155
+ log.error(f"{tool_config.name} generated an exception: {exc}")
156
+ break
157
+ _, tr = prioritized[-1]
158
+ if tr.metrics and min(tr.metrics.values()) < tool_config.error_threshold:
159
+ break
160
+ log.info(f"run_tools elapsed: {time.perf_counter() - t_start:.2f}s")
161
+ return [tr for _, tr in sorted(prioritized)]
150
162
  with ThreadPoolExecutor(max_workers=max_workers or len(tool_configs)) as executor:
151
163
  future_to_tool = {
152
164
  executor.submit(_run_and_parse, tool_config): tool_config
@@ -0,0 +1,29 @@
1
+ """Detect the primary language of a project from its file markers."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Ordered: first match wins. Python is listed first so it takes priority.
6
+ _MARKERS: list[tuple[str, list[str]]] = [
7
+ ("python", ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]),
8
+ ("typescript", ["tsconfig.json", "package.json"]),
9
+ ("rust", ["Cargo.toml"]),
10
+ ("go", ["go.mod"]),
11
+ ("ruby", ["Gemfile"]),
12
+ ("java", ["pom.xml", "build.gradle"]),
13
+ ]
14
+
15
+ _DOTNET_SUFFIXES = {".csproj", ".sln"}
16
+
17
+
18
+ def detect_language(path: Path) -> str | None:
19
+ """Return the detected language for a project path, or None if unrecognised.
20
+
21
+ If path is a file, the parent directory is checked.
22
+ Dotnet is checked last as it uses suffix matching rather than fixed filenames."""
23
+ directory = path if path.is_dir() else path.parent
24
+ for language, markers in _MARKERS:
25
+ if any((directory / marker).exists() for marker in markers):
26
+ return language
27
+ if any(f.suffix in _DOTNET_SUFFIXES for f in directory.iterdir() if f.is_file()):
28
+ return "dotnet"
29
+ return None
py_cq/llm_formatter.py CHANGED
@@ -18,6 +18,7 @@ def format_for_llm(
18
18
  tool_configs: dict,
19
19
  combined: CombinedToolResults,
20
20
  cq_invocation: str | None = None,
21
+ context_lines: int = 15,
21
22
  ) -> str:
22
23
  """Return a markdown prompt describing the single most important defect."""
23
24
  by_name = {tc.name: tc for tc in tool_configs.values()}
@@ -29,7 +30,7 @@ def format_for_llm(
29
30
  ],
30
31
  key=lambda tr: (
31
32
  _severity(min(tr.metrics.values()), by_name[tr.raw.tool_name]),
32
- by_name[tr.raw.tool_name].priority,
33
+ by_name[tr.raw.tool_name].order,
33
34
  min(tr.metrics.values()),
34
35
  ),
35
36
  )
@@ -38,7 +39,7 @@ def format_for_llm(
38
39
 
39
40
  worst = failing[0]
40
41
  config = by_name[worst.raw.tool_name]
41
- defect_md = config.parser_class().format_llm_message(worst)
42
+ defect_md = config.parser_class().format_llm_message(worst, context_lines=context_lines)
42
43
  if cq_invocation is None:
43
44
  cq_invocation = "cq " + " ".join(sys.argv[1:])
44
45
  return (
py_cq/localtypes.py CHANGED
@@ -10,17 +10,18 @@ from typing import Any
10
10
 
11
11
  @dataclass
12
12
  class ToolConfig:
13
- """Represents the configuration for an analysis tool, including its name, command, parser class, context path, priority, and thresholds for warnings and errors."""
13
+ """Represents the configuration for an analysis tool, including its name, command, parser class, context path, order, and thresholds for warnings and errors."""
14
14
 
15
15
  name: str # e.g., "pytest", "coverage", "pydocstyle"
16
16
  command: str # The command to execute (can include placeholders)
17
17
  parser_class: Callable # Name of the parser class to use
18
18
  context_path: str = "" # Path to project or file
19
- priority: int = 5 # 1=critical (compilation), 5=low (style)
19
+ order: int = 5 # 1=first (compilation), 11=last (style)
20
20
  warning_threshold: float = 0.7 # Yellow warning if below this
21
21
  error_threshold: float = 0.5 # Red error if below this
22
22
  run_in_target_env: bool = False # If True, run in target project's env via uv
23
23
  extra_deps: list[str] = field(default_factory=list) # Extra deps to inject via uv --with
24
+ parser_config: dict[str, Any] = field(default_factory=dict)
24
25
 
25
26
 
26
27
  @dataclass
@@ -73,11 +74,11 @@ class ToolResult:
73
74
  self.metrics = {}
74
75
 
75
76
  def to_dict(self) -> dict:
76
- """Returns a dictionary containing the metrics, details, and the raw data serialized via its own `to_dict` method."""
77
+ """Returns a dictionary containing the tool name, metrics, details, and duration."""
77
78
  return {
79
+ "tool_name": self.raw.tool_name,
78
80
  "metrics": self.metrics,
79
81
  "details": self.details,
80
- "raw": self.raw.to_dict(),
81
82
  "duration_s": self.duration_s,
82
83
  }
83
84
 
@@ -119,12 +120,15 @@ class AbstractParser(ABC):
119
120
 
120
121
  Subclasses must implement `parse` to convert a `RawResult` into a `ToolResult`. An optional `provide_help` can be overridden to supply contextual guidance for a parsed result."""
121
122
 
123
+ def __init__(self, parser_config: dict | None = None):
124
+ self.parser_config = parser_config or {}
125
+
122
126
  @abstractmethod
123
127
  def parse(self, raw_result: RawResult) -> ToolResult:
124
128
  """Converts raw tool output into a structured ToolResult."""
125
129
  pass
126
130
 
127
- def format_llm_message(self, tr: ToolResult) -> str:
131
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
128
132
  """Return a single-defect description for LLM consumption.
129
133
 
130
134
  Default implementation reports the worst metric by name and score.
@@ -42,7 +42,7 @@ class BanditParser(AbstractParser):
42
42
  score = score_logistic_variant(weighted, scale_factor=10)
43
43
  return ToolResult(raw=raw_result, metrics={"security": score}, details=files)
44
44
 
45
- def format_llm_message(self, tr: ToolResult) -> str:
45
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
46
46
  if not tr.details:
47
47
  return "bandit reported issues (no details available)"
48
48
  file, issues = next(iter(tr.details.items()))
@@ -51,4 +51,4 @@ class BanditParser(AbstractParser):
51
51
  code = issue.get("code", "")
52
52
  severity = issue.get("severity", "")
53
53
  message = issue.get("message", "")
54
- return f"`{file}:{line}` — **{code}** [{severity}]: {message}{format_source_context(file, line)}"
54
+ return f"`{file}:{line}` — **{code}** [{severity}]: {message}{format_source_context(file, line, count=context_lines)}"
py_cq/parsers/common.py CHANGED
@@ -37,6 +37,37 @@ def format_source_context(file: str, line: int | str, context: int = 3, count: i
37
37
  return f"\n```python\n{src}\n```"
38
38
 
39
39
 
40
+ def find_function_source(file: str, func_name: str, max_lines: int = 15) -> str:
41
+ """Return a fenced python block for the body of func_name, or '' if unavailable."""
42
+ from pathlib import Path
43
+ try:
44
+ all_lines = Path(file).read_text(encoding="utf-8").splitlines()
45
+ except OSError:
46
+ return ""
47
+ import re
48
+ pattern = re.compile(rf"^(\s*)(?:async\s+)?def\s+{re.escape(func_name)}\s*\(")
49
+ match_result: tuple[int, int] | None = None
50
+ for i, line in enumerate(all_lines):
51
+ m = pattern.match(line)
52
+ if m:
53
+ match_result = (i, len(m.group(1)))
54
+ break
55
+ if match_result is None:
56
+ return ""
57
+ start_idx, baseline_indent = match_result
58
+ collected = [all_lines[start_idx]]
59
+ for line in all_lines[start_idx + 1 :]:
60
+ stripped = line.lstrip()
61
+ indent = len(line) - len(stripped)
62
+ if stripped and indent <= baseline_indent:
63
+ break
64
+ collected.append(line)
65
+ if len(collected) >= max_lines:
66
+ break
67
+ numbered = "\n".join(f"{start_idx + 1 + i}: {ln}" for i, ln in enumerate(collected))
68
+ return f"\n```python\n{numbered}\n```"
69
+
70
+
40
71
  def inv_normalize(value: float, max_value: float) -> float:
41
72
  """Returns the inverse normalized value of `value` relative to `max_value`."""
42
73
  return (max_value - min(value, max_value)) / max_value
@@ -115,7 +115,7 @@ class CompileParser(AbstractParser):
115
115
  tr.details["failed_files"] = failed_files
116
116
  return tr
117
117
 
118
- def format_llm_message(self, tr: ToolResult) -> str:
118
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
119
119
  """Return the first compilation failure as a defect description."""
120
120
  failed = tr.details.get("failed_files", {})
121
121
  if not failed:
@@ -124,5 +124,5 @@ class CompileParser(AbstractParser):
124
124
  line = info.get("line", "?")
125
125
  typ = info.get("type", "Error")
126
126
  help_msg = info.get("help", "")
127
- code_block = format_source_context(file, line) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
127
+ code_block = format_source_context(file, line, count=context_lines) or (f"\n```python\n{info['src']}\n```" if info.get("src") else "")
128
128
  return f"`{file}:{line}` — **{typ}**: {help_msg}{code_block}"
@@ -71,7 +71,7 @@ class CoverageParser(AbstractParser):
71
71
  tr.details = details
72
72
  return tr
73
73
 
74
- def format_llm_message(self, tr: ToolResult) -> str:
74
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
75
75
  """Return the files with lowest coverage as a defect description."""
76
76
  score = tr.metrics.get("coverage", 0)
77
77
  uncovered = sorted(
@@ -0,0 +1,16 @@
1
+ """Parser that scores a tool pass/fail based solely on its exit code."""
2
+
3
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
4
+
5
+
6
+ class ExitCodeParser(AbstractParser):
7
+ """Score 1.0 if the tool exited with code 0, else 0.0."""
8
+
9
+ def parse(self, raw_result: RawResult) -> ToolResult:
10
+ score = 1.0 if raw_result.return_code == 0 else 0.0
11
+ return ToolResult(raw=raw_result, metrics={"exit_code": score})
12
+
13
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
14
+ output = tr.raw.stdout.strip() or tr.raw.stderr.strip()
15
+ lines = output.splitlines()[:context_lines]
16
+ return "\n".join(lines) if lines else "Tool exited with non-zero status (no output)"
@@ -62,7 +62,7 @@ class HalsteadParser(AbstractParser):
62
62
  MAX_FILE_BUGS = 1
63
63
  MAX_FILE_VOLUME = 2000
64
64
  MAX_FUNCTION_BUGS = 0.2
65
- MAX_FUNCTION_VOLUME = 300
65
+ MAX_FUNCTION_VOLUME = 600
66
66
  min_file_nb = 1.0
67
67
  min_file_sm = 1.0
68
68
  min_function_nb = 1.0
@@ -113,7 +113,7 @@ class HalsteadParser(AbstractParser):
113
113
  }
114
114
  return tr
115
115
 
116
- def format_llm_message(self, tr: ToolResult) -> str:
116
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
117
117
  """Return the worst Halstead offender as an actionable defect description."""
118
118
  if not tr.metrics:
119
119
  return "No Halstead details available"
@@ -42,7 +42,7 @@ class InterrogateParser(AbstractParser):
42
42
  score = total_coverage if total_coverage is not None else 1.0
43
43
  return ToolResult(raw=raw_result, metrics={"doc_coverage": score}, details=files)
44
44
 
45
- def format_llm_message(self, tr: ToolResult) -> str:
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
48
  [(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
@@ -0,0 +1,26 @@
1
+ """Parser that scores a tool by counting non-empty output lines as violations."""
2
+
3
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
4
+ from py_cq.parsers.common import score_logistic_variant
5
+
6
+
7
+ class LineCountParser(AbstractParser):
8
+ """Score based on number of non-empty stdout lines.
9
+
10
+ parser_config keys:
11
+ scale_factor (int, default 15): passed to score_logistic_variant.
12
+ """
13
+
14
+ def parse(self, raw_result: RawResult) -> ToolResult:
15
+ lines = [ln for ln in (raw_result.stdout or "").splitlines() if ln.strip()]
16
+ count = len(lines)
17
+ scale = self.parser_config.get("scale_factor", 15)
18
+ score = score_logistic_variant(count, scale_factor=scale)
19
+ return ToolResult(raw=raw_result, metrics={"violations": score}, details={"lines": lines})
20
+
21
+ def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
22
+ lines = tr.details.get("lines", [])
23
+ if not lines:
24
+ return "No violations found"
25
+ shown = lines[:context_lines]
26
+ return "\n".join(shown)