python-code-quality 0.1.15__py3-none-any.whl → 0.2.1__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/execution_engine.py CHANGED
@@ -13,6 +13,8 @@ where tool invocations may be expensive and should be avoided
13
13
  when a cached result already exists."""
14
14
 
15
15
  import logging
16
+ import os
17
+ import shlex
16
18
  import shutil
17
19
  import subprocess
18
20
  import sys
@@ -29,7 +31,9 @@ from py_cq.localtypes import RawResult, ToolConfig, ToolResult
29
31
 
30
32
  log = logging.getLogger("cq")
31
33
 
32
- _cache = Cache(Path.home() / ".cache" / "cq", size_limit=100 * 1024 * 1024, disk=JSONDisk)
34
+ _cache = Cache(
35
+ Path.home() / ".cache" / "cq", size_limit=100 * 1024 * 1024, disk=JSONDisk
36
+ )
33
37
 
34
38
 
35
39
  def _find_project_root(path: Path) -> Path | None:
@@ -53,17 +57,58 @@ def _dep_in_venv(dep: str, project_root: Path) -> bool:
53
57
  return False
54
58
 
55
59
 
56
- def _build_exclude_str(exclude_format: str, excludes: list[str], **extra_vars: str) -> str:
60
+ def _compute_scan_targets(
61
+ context_path: str,
62
+ scan_exclude_names: list[str],
63
+ user_excludes: list[str] | None = None,
64
+ ) -> str:
65
+ """Return space-separated quoted absolute paths for bandit-style scanning.
66
+
67
+ When context_path is a directory, enumerates its top-level children and
68
+ omits any whose name is in scan_exclude_names or user_excludes. When it's
69
+ a file, returns just that file. Falls back to the root itself if all
70
+ children are excluded.
71
+ """
72
+ root = Path(context_path).resolve()
73
+ if not root.is_dir():
74
+ return f'"{root}"'
75
+ excluded = set(scan_exclude_names) | {Path(e).name for e in (user_excludes or [])}
76
+ targets = [str(p) for p in sorted(root.iterdir()) if p.name not in excluded]
77
+ paths = targets if targets else [str(root)]
78
+ return " ".join(f'"{p}"' for p in paths)
79
+
80
+
81
+ def _build_exclude_str(
82
+ exclude_format: str, excludes: list[str], **extra_vars: str
83
+ ) -> str:
84
+ """Builds an exclude string from a list of excludes and a format string."""
85
+
57
86
  if not exclude_format or not excludes:
58
87
  return ""
59
88
  parts = []
60
89
  for exc in excludes:
61
90
  abs_posix_path = Path(exc).resolve().as_posix()
62
- parts.append(exclude_format.format(path=exc, abs_posix_path=abs_posix_path, **extra_vars))
91
+ abs_native_path = str(Path(exc).resolve())
92
+ # shlex.quote prevents shell injection via exclude paths
93
+ parts.append(
94
+ exclude_format.format(
95
+ path=shlex.quote(exc),
96
+ abs_posix_path=shlex.quote(abs_posix_path),
97
+ abs_native_path=shlex.quote(abs_native_path),
98
+ **{k: shlex.quote(v) for k, v in extra_vars.items()},
99
+ )
100
+ )
63
101
  return "".join(parts)
64
102
 
65
103
 
66
- def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | None = None) -> RawResult:
104
+ def run_tool(
105
+ tool_config: ToolConfig,
106
+ context_path: str,
107
+ excludes: list[str] | None = None,
108
+ *,
109
+ precomputed_hash: str | None = None,
110
+ project_tag: str | None = None,
111
+ ) -> RawResult:
67
112
  """Runs a tool defined by its configuration and returns the execution result.
68
113
 
69
114
  Args:
