python-code-quality 0.1.4__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 +10 -0
- py_cq/cli.py +229 -0
- py_cq/config/__init__.py +27 -0
- py_cq/config/tools.yaml +97 -0
- py_cq/context_hash.py +81 -0
- py_cq/execution_engine.py +160 -0
- py_cq/llm_formatter.py +47 -0
- py_cq/localtypes.py +135 -0
- py_cq/main.py +12 -0
- py_cq/metric_aggregator.py +14 -0
- py_cq/parsers/__init__.py +0 -0
- py_cq/parsers/banditparser.py +52 -0
- py_cq/parsers/common.py +87 -0
- py_cq/parsers/compileparser.py +134 -0
- py_cq/parsers/complexityparser.py +86 -0
- py_cq/parsers/coverageparser.py +88 -0
- py_cq/parsers/halsteadparser.py +174 -0
- py_cq/parsers/interrogateparser.py +58 -0
- py_cq/parsers/maintainabilityparser.py +63 -0
- py_cq/parsers/pytestparser.py +81 -0
- py_cq/parsers/ruffparser.py +61 -0
- py_cq/parsers/typarser.py +65 -0
- py_cq/parsers/vultureparser.py +48 -0
- py_cq/py.typed +0 -0
- py_cq/storage.py +27 -0
- py_cq/tool_registry.py +36 -0
- python_code_quality-0.1.4.dist-info/METADATA +188 -0
- python_code_quality-0.1.4.dist-info/RECORD +31 -0
- python_code_quality-0.1.4.dist-info/WHEEL +4 -0
- python_code_quality-0.1.4.dist-info/entry_points.txt +2 -0
- python_code_quality-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Utilities for parsing Halstead-based metrics.
|
|
2
|
+
|
|
3
|
+
This module implements :class:`HalsteadParser`, an ``AbstractParser`` that
|
|
4
|
+
converts the JSON output from Halstead metric tools into a
|
|
5
|
+
``ToolResult``. It extracts bug estimates and program volume, applies
|
|
6
|
+
maximum thresholds, and aggregates file- and function-level metrics."""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
11
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HalsteadParser(AbstractParser):
|
|
15
|
+
"""Parses Halstead metric output and converts it into a `ToolResult`.
|
|
16
|
+
|
|
17
|
+
Implements the `AbstractParser` interface for tools that emit
|
|
18
|
+
Halstead-based JSON. The `parse` method consumes a `RawResult`,
|
|
19
|
+
extracts per-file and per-function metrics, applies the configured
|
|
20
|
+
maximum bug and volume thresholds, and aggregates the results.
|
|
21
|
+
The helper `extract_bugs_and_volume` performs the core calculation of
|
|
22
|
+
bug-free and smallness scores.
|
|
23
|
+
|
|
24
|
+
The parser also records the tool's return code and any errors in the
|
|
25
|
+
result details."""
|
|
26
|
+
|
|
27
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
28
|
+
"""Parses Halstead tool JSON output and returns a ToolResult.
|
|
29
|
+
|
|
30
|
+
The method reads ``raw_result.stdout``—a JSON string containing
|
|
31
|
+
per-file and per-function Halstead metrics. For each file it
|
|
32
|
+
populates a ``ToolResult`` detail entry with bug-free and
|
|
33
|
+
smallness scores. If a file contains an ``error`` key, the
|
|
34
|
+
associated metrics are set to zero and the error message is
|
|
35
|
+
stored. File-level and function-level thresholds are applied
|
|
36
|
+
when computing metrics via :meth:`extract_bugs_and_volume`.
|
|
37
|
+
|
|
38
|
+
After processing all entries, aggregate metrics
|
|
39
|
+
(``file_bug_free``, ``file_smallness``,
|
|
40
|
+
``functions_bug_free``, ``functions_smallness``) are calculated
|
|
41
|
+
from the minimum values observed. The tool's return code is also
|
|
42
|
+
recorded in the ``ToolResult`` details.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
raw_result (RawResult): The raw output from a Halstead
|
|
46
|
+
analysis tool. Its ``stdout`` attribute must contain a
|
|
47
|
+
JSON string with the expected structure.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
ToolResult: A populated ``ToolResult`` instance with
|
|
51
|
+
detailed per-file and per-function metrics, aggregate
|
|
52
|
+
metrics, and the tool's return code.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
json.JSONDecodeError: If the ``stdout`` cannot be parsed as
|
|
56
|
+
JSON."""
|
|
57
|
+
# radon hal -f --json .\data\problems\travelling_salesman\ts_bad.py
|
|
58
|
+
# {".\\data\\problems\\travelling_salesman\\ts_bad.py":
|
|
59
|
+
# {"total": {"h1": 6, "h2": 18, "N1": 13, "N2": 22, "vocabulary": 24, "length": 35, "calculated_length": 90.56842503028855, "volume": 160.4736875252405, "difficulty": 3.6666666666666665, "effort": 588.4035209258818, "time": 32.68908449588233, "bugs": 0.05349122917508017},
|
|
60
|
+
# "functions": {"calc_dist": {"h1": 3, "h2": 9, "N1": 5, "N2": 10, "vocabulary": 12, "length": 15, "calculated_length": 33.28421251514428, "volume": 53.77443751081735, "difficulty": 1.6666666666666667, "effort": 89.62406251802892, "time": 4.9791145843349405, "bugs": 0.017924812503605784}, "find_nearest_city": {"h1": 1, "h2": 2, "N1": 1, "N2": 2, "vocabulary": 3, "length": 3, "calculated_length": 2.0, "volume": 4.754887502163469, "difficulty": 0.5, "effort": 2.3774437510817346, "time": 0.1320802083934297, "bugs": 0.0015849625007211565}, "generate_tour": {"h1": 2, "h2": 5, "N1": 6, "N2": 8, "vocabulary": 7, "length": 14, "calculated_length": 13.60964047443681, "volume": 39.302968908806456, "difficulty": 1.6, "effort": 62.884750254090335, "time": 3.493597236338352, "bugs": 0.01310098963626882}, "main": {"h1": 0, "h2": 0, "N1": 0, "N2": 0, "vocabulary": 0, "length": 0, "calculated_length": 0, "volume": 0, "difficulty": 0, "effort": 0, "time": 0.0, "bugs": 0.0}}}
|
|
61
|
+
tr = ToolResult(raw=raw_result)
|
|
62
|
+
MAX_FILE_BUGS = 1
|
|
63
|
+
MAX_FILE_VOLUME = 2000
|
|
64
|
+
MAX_FUNCTION_BUGS = 0.2
|
|
65
|
+
MAX_FUNCTION_VOLUME = 300
|
|
66
|
+
min_file_nb = 1.0
|
|
67
|
+
min_file_sm = 1.0
|
|
68
|
+
min_function_nb = 1.0
|
|
69
|
+
min_function_sm = 1.0
|
|
70
|
+
data = json.loads(raw_result.stdout)
|
|
71
|
+
for file, values in data.items():
|
|
72
|
+
file_name = file.replace("\\", "/")
|
|
73
|
+
if file_name not in tr.details:
|
|
74
|
+
tr.details[file_name] = {
|
|
75
|
+
"bug_free": 0.0,
|
|
76
|
+
"smallness": 0.0,
|
|
77
|
+
"functions": {},
|
|
78
|
+
}
|
|
79
|
+
if "error" in values:
|
|
80
|
+
min_file_nb = 0.0
|
|
81
|
+
min_file_sm = 0.0
|
|
82
|
+
min_function_nb = 0.0
|
|
83
|
+
min_function_sm = 0.0
|
|
84
|
+
tr.details[file_name]["error"] = values["error"]
|
|
85
|
+
if "total" in values:
|
|
86
|
+
nb, sm = self.extract_bugs_and_volume(
|
|
87
|
+
values.get("total", {}), MAX_FILE_BUGS, MAX_FILE_VOLUME
|
|
88
|
+
)
|
|
89
|
+
min_file_nb = min(nb, min_file_nb)
|
|
90
|
+
min_file_sm = min(sm, min_file_sm)
|
|
91
|
+
tr.details[file_name]["bug_free"] = nb
|
|
92
|
+
tr.details[file_name]["smallness"] = sm
|
|
93
|
+
tr.details[file_name]["bugs"] = values["total"].get("bugs", 0)
|
|
94
|
+
tr.details[file_name]["volume"] = values["total"].get("volume", 0)
|
|
95
|
+
if "functions" in values:
|
|
96
|
+
for function, function_values in values["functions"].items():
|
|
97
|
+
nb, sm = self.extract_bugs_and_volume(
|
|
98
|
+
function_values, MAX_FUNCTION_BUGS, MAX_FUNCTION_VOLUME
|
|
99
|
+
)
|
|
100
|
+
min_function_nb = min(nb, min_function_nb)
|
|
101
|
+
min_function_sm = min(sm, min_function_sm)
|
|
102
|
+
tr.details[file_name]["functions"][function] = {
|
|
103
|
+
"no_bugs": nb,
|
|
104
|
+
"smallness": sm,
|
|
105
|
+
"bugs": function_values.get("bugs", 0),
|
|
106
|
+
"volume": function_values.get("volume", 0),
|
|
107
|
+
}
|
|
108
|
+
tr.metrics = {
|
|
109
|
+
"file_bug_free": min_file_nb,
|
|
110
|
+
"file_smallness": min_file_sm,
|
|
111
|
+
"functions_bug_free": min_function_nb,
|
|
112
|
+
"functions_smallness": min_function_sm,
|
|
113
|
+
}
|
|
114
|
+
return tr
|
|
115
|
+
|
|
116
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
117
|
+
"""Return the worst Halstead offender as an actionable defect description."""
|
|
118
|
+
if not tr.metrics:
|
|
119
|
+
return "No Halstead details available"
|
|
120
|
+
|
|
121
|
+
metric_name, score = min(tr.metrics.items(), key=lambda x: x[1])
|
|
122
|
+
is_function_metric = metric_name.startswith("functions_")
|
|
123
|
+
is_bug_metric = "bug_free" in metric_name
|
|
124
|
+
|
|
125
|
+
worst_file = None
|
|
126
|
+
worst_function = None
|
|
127
|
+
worst_score = 1.0
|
|
128
|
+
worst_bugs = None
|
|
129
|
+
worst_volume = None
|
|
130
|
+
|
|
131
|
+
for file_name, file_data in tr.details.items():
|
|
132
|
+
if is_function_metric:
|
|
133
|
+
for func_name, func_data in file_data.get("functions", {}).items():
|
|
134
|
+
s = func_data.get("no_bugs" if is_bug_metric else "smallness", 1.0)
|
|
135
|
+
if s < worst_score:
|
|
136
|
+
worst_score = s
|
|
137
|
+
worst_file = file_name
|
|
138
|
+
worst_function = func_name
|
|
139
|
+
worst_bugs = func_data.get("bugs")
|
|
140
|
+
worst_volume = func_data.get("volume")
|
|
141
|
+
else:
|
|
142
|
+
s = file_data.get("bug_free" if is_bug_metric else "smallness", 1.0)
|
|
143
|
+
if s < worst_score:
|
|
144
|
+
worst_score = s
|
|
145
|
+
worst_file = file_name
|
|
146
|
+
worst_bugs = file_data.get("bugs")
|
|
147
|
+
worst_volume = file_data.get("volume")
|
|
148
|
+
|
|
149
|
+
if worst_file is None:
|
|
150
|
+
return f"**{metric_name}** score: {score:.3f}"
|
|
151
|
+
|
|
152
|
+
location = f"`{worst_file}` — function `{worst_function}`" if worst_function else f"`{worst_file}`"
|
|
153
|
+
if is_bug_metric:
|
|
154
|
+
detail = f" (Halstead bug estimate: {worst_bugs:.3f})" if worst_bugs is not None else ""
|
|
155
|
+
return (
|
|
156
|
+
f"{location} has high estimated bug density{detail}\n\n"
|
|
157
|
+
f"Reduce complexity by extracting helper functions or simplifying logic."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
detail = f" (volume: {worst_volume:.0f})" if worst_volume is not None else ""
|
|
161
|
+
return (
|
|
162
|
+
f"{location} is too large{detail}\n\n"
|
|
163
|
+
f"Split into smaller functions or modules."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def extract_bugs_and_volume(
|
|
167
|
+
self, values: dict, max_bugs: float, max_volume: float
|
|
168
|
+
) -> tuple[float, float]:
|
|
169
|
+
"""Calculates bug-free and smallness scores from Halstead metrics using logistic scoring."""
|
|
170
|
+
no_bugs_score = score_logistic_variant(values.get("bugs", max_bugs), max_bugs)
|
|
171
|
+
smallness_score = score_logistic_variant(
|
|
172
|
+
values.get("volume", max_volume), max_volume
|
|
173
|
+
)
|
|
174
|
+
return (no_bugs_score, smallness_score)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Parses output from interrogate into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
Interrogate is invoked with ``-v --fail-under 0``, producing a table of
|
|
4
|
+
per-file docstring coverage on stdout::
|
|
5
|
+
|
|
6
|
+
| src/foo.py | 5 | 2 | 60% |
|
|
7
|
+
| TOTAL | 5 | 2 | 60% |
|
|
8
|
+
|
|
9
|
+
The parser extracts per-file coverage and the TOTAL row, storing the TOTAL
|
|
10
|
+
as the ``doc_coverage`` metric (0.0–1.0).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
16
|
+
|
|
17
|
+
_ROW_RE = re.compile(r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)%\s+\|")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InterrogateParser(AbstractParser):
|
|
21
|
+
"""Parses raw output from ``interrogate -v`` into a ToolResult."""
|
|
22
|
+
|
|
23
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
24
|
+
files: dict[str, dict] = {}
|
|
25
|
+
total_coverage: float | None = None
|
|
26
|
+
for line in (raw_result.stdout or "").splitlines():
|
|
27
|
+
m = _ROW_RE.match(line)
|
|
28
|
+
if not m:
|
|
29
|
+
continue
|
|
30
|
+
name = m.group(1).strip()
|
|
31
|
+
total = int(m.group(2))
|
|
32
|
+
miss = int(m.group(3))
|
|
33
|
+
cover = int(m.group(4))
|
|
34
|
+
if name == "TOTAL":
|
|
35
|
+
total_coverage = cover / 100.0
|
|
36
|
+
elif total > 0:
|
|
37
|
+
files[name.replace("\\", "/")] = {
|
|
38
|
+
"total": total,
|
|
39
|
+
"missing": miss,
|
|
40
|
+
"coverage": cover / 100.0,
|
|
41
|
+
}
|
|
42
|
+
score = total_coverage if total_coverage is not None else 1.0
|
|
43
|
+
return ToolResult(raw=raw_result, metrics={"doc_coverage": score}, details=files)
|
|
44
|
+
|
|
45
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
46
|
+
score = tr.metrics.get("doc_coverage", 0)
|
|
47
|
+
uncovered = sorted(
|
|
48
|
+
[(f, d) for f, d in tr.details.items() if d.get("missing", 0) > 0],
|
|
49
|
+
key=lambda x: x[1]["coverage"],
|
|
50
|
+
)[:5]
|
|
51
|
+
if not uncovered:
|
|
52
|
+
return f"**doc_coverage** score: {score:.3f}"
|
|
53
|
+
lines = [f"**doc coverage** {score:.1%} — files with most missing docstrings:"]
|
|
54
|
+
for path, data in uncovered:
|
|
55
|
+
miss = data["missing"]
|
|
56
|
+
pct = data["coverage"]
|
|
57
|
+
lines.append(f"- `{path}`: {pct:.0%} ({miss} undocumented)")
|
|
58
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Module providing a concrete parser for maintainability analysis tools.
|
|
2
|
+
|
|
3
|
+
The `MaintainabilityParser` implements the `AbstractParser` interface to
|
|
4
|
+
convert raw JSON output from a maintainability tool into a
|
|
5
|
+
`ToolResult` structure that other components of the framework can consume."""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
10
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MaintainabilityParser(AbstractParser):
|
|
14
|
+
"""Parses maintainability metrics from raw tool output into :class:`ToolResult` objects.
|
|
15
|
+
|
|
16
|
+
This parser consumes the JSON produced by a maintainability analysis tool on
|
|
17
|
+
``stdout``. For each file it extracts the maintainability index (`mi`) and
|
|
18
|
+
rank, converts the index into a normalized score using
|
|
19
|
+
:func:`score_logistic_variant`, aggregates per-file scores, and returns a
|
|
20
|
+
:class:`ToolResult` containing an overall maintainability metric and
|
|
21
|
+
detailed per-file information.
|
|
22
|
+
|
|
23
|
+
It implements the :meth:`parse` method required by :class:`AbstractParser`
|
|
24
|
+
and is intended for use by the framework to transform raw results into a
|
|
25
|
+
structured format."""
|
|
26
|
+
|
|
27
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
28
|
+
"""Parses maintainability metrics from a raw tool result.
|
|
29
|
+
|
|
30
|
+
The parser expects the tool to output JSON on ``stdout`` where each key is a file name mapping to a dictionary that contains at least an ``mi`` value (maintainability index) and a ``rank`` string. If a file entry contains an ``error`` key, that file is recorded with a zero score and the error message.
|
|
31
|
+
|
|
32
|
+
For each file with a valid ``mi`` value the method converts the raw maintainability index into a score using :func:`score_logistic_variant`, aggregates the scores, and stores per-file details. The overall maintainability metric for the tool is then calculated as the average score across all processed files.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
raw_result (RawResult): The raw result object produced by the underlying maintainability tool.
|
|
36
|
+
It must provide a ``stdout`` attribute containing a JSON string and a ``return_code`` attribute.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
ToolResult: A structured result object containing:
|
|
40
|
+
* ``metrics['maintainability']`` - the average score of all processed files (or ``0.0`` if none were processed).
|
|
41
|
+
* ``details`` - a mapping from each file name (converted to use forward slashes) to a dictionary with keys ``mi``, ``rank``, and optionally ``error``.
|
|
42
|
+
* ``details['return_code']`` - the tool's exit code."""
|
|
43
|
+
tr = ToolResult(raw=raw_result)
|
|
44
|
+
data = json.loads(raw_result.stdout)
|
|
45
|
+
num_items = 0
|
|
46
|
+
score = 0
|
|
47
|
+
for file, values in data.items():
|
|
48
|
+
if "error" in values:
|
|
49
|
+
tr.details[file.replace("\\", "/")] = {
|
|
50
|
+
"mi": 0.0,
|
|
51
|
+
"rank": "F",
|
|
52
|
+
"error": values["error"],
|
|
53
|
+
}
|
|
54
|
+
elif "mi" in values:
|
|
55
|
+
file_score = score_logistic_variant(100 - values["mi"], 85)
|
|
56
|
+
score += file_score
|
|
57
|
+
num_items += 1
|
|
58
|
+
tr.details[file.replace("\\", "/")] = {
|
|
59
|
+
"mi": file_score,
|
|
60
|
+
"rank": values["rank"],
|
|
61
|
+
}
|
|
62
|
+
tr.metrics["maintainability"] = score / num_items if num_items > 0 else 0.0
|
|
63
|
+
return tr
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Parses pytest test run output into a standardized :class:`ToolResult`.
|
|
2
|
+
|
|
3
|
+
This module provides :class:`PytestParser`, a concrete implementation of
|
|
4
|
+
:class:`AbstractParser` that consumes a :class:`RawResult` produced by a pytest
|
|
5
|
+
invocation and returns a :class:`ToolResult` instance. The parser extracts
|
|
6
|
+
per-test statuses, calculates the overall pass rate, and attaches the
|
|
7
|
+
process return code so downstream components can uniformly consume results
|
|
8
|
+
from multiple test tools. It is part of the test-collection framework and
|
|
9
|
+
enables consistent handling of pytest output across the system."""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PytestParser(AbstractParser):
|
|
17
|
+
"""Parses raw pytest output into a structured `ToolResult`.
|
|
18
|
+
|
|
19
|
+
Transforms the low-level output from a pytest run into a `ToolResult` that includes pass-rate metrics and detailed per-file test information, enabling downstream components to consume test metrics in a consistent, typed format. Inherits from `AbstractParser` to integrate with the existing parsing framework."""
|
|
20
|
+
|
|
21
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
22
|
+
"""Parse pytest raw output into a structured ToolResult.
|
|
23
|
+
|
|
24
|
+
This method transforms the textual output of a pytest run into a
|
|
25
|
+
:class:`ToolResult` object containing two pieces of information:
|
|
26
|
+
|
|
27
|
+
* ``metrics['tests']`` - the fraction of tests that passed (``0`` when no
|
|
28
|
+
tests were executed).
|
|
29
|
+
* ``details`` - a mapping from each test file to the names of the
|
|
30
|
+
tests defined in that file and their status (`PASSED`, `FAILED`, etc.).
|
|
31
|
+
The dictionary also includes the process ``return_code`` for
|
|
32
|
+
downstream consumers.
|
|
33
|
+
|
|
34
|
+
The method splits ``raw_result.stdout`` into lines, checks for the
|
|
35
|
+
``'no tests ran'`` sentinel, then uses a regular expression to locate
|
|
36
|
+
``<file>::<test_name> <status>`` patterns. It counts the total number of
|
|
37
|
+
tests and the number of passed tests to compute the pass-rate metric.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
raw_result (RawResult): Raw output produced by a pytest run,
|
|
41
|
+
containing ``stdout`` and ``return_code``.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
ToolResult: A structured result that includes a ``tests`` metric
|
|
45
|
+
and a ``details`` dictionary as described above.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
None. The method never raises an exception; it gracefully handles
|
|
49
|
+
malformed input by treating it as no tests were run."""
|
|
50
|
+
# Simplified parsing - replace with actual logic
|
|
51
|
+
lines = raw_result.stdout.splitlines()
|
|
52
|
+
tr = ToolResult(raw=raw_result)
|
|
53
|
+
if "no tests ran" in raw_result.stdout:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
tests_found = dict()
|
|
57
|
+
num_tests = 0
|
|
58
|
+
passed_tests = 0
|
|
59
|
+
for line in lines:
|
|
60
|
+
# tests/test_common.py::test_name[param] PASSED [ 8%]
|
|
61
|
+
tests_match = re.search(r"(.*\.py)::([\w\[\].,+\- ]+) (PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)", line)
|
|
62
|
+
if tests_match:
|
|
63
|
+
test_file = tests_match.group(1)
|
|
64
|
+
test_name = tests_match.group(2).strip()
|
|
65
|
+
test_status = tests_match.group(3)
|
|
66
|
+
tests_found.setdefault(test_file, {})[test_name] = test_status
|
|
67
|
+
num_tests += 1
|
|
68
|
+
if test_status == "PASSED":
|
|
69
|
+
passed_tests += 1
|
|
70
|
+
tr.metrics["tests"] = passed_tests / num_tests if num_tests else 0
|
|
71
|
+
tr.details = tests_found
|
|
72
|
+
return tr
|
|
73
|
+
|
|
74
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
75
|
+
"""Return the first failing test as a defect description."""
|
|
76
|
+
for file, tests in tr.details.items():
|
|
77
|
+
if isinstance(tests, dict):
|
|
78
|
+
for test_name, status in tests.items():
|
|
79
|
+
if status == "FAILED":
|
|
80
|
+
return f"`{file}::{test_name}` — test **FAILED**"
|
|
81
|
+
return "pytest reported failures (no details available)"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Parses output from ruff check into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
This module defines :class:`RuffParser`, an implementation of
|
|
4
|
+
:class:`~.AbstractParser` that converts the raw stdout produced by
|
|
5
|
+
``ruff check --output-format concise`` into a :class:`~.ToolResult`.
|
|
6
|
+
|
|
7
|
+
The concise output format is one violation per line::
|
|
8
|
+
|
|
9
|
+
<file>:<line>:<col>: <CODE> <message>
|
|
10
|
+
|
|
11
|
+
followed by a summary line ``Found N error.`` or ``All checks passed!``."""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
16
|
+
from py_cq.parsers.common import read_source_lines, score_logistic_variant
|
|
17
|
+
|
|
18
|
+
_DIAG_RE = re.compile(r"^(.+):(\d+):(\d+): ([A-Z]\d+) (.+)$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RuffParser(AbstractParser):
|
|
22
|
+
"""Parses raw output from ``ruff check`` into a structured ToolResult."""
|
|
23
|
+
|
|
24
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
25
|
+
"""Parse concise ruff output and return a ToolResult.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
raw_result: Raw output from ``ruff check --output-format concise``.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
ToolResult with a ``lint`` metric in [0, 1] and per-file violations in details.
|
|
32
|
+
"""
|
|
33
|
+
files: dict[str, list] = {}
|
|
34
|
+
for line in (raw_result.stdout or "").splitlines():
|
|
35
|
+
m = _DIAG_RE.match(line)
|
|
36
|
+
if m:
|
|
37
|
+
path = m.group(1).replace("\\", "/")
|
|
38
|
+
files.setdefault(path, []).append({
|
|
39
|
+
"line": int(m.group(2)),
|
|
40
|
+
"code": m.group(4),
|
|
41
|
+
"message": m.group(5),
|
|
42
|
+
})
|
|
43
|
+
score = score_logistic_variant(
|
|
44
|
+
sum(len(v) for v in files.values()), scale_factor=20
|
|
45
|
+
)
|
|
46
|
+
return ToolResult(raw=raw_result, metrics={"lint": score}, details=files)
|
|
47
|
+
|
|
48
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
49
|
+
"""Return the first lint violation as a defect description."""
|
|
50
|
+
if not tr.details:
|
|
51
|
+
return "ruff reported issues (no details available)"
|
|
52
|
+
file, issues = next(iter(tr.details.items()))
|
|
53
|
+
issue = issues[0]
|
|
54
|
+
line = issue.get("line", "?")
|
|
55
|
+
code = issue.get("code", "")
|
|
56
|
+
message = issue.get("message", "")
|
|
57
|
+
context_start = max(1, line - 3) if isinstance(line, int) else line
|
|
58
|
+
raw_lines = read_source_lines(file, context_start, count=8).splitlines() if isinstance(line, int) else []
|
|
59
|
+
src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines)) if raw_lines else ""
|
|
60
|
+
code_block = f"\n```python\n{src}\n```" if src else ""
|
|
61
|
+
return f"`{file}:{line}` — **{code}**: {message}{code_block}"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parses output from the ty type checker into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
This module defines :class:`TyParser`, an implementation of
|
|
4
|
+
:class:`~.AbstractParser` that converts the raw stdout produced by
|
|
5
|
+
``ty check --output-format concise`` into a :class:`~.ToolResult`.
|
|
6
|
+
|
|
7
|
+
The concise output format is one diagnostic per line::
|
|
8
|
+
|
|
9
|
+
<file>:<line>:<col>: <severity>[<code>] <message>
|
|
10
|
+
|
|
11
|
+
followed by a summary line ``Found N diagnostic`` or ``All checks passed!``.
|
|
12
|
+
Errors count more heavily than warnings toward the score."""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
17
|
+
from py_cq.parsers.common import read_source_lines, score_logistic_variant
|
|
18
|
+
|
|
19
|
+
_DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TyParser(AbstractParser):
|
|
23
|
+
"""Parses raw output from ``ty check`` into a structured ToolResult."""
|
|
24
|
+
|
|
25
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
26
|
+
"""Parse concise ty output and return a ToolResult.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
raw_result: Raw output from ``ty check --output-format concise``.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ToolResult with a ``type_check`` metric in [0, 1] and per-file diagnostics in details.
|
|
33
|
+
"""
|
|
34
|
+
files: dict[str, list] = {}
|
|
35
|
+
weighted = 0
|
|
36
|
+
for line in (raw_result.stdout or "").splitlines():
|
|
37
|
+
m = _DIAG_RE.match(line)
|
|
38
|
+
if m:
|
|
39
|
+
path = m.group(1).replace("\\", "/")
|
|
40
|
+
severity = m.group(3)
|
|
41
|
+
files.setdefault(path, []).append({
|
|
42
|
+
"line": int(m.group(2)),
|
|
43
|
+
"code": m.group(4),
|
|
44
|
+
"severity": severity,
|
|
45
|
+
"message": m.group(5),
|
|
46
|
+
})
|
|
47
|
+
weighted += 3 if severity == "error" else 1
|
|
48
|
+
|
|
49
|
+
score = score_logistic_variant(weighted, scale_factor=10)
|
|
50
|
+
return ToolResult(raw=raw_result, metrics={"type_check": score}, details=files)
|
|
51
|
+
|
|
52
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
53
|
+
"""Return the first type-check diagnostic as a defect description."""
|
|
54
|
+
if not tr.details:
|
|
55
|
+
return "ty reported issues (no details available)"
|
|
56
|
+
file, issues = next(iter(tr.details.items()))
|
|
57
|
+
issue = issues[0]
|
|
58
|
+
line = issue.get("line", "?")
|
|
59
|
+
code = issue.get("code", "")
|
|
60
|
+
message = issue.get("message", "")
|
|
61
|
+
context_start = max(1, line - 3) if isinstance(line, int) else line
|
|
62
|
+
raw_lines = read_source_lines(file, context_start, count=8).splitlines() if isinstance(line, int) else []
|
|
63
|
+
src = "\n".join(f"{context_start + i}: {rline}" for i, rline in enumerate(raw_lines)) if raw_lines else ""
|
|
64
|
+
code_block = f"\n```python\n{src}\n```" if src else ""
|
|
65
|
+
return f"`{file}:{line}` — **{code}**: {message}{code_block}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Parses output from vulture dead-code detector into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
Vulture is invoked with ``--min-confidence 80``, producing one text line
|
|
4
|
+
per unused symbol::
|
|
5
|
+
|
|
6
|
+
src/foo.py:10: unused function 'bar' (80% confidence)
|
|
7
|
+
|
|
8
|
+
The parser counts violations and converts the count into a logistic-variant
|
|
9
|
+
score stored under the ``dead_code`` metric key.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
15
|
+
from py_cq.parsers.common import score_logistic_variant
|
|
16
|
+
|
|
17
|
+
_LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VultureParser(AbstractParser):
|
|
21
|
+
"""Parses raw text output from ``vulture`` into a ToolResult."""
|
|
22
|
+
|
|
23
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
24
|
+
files: dict[str, list] = {}
|
|
25
|
+
for line in (raw_result.stdout or "").splitlines():
|
|
26
|
+
m = _LINE_RE.match(line)
|
|
27
|
+
if m:
|
|
28
|
+
path = m.group(1).replace("\\", "/")
|
|
29
|
+
files.setdefault(path, []).append({
|
|
30
|
+
"line": int(m.group(2)),
|
|
31
|
+
"type": m.group(3),
|
|
32
|
+
"name": m.group(4),
|
|
33
|
+
"confidence": int(m.group(5)),
|
|
34
|
+
})
|
|
35
|
+
count = sum(len(v) for v in files.values())
|
|
36
|
+
score = score_logistic_variant(count, scale_factor=15)
|
|
37
|
+
return ToolResult(raw=raw_result, metrics={"dead_code": score}, details=files)
|
|
38
|
+
|
|
39
|
+
def format_llm_message(self, tr: ToolResult) -> str:
|
|
40
|
+
if not tr.details:
|
|
41
|
+
return "vulture reported issues (no details available)"
|
|
42
|
+
file, issues = next(iter(tr.details.items()))
|
|
43
|
+
issue = issues[0]
|
|
44
|
+
line = issue.get("line", "?")
|
|
45
|
+
kind = issue.get("type", "unused")
|
|
46
|
+
name = issue.get("name", "")
|
|
47
|
+
confidence = issue.get("confidence", "?")
|
|
48
|
+
return f"`{file}:{line}` — **{kind}** `{name}` ({confidence}% confidence)"
|
py_cq/py.typed
ADDED
|
File without changes
|
py_cq/storage.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Utilities for persisting combined tool results.
|
|
2
|
+
|
|
3
|
+
This module provides a single helper function, :func:`save_result`, which
|
|
4
|
+
serializes a :class:`CombinedToolResults` instance to a JSON file. The
|
|
5
|
+
function ensures that the target directory exists, writes a readable
|
|
6
|
+
representation of the results, and returns the absolute path of the created
|
|
7
|
+
file.
|
|
8
|
+
|
|
9
|
+
Example
|
|
10
|
+
-------
|
|
11
|
+
>>> from your_package import CombinedToolResults, save_result
|
|
12
|
+
>>> results = CombinedToolResults(...)
|
|
13
|
+
>>> output_path = save_result(results, "output.json")
|
|
14
|
+
>>> print(output_path)"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
from py_cq.localtypes import CombinedToolResults
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_result(combined_tool_results: CombinedToolResults, file_name: str):
|
|
22
|
+
"""Saves combined tool results to a JSON file named by `file_name`."""
|
|
23
|
+
if not file_name:
|
|
24
|
+
return
|
|
25
|
+
data = combined_tool_results.to_dict()
|
|
26
|
+
with open(file_name, "w") as f:
|
|
27
|
+
json.dump(data, f, indent=4)
|
py_cq/tool_registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Loads tool configurations from a YAML file and builds a registry that maps tool names to their configuration objects, enabling efficient lookup and instantiation of tools throughout the application. The module relies on PyYAML for parsing the configuration file."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from py_cq.localtypes import ToolConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_tool_configs() -> dict[str, ToolConfig]:
|
|
12
|
+
"""Load tool configurations from the bundled tools.yaml and return a registry.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
dict[str, ToolConfig]: A mapping from tool ID to its configuration instance."""
|
|
16
|
+
yaml_text = files("py_cq.config").joinpath("tools.yaml").read_text(encoding="utf-8")
|
|
17
|
+
config = yaml.safe_load(yaml_text)
|
|
18
|
+
registry = {}
|
|
19
|
+
for tool_id, tool_data in config["tools"].items():
|
|
20
|
+
# Dynamically import parser class
|
|
21
|
+
module = import_module(f"py_cq.parsers.{tool_data['parser'].lower()}")
|
|
22
|
+
parser_class = getattr(module, tool_data["parser"])
|
|
23
|
+
registry[tool_id] = ToolConfig(
|
|
24
|
+
name=tool_data["name"],
|
|
25
|
+
command=tool_data["command"],
|
|
26
|
+
parser_class=parser_class,
|
|
27
|
+
priority=tool_data["priority"],
|
|
28
|
+
warning_threshold=tool_data["warning_threshold"],
|
|
29
|
+
error_threshold=tool_data["error_threshold"],
|
|
30
|
+
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
31
|
+
extra_deps=tool_data.get("extra_deps", []),
|
|
32
|
+
)
|
|
33
|
+
return registry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
tool_registry = load_tool_configs()
|