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