@@ -83,6 +128,8 @@ def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | N
83
128
  0"""
84
129
  python = sys.executable
85
130
  path = str(Path(context_path))
131
+ run_env = None
132
+ project_dir = ""
86
133
  if tool_config.run_in_target_env:
87
134
  uv = shutil.which("uv")
88
135
  if uv:
@@ -94,21 +141,94 @@ def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | N
94
141
  project_root = _find_project_root(resolved)
95
142
  abs_dir = str(project_root) if project_root else str(resolved.parent)
96
143
  path = str(resolved)
144
+ project_dir = Path(abs_dir).as_posix()
97
145
  project_root_path = Path(abs_dir)
98
- missing_deps = [d for d in tool_config.extra_deps if not _dep_in_venv(d, project_root_path)]
99
- with_flags = " ".join(f"--with {dep}" for dep in missing_deps)
146
+ missing_deps = [
147
+ d
148
+ for d in tool_config.extra_deps
149
+ if not _dep_in_venv(d, project_root_path)
150
+ ]
151
+ # Quote deps with shlex.quote to prevent injection via extra_deps.
152
+ # The uv path and abs_dir use standard double-quoting which is
153
+ # compatible with both POSIX and MSYS bash on Windows.
154
+ with_flags = " ".join(f"--with {shlex.quote(dep)}" for dep in missing_deps)
100
155
  no_sync = "--no-sync" if sys.executable.startswith(abs_dir) else ""
101
- python = f'"{uv}" run {no_sync} --directory "{abs_dir}" {with_flags}'.strip()
156
+ python = (
157
+ f'"{uv}" run {no_sync} --directory "{abs_dir}" {with_flags}'.strip()
158
+ )
159
+ # Strip venv env vars so the target project's environment is used cleanly.
160
+ # VIRTUAL_ENV pointing to cq's own venv would cause uv to warn and can
161
+ # corrupt the subprocess's sys.path, mixing packages from both projects.
162
+ run_env = {
163
+ k: v
164
+ for k, v in os.environ.items()
165
+ if k not in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH")
166
+ }
102
167
  abs_context_path = str(Path(context_path).resolve())
168
+ abs_context_path_posix = Path(context_path).resolve().as_posix()
169
+ native_sep = os.sep
170
+ if not project_dir:
171
+ project_dir = (
172
+ Path(abs_context_path).as_posix()
173
+ if Path(abs_context_path).is_dir()
174
+ else Path(abs_context_path).parent.as_posix()
175
+ )
103
176
  input_path_posix = Path(context_path).as_posix().rstrip("/")
104
- exclude = _build_exclude_str(tool_config.exclude_format, excludes or [], input_path_posix=input_path_posix)
105
- command = tool_config.command.format(context_path=path, abs_context_path=abs_context_path, input_path_posix=input_path_posix, python=python, exclude=exclude)
106
- cache_key = f"{command}:{get_context_hash(context_path)}"
107
- if cache_key in _cache:
108
- log.info(f"Cache hit: {command}")
109
- return RawResult(**cast(dict[str, Any], _cache[cache_key]))
110
- log.info(f"Running: {command}")
111
- result = subprocess.run(command, capture_output=True, text=True, shell=True, encoding="utf-8", errors="replace") # nosec
177
+ exclude = _build_exclude_str(
178
+ tool_config.exclude_format,
179
+ excludes or [],
180
+ input_path_posix=input_path_posix,
181
+ abs_context_path_posix=abs_context_path_posix,
182
+ )
183
+ scan_targets = _compute_scan_targets(
184
+ context_path, tool_config.scan_exclude_names, excludes
185
+ )
186
+
187
+ command = tool_config.command.format(
188
+ context_path=path,
189
+ abs_context_path=abs_context_path,
190
+ abs_context_path_posix=abs_context_path_posix,
191
+ input_path_posix=input_path_posix,
192
+ native_sep=native_sep,
193
+ scan_targets=scan_targets,
194
+ python=python,
195
+ exclude=exclude,
196
+ )
197
+ context_hash = (
198
+ precomputed_hash
199
+ if precomputed_hash is not None
200
+ else get_context_hash(context_path)
201
+ )
202
+ cache_key = f"{command}:{context_hash}"
203
+
204
+ t_cache0 = time.perf_counter()
205
+ cached = _cache.get(cache_key)
206
+ t_cache = time.perf_counter() - t_cache0
207
+ if cached is not None:
208
+ log.debug(
209
+ f"{tool_config.name}: [CACHE HIT] cache={t_cache * 1000:.1f}ms {command}"
210
+ )
211
+ return RawResult(**cast(dict[str, Any], cached))
212
+
213
+ # shell=True is required because commands use shell features (&&, |) and
214
+ # variable substitution ({python} expands to a compound uv command).
215
+ # All user-supplied values (context_path, excludes) are properly quoted
216
+ # via shlex.quote() to prevent injection - see _build_exclude_str and
217
+ # the uv command assembly above.
218
+ t_sub0 = time.perf_counter()
219
+ result = subprocess.run(
220
+ command,
221
+ capture_output=True,
222
+ text=True,
223
+ shell=True,
224
+ encoding="utf-8",
225
+ errors="replace",
226
+ env=run_env,
227
+ ) # nosec
228
+ t_sub = time.perf_counter() - t_sub0
229
+ log.debug(
230
+ f"{tool_config.name}: [MISS] cache={t_cache * 1000:.1f}ms tool={t_sub * 1000:.0f}ms: {command}"
231
+ )
112
232
  timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
113
233
  raw_result = RawResult(
114
234
  tool_name=tool_config.name,
@@ -117,12 +237,20 @@ def run_tool(tool_config: ToolConfig, context_path: str, excludes: list[str] | N
117
237
  stderr=result.stderr,
118
238
  return_code=result.returncode,
119
239
  timestamp=timestamp,
240
+ project_path=project_dir,
120
241
  )
121
- _cache.set(cache_key, raw_result.to_dict(), expire=5 * 24 * 60 * 60)
242
+ _cache.set(cache_key, raw_result.to_dict(), expire=5 * 24 * 60 * 60, tag=project_tag)
122
243
  return raw_result
123
244
 
124
245
 
125
- def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int = 0, early_exit: bool = False, excludes: list[str] | None = None) -> list[ToolResult]:
246
+ def run_tools(
247
+ tool_configs: Collection[ToolConfig],
248
+ path: str,
249
+ max_workers: int = 0,
250
+ early_exit: bool = False,
251
+ excludes: list[str] | None = None,
252
+ project_root: str | None = None,
253
+ ) -> list[ToolResult]:
126
254
  """Run multiple tools and return their parsed results.
