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.
- py_cq/__init__.py +3 -4
- py_cq/api.py +248 -0
- py_cq/cli.py +216 -90
- py_cq/config/config.toml +95 -0
- py_cq/context_hash.py +18 -8
- py_cq/execution_engine.py +191 -26
- py_cq/language_detector.py +4 -1
- py_cq/llm_formatter.py +200 -18
- py_cq/localtypes.py +53 -7
- py_cq/parsers/__init__.py +1 -1
- py_cq/parsers/banditparser.py +42 -19
- py_cq/parsers/common.py +184 -15
- py_cq/parsers/compileparser.py +9 -4
- py_cq/parsers/complexityparser.py +38 -9
- py_cq/parsers/coverageparser.py +184 -70
- py_cq/parsers/exitcodeparser.py +11 -2
- py_cq/parsers/halsteadparser.py +41 -20
- py_cq/parsers/interrogateparser.py +261 -25
- py_cq/parsers/linecountparser.py +10 -2
- py_cq/parsers/maintainabilityparser.py +32 -9
- py_cq/parsers/pytestparser.py +77 -20
- py_cq/parsers/regexcountparser.py +13 -3
- py_cq/parsers/ruffparser.py +160 -16
- py_cq/parsers/typarser.py +175 -43
- py_cq/parsers/vultureparser.py +22 -16
- py_cq/table_formatter.py +16 -2
- py_cq/tool_registry.py +7 -6
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.2.dist-info}/METADATA +88 -3
- python_code_quality-0.2.2.dist-info/RECORD +35 -0
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.2.dist-info}/WHEEL +1 -1
- py_cq/config/config.yaml +0 -94
- python_code_quality-0.1.16.dist-info/RECORD +0 -34
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.2.dist-info}/entry_points.txt +0 -0
py_cq/cli.py
CHANGED
|
@@ -10,26 +10,24 @@ 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
|
-
|
|
13
|
+
|
|
14
14
|
import io
|
|
15
15
|
import json
|
|
16
16
|
import logging
|
|
17
|
+
import time
|
|
17
18
|
import tomllib
|
|
18
19
|
from enum import Enum
|
|
19
|
-
from importlib import import_module
|
|
20
20
|
from importlib.metadata import requires, version
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
+
import tomlkit
|
|
23
24
|
import typer
|
|
24
25
|
from rich.console import Console
|
|
25
26
|
from rich.logging import RichHandler
|
|
26
27
|
from rich.table import Table
|
|
27
28
|
|
|
28
|
-
from py_cq.
|
|
29
|
-
from py_cq.execution_engine import _cache as tool_cache
|
|
30
|
-
from py_cq.execution_engine import run_tools
|
|
29
|
+
from py_cq.api import CQ, _apply_user_config
|
|
31
30
|
from py_cq.language_detector import detect_language
|
|
32
|
-
from py_cq.localtypes import ToolConfig
|
|
33
31
|
from py_cq.metric_aggregator import aggregate_metrics
|
|
34
32
|
from py_cq.table_formatter import format_as_table
|
|
35
33
|
from py_cq.tool_registry import tool_registry
|
|
@@ -45,60 +43,27 @@ app = typer.Typer(
|
|
|
45
43
|
epilog=(
|
|
46
44
|
"Examples:\n\n"
|
|
47
45
|
" cq check . # full table with all metrics (default)\n\n"
|
|
48
|
-
" cq check . -o llm
|
|
49
|
-
" cq check . -o
|
|
50
|
-
" cq check . -o
|
|
51
|
-
" cq check . -o
|
|
52
|
-
" cq
|
|
46
|
+
" cq check . -o llm # top defect as markdown (primary LLM workflow)\n\n"
|
|
47
|
+
" cq check . -o llm-json # top defect as JSON with fingerprint for automation\n\n"
|
|
48
|
+
" cq check . -o score # numeric score only\n\n"
|
|
49
|
+
" cq check . -o json # parsed metrics as json\n\n"
|
|
50
|
+
" cq check . -o raw # unprocessed tool output as json\n\n"
|
|
51
|
+
" cq config # show effective tool configuration\n"
|
|
52
|
+
" cq config --path . # show configuration for current project\n\n"
|
|
53
|
+
" cq config set radon-hal --warning 0.45 --error 0.25 # set thresholds\n\n"
|
|
54
|
+
" cq config set radon-hal --error 0.25 --path . # set with path"
|
|
53
55
|
),
|
|
54
56
|
)
|
|
55
57
|
|
|
56
58
|
|
|
57
|
-
def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str, ToolConfig]:
|
|
58
|
-
"""Return a modified copy of base with user overrides applied.
|
|
59
|
-
|
|
60
|
-
Supports:
|
|
61
|
-
- ``disable``: list of tool IDs to remove
|
|
62
|
-
- ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
|
|
63
|
-
- ``tools.<tool_id>``: declare new tools (or override built-ins)
|
|
64
|
-
"""
|
|
65
|
-
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
66
|
-
for tool_id in user_cfg.get("disable", []):
|
|
67
|
-
registry.pop(tool_id, None)
|
|
68
|
-
for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
|
|
69
|
-
if tool_id in registry:
|
|
70
|
-
if "warning" in thresholds:
|
|
71
|
-
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
72
|
-
if "error" in thresholds:
|
|
73
|
-
registry[tool_id].error_threshold = float(thresholds["error"])
|
|
74
|
-
for tool_id, tool_data in user_cfg.get("tools", {}).items():
|
|
75
|
-
try:
|
|
76
|
-
parser_name = tool_data["parser"]
|
|
77
|
-
module = import_module(f"py_cq.parsers.{parser_name.lower()}")
|
|
78
|
-
parser_class = getattr(module, parser_name)
|
|
79
|
-
registry[tool_id] = ToolConfig(
|
|
80
|
-
name=tool_id,
|
|
81
|
-
command=tool_data["command"],
|
|
82
|
-
parser_class=parser_class,
|
|
83
|
-
order=tool_data["order"],
|
|
84
|
-
warning_threshold=tool_data["warning_threshold"],
|
|
85
|
-
error_threshold=tool_data["error_threshold"],
|
|
86
|
-
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
87
|
-
extra_deps=tool_data.get("extra_deps", []),
|
|
88
|
-
parser_config=tool_data.get("parser_config", {}),
|
|
89
|
-
exclude_format=tool_data.get("exclude_format", ""),
|
|
90
|
-
)
|
|
91
|
-
except KeyError as e:
|
|
92
|
-
raise typer.BadParameter(f"[tool.cq.tools.{tool_id}] missing required field {e}")
|
|
93
|
-
return registry
|
|
94
|
-
|
|
95
|
-
|
|
96
59
|
class OutputMode(str, Enum):
|
|
97
60
|
"""Enum of output types."""
|
|
61
|
+
|
|
98
62
|
TABLE = "table"
|
|
99
63
|
SCORE = "score"
|
|
100
64
|
JSON = "json"
|
|
101
65
|
LLM = "llm"
|
|
66
|
+
LLM_JSON = "llm-json"
|
|
102
67
|
RAW = "raw"
|
|
103
68
|
|
|
104
69
|
|
|
@@ -106,13 +71,11 @@ def _version_callback(value: bool) -> None:
|
|
|
106
71
|
if not value:
|
|
107
72
|
return
|
|
108
73
|
import re
|
|
109
|
-
|
|
110
|
-
if isinstance(sys.stdout, io.TextIOWrapper): # pragma: no branch
|
|
111
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
74
|
+
|
|
112
75
|
pkg = "python-code-quality"
|
|
113
76
|
pkg_version = version(pkg)
|
|
114
77
|
dep_versions: list[tuple[str, str]] = []
|
|
115
|
-
for req in
|
|
78
|
+
for req in requires(pkg) or []:
|
|
116
79
|
if "; extra ==" in req:
|
|
117
80
|
continue
|
|
118
81
|
dep_name = re.split(r"[>=<!;\s\[]", req)[0]
|
|
@@ -122,17 +85,28 @@ def _version_callback(value: bool) -> None:
|
|
|
122
85
|
pass
|
|
123
86
|
typer.echo(f"{pkg} v{pkg_version}")
|
|
124
87
|
for dep_name, dep_ver in sorted(dep_versions):
|
|
125
|
-
typer.echo(f"
|
|
88
|
+
typer.echo(f"+-- {dep_name} v{dep_ver}")
|
|
126
89
|
raise typer.Exit()
|
|
127
90
|
|
|
128
91
|
|
|
129
92
|
@app.callback()
|
|
130
93
|
def callback(
|
|
131
94
|
_: bool = typer.Option(
|
|
132
|
-
False,
|
|
95
|
+
False,
|
|
96
|
+
"--version",
|
|
97
|
+
"-V",
|
|
98
|
+
callback=_version_callback,
|
|
99
|
+
is_eager=True,
|
|
100
|
+
help="Show version and dependencies",
|
|
133
101
|
),
|
|
134
102
|
) -> None:
|
|
135
103
|
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
107
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
136
110
|
console = Console()
|
|
137
111
|
|
|
138
112
|
|
|
@@ -140,7 +114,10 @@ console = Console()
|
|
|
140
114
|
def check(
|
|
141
115
|
path: str = typer.Argument(".", help="Path to Python file or project directory"),
|
|
142
116
|
output: OutputMode = typer.Option(
|
|
143
|
-
OutputMode.TABLE,
|
|
117
|
+
OutputMode.TABLE,
|
|
118
|
+
"--output",
|
|
119
|
+
"-o",
|
|
120
|
+
help="Output mode: table (default), score, json, llm",
|
|
144
121
|
),
|
|
145
122
|
log_level: str = typer.Option(
|
|
146
123
|
"CRITICAL",
|
|
@@ -151,10 +128,15 @@ def check(
|
|
|
151
128
|
False, "--clear-cache", help="Clear cached tool results before running"
|
|
152
129
|
),
|
|
153
130
|
workers: int = typer.Option(
|
|
154
|
-
0,
|
|
131
|
+
0,
|
|
132
|
+
"--workers",
|
|
133
|
+
help="Max parallel workers (default: one per tool, use 1 for sequential)",
|
|
155
134
|
),
|
|
156
135
|
language: str | None = typer.Option(
|
|
157
|
-
None,
|
|
136
|
+
None,
|
|
137
|
+
"--language",
|
|
138
|
+
"-l",
|
|
139
|
+
help="Override language detection (e.g. python, typescript, rust)",
|
|
158
140
|
),
|
|
159
141
|
only: str | None = typer.Option(
|
|
160
142
|
None, "--only", help="Comma-separated tool IDs to run (e.g. ruff,ty,pytest)"
|
|
@@ -165,8 +147,20 @@ def check(
|
|
|
165
147
|
exclude: str | None = typer.Option(
|
|
166
148
|
None, "--exclude", help="Comma-separated paths to exclude (e.g. demo,docs)"
|
|
167
149
|
),
|
|
150
|
+
hint: bool = typer.Option(
|
|
151
|
+
False, "--hint", help="Append 'run cq again to verify' to -o llm output"
|
|
152
|
+
),
|
|
153
|
+
limit: int = typer.Option(
|
|
154
|
+
1, "--limit", help="Number of issues to show with -o llm (default: 1)"
|
|
155
|
+
),
|
|
156
|
+
silence: list[str] = typer.Option(
|
|
157
|
+
[],
|
|
158
|
+
"--silence",
|
|
159
|
+
"-s",
|
|
160
|
+
help="Silence issues from -o llm output (e.g. -s src/foo.py or -s src/foo.py:42:E501)",
|
|
161
|
+
),
|
|
168
162
|
):
|
|
169
|
-
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm"""
|
|
163
|
+
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
|
|
170
164
|
path_obj = Path(path)
|
|
171
165
|
if not path_obj.exists():
|
|
172
166
|
raise typer.BadParameter(f"Path does not exist: {path}")
|
|
@@ -180,9 +174,6 @@ def check(
|
|
|
180
174
|
)
|
|
181
175
|
raise typer.Exit(0)
|
|
182
176
|
|
|
183
|
-
# Python path (or unknown — fall through to existing validation).
|
|
184
|
-
# Note: --language python still requires pyproject.toml; the flag selects
|
|
185
|
-
# the tool set, not the input validation rules.
|
|
186
177
|
if path_obj.is_file():
|
|
187
178
|
if path_obj.suffix != ".py":
|
|
188
179
|
raise typer.BadParameter(f"File must be a Python file (.py): {path}")
|
|
@@ -190,39 +181,69 @@ def check(
|
|
|
190
181
|
if not (path_obj / "pyproject.toml").exists():
|
|
191
182
|
raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
|
|
192
183
|
log.setLevel(log_level)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
184
|
+
|
|
185
|
+
only_list = [t.strip() for t in only.split(",")] if only else None
|
|
186
|
+
skip_list = [t.strip() for t in skip.split(",")] if skip else None
|
|
187
|
+
exclude_list = [e.strip() for e in exclude.split(",")] if exclude else None
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
cq = CQ(
|
|
191
|
+
path_obj,
|
|
192
|
+
only=only_list,
|
|
193
|
+
skip=skip_list,
|
|
194
|
+
exclude=exclude_list,
|
|
195
|
+
workers=workers,
|
|
196
|
+
clear_cache=clear_cache,
|
|
197
|
+
)
|
|
198
|
+
except ValueError as e:
|
|
199
|
+
raise typer.BadParameter(str(e))
|
|
200
|
+
|
|
201
|
+
is_llm = output in (OutputMode.LLM, OutputMode.LLM_JSON)
|
|
202
|
+
t0 = time.perf_counter()
|
|
203
|
+
tool_results = cq.raw(early_exit=is_llm)
|
|
204
|
+
total_s = time.perf_counter() - t0
|
|
205
|
+
combined = aggregate_metrics(path, tool_results)
|
|
206
|
+
|
|
211
207
|
if output == OutputMode.SCORE:
|
|
212
|
-
console.print(
|
|
208
|
+
console.print(combined.score)
|
|
213
209
|
elif output == OutputMode.JSON:
|
|
214
210
|
print(json.dumps([tr.to_dict() for tr in tool_results], indent=2))
|
|
215
211
|
elif output == OutputMode.RAW:
|
|
216
212
|
print(json.dumps([tr.raw.to_dict() for tr in tool_results], indent=2))
|
|
217
213
|
elif output == OutputMode.LLM:
|
|
218
|
-
# log.setLevel("CRITICAL")
|
|
219
214
|
from py_cq.llm_formatter import format_for_llm
|
|
220
|
-
|
|
215
|
+
|
|
216
|
+
print(
|
|
217
|
+
format_for_llm(
|
|
218
|
+
cq._registry,
|
|
219
|
+
combined,
|
|
220
|
+
context_lines=cq._context_lines,
|
|
221
|
+
hint=hint,
|
|
222
|
+
limit=limit,
|
|
223
|
+
silence=silence,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
elif output == OutputMode.LLM_JSON:
|
|
227
|
+
from py_cq.llm_formatter import format_for_llm_json
|
|
228
|
+
|
|
229
|
+
print(
|
|
230
|
+
json.dumps(
|
|
231
|
+
format_for_llm_json(
|
|
232
|
+
cq._registry,
|
|
233
|
+
combined,
|
|
234
|
+
context_lines=cq._context_lines,
|
|
235
|
+
hint=hint,
|
|
236
|
+
limit=limit,
|
|
237
|
+
silence=silence,
|
|
238
|
+
project_root=cq._project_root,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
)
|
|
221
242
|
else:
|
|
222
243
|
console.print(f"[bold green]{path_obj.resolve()}[/]")
|
|
223
|
-
console.print(format_as_table(
|
|
244
|
+
console.print(format_as_table(combined, cq._registry, total_s=total_s))
|
|
224
245
|
|
|
225
|
-
tool_by_name = {tc.name: tc for tc in
|
|
246
|
+
tool_by_name = {tc.name: tc for tc in cq._registry.values()}
|
|
226
247
|
if any(
|
|
227
248
|
min(tr.metrics.values()) < tool_by_name[tr.raw.tool_name].error_threshold
|
|
228
249
|
for tr in tool_results
|
|
@@ -231,11 +252,20 @@ def check(
|
|
|
231
252
|
raise typer.Exit(code=1)
|
|
232
253
|
|
|
233
254
|
|
|
234
|
-
|
|
255
|
+
config_app = typer.Typer(help="Show or modify tool configuration")
|
|
256
|
+
app.add_typer(config_app, name="config")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@config_app.callback(invoke_without_command=True)
|
|
235
260
|
def config(
|
|
236
|
-
|
|
261
|
+
ctx: typer.Context,
|
|
262
|
+
path: str = typer.Option(
|
|
263
|
+
".", "--path", "-p", help="Path to Python file or project directory"
|
|
264
|
+
),
|
|
237
265
|
) -> None:
|
|
238
266
|
"""Show the effective tool configuration for a project."""
|
|
267
|
+
if ctx.invoked_subcommand is not None:
|
|
268
|
+
return
|
|
239
269
|
path_obj = Path(path).resolve()
|
|
240
270
|
toml_path = (
|
|
241
271
|
path_obj.parent / "pyproject.toml"
|
|
@@ -259,7 +289,10 @@ def config(
|
|
|
259
289
|
|
|
260
290
|
console.print(f"Config: [bold]{toml_path}[/bold] ({status_text})\n")
|
|
261
291
|
|
|
262
|
-
|
|
292
|
+
try:
|
|
293
|
+
effective_registry = _apply_user_config(tool_registry, user_cfg)
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
raise typer.BadParameter(str(e))
|
|
263
296
|
disabled_ids = set(tool_registry.keys()) - set(effective_registry.keys())
|
|
264
297
|
|
|
265
298
|
table = Table()
|
|
@@ -270,7 +303,10 @@ def config(
|
|
|
270
303
|
table.add_column("Status", justify="center")
|
|
271
304
|
|
|
272
305
|
all_tool_ids = set(tool_registry) | set(effective_registry)
|
|
273
|
-
for tool_id in sorted(
|
|
306
|
+
for tool_id in sorted(
|
|
307
|
+
all_tool_ids,
|
|
308
|
+
key=lambda t: (effective_registry.get(t) or tool_registry[t]).order,
|
|
309
|
+
):
|
|
274
310
|
tc = effective_registry.get(tool_id) or tool_registry[tool_id]
|
|
275
311
|
is_disabled = tool_id in disabled_ids
|
|
276
312
|
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
|
@@ -285,3 +321,93 @@ def config(
|
|
|
285
321
|
console.print(table)
|
|
286
322
|
|
|
287
323
|
|
|
324
|
+
@config_app.command("set")
|
|
325
|
+
def config_set(
|
|
326
|
+
tool_id: str = typer.Argument(..., help="Tool ID (e.g. radon-hal, ruff)"),
|
|
327
|
+
warning: float | None = typer.Option(
|
|
328
|
+
None, "--warning", "-w", help="Warning threshold (0-1)"
|
|
329
|
+
),
|
|
330
|
+
error: float | None = typer.Option(
|
|
331
|
+
None, "--error", "-e", help="Error threshold (0-1)"
|
|
332
|
+
),
|
|
333
|
+
path: str = typer.Option(".", "--path", "-p", help="Path to project directory"),
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Set warning/error thresholds for a tool in pyproject.toml."""
|
|
336
|
+
if warning is None and error is None:
|
|
337
|
+
raise typer.BadParameter("At least one of --warning or --error is required")
|
|
338
|
+
|
|
339
|
+
if tool_id not in tool_registry:
|
|
340
|
+
available = ", ".join(sorted(tool_registry))
|
|
341
|
+
raise typer.BadParameter(
|
|
342
|
+
f"Unknown tool: {tool_id!r}. Available tools: {available}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
path_obj = Path(path).resolve()
|
|
346
|
+
if not path_obj.is_dir():
|
|
347
|
+
raise typer.BadParameter(f"Path must be a directory: {path}")
|
|
348
|
+
|
|
349
|
+
toml_path = path_obj / "pyproject.toml"
|
|
350
|
+
if not toml_path.exists():
|
|
351
|
+
raise typer.BadParameter(f"No pyproject.toml found at {toml_path}")
|
|
352
|
+
|
|
353
|
+
with toml_path.open("r", encoding="utf-8") as f:
|
|
354
|
+
doc = tomlkit.parse(f.read())
|
|
355
|
+
|
|
356
|
+
if "tool" not in doc:
|
|
357
|
+
doc["tool"] = tomlkit.table()
|
|
358
|
+
tool_tbl = doc["tool"]
|
|
359
|
+
if "cq" not in tool_tbl:
|
|
360
|
+
tool_tbl["cq"] = tomlkit.table()
|
|
361
|
+
cq_tbl = tool_tbl["cq"]
|
|
362
|
+
if "thresholds" not in cq_tbl:
|
|
363
|
+
cq_tbl["thresholds"] = tomlkit.table()
|
|
364
|
+
thresholds = cq_tbl["thresholds"]
|
|
365
|
+
|
|
366
|
+
if tool_id in thresholds:
|
|
367
|
+
entry = thresholds[tool_id]
|
|
368
|
+
else:
|
|
369
|
+
entry = tomlkit.inline_table()
|
|
370
|
+
|
|
371
|
+
if warning is not None:
|
|
372
|
+
entry["warning"] = warning
|
|
373
|
+
if error is not None:
|
|
374
|
+
entry["error"] = error
|
|
375
|
+
thresholds[tool_id] = entry
|
|
376
|
+
|
|
377
|
+
with toml_path.open("w", encoding="utf-8") as f:
|
|
378
|
+
f.write(tomlkit.dumps(doc))
|
|
379
|
+
|
|
380
|
+
parts = []
|
|
381
|
+
if warning is not None:
|
|
382
|
+
parts.append(f"warning={warning}")
|
|
383
|
+
if error is not None:
|
|
384
|
+
parts.append(f"error={error}")
|
|
385
|
+
console.print(
|
|
386
|
+
f"[green]Set {tool_id} thresholds ({', '.join(parts)}) in {toml_path}[/green]"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
from py_cq.execution_engine import _cache
|
|
390
|
+
|
|
391
|
+
_cache.clear()
|
|
392
|
+
console.print("[dim]Tool cache cleared[/dim]")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.command()
|
|
396
|
+
def is_fixed(
|
|
397
|
+
fingerprint: str = typer.Argument(
|
|
398
|
+
...,
|
|
399
|
+
help="Fingerprint from -o llm-json output (tool::project::path[::line[::code]])",
|
|
400
|
+
),
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Return True if the fingerprinted issue is no longer present."""
|
|
403
|
+
try:
|
|
404
|
+
cq = CQ(".")
|
|
405
|
+
fixed = cq.is_fixed(fingerprint)
|
|
406
|
+
except ValueError as e:
|
|
407
|
+
raise typer.BadParameter(str(e))
|
|
408
|
+
|
|
409
|
+
if fixed:
|
|
410
|
+
typer.echo("FIXED")
|
|
411
|
+
else:
|
|
412
|
+
typer.echo(f"FAILED: {fingerprint}")
|
|
413
|
+
raise typer.Exit(1)
|
py_cq/config/config.toml
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[python.compile]
|
|
2
|
+
command = "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv|\\.claude"
|
|
3
|
+
parser = "CompileParser"
|
|
4
|
+
order = 1
|
|
5
|
+
warning_threshold = 0.9999
|
|
6
|
+
error_threshold = 0.9999
|
|
7
|
+
|
|
8
|
+
[python.ruff]
|
|
9
|
+
command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
|
|
10
|
+
exclude_format = " --exclude {path}"
|
|
11
|
+
parser = "RuffParser"
|
|
12
|
+
order = 2
|
|
13
|
+
warning_threshold = 0.9999
|
|
14
|
+
error_threshold = 0.9
|
|
15
|
+
|
|
16
|
+
[python.ty]
|
|
17
|
+
command = "{python} -m ty check --output-format concise --color never \"{context_path}\" --exclude .claude{exclude}"
|
|
18
|
+
exclude_format = " --exclude {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 = ["ty"]
|
|
25
|
+
|
|
26
|
+
[python.bandit]
|
|
27
|
+
command = "{python} -m bandit -r {scan_targets} -f json -q -s B101 --severity-level medium{exclude}"
|
|
28
|
+
scan_exclude_names = [".venv", ".claude", "tests"]
|
|
29
|
+
exclude_format = " --exclude {abs_native_path}"
|
|
30
|
+
parser = "BanditParser"
|
|
31
|
+
order = 4
|
|
32
|
+
warning_threshold = 0.9999
|
|
33
|
+
error_threshold = 0.8
|
|
34
|
+
|
|
35
|
+
[python.pytest]
|
|
36
|
+
command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
|
|
37
|
+
exclude_format = " --ignore {path}"
|
|
38
|
+
parser = "PytestParser"
|
|
39
|
+
order = 5
|
|
40
|
+
warning_threshold = 1.0
|
|
41
|
+
error_threshold = 1.0
|
|
42
|
+
run_in_target_env = true
|
|
43
|
+
extra_deps = ["pytest"]
|
|
44
|
+
skip_for_file = true
|
|
45
|
+
|
|
46
|
+
[python.coverage]
|
|
47
|
+
command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
|
|
48
|
+
exclude_format = " --ignore {path}"
|
|
49
|
+
parser = "CoverageParser"
|
|
50
|
+
order = 6
|
|
51
|
+
warning_threshold = 0.9
|
|
52
|
+
error_threshold = 0.5
|
|
53
|
+
run_in_target_env = true
|
|
54
|
+
extra_deps = ["coverage", "pytest"]
|
|
55
|
+
skip_for_file = true
|
|
56
|
+
|
|
57
|
+
[python.radon-cc]
|
|
58
|
+
command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
|
|
59
|
+
parser = "ComplexityParser"
|
|
60
|
+
order = 7
|
|
61
|
+
warning_threshold = 0.6
|
|
62
|
+
error_threshold = 0.4
|
|
63
|
+
|
|
64
|
+
[python.radon-mi]
|
|
65
|
+
command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
|
|
66
|
+
parser = "MaintainabilityParser"
|
|
67
|
+
order = 8
|
|
68
|
+
warning_threshold = 0.6
|
|
69
|
+
error_threshold = 0.4
|
|
70
|
+
|
|
71
|
+
[python.radon-hal]
|
|
72
|
+
command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
|
|
73
|
+
parser = "HalsteadParser"
|
|
74
|
+
order = 9
|
|
75
|
+
warning_threshold = 0.5
|
|
76
|
+
error_threshold = 0.3
|
|
77
|
+
|
|
78
|
+
[python.vulture]
|
|
79
|
+
command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
|
|
80
|
+
exclude_format = ",{path}"
|
|
81
|
+
parser = "VultureParser"
|
|
82
|
+
order = 10
|
|
83
|
+
warning_threshold = 0.9999
|
|
84
|
+
error_threshold = 0.8
|
|
85
|
+
|
|
86
|
+
[python.interrogate]
|
|
87
|
+
command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
|
|
88
|
+
exclude_format = " -e {path}"
|
|
89
|
+
parser = "InterrogateParser"
|
|
90
|
+
order = 11
|
|
91
|
+
warning_threshold = 0.8
|
|
92
|
+
error_threshold = 0.3
|
|
93
|
+
|
|
94
|
+
[python.interrogate.parser_config]
|
|
95
|
+
skip_empty_init = true
|
py_cq/context_hash.py
CHANGED
|
@@ -44,15 +44,22 @@ def get_sigs(path: str):
|
|
|
44
44
|
items = []
|
|
45
45
|
with os.scandir(path) as entries:
|
|
46
46
|
for entry in entries:
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
# Use follow_symlinks=False to prevent cache poisoning from
|
|
48
|
+
# symlinks pointing outside the project tree (M-2)
|
|
49
|
+
if entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
|
|
50
|
+
stat_info = entry.stat(follow_symlinks=False)
|
|
49
51
|
items.append(f"{entry.path}:{stat_info.st_size}:{stat_info.st_mtime}")
|
|
50
|
-
if entry.is_dir() and entry.name not in [
|
|
52
|
+
if entry.is_dir(follow_symlinks=False) and entry.name not in [
|
|
53
|
+
".venv",
|
|
54
|
+
"venv",
|
|
55
|
+
"__pycache__",
|
|
56
|
+
".git",
|
|
57
|
+
]:
|
|
51
58
|
items.extend(get_sigs(entry.path))
|
|
52
59
|
return items
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
def get_context_hash(path: str):
|
|
62
|
+
def get_context_hash(path: str) -> str:
|
|
56
63
|
"""Compute an MD5 hash that uniquely identifies a file or directory.
|
|
57
64
|
|
|
58
65
|
The hash is derived from a signature string. For a file, the signature consists of
|
|
@@ -72,10 +79,13 @@ def get_context_hash(path: str):
|
|
|
72
79
|
>>> get_context_hash('/tmp/example.txt')
|
|
73
80
|
'5d41402abc4b2a76b9719d911017c592'
|
|
74
81
|
"""
|
|
75
|
-
|
|
82
|
+
h = hashlib.md5() # nosec
|
|
76
83
|
if os.path.isfile(path):
|
|
77
84
|
s = os.stat(path)
|
|
78
|
-
|
|
85
|
+
h.update(f"{path}:{s.st_size}:{s.st_mtime}".encode())
|
|
79
86
|
elif os.path.isdir(path):
|
|
80
|
-
sig
|
|
81
|
-
|
|
87
|
+
for sig in sorted(get_sigs(path)):
|
|
88
|
+
h.update(sig.encode())
|
|
89
|
+
else:
|
|
90
|
+
h.update(b"empty")
|
|
91
|
+
return h.hexdigest()
|