python-code-quality 0.1.8__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 +54 -5
- py_cq/config/{tools.yaml → config.yaml} +28 -37
- py_cq/execution_engine.py +2 -2
- py_cq/language_detector.py +29 -0
- py_cq/llm_formatter.py +2 -1
- py_cq/localtypes.py +5 -1
- py_cq/parsers/banditparser.py +2 -2
- py_cq/parsers/common.py +31 -0
- py_cq/parsers/compileparser.py +2 -2
- py_cq/parsers/coverageparser.py +1 -1
- py_cq/parsers/exitcodeparser.py +16 -0
- py_cq/parsers/halsteadparser.py +1 -1
- py_cq/parsers/interrogateparser.py +1 -1
- py_cq/parsers/linecountparser.py +26 -0
- py_cq/parsers/pytestparser.py +53 -10
- py_cq/parsers/regexcountparser.py +35 -0
- py_cq/parsers/ruffparser.py +2 -2
- py_cq/parsers/typarser.py +2 -2
- py_cq/parsers/vultureparser.py +2 -2
- py_cq/tool_registry.py +5 -4
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/METADATA +43 -46
- python_code_quality-0.1.9.dist-info/RECORD +34 -0
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/WHEEL +1 -1
- python_code_quality-0.1.8.dist-info/RECORD +0 -30
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/entry_points.txt +0 -0
- {python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/licenses/LICENSE +0 -0
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
|
|
@@ -56,6 +58,7 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
|
|
|
56
58
|
Supports:
|
|
57
59
|
- ``disable``: list of tool IDs to remove
|
|
58
60
|
- ``thresholds.<tool_id>.warning`` / ``.error``: override per-tool thresholds
|
|
61
|
+
- ``tools.<tool_id>``: declare new tools (or override built-ins)
|
|
59
62
|
"""
|
|
60
63
|
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
61
64
|
for tool_id in user_cfg.get("disable", []):
|
|
@@ -66,6 +69,24 @@ def _apply_user_config(base: dict[str, ToolConfig], user_cfg: dict) -> dict[str,
|
|
|
66
69
|
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
67
70
|
if "error" in thresholds:
|
|
68
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}")
|
|
69
90
|
return registry
|
|
70
91
|
|
|
71
92
|
|
|
@@ -101,11 +122,27 @@ def check(
|
|
|
101
122
|
workers: int = typer.Option(
|
|
102
123
|
0, "--workers", help="Max parallel workers (default: one per tool, use 1 for sequential)"
|
|
103
124
|
),
|
|
125
|
+
language: str | None = typer.Option(
|
|
126
|
+
None, "--language", "-l", help="Override language detection (e.g. python, typescript, rust)"
|
|
127
|
+
),
|
|
104
128
|
):
|
|
105
129
|
"""Feed the results from 11+ code quality tools to an LLM. Try: cq check . -o llm""" # --help
|
|
106
130
|
path_obj = Path(path)
|
|
107
131
|
if not path_obj.exists():
|
|
108
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.
|
|
109
146
|
if path_obj.is_file():
|
|
110
147
|
if path_obj.suffix != ".py":
|
|
111
148
|
raise typer.BadParameter(f"File must be a Python file (.py): {path}")
|
|
@@ -113,7 +150,9 @@ def check(
|
|
|
113
150
|
if not (path_obj / "pyproject.toml").exists():
|
|
114
151
|
raise typer.BadParameter(f"Directory must contain pyproject.toml: {path}")
|
|
115
152
|
log.setLevel(log_level)
|
|
116
|
-
|
|
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)
|
|
117
156
|
if clear_cache:
|
|
118
157
|
tool_cache.clear()
|
|
119
158
|
tool_results = run_tools(effective_registry.values(), path, workers, early_exit=(output == OutputMode.LLM))
|
|
@@ -129,10 +168,19 @@ def check(
|
|
|
129
168
|
elif output == OutputMode.LLM:
|
|
130
169
|
# log.setLevel("CRITICAL")
|
|
131
170
|
from py_cq.llm_formatter import format_for_llm
|
|
132
|
-
console.print(format_for_llm(effective_registry, combined_metrics))
|
|
171
|
+
console.print(format_for_llm(effective_registry, combined_metrics, context_lines=context_lines))
|
|
133
172
|
else:
|
|
173
|
+
console.print(f"[bold green]{path_obj.resolve()}[/]")
|
|
134
174
|
console.print(format_as_table(combined_metrics, effective_registry))
|
|
135
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
|
+
|
|
136
184
|
|
|
137
185
|
@app.command()
|
|
138
186
|
def config(
|
|
@@ -172,8 +220,9 @@ def config(
|
|
|
172
220
|
table.add_column("Error", justify="right")
|
|
173
221
|
table.add_column("Status", justify="center")
|
|
174
222
|
|
|
175
|
-
|
|
176
|
-
|
|
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]
|
|
177
226
|
is_disabled = tool_id in disabled_ids
|
|
178
227
|
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
|
179
228
|
table.add_row(
|
|
@@ -204,7 +253,7 @@ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig]):
|
|
|
204
253
|
>>> table = format_as_table(combined_results)
|
|
205
254
|
>>> console.print(table)
|
|
206
255
|
"""
|
|
207
|
-
table = Table(
|
|
256
|
+
table = Table(width=80)
|
|
208
257
|
table.add_column("Tool", justify="left", no_wrap=True)
|
|
209
258
|
table.add_column("Time", justify="right", style="dim")
|
|
210
259
|
table.add_column("Metric", justify="right", style="cyan", no_wrap=True)
|
|
@@ -1,52 +1,48 @@
|
|
|
1
|
-
|
|
1
|
+
python:
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
|
|
3
|
+
compile:
|
|
4
|
+
command: "{python} -m compileall -r 10 -j 8 \"{context_path}\" -x .*venv"
|
|
6
5
|
parser: "CompileParser"
|
|
7
6
|
order: 1
|
|
8
7
|
warning_threshold: 0.9999
|
|
9
8
|
error_threshold: 0.9999
|
|
10
9
|
|
|
11
|
-
bandit:
|
|
12
|
-
name: "bandit"
|
|
13
|
-
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
14
|
-
parser: "BanditParser"
|
|
15
|
-
order: 2
|
|
16
|
-
warning_threshold: 0.9999
|
|
17
|
-
error_threshold: 0.8
|
|
18
|
-
|
|
19
10
|
ruff:
|
|
20
|
-
|
|
21
|
-
command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
|
|
11
|
+
command: "{python} -m ruff check --output-format concise --no-cache \"{context_path}\""
|
|
22
12
|
parser: "RuffParser"
|
|
23
|
-
order:
|
|
13
|
+
order: 2
|
|
24
14
|
warning_threshold: 0.9999
|
|
25
15
|
error_threshold: 0.9
|
|
26
16
|
|
|
27
17
|
ty:
|
|
28
|
-
|
|
29
|
-
command: "{python} -m ty check --output-format concise --color never {context_path}"
|
|
18
|
+
command: "{python} -m ty check --output-format concise --color never \"{context_path}\""
|
|
30
19
|
parser: "TyParser"
|
|
31
|
-
order:
|
|
20
|
+
order: 3
|
|
32
21
|
warning_threshold: 0.9999
|
|
33
22
|
error_threshold: 0.8
|
|
34
23
|
run_in_target_env: true
|
|
35
24
|
extra_deps:
|
|
36
25
|
- ty
|
|
37
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
|
+
|
|
38
34
|
pytest:
|
|
39
|
-
|
|
40
|
-
command: "{python} -m pytest -v {context_path}"
|
|
35
|
+
command: "{python} -m pytest -v \"{context_path}\""
|
|
41
36
|
parser: "PytestParser"
|
|
42
37
|
order: 5
|
|
43
|
-
warning_threshold: 0
|
|
44
|
-
error_threshold: 0
|
|
38
|
+
warning_threshold: 1.0
|
|
39
|
+
error_threshold: 1.0
|
|
45
40
|
run_in_target_env: true
|
|
41
|
+
extra_deps:
|
|
42
|
+
- pytest
|
|
46
43
|
|
|
47
44
|
coverage:
|
|
48
|
-
|
|
49
|
-
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
45
|
+
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
50
46
|
parser: "CoverageParser"
|
|
51
47
|
order: 6
|
|
52
48
|
warning_threshold: 0.9
|
|
@@ -56,41 +52,36 @@ tools:
|
|
|
56
52
|
- coverage
|
|
57
53
|
- pytest
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
command: "{python} -m radon cc --json {context_path}"
|
|
55
|
+
radon-cc:
|
|
56
|
+
command: "{python} -m radon cc --json \"{context_path}\""
|
|
62
57
|
parser: "ComplexityParser"
|
|
63
58
|
order: 7
|
|
64
59
|
warning_threshold: 0.6
|
|
65
60
|
error_threshold: 0.4
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
command: "{python} -m radon mi -s --json {context_path}"
|
|
62
|
+
radon-mi:
|
|
63
|
+
command: "{python} -m radon mi -s --json \"{context_path}\""
|
|
70
64
|
parser: "MaintainabilityParser"
|
|
71
65
|
order: 8
|
|
72
66
|
warning_threshold: 0.6
|
|
73
67
|
error_threshold: 0.4
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
command: "{python} -m radon hal -f --json {context_path}"
|
|
69
|
+
radon-hal:
|
|
70
|
+
command: "{python} -m radon hal -f --json \"{context_path}\""
|
|
78
71
|
parser: "HalsteadParser"
|
|
79
72
|
order: 9
|
|
80
73
|
warning_threshold: 0.5
|
|
81
74
|
error_threshold: 0.3
|
|
82
75
|
|
|
83
76
|
vulture:
|
|
84
|
-
|
|
85
|
-
command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
77
|
+
command: "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
86
78
|
parser: "VultureParser"
|
|
87
79
|
order: 10
|
|
88
80
|
warning_threshold: 0.9999
|
|
89
81
|
error_threshold: 0.8
|
|
90
82
|
|
|
91
83
|
interrogate:
|
|
92
|
-
|
|
93
|
-
command: "{python} -m interrogate {context_path} -v --fail-under 0"
|
|
84
|
+
command: "{python} -m interrogate \"{context_path}\" -v --fail-under 0"
|
|
94
85
|
parser: "InterrogateParser"
|
|
95
86
|
order: 11
|
|
96
87
|
warning_threshold: 0.8
|
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,
|
|
@@ -139,7 +139,7 @@ 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
144
|
return tool_config.order, tr
|
|
145
145
|
|
|
@@ -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()}
|
|
@@ -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
|
@@ -21,6 +21,7 @@ class ToolConfig:
|
|
|
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
|
|
@@ -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.
|
py_cq/parsers/banditparser.py
CHANGED
|
@@ -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
|
py_cq/parsers/compileparser.py
CHANGED
|
@@ -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}"
|
py_cq/parsers/coverageparser.py
CHANGED
|
@@ -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)"
|
py_cq/parsers/halsteadparser.py
CHANGED
|
@@ -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)
|
py_cq/parsers/pytestparser.py
CHANGED
|
@@ -8,11 +8,33 @@ process return code so downstream components can uniformly consume results
|
|
|
8
8
|
from multiple test tools. It is part of the test-collection framework and
|
|
9
9
|
enables consistent handling of pytest output across the system."""
|
|
10
10
|
|
|
11
|
-
import re
|
|
11
|
+
import re as _re
|
|
12
12
|
|
|
13
13
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _extract_failure(stdout: str, test_name: str, max_lines: int) -> str:
|
|
17
|
+
"""Extract the failure section for test_name from pytest stdout."""
|
|
18
|
+
lines = stdout.splitlines()
|
|
19
|
+
pattern = _re.compile(rf"_{{4,}}\s+{_re.escape(test_name)}\s+_{{4,}}")
|
|
20
|
+
start = None
|
|
21
|
+
for i, line in enumerate(lines):
|
|
22
|
+
if pattern.search(line):
|
|
23
|
+
start = i + 1
|
|
24
|
+
break
|
|
25
|
+
if start is None:
|
|
26
|
+
return ""
|
|
27
|
+
collected = []
|
|
28
|
+
for line in lines[start:]:
|
|
29
|
+
if line.strip().startswith("_") or line.strip().startswith("="):
|
|
30
|
+
break
|
|
31
|
+
collected.append(line)
|
|
32
|
+
if len(collected) >= max_lines:
|
|
33
|
+
break
|
|
34
|
+
text = "\n".join(collected).strip()
|
|
35
|
+
return f"\n```\n{text}\n```" if text else ""
|
|
36
|
+
|
|
37
|
+
|
|
16
38
|
class PytestParser(AbstractParser):
|
|
17
39
|
"""Parses raw pytest output into a structured `ToolResult`.
|
|
18
40
|
|
|
@@ -51,14 +73,14 @@ class PytestParser(AbstractParser):
|
|
|
51
73
|
lines = raw_result.stdout.splitlines()
|
|
52
74
|
tr = ToolResult(raw=raw_result)
|
|
53
75
|
if "no tests ran" in raw_result.stdout:
|
|
54
|
-
|
|
76
|
+
tr.metrics["tests"] = 0.0
|
|
55
77
|
else:
|
|
56
78
|
tests_found = dict()
|
|
57
79
|
num_tests = 0
|
|
58
80
|
passed_tests = 0
|
|
59
81
|
for line in lines:
|
|
60
82
|
# tests/test_common.py::test_name[param] PASSED [ 8%]
|
|
61
|
-
tests_match =
|
|
83
|
+
tests_match = _re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
|
|
62
84
|
if tests_match:
|
|
63
85
|
test_file = tests_match.group(1)
|
|
64
86
|
test_name = tests_match.group(2).strip()
|
|
@@ -70,7 +92,7 @@ class PytestParser(AbstractParser):
|
|
|
70
92
|
if num_tests == 0:
|
|
71
93
|
# No individual test lines found (e.g. non-verbose output);
|
|
72
94
|
# fall back to parsing the pytest summary line.
|
|
73
|
-
summary =
|
|
95
|
+
summary = _re.search(r"(\d+) passed(?:.*?(\d+) failed)?", raw_result.stdout)
|
|
74
96
|
if summary:
|
|
75
97
|
passed_tests = int(summary.group(1))
|
|
76
98
|
failed_tests = int(summary.group(2)) if summary.group(2) else 0
|
|
@@ -79,11 +101,32 @@ class PytestParser(AbstractParser):
|
|
|
79
101
|
tr.details = tests_found
|
|
80
102
|
return tr
|
|
81
103
|
|
|
82
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
83
|
-
"""Return the first failing test
|
|
104
|
+
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
|
|
84
107
|
for file, tests in tr.details.items():
|
|
85
|
-
if isinstance(tests, dict):
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
if not isinstance(tests, dict):
|
|
109
|
+
continue
|
|
110
|
+
for test_name, status in tests.items():
|
|
111
|
+
if status != "FAILED":
|
|
112
|
+
continue
|
|
113
|
+
header = f"`{file}::{test_name}` — test **FAILED**"
|
|
114
|
+
body = find_function_source(file, test_name, max_lines=context_lines)
|
|
115
|
+
failure = _extract_failure(tr.raw.stdout, test_name, max_lines=context_lines)
|
|
116
|
+
parts = [header]
|
|
117
|
+
if body:
|
|
118
|
+
parts.append(body)
|
|
119
|
+
if failure:
|
|
120
|
+
parts.append(failure)
|
|
121
|
+
return "\n".join(parts)
|
|
122
|
+
if "no tests ran" in tr.raw.stdout:
|
|
123
|
+
return (
|
|
124
|
+
"**No tests found.** This project has no pytest test suite.\n\n"
|
|
125
|
+
"Add a `tests/` directory with at least one test file (e.g. `tests/test_basic.py`) "
|
|
126
|
+
"and write a first test covering a core function."
|
|
127
|
+
)
|
|
128
|
+
output = (tr.raw.stdout + tr.raw.stderr).strip()
|
|
129
|
+
if output:
|
|
130
|
+
tail = "\n".join(output.splitlines()[-30:])
|
|
131
|
+
return f"pytest reported failures:\n\n```\n{tail}\n```"
|
|
89
132
|
return "pytest reported failures (no details available)"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Parser that counts stdout lines matching a regex pattern."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
6
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegexCountParser(AbstractParser):
|
|
10
|
+
"""Score based on the number of stdout lines matching a regex.
|
|
11
|
+
|
|
12
|
+
parser_config keys:
|
|
13
|
+
pattern (str, required): regex pattern to match against each line.
|
|
14
|
+
scale_factor (int, default 15): passed to score_logistic_variant.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
18
|
+
pattern = re.compile(self.parser_config["pattern"])
|
|
19
|
+
scale = self.parser_config.get("scale_factor", 15)
|
|
20
|
+
lines = (raw_result.stdout or "").splitlines()
|
|
21
|
+
matches = [ln for ln in lines if pattern.search(ln)]
|
|
22
|
+
count = len(matches)
|
|
23
|
+
score = score_logistic_variant(count, scale_factor=scale)
|
|
24
|
+
return ToolResult(
|
|
25
|
+
raw=raw_result,
|
|
26
|
+
metrics={"violations": score},
|
|
27
|
+
details={"count": count, "matches": matches},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
31
|
+
matches = tr.details.get("matches", [])
|
|
32
|
+
if not matches:
|
|
33
|
+
return "No violations found"
|
|
34
|
+
shown = matches[:context_lines]
|
|
35
|
+
return "\n".join(shown)
|
py_cq/parsers/ruffparser.py
CHANGED
|
@@ -45,7 +45,7 @@ class RuffParser(AbstractParser):
|
|
|
45
45
|
)
|
|
46
46
|
return ToolResult(raw=raw_result, metrics={"lint": score}, details=files)
|
|
47
47
|
|
|
48
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
48
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
49
49
|
"""Return the first lint violation as a defect description."""
|
|
50
50
|
if not tr.details:
|
|
51
51
|
return "ruff reported issues (no details available)"
|
|
@@ -54,4 +54,4 @@ class RuffParser(AbstractParser):
|
|
|
54
54
|
line = issue.get("line", "?")
|
|
55
55
|
code = issue.get("code", "")
|
|
56
56
|
message = issue.get("message", "")
|
|
57
|
-
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line)}"
|
|
57
|
+
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
|
py_cq/parsers/typarser.py
CHANGED
|
@@ -49,7 +49,7 @@ class TyParser(AbstractParser):
|
|
|
49
49
|
score = score_logistic_variant(weighted, scale_factor=10)
|
|
50
50
|
return ToolResult(raw=raw_result, metrics={"type_check": score}, details=files)
|
|
51
51
|
|
|
52
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
52
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
53
53
|
"""Return the first type-check diagnostic as a defect description."""
|
|
54
54
|
if not tr.details:
|
|
55
55
|
return "ty reported issues (no details available)"
|
|
@@ -58,4 +58,4 @@ class TyParser(AbstractParser):
|
|
|
58
58
|
line = issue.get("line", "?")
|
|
59
59
|
code = issue.get("code", "")
|
|
60
60
|
message = issue.get("message", "")
|
|
61
|
-
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line)}"
|
|
61
|
+
return f"`{file}:{line}` — **{code}**: {message}{format_source_context(file, line, count=context_lines)}"
|
py_cq/parsers/vultureparser.py
CHANGED
|
@@ -36,7 +36,7 @@ class VultureParser(AbstractParser):
|
|
|
36
36
|
score = score_logistic_variant(count, scale_factor=15)
|
|
37
37
|
return ToolResult(raw=raw_result, metrics={"dead_code": score}, details=files)
|
|
38
38
|
|
|
39
|
-
def format_llm_message(self, tr: ToolResult) -> str:
|
|
39
|
+
def format_llm_message(self, tr: ToolResult, *, context_lines: int = 15) -> str:
|
|
40
40
|
if not tr.details:
|
|
41
41
|
return "vulture reported issues (no details available)"
|
|
42
42
|
file, issues = next(iter(tr.details.items()))
|
|
@@ -45,4 +45,4 @@ class VultureParser(AbstractParser):
|
|
|
45
45
|
kind = issue.get("type", "unused")
|
|
46
46
|
name = issue.get("name", "")
|
|
47
47
|
confidence = issue.get("confidence", "?")
|
|
48
|
-
return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line)}"
|
|
48
|
+
return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
|
py_cq/tool_registry.py
CHANGED
|
@@ -9,19 +9,19 @@ from py_cq.localtypes import ToolConfig
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def load_tool_configs() -> dict[str, ToolConfig]:
|
|
12
|
-
"""Load tool configurations from the bundled
|
|
12
|
+
"""Load tool configurations from the bundled config.yaml and return a registry.
|
|
13
13
|
|
|
14
14
|
Returns:
|
|
15
15
|
dict[str, ToolConfig]: A mapping from tool ID to its configuration instance."""
|
|
16
|
-
yaml_text = files("py_cq.config").joinpath("
|
|
16
|
+
yaml_text = files("py_cq.config").joinpath("config.yaml").read_text(encoding="utf-8")
|
|
17
17
|
config = yaml.safe_load(yaml_text)
|
|
18
18
|
registry = {}
|
|
19
|
-
for tool_id, tool_data in config["
|
|
19
|
+
for tool_id, tool_data in config["python"].items():
|
|
20
20
|
# Dynamically import parser class
|
|
21
21
|
module = import_module(f"py_cq.parsers.{tool_data['parser'].lower()}")
|
|
22
22
|
parser_class = getattr(module, tool_data["parser"])
|
|
23
23
|
registry[tool_id] = ToolConfig(
|
|
24
|
-
name=
|
|
24
|
+
name=tool_id,
|
|
25
25
|
command=tool_data["command"],
|
|
26
26
|
parser_class=parser_class,
|
|
27
27
|
order=tool_data["order"],
|
|
@@ -29,6 +29,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
29
29
|
error_threshold=tool_data["error_threshold"],
|
|
30
30
|
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
31
31
|
extra_deps=tool_data.get("extra_deps", []),
|
|
32
|
+
parser_config=tool_data.get("parser_config", {}),
|
|
32
33
|
)
|
|
33
34
|
return registry
|
|
34
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-code-quality
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rhiza-fr/py-cq
|
|
6
6
|
Project-URL: Repository, https://github.com/rhiza-fr/py-cq
|
|
@@ -28,20 +28,16 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
Feed the results from 11+ code quality tools to an LLM. Minimal tokens.
|
|
30
30
|
|
|
31
|
+
Why? It removes the mental burden of understanding all these tools and parsing their results.
|
|
32
|
+
|
|
31
33
|
The primary workflow is:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
36
|
# get the single most critical defect as markdown
|
|
35
37
|
cq check . -o llm
|
|
36
38
|
```
|
|
37
|
-
Selects the single most critical defect using this priority order:
|
|
38
|
-
|
|
39
|
-
1. **Severity** — tools with score below `error_threshold` come before those only below `warning_threshold`
|
|
40
|
-
2. **Order** — among tools at the same severity, lower-order tools win (compile before lint before style)
|
|
41
|
-
3. **Score** — among ties, the lower score wins
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
```md
|
|
40
|
+
```python
|
|
45
41
|
`data/problems/travelling_salesman/ts_bad.py:21` — **F841**: Local variable `unused_variable` is assigned to but never used
|
|
46
42
|
|
|
47
43
|
18: min_dist = float("inf")
|
|
@@ -60,9 +56,10 @@ Feed to an LLM with edit tools and repeat until there are no issues, e.g.
|
|
|
60
56
|
```python
|
|
61
57
|
cq check . -o llm | claude -p "fix this"
|
|
62
58
|
# or
|
|
63
|
-
cq check . -o llm | ollama gpt-oss:20b "Explain how to fix this"
|
|
59
|
+
cq check . -o llm | ollama run gpt-oss:20b "Explain how to fix this"
|
|
64
60
|
```
|
|
65
61
|
|
|
62
|
+
|
|
66
63
|
## Install
|
|
67
64
|
|
|
68
65
|
```bash
|
|
@@ -70,21 +67,22 @@ cq check . -o llm | ollama gpt-oss:20b "Explain how to fix this"
|
|
|
70
67
|
uv tool install python-code-quality
|
|
71
68
|
|
|
72
69
|
# or, clone it then install
|
|
73
|
-
git
|
|
70
|
+
git clone https://github.com/rhiza-fr/py-cq.git
|
|
74
71
|
cd py-cq
|
|
75
72
|
uv tool install .
|
|
76
73
|
```
|
|
77
74
|
|
|
78
75
|
## Tools
|
|
79
76
|
|
|
80
|
-
These tools are run in **parallel** except
|
|
77
|
+
These tools are run in **parallel** except:
|
|
78
|
+
When running '-o llm', we run sequentially and exit early at the first error.
|
|
81
79
|
|
|
82
80
|
| Order | Tool | Measures |
|
|
83
81
|
|----------|------|----------|
|
|
84
82
|
| 1 | compileall | Syntax errors |
|
|
85
|
-
| 2 |
|
|
86
|
-
| 3 |
|
|
87
|
-
| 4 |
|
|
83
|
+
| 2 | ruff | Lint / style |
|
|
84
|
+
| 3 | ty | Type errors |
|
|
85
|
+
| 4 | bandit | Security vulnerabilities |
|
|
88
86
|
| 5 | pytest | Test pass rate |
|
|
89
87
|
| 6 | coverage | Test coverage |
|
|
90
88
|
| 7 | radon cc | Cyclomatic complexity |
|
|
@@ -93,14 +91,14 @@ These tools are run in **parallel** except when looking for the first error in -
|
|
|
93
91
|
| 10 | vulture | Dead code |
|
|
94
92
|
| 11 | interrogate | Docstring coverage |
|
|
95
93
|
|
|
96
|
-
Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle
|
|
94
|
+
Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle risk.
|
|
97
95
|
|
|
98
96
|
|
|
99
97
|
## Usage
|
|
100
98
|
|
|
101
99
|
```bash
|
|
102
100
|
cq check . # Table overview of scores for humans
|
|
103
|
-
cq check -o llm
|
|
101
|
+
cq check . -o llm # Top defect as markdown for LLMs
|
|
104
102
|
cq check . -o score # Numeric score only for CI
|
|
105
103
|
cq check . -o json # Detailed parsed JSON output for jq
|
|
106
104
|
cq check . -o raw # Raw tool output for debug
|
|
@@ -110,6 +108,13 @@ cq check . --clear-cache # Clear cached results before running (rarely needed)
|
|
|
110
108
|
cq config path/to/project/ # Show effective tool configuration
|
|
111
109
|
```
|
|
112
110
|
|
|
111
|
+
**Exit codes:** `cq check` exits with code `1` if any tool metric falls below its `error_threshold`, making it suitable as a CI gate:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cq check . && deploy # block deploy on errors
|
|
115
|
+
cq check . -o score # print score, exit 1 on errors
|
|
116
|
+
```
|
|
117
|
+
|
|
113
118
|
## Table output
|
|
114
119
|
|
|
115
120
|
```bash
|
|
@@ -121,9 +126,9 @@ cq config path/to/project/ # Show effective tool configuration
|
|
|
121
126
|
┃ Tool ┃ Time ┃ Metric ┃ Score ┃ Status ┃
|
|
122
127
|
┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━┩
|
|
123
128
|
│ compile │ 0.42s │ compile │ 1.000 │ OK │
|
|
124
|
-
│ bandit │ 0.56s │ security │ 1.000 │ OK │
|
|
125
129
|
│ ruff │ 0.17s │ lint │ 1.000 │ OK │
|
|
126
130
|
│ ty │ 0.33s │ type_check │ 1.000 │ OK │
|
|
131
|
+
│ bandit │ 0.56s │ security │ 1.000 │ OK │
|
|
127
132
|
│ pytest │ 0.91s │ tests │ 1.000 │ OK │
|
|
128
133
|
│ coverage │ 1.26s │ coverage │ 0.910 │ OK │
|
|
129
134
|
│ radon cc │ 0.32s │ simplicity │ 0.982 │ OK │
|
|
@@ -167,7 +172,7 @@ cq config path/to/project/ # Show effective tool configuration
|
|
|
167
172
|
|
|
168
173
|
## Raw output
|
|
169
174
|
```bash
|
|
170
|
-
> cq check -o raw
|
|
175
|
+
> cq check . -o raw
|
|
171
176
|
```
|
|
172
177
|
|
|
173
178
|
```json
|
|
@@ -193,66 +198,63 @@ Add a `[tool.cq]` section to your project's `pyproject.toml`:
|
|
|
193
198
|
# Skip tools that are slow or not relevant to your project
|
|
194
199
|
disable = ["coverage", "interrogate"]
|
|
195
200
|
|
|
201
|
+
# Lines of source context shown around each defect in LLM output (default: 15)
|
|
202
|
+
context_lines = 15
|
|
203
|
+
|
|
196
204
|
# Override warning/error thresholds per tool
|
|
197
205
|
[tool.cq.thresholds.coverage]
|
|
198
206
|
warning = 0.9
|
|
199
207
|
error = 0.7
|
|
200
208
|
```
|
|
201
209
|
|
|
202
|
-
Tool IDs match the keys in `config/
|
|
210
|
+
Tool IDs match the keys in `config/config.yaml`: `compile`, `ruff`, `ty`, `bandit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`.
|
|
203
211
|
|
|
204
212
|
|
|
205
213
|
### Default config
|
|
206
214
|
|
|
207
215
|
```yaml
|
|
208
|
-
|
|
216
|
+
python:
|
|
209
217
|
|
|
210
|
-
|
|
211
|
-
name: "compile"
|
|
218
|
+
compile:
|
|
212
219
|
command: "{python} -m compileall -r 10 -j 8 {context_path} -x .*venv"
|
|
213
220
|
parser: "CompileParser"
|
|
214
221
|
order: 1
|
|
215
222
|
warning_threshold: 0.9999
|
|
216
223
|
error_threshold: 0.9999
|
|
217
224
|
|
|
218
|
-
bandit:
|
|
219
|
-
name: "bandit"
|
|
220
|
-
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
221
|
-
parser: "BanditParser"
|
|
222
|
-
order: 2
|
|
223
|
-
warning_threshold: 0.9999
|
|
224
|
-
error_threshold: 0.8
|
|
225
|
-
|
|
226
225
|
ruff:
|
|
227
|
-
name: "ruff"
|
|
228
226
|
command: "{python} -m ruff check --output-format concise --no-cache {context_path}"
|
|
229
227
|
parser: "RuffParser"
|
|
230
|
-
order:
|
|
228
|
+
order: 2
|
|
231
229
|
warning_threshold: 0.9999
|
|
232
230
|
error_threshold: 0.9
|
|
233
231
|
|
|
234
232
|
ty:
|
|
235
|
-
name: "ty"
|
|
236
233
|
command: "{python} -m ty check --output-format concise --color never {context_path}"
|
|
237
234
|
parser: "TyParser"
|
|
238
|
-
order:
|
|
235
|
+
order: 3
|
|
239
236
|
warning_threshold: 0.9999
|
|
240
237
|
error_threshold: 0.8
|
|
241
238
|
run_in_target_env: true
|
|
242
239
|
extra_deps:
|
|
243
240
|
- ty
|
|
244
241
|
|
|
242
|
+
bandit:
|
|
243
|
+
command: "{python} -m bandit -r {context_path} -f json -q -s B101 --severity-level medium --exclude {input_path_posix}/.venv,{input_path_posix}/tests"
|
|
244
|
+
parser: "BanditParser"
|
|
245
|
+
order: 4
|
|
246
|
+
warning_threshold: 0.9999
|
|
247
|
+
error_threshold: 0.8
|
|
248
|
+
|
|
245
249
|
pytest:
|
|
246
|
-
name: "pytest"
|
|
247
250
|
command: "{python} -m pytest -v {context_path}"
|
|
248
251
|
parser: "PytestParser"
|
|
249
252
|
order: 5
|
|
250
|
-
warning_threshold: 0
|
|
251
|
-
error_threshold: 0
|
|
253
|
+
warning_threshold: 1.0
|
|
254
|
+
error_threshold: 1.0
|
|
252
255
|
run_in_target_env: true
|
|
253
256
|
|
|
254
257
|
coverage:
|
|
255
|
-
name: "coverage"
|
|
256
258
|
command: "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest {context_path} && {python} -m coverage report --omit=*/tests/*,*/test_*.py"
|
|
257
259
|
parser: "CoverageParser"
|
|
258
260
|
order: 6
|
|
@@ -263,24 +265,21 @@ tools:
|
|
|
263
265
|
- coverage
|
|
264
266
|
- pytest
|
|
265
267
|
|
|
266
|
-
|
|
267
|
-
name: "radon cc"
|
|
268
|
+
radon-cc:
|
|
268
269
|
command: "{python} -m radon cc --json {context_path}"
|
|
269
270
|
parser: "ComplexityParser"
|
|
270
271
|
order: 7
|
|
271
272
|
warning_threshold: 0.6
|
|
272
273
|
error_threshold: 0.4
|
|
273
274
|
|
|
274
|
-
|
|
275
|
-
name: "radon mi"
|
|
275
|
+
radon-mi:
|
|
276
276
|
command: "{python} -m radon mi -s --json {context_path}"
|
|
277
277
|
parser: "MaintainabilityParser"
|
|
278
278
|
order: 8
|
|
279
279
|
warning_threshold: 0.6
|
|
280
280
|
error_threshold: 0.4
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
name: "radon hal"
|
|
282
|
+
radon-hal:
|
|
284
283
|
command: "{python} -m radon hal -f --json {context_path}"
|
|
285
284
|
parser: "HalsteadParser"
|
|
286
285
|
order: 9
|
|
@@ -288,7 +287,6 @@ tools:
|
|
|
288
287
|
error_threshold: 0.3
|
|
289
288
|
|
|
290
289
|
vulture:
|
|
291
|
-
name: "vulture"
|
|
292
290
|
command: "{python} -m vulture {context_path} --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git"
|
|
293
291
|
parser: "VultureParser"
|
|
294
292
|
order: 10
|
|
@@ -296,7 +294,6 @@ tools:
|
|
|
296
294
|
error_threshold: 0.8
|
|
297
295
|
|
|
298
296
|
interrogate:
|
|
299
|
-
name: "interrogate"
|
|
300
297
|
command: "{python} -m interrogate {context_path} -v --fail-under 0"
|
|
301
298
|
parser: "InterrogateParser"
|
|
302
299
|
order: 11
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
|
|
2
|
+
py_cq/cli.py,sha256=wu1GlxSDxS835i9-mO4-xmyBLfr6puU-ES-26T7Mty0,11007
|
|
3
|
+
py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
|
|
4
|
+
py_cq/execution_engine.py,sha256=tgNGFOO3h-EyetCEzC_RS2K-b9OkOFpOwGwrEAHIpZA,7477
|
|
5
|
+
py_cq/language_detector.py,sha256=6av5HaimcZ54RkN69xQmGgC0mxtTvGzPV3SL8NGG8Uc,1116
|
|
6
|
+
py_cq/llm_formatter.py,sha256=l74O5iqNWMR16789Xncoi93xOqgQJhW5IQK9_OyA9mE,1632
|
|
7
|
+
py_cq/localtypes.py,sha256=jCX1ZuwTixQooEk2N1Gi1XRuDcdBc2NlOdQBKysEybk,6128
|
|
8
|
+
py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
|
|
9
|
+
py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
|
|
10
|
+
py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
py_cq/tool_registry.py,sha256=oMEkFHkU3gg5UpeGD4zHtynOYmWieRgDN5kTwZ5KsE8,1584
|
|
12
|
+
py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
|
|
13
|
+
py_cq/config/config.yaml,sha256=R8CjZH8erBQSbJFKgHt-CCwD1Mgj6ZCGQ3OtWH0Ebig,2402
|
|
14
|
+
py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
py_cq/parsers/banditparser.py,sha256=Ju_CkuXtkVn1Th9aQ6mv6fTUTpb9pc1YD8Nzt_nMgFQ,2326
|
|
16
|
+
py_cq/parsers/common.py,sha256=thbziNjqaf-SY3Y0W-KmPwzlcC-YCV98Z8jubUJM7_4,5203
|
|
17
|
+
py_cq/parsers/compileparser.py,sha256=YsT7ePUDRjsUHTLqgRv4x6jJBCTt6cbm1mgxplzxETg,6256
|
|
18
|
+
py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
|
|
19
|
+
py_cq/parsers/coverageparser.py,sha256=n4h_RCxKvJoYgp356PA4laAmFcngjQ41b_GXZvKkVgc,4041
|
|
20
|
+
py_cq/parsers/exitcodeparser.py,sha256=ZFL3EbPhGvFjm2qZhfLD1_5dNjR9ULYNQpKgy0Z_dGo,729
|
|
21
|
+
py_cq/parsers/halsteadparser.py,sha256=ZCN7LP1iUZ91tf3tlspKAPQrTEa78XVdE9Fjn9yPvYk,9008
|
|
22
|
+
py_cq/parsers/interrogateparser.py,sha256=G07beMaGR0Mq-pGVozAbwK0xOx9MdMaNVGq2CGrUb7w,2225
|
|
23
|
+
py_cq/parsers/linecountparser.py,sha256=nxWvFntuR8T6FDGpafrlevvt-jR3rwkbVo_t2JX4TF0,1067
|
|
24
|
+
py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
|
|
25
|
+
py_cq/parsers/pytestparser.py,sha256=3N1X4wKhJ2h-2U1GuM3gHWHpHxU9f0LPGcPaojra9W4,6377
|
|
26
|
+
py_cq/parsers/regexcountparser.py,sha256=KSoNh2spucXU06pxxr2QW0LrPLfJkFMAsmSgjooaFv0,1316
|
|
27
|
+
py_cq/parsers/ruffparser.py,sha256=ZdIya4sct2PrsOyfKfMGmXHLh3Qu7HtFqXNY9IuNFog,2275
|
|
28
|
+
py_cq/parsers/typarser.py,sha256=zrI0KS65MUGPxYPP74B6BRyTbjcPhKyQPjH2KZIyxN0,2495
|
|
29
|
+
py_cq/parsers/vultureparser.py,sha256=U6zC7P0ATA_N4SB90BahKF5QHMITf_z-NsOgrh_Q5rA,1995
|
|
30
|
+
python_code_quality-0.1.9.dist-info/METADATA,sha256=MtSk0DgDh6bkeATKhz03Y8thLZvxxuOcYlvfvOmHkD8,10149
|
|
31
|
+
python_code_quality-0.1.9.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
32
|
+
python_code_quality-0.1.9.dist-info/entry_points.txt,sha256=j5Q_gGr0b7389lddt1JlZ7gcL4Z7RHxuDhij_G-IhBY,39
|
|
33
|
+
python_code_quality-0.1.9.dist-info/licenses/LICENSE,sha256=Bpuh8tbf37so8M5NtRGTLmT5ue7diJ17223L9f9nsT0,1086
|
|
34
|
+
python_code_quality-0.1.9.dist-info/RECORD,,
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
py_cq/__init__.py,sha256=rS7kf1RU1zZskvJlkbZaMqEpdRYPspwDPAFbxzF3tXg,373
|
|
2
|
-
py_cq/cli.py,sha256=9rNdLdU2mcZVz3qdkczO4Rs_PU0-brZMmj5wJfv-Jcs,8688
|
|
3
|
-
py_cq/context_hash.py,sha256=h-i7Rhd7AUfLv9SkQvE79bjJvTsm_ZwoVwSmUKXWmfM,2977
|
|
4
|
-
py_cq/execution_engine.py,sha256=Q7Z8iibkE_E9VfkbUdnI_g5wA8GdwDbnJI4Mex-V8mE,7416
|
|
5
|
-
py_cq/llm_formatter.py,sha256=EdUMhvsnPLplSSUKDknMHiaLdKsd9B6aH-tTdpxukdY,1574
|
|
6
|
-
py_cq/localtypes.py,sha256=_PAx-F0cj03r_3YR1cyR9ilYYmYxUC14TkRtgLjH-Rc,5927
|
|
7
|
-
py_cq/main.py,sha256=VKoXI8R8rB2fEROBYoTVURfinLqyh8XTNIIWAOtH7dw,380
|
|
8
|
-
py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
|
|
9
|
-
py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
py_cq/tool_registry.py,sha256=Ov5kQIRc5C5vkAq5Nc2Otp5kYiQzJuK5_nA-ZkY_-NQ,1529
|
|
11
|
-
py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
|
|
12
|
-
py_cq/config/tools.yaml,sha256=cNs4h4sIJA-j28QhEFG0uWL9ELaPy_z8BNo4l5F3is4,2553
|
|
13
|
-
py_cq/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
py_cq/parsers/banditparser.py,sha256=vj23tTbipaeVkhS_ldWI7GrpHGwlIkOuaEgszkrjzh0,2277
|
|
15
|
-
py_cq/parsers/common.py,sha256=lc9Chtr3H5l3vTk-vRVhptQVJfOeLax0UWTRjhA9IOU,4044
|
|
16
|
-
py_cq/parsers/compileparser.py,sha256=mVY8qh1oZQ8n9GJLW2ruF9j89G5GuxWb7fb6JeogTJ4,6207
|
|
17
|
-
py_cq/parsers/complexityparser.py,sha256=2t1-wmNjUu65fULcIm5jcgv7ZLWwjakyiY_r-Fx1QQg,3983
|
|
18
|
-
py_cq/parsers/coverageparser.py,sha256=xDNlLNEsA0U3z4GV02iEq97IL90-UJAQrhMlFzpIdy0,4013
|
|
19
|
-
py_cq/parsers/halsteadparser.py,sha256=9z_abpPPuclUQBgq4P6u2vIfIB7ZShX2NbFnubTatqI,8980
|
|
20
|
-
py_cq/parsers/interrogateparser.py,sha256=eMROINtyZE2eHrRxVU0jA-nYTdvr0PZ8iERVn7kPH5o,2197
|
|
21
|
-
py_cq/parsers/maintainabilityparser.py,sha256=Ax0ZFA6zzqYIWZH1hP1_GUtdVn2LIJ8SKWtqVNdszYs,3411
|
|
22
|
-
py_cq/parsers/pytestparser.py,sha256=ERgS1aTTi7aB-Xk_Y9Xpo9h5jT9n0q3_vAfHCZwBTFE,4539
|
|
23
|
-
py_cq/parsers/ruffparser.py,sha256=Wgch9rDkR-6tJlfhDfG1CXXEZu0Gfk0qRpsI3KzjCZU,2226
|
|
24
|
-
py_cq/parsers/typarser.py,sha256=u7ktoH0TzmhB2saCJF1iY1LkfGui5ami2leLlYSCcns,2446
|
|
25
|
-
py_cq/parsers/vultureparser.py,sha256=X2fen6yQu-r_zoRt1qKfIGlGZKORkHCiZE4BFlxtzdE,1946
|
|
26
|
-
python_code_quality-0.1.8.dist-info/METADATA,sha256=2-E2SuWhsQMwYtv4NERKmE9CajsglC81tZY5DPPefrc,10266
|
|
27
|
-
python_code_quality-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
28
|
-
python_code_quality-0.1.8.dist-info/entry_points.txt,sha256=j5Q_gGr0b7389lddt1JlZ7gcL4Z7RHxuDhij_G-IhBY,39
|
|
29
|
-
python_code_quality-0.1.8.dist-info/licenses/LICENSE,sha256=Bpuh8tbf37so8M5NtRGTLmT5ue7diJ17223L9f9nsT0,1086
|
|
30
|
-
python_code_quality-0.1.8.dist-info/RECORD,,
|
{python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{python_code_quality-0.1.8.dist-info → python_code_quality-0.1.9.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|