127
255
 
128
256
  Runs each tool specified in *tool_configs* on the file or directory at
@@ -162,39 +290,67 @@ def run_tools(tool_configs: Collection[ToolConfig], path: str, max_workers: int
162
290
  ... ToolConfig(name='scan', parser_class=ScanParser),
163
291
  ... ]
164
292
  >>> results = run_tools(configs, '/path/to/project', parallel=True)"""
293
+ if not tool_configs:
294
+ return []
295
+ t_start = time.perf_counter()
296
+ t_hash0 = time.perf_counter()
297
+ root = project_root or str(Path(path).resolve())
298
+ shared_hash = get_context_hash(root)
299
+ log.debug(f"context_hash: {(time.perf_counter() - t_hash0) * 1000:.1f}ms {shared_hash}")
300
+
301
+ sentinel_key = f"_project_hash:{root}"
302
+ prev_hash = _cache.get(sentinel_key)
303
+ if prev_hash is not None and prev_hash != shared_hash:
304
+ evicted = _cache.evict(root)
305
+ log.debug(f"project changed: evicted {evicted} stale cache entries for {root}")
306
+ _cache.set(sentinel_key, shared_hash, expire=5 * 24 * 60 * 60, tag=root)
307
+
165
308
  def _run_and_parse(tool_config: ToolConfig) -> tuple[int, ToolResult]:
166
309
  t0 = time.perf_counter()
167
- raw_result = run_tool(tool_config, path, excludes)
310
+ raw_result = run_tool(tool_config, path, excludes, precomputed_hash=shared_hash, project_tag=root)
168
311
  tr = tool_config.parser_class(tool_config.parser_config).parse(raw_result)
169
312
  tr.duration_s = time.perf_counter() - t0
170
313
  return tool_config.order, tr
171
314
 
172
- if not tool_configs:
173
- return []
174
- t_start = time.perf_counter()
175
315
  prioritized: list[tuple[int, ToolResult]] = []
176
316
  if early_exit:
177
- for tool_config in sorted(tool_configs, key=lambda tc: tc.order):
317
+ sorted_configs = sorted(tool_configs, key=lambda tc: tc.order)
318
+ n_total = len(sorted_configs)
319
+ for i, tool_config in enumerate(sorted_configs):
178
320
  try:
179
321
  prioritized.append(_run_and_parse(tool_config))
180
322
  except Exception as exc:
181
- log.error(f"{tool_config.name} generated an exception: {exc}")
323
+ log.error(f"{tool_config.name} generated an exception: {exc} {exc.__traceback__}")
324
+ n_skipped = n_total - i - 1
325
+ if n_skipped:
326
+ remaining = ", ".join(tc.name for tc in sorted_configs[i + 1 :])
327
+ log.warning(f"Early exit: skipped {n_skipped} tool(s): {remaining}")
182
328
  break
183
329
  _, tr = prioritized[-1]
184
330
  if tr.metrics and min(tr.metrics.values()) < tool_config.error_threshold:
331
+ n_skipped = n_total - i - 1
332
+ if n_skipped:
333
+ remaining = ", ".join(tc.name for tc in sorted_configs[i + 1 :])
334
+ log.debug(
335
+ f"Error threshold hit at {tool_config.name}: skipped {n_skipped} tool(s): {remaining}"
336
+ )
185
337
  break
186
- log.info(f"run_tools elapsed: {time.perf_counter() - t_start:.2f}s")
338
+ log.info(f"cq run_tools elapsed: {time.perf_counter() - t_start:.2f}s")
187
339
  return [tr for _, tr in sorted(prioritized)]
188
340
  with ThreadPoolExecutor(max_workers=max_workers or len(tool_configs)) as executor:
189
341
  future_to_tool = {
190
342
  executor.submit(_run_and_parse, tool_config): tool_config
191
343
  for tool_config in tool_configs
192
344
  }
345
+ timings: list[tuple[int, str, float]] = []
193
346
  for future in as_completed(future_to_tool):
194
347
  tool_config = future_to_tool[future]
195
348
  try:
196
- prioritized.append(future.result())
349
+ order, tr = future.result()
350
+ prioritized.append((order, tr))
351
+ timings.append((order, tool_config.name, tr.duration_s))
197
352
  except Exception as exc:
198
353
  log.error(f"{tool_config.name} generated an exception: {exc}")
199
- log.info(f"run_tools elapsed: {time.perf_counter() - t_start:.2f}s")
354
+ per_tool = ", ".join(f"{name}={dur:.2f}s" for _, name, dur in sorted(timings))
355
+ log.debug(f"run_tools elapsed: {time.perf_counter() - t_start:.2f}s [{per_tool}]")
200
356
  return [tr for _, tr in sorted(prioritized)]
@@ -4,7 +4,10 @@ from pathlib import Path
4
4
 
5
5
  # Ordered: first match wins. Python is listed first so it takes priority.
6
6
  _MARKERS: list[tuple[str, list[str]]] = [
7
- ("python", ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]),
7
+ (
8
+ "python",
9
+ ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"],
10
+ ),
8
11
  ("typescript", ["tsconfig.json", "package.json"]),
9
12
  ("rust", ["Cargo.toml"]),
10
13
  ("go", ["go.mod"]),
py_cq/llm_formatter.py CHANGED
@@ -1,8 +1,10 @@
1
1
  """Format the most important code quality defect as a markdown prompt for LLM consumption."""
2
2
 
3
3
  import sys
4
+ from pathlib import Path
5
+ from typing import cast
4
6
 
5
- from py_cq.localtypes import CombinedToolResults, ToolConfig
7
+ from py_cq.localtypes import CombinedToolResults, Fingerprint, ToolConfig, ToolResult
6
8
 
7
9
 
8
10
  def _severity(score: float, config: ToolConfig) -> int:
@@ -14,19 +16,91 @@ def _severity(score: float, config: ToolConfig) -> int:
14
16
  return 2
15
17
 
16
18
 
17
- def format_for_llm(
19
+ def _single_issue_slices(
20
+ tr: ToolResult,
21
+ limit: int,
22
+ silence: list[str] | None = None,
23
+ project_root: Path | None = None,
24
+ ) -> list[ToolResult]:
25
+ """Return up to `limit` ToolResults each containing one issue from tr.details.
26
+
27
+ Returns empty list (not [tr]) when silence specs filter out all issues."""
28
+ silence_set = set(silence or [])
29
+ slices: list[ToolResult] = []
30
+ has_list = any(isinstance(v, list) for v in tr.details.values())
31
+
32
+ if has_list:
33
+ for file, issues in tr.details.items():
34
+ if isinstance(issues, list):
35
+ for issue in issues:
36
+ candidate = ToolResult(
37
+ raw=tr.raw,
38
+ metrics=tr.metrics,
39
+ details={file: [issue]},
40
+ project_path=tr.project_path,
41
+ )
42
+ if (
43
+ _fingerprint_from_slice(
44
+ tr.raw.tool_name, candidate, project_root
45
+ )
46
+ in silence_set
47
+ ):
48
+ continue
49
+ slices.append(candidate)
50
+ if len(slices) >= limit:
51
+ break
52
+ else:
53
+ # Non-list details: sort so files with failures (pytest-style) come first, then by coverage ascending
54
+ def _dict_sort_key(v: object) -> tuple[int, float, float]:
55
+ if not isinstance(v, dict):
56
+ return (0, 0.0, 1.0)
57
+ d = cast("dict[str, object]", v)
58
+ failures = sum(
59
+ 1
60
+ for val in d.values()
61
+ if isinstance(val, str) and val in ("FAILED", "ERROR")
62
+ )
63
+ cov_val = d.get("coverage", 0)
64
+ coverage = float(cov_val) if isinstance(cov_val, (int, float, str)) else 0.0
65
+ sm_val = d.get("smallness", 1.0)
66
+ smallness = float(sm_val) if isinstance(sm_val, (int, float)) else 1.0
67
+ return (-failures, coverage, smallness)
68
+
69
+ items = sorted(tr.details.items(), key=lambda x: _dict_sort_key(x[1]))
70
+ for file, data in items:
71
+ candidate = ToolResult(
72
+ raw=tr.raw,
73
+ metrics=tr.metrics,
74
+ details={file: data},
75
+ project_path=tr.project_path,
76
+ )
77
+ if (
78
+ _fingerprint_from_slice(tr.raw.tool_name, candidate, project_root)
79
+ in silence_set
80
+ ):
81
+ continue
82
+ slices.append(candidate)
83
+ if len(slices) >= limit:
84
+ break
85
+ return slices[:limit] or ([] if silence_set else [tr])
86
+
87
+
88
+ def _select_top_issue(
18
89
  tool_configs: dict,
19
90
  combined: CombinedToolResults,
20
- cq_invocation: str | None = None,
21
- context_lines: int = 15,
22
- ) -> str:
23
- """Return a markdown prompt describing the single most important defect."""
91
+ limit: int,
92
+ silence: list[str],
93
+ project_root: Path | None = None,
94
+ ):
95
+ """Return (worst, slices, config, parser) for the top failing tool, or None if all pass."""
24
96
  by_name = {tc.name: tc for tc in tool_configs.values()}
25
-
26
97
  failing = sorted(
27
98
  [
28
- tr for tr in combined.tool_results
29
- if tr.metrics and (cfg := by_name.get(tr.raw.tool_name)) and min(tr.metrics.values()) < cfg.warning_threshold
99
+ tr
100
+ for tr in combined.tool_results
101
+ if tr.metrics
102
+ and (cfg := by_name.get(tr.raw.tool_name))
103
+ and min(tr.metrics.values()) < cfg.warning_threshold
30
104
  ],
31
105
  key=lambda tr: (
32
106
  _severity(min(tr.metrics.values()), by_name[tr.raw.tool_name]),
@@ -34,15 +108,123 @@ def format_for_llm(
34
108
  min(tr.metrics.values()),
35
109
  ),
36
110
  )
37
- if not failing:
111
+
112
+ for candidate in failing:
113
+ slices = _single_issue_slices(candidate, limit, silence, project_root)
114
+ if slices:
115
+ config = by_name[candidate.raw.tool_name]
116
+ return candidate, slices, config, config.parser_class()
117
+ return None
118
+
119
+
120
+ def _build_message(
121
+ slices, parser, context_lines: int, limit: int, hint: bool, cq_invocation
122
+ ) -> str:
123
+ parts = [
124
+ parser.format_llm_message(s, context_lines=context_lines, limit=limit)
125
+ for s in slices
126
+ ]
127
+ n = len(parts)
128
+ close = "Please fix only this issue." if n == 1 else f"Please fix these {n} issues."
129
+ body = "\n\n---\n\n".join(parts) + f"\n\n{close}"
130
+ if hint:
131
+ if cq_invocation is None:
132
+ cq_invocation = "cq " + " ".join(sys.argv[1:])
133
+ body += f" After fixing, run `{cq_invocation}` to verify."
134
+ return body
135
+
136
+
137
+ def _fingerprint_from_slice(
138
+ tool_name: str, tr: ToolResult, project_root: Path | None = None
139
+ ) -> str:
140
+ """Return fingerprint string for a single-issue ToolResult slice."""
141
+ root = project_root.resolve() if project_root else None
142
+ project_str = root.as_posix() if root else ""
143
+ for file, issues in tr.details.items():
144
+ if root:
145
+ p = Path(file)
146
+ resolved = (root / p).resolve() if not p.is_absolute() else p.resolve()
147
+ try:
148
+ path_str = resolved.relative_to(root).as_posix()
149
+ except ValueError:
150
+ path_str = resolved.as_posix()
151
+ else:
152
+ path_str = Path(file).as_posix()
153
+ if isinstance(issues, list) and issues:
154
+ first = issues[0]
155
+ line = str(first.get("line", "")) if isinstance(first, dict) else ""
156
+ code = first.get("code", "") if isinstance(first, dict) else ""
157
+ fp = Fingerprint(
158
+ tool=tool_name, project=project_str, path=path_str, line=line, code=code
159
+ )
160
+ elif isinstance(issues, dict):
161
+ str_vals = [v for v in issues.values() if isinstance(v, str)]
162
+ if str_vals and all(v not in ("FAILED", "ERROR") for v in str_vals):
163
+ continue
164
+ fp = Fingerprint(tool=tool_name, project=project_str, path=path_str)
165
+ else:
166
+ fp = Fingerprint(tool=tool_name, project=project_str, path="")
167
+ return str(fp)
168
+ return tool_name
169
+
170
+
171
+ def format_for_llm(
172
+ tool_configs: dict,
173
+ combined: CombinedToolResults,
174
+ cq_invocation: str | None = None,
175
+ context_lines: int = 15,
176
+ hint: bool = False,
177
+ limit: int = 1,
178
+ silence: list[str] | None = None,
179
+ project_root: Path | None = None,
180
+ ) -> str:
181
+ """Return a markdown prompt describing the top `limit` defects from the worst-scoring tool."""
182
+ result = _select_top_issue(
183
+ tool_configs, combined, limit, silence or [], project_root
184
+ )
185
+ if result is None:
38
186
  return f"# No issues found\n\nOverall score: **{combined.score:.3f} / 1.0**"
187
+ _, slices, _, parser = result
188
+ return _build_message(slices, parser, context_lines, limit, hint, cq_invocation)
39
189
 
40
- worst = failing[0]
41
- config = by_name[worst.raw.tool_name]
42
- defect_md = config.parser_class().format_llm_message(worst, context_lines=context_lines)
43
- if cq_invocation is None:
44
- cq_invocation = "cq " + " ".join(sys.argv[1:])
45
- return (
46
- f"{defect_md}\n\n"
47
- f"Please fix only this issue. After fixing, run `{cq_invocation}` to verify."
190
+
191
+ def format_for_llm_json(
192
+ tool_configs: dict,
193
+ combined: CombinedToolResults,
194
+ cq_invocation: str | None = None,
195
+ context_lines: int = 15,
196
+ hint: bool = False,
197
+ limit: int = 1,
198
+ silence: list[str] | None = None,
199
+ project_root: Path | None = None,
200
+ ) -> dict:
201
+ """Like format_for_llm but returns a dict with id, file, project, and message for automation use."""
202
+ message = format_for_llm(
203
+ tool_configs,
204
+ combined,
205
+ cq_invocation,
206
+ context_lines,
207
+ hint,
208
+ limit,
209
+ silence,
210
+ project_root,
211
+ )
212
+ project = project_root.as_posix() if project_root else None
213
+ result = _select_top_issue(
214
+ tool_configs, combined, limit, silence or [], project_root
48
215
  )
216
+ if result is None:
217
+ return {"id": None, "file": None, "project": project, "message": message}
218
+ worst, slices, _, _ = result
219
+ issue_id = _fingerprint_from_slice(worst.raw.tool_name, slices[0], project_root)
220
+ raw_file = next(iter(slices[0].details), "")
221
+ if project_root and raw_file:
222
+ try:
223
+ file: str | None = (
224
+ Path(raw_file).resolve().relative_to(project_root).as_posix() or None
225
+ )
226
+ except ValueError:
227
+ file = Path(raw_file).as_posix() or None
228
+ else:
229
+ file = Path(raw_file).as_posix() or None
230
+ return {"id": issue_id, "file": file, "project": project, "message": message}
py_cq/localtypes.py CHANGED
@@ -8,6 +8,35 @@ from dataclasses import dataclass, field
8
8
  from typing import Any
9
9
 
10
10
 
11
+ @dataclass
12
+ class Fingerprint:
13
+ """Stable identity for a single reported issue.
14
+
15
+ String form: ``tool::project::path[::line[::code]]`` (trailing empty fields omitted).
16
+ ``project`` is an absolute path; ``path`` is relative to it.
17
+ """
18
+
19
+ tool: str
20
+ project: str # absolute path to project root
21
+ path: str # path relative to project
22
+ line: str = ""
23
+ code: str = ""
24
+
25
+ def __str__(self) -> str:
26
+ parts = [self.tool, self.project, self.path, self.line, self.code]
27
+ while parts and not parts[-1]:
28
+ parts.pop()
29
+ return "::".join(parts)
30
+
31
+ @classmethod
32
+ def from_string(cls, s: str) -> "Fingerprint":
33
+ parts = s.split("::")
34
+ parts += [""] * (5 - len(parts))
35
+ return cls(
36
+ tool=parts[0], project=parts[1], path=parts[2], line=parts[3], code=parts[4]
37
+ )
38
+
39
+
11
40
  @dataclass
12
41
  class ToolConfig:
13
42
  """Represents the configuration for an analysis tool, including its name, command, parser class, context path, order, and thresholds for warnings and errors."""
@@ -20,9 +49,17 @@ class ToolConfig:
20
49
  warning_threshold: float = 0.7 # Yellow warning if below this
21
50
  error_threshold: float = 0.5 # Red error if below this
22
51
  run_in_target_env: bool = False # If True, run in target project's env via uv
23
- extra_deps: list[str] = field(default_factory=list) # Extra deps to inject via uv --with
52
+ extra_deps: list[str] = field(
53
+ default_factory=list
54
+ ) # Extra deps to inject via uv --with
24
55
  parser_config: dict[str, Any] = field(default_factory=dict)
25
- exclude_format: str = "" # Per-path template for --exclude injection, e.g. " --exclude {path}"
56
+ exclude_format: str = (
57
+ "" # Per-path template for --exclude injection, e.g. " --exclude {path}"
58
+ )
59
+ scan_exclude_names: list[str] = field(
60
+ default_factory=list
61
+ ) # Top-level dir/file names to omit from {scan_targets}
62
+ skip_for_file: bool = False # If True, skip when context_path is a single file
26
63
 
27
64
 
28
65
  @dataclass
@@ -38,6 +75,7 @@ class RawResult:
38
75
  stderr: str = ""
39
76
  return_code: int = 0
40
77
  timestamp: str = "" # For tracking when the analysis ran
78
+ project_path: str = "" # Absolute path to the target project root
41
79
 
42
80
  def to_dict(self):
43
81
  """Returns a dictionary containing the tool name, command, stdout, stderr, return code, and timestamp."""
@@ -48,6 +86,7 @@ class RawResult:
48
86
  "stderr": self.stderr,
49
87
  "return_code": self.return_code,
50
88
  "timestamp": self.timestamp,
89
+ "project_path": self.project_path,
51
90
  }
52
91
 
53
92
 
@@ -61,10 +100,9 @@ class ToolResult:
61
100
  data into a plain dictionary."""
62
101
 
63
102
  metrics: dict[str, float] = field(default_factory=dict)
64
- details: dict[str, Any] = field(
65
- default_factory=dict
66
- ) # Additional details about the metric
103
+ details: dict[str, Any] = field(default_factory=dict)
67
104
  raw: RawResult = field(default_factory=RawResult)
105
+ project_path: str = ""
68
106
  duration_s: float = 0.0
69
107
 
70
108
  def __post_init__(self):
@@ -80,6 +118,7 @@ class ToolResult:
80
118
  "tool_name": self.raw.tool_name,
81
119
  "metrics": self.metrics,
82
120
  "details": self.details,
121
+ "project_path": self.project_path,
83
122
  "duration_s": self.duration_s,
84
123
  }
85
124
 
@@ -102,7 +141,12 @@ class CombinedToolResults:
102
141
  self.tool_results = tool_results
103
142
  self.path = path
104
143
  scored = [tr for tr in tool_results if tr.metrics]
105
- self.score = sum(sum(tr.metrics.values()) / len(tr.metrics) for tr in scored) / len(scored) if scored else 0.0
144
+ self.score = (
145
+ sum(sum(tr.metrics.values()) / len(tr.metrics) for tr in scored)
146
+ / len(scored)
147
+ if scored
148
+ else 0.0
149
+ )
106
150
 
107
151
  score: float = 0.0
108
152
  path: str = ""
@@ -129,7 +173,9 @@ class AbstractParser(ABC):
129
173
  """Converts raw tool output into a structured ToolResult."""
130
174
  pass
131
175
 
132
- def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
176
+ def format_llm_message(
177
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
178
+ ) -> str:
133
179
  """Return a single-defect description for LLM consumption.
134
180
 
135
181
  Default implementation reports the worst metric by name and score.
py_cq/main.py CHANGED
@@ -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()
py_cq/parsers/__init__.py CHANGED
@@ -1 +1 @@
1
- """Tool Response parsers"""
1
+ """Tool Response parsers"""