python-code-quality 0.1.16__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_cq/__init__.py +3 -4
- py_cq/api.py +248 -0
- py_cq/cli.py +216 -90
- py_cq/config/config.toml +95 -0
- py_cq/context_hash.py +18 -8
- py_cq/execution_engine.py +182 -26
- py_cq/language_detector.py +4 -1
- py_cq/llm_formatter.py +200 -18
- py_cq/localtypes.py +53 -7
- py_cq/parsers/__init__.py +1 -1
- py_cq/parsers/banditparser.py +42 -19
- py_cq/parsers/common.py +184 -15
- py_cq/parsers/compileparser.py +9 -4
- py_cq/parsers/complexityparser.py +38 -9
- py_cq/parsers/coverageparser.py +184 -70
- py_cq/parsers/exitcodeparser.py +11 -2
- py_cq/parsers/halsteadparser.py +41 -20
- py_cq/parsers/interrogateparser.py +261 -25
- py_cq/parsers/linecountparser.py +10 -2
- py_cq/parsers/maintainabilityparser.py +32 -9
- py_cq/parsers/pytestparser.py +77 -20
- py_cq/parsers/regexcountparser.py +13 -3
- py_cq/parsers/ruffparser.py +160 -16
- py_cq/parsers/typarser.py +175 -43
- py_cq/parsers/vultureparser.py +22 -16
- py_cq/table_formatter.py +16 -2
- py_cq/tool_registry.py +7 -6
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.1.dist-info}/METADATA +88 -3
- python_code_quality-0.2.1.dist-info/RECORD +35 -0
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.1.dist-info}/WHEEL +1 -1
- py_cq/config/config.yaml +0 -94
- python_code_quality-0.1.16.dist-info/RECORD +0 -34
- {python_code_quality-0.1.16.dist-info → python_code_quality-0.2.1.dist-info}/entry_points.txt +0 -0
py_cq/parsers/ruffparser.py
CHANGED
|
@@ -11,11 +11,149 @@ The concise output format is one violation per line::
|
|
|
11
11
|
followed by a summary line ``Found N error.`` or ``All checks passed!``."""
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
14
16
|
|
|
15
17
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
16
|
-
from py_cq.parsers.common import
|
|
18
|
+
from py_cq.parsers.common import (
|
|
19
|
+
enclosing_function_range,
|
|
20
|
+
extract_first_issue,
|
|
21
|
+
find_enclosing_function,
|
|
22
|
+
format_issue_header,
|
|
23
|
+
format_source_context,
|
|
24
|
+
score_logistic_variant,
|
|
25
|
+
)
|
|
17
26
|
|
|
18
|
-
_DIAG_RE = re.compile(r"^(.+):(\d+):(\d+): ([A-Z]\d+) (.+)$")
|
|
27
|
+
_DIAG_RE = re.compile(r"^(.+):(\d+):(\d+): ([A-Z]{1,5}\d+) (.+)$")
|
|
28
|
+
_VARNAME_RE = re.compile(r"[`'](\w+)[`']")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_F841(file: str, line: int, message: str) -> str:
|
|
32
|
+
"""Unused variable: show source context, then report same-function references or advise deletion."""
|
|
33
|
+
base = format_issue_header(file, line, "F841", message) + format_source_context(
|
|
34
|
+
file, line
|
|
35
|
+
)
|
|
36
|
+
m = _VARNAME_RE.search(message)
|
|
37
|
+
if not m:
|
|
38
|
+
return base
|
|
39
|
+
var = m.group(1)
|
|
40
|
+
try:
|
|
41
|
+
lines = Path(file).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
42
|
+
except OSError:
|
|
43
|
+
return base
|
|
44
|
+
func_range = enclosing_function_range(file, line)
|
|
45
|
+
var_re = re.compile(rf"\b{re.escape(var)}\b")
|
|
46
|
+
refs = [
|
|
47
|
+
i + 1
|
|
48
|
+
for i, ln in enumerate(lines)
|
|
49
|
+
if i + 1 != line
|
|
50
|
+
and var_re.search(ln)
|
|
51
|
+
and (func_range is None or func_range[0] <= i + 1 <= func_range[1])
|
|
52
|
+
]
|
|
53
|
+
if refs and len(var) > 1:
|
|
54
|
+
ref_str = ", ".join(str(r) for r in refs[:8])
|
|
55
|
+
return (
|
|
56
|
+
base
|
|
57
|
+
+ f"\n\n`{var}` is also referenced at line(s): {ref_str}. Determine whether this assignment should feed into those uses or is redundant."
|
|
58
|
+
)
|
|
59
|
+
return (
|
|
60
|
+
base
|
|
61
|
+
+ f"\n\n`{var}` is not referenced anywhere else in this function. Delete line {line}."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_F541(file: str, line: int, message: str) -> str:
|
|
66
|
+
"""Format F541 error message."""
|
|
67
|
+
base = format_issue_header(file, line, "F541", message) + format_source_context(
|
|
68
|
+
file, line
|
|
69
|
+
)
|
|
70
|
+
return base + "\n\nFix: remove the `f` prefix from this string literal."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _file_refs(file: str, name: str, exclude_line: int) -> list[tuple[int, str]]:
|
|
74
|
+
"""Return (line_no, stripped_text) for every line in file containing `name` as a word."""
|
|
75
|
+
try:
|
|
76
|
+
lines = Path(file).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
77
|
+
except OSError:
|
|
78
|
+
return []
|
|
79
|
+
name_re = re.compile(rf"\b{re.escape(name)}\b")
|
|
80
|
+
return [
|
|
81
|
+
(i + 1, ln.strip())
|
|
82
|
+
for i, ln in enumerate(lines)
|
|
83
|
+
if i + 1 != exclude_line and name_re.search(ln)
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_F401(file: str, line: int, message: str) -> str:
|
|
88
|
+
"""Unused import: warn if name appears elsewhere (string annotations, __all__), else confirm safe delete."""
|
|
89
|
+
autofixable = "[*]" in message
|
|
90
|
+
clean_message = re.sub(r"^\[\*\]\s*", "", message)
|
|
91
|
+
base = format_issue_header(
|
|
92
|
+
file, line, "F401", clean_message
|
|
93
|
+
) + format_source_context(file, line)
|
|
94
|
+
m = re.search(r"`([^`]+)`", clean_message)
|
|
95
|
+
if not m:
|
|
96
|
+
return base
|
|
97
|
+
# "typing.Optional" -> bound name is "Optional"; "os" -> "os"
|
|
98
|
+
name = m.group(1).split(".")[-1]
|
|
99
|
+
refs = _file_refs(file, name, exclude_line=line)
|
|
100
|
+
if refs:
|
|
101
|
+
ref_lines = "\n".join(f" line {line_no}: {txt}" for line_no, txt in refs[:6])
|
|
102
|
+
return (
|
|
103
|
+
base
|
|
104
|
+
+ f"\n\n`{name}` appears elsewhere in this file - verify these are not active uses before deleting:\n{ref_lines}"
|
|
105
|
+
)
|
|
106
|
+
suffix = " Auto-fixable: `ruff check --fix`." if autofixable else ""
|
|
107
|
+
return base + f"\n\nNo other uses found. Delete this import.{suffix}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_F821(file: str, line: int, message: str) -> str:
|
|
111
|
+
"""Undefined name: show enclosing function and all in-file references to help diagnose the gap."""
|
|
112
|
+
base = format_issue_header(file, line, "F821", message) + format_source_context(
|
|
113
|
+
file, line
|
|
114
|
+
)
|
|
115
|
+
m = re.search(r"`(\w+)`", message)
|
|
116
|
+
if not m:
|
|
117
|
+
return base
|
|
118
|
+
name = m.group(1)
|
|
119
|
+
refs = _file_refs(file, name, exclude_line=line)
|
|
120
|
+
enclosing = find_enclosing_function(file, line)
|
|
121
|
+
if refs:
|
|
122
|
+
ref_lines = "\n".join(f" line {line_no}: {txt}" for line_no, txt in refs[:8])
|
|
123
|
+
base += f"\n\nOther references to `{name}` in this file:\n{ref_lines}"
|
|
124
|
+
else:
|
|
125
|
+
base += f"\n\n`{name}` is not imported or defined anywhere else in this file."
|
|
126
|
+
if enclosing:
|
|
127
|
+
base += f"\n\nEnclosing function:{enclosing}"
|
|
128
|
+
return base
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _format_E701(file: str, line: int, message: str) -> str:
|
|
132
|
+
base = format_issue_header(file, line, "E701", message) + format_source_context(
|
|
133
|
+
file, line, context=2, count=4
|
|
134
|
+
)
|
|
135
|
+
return base + "\n\nFix: split the second statement onto its own line."
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_E721(file: str, line: int, message: str) -> str:
|
|
139
|
+
base = format_issue_header(file, line, "E721", message) + format_source_context(
|
|
140
|
+
file, line, context=2, count=4
|
|
141
|
+
)
|
|
142
|
+
return base + (
|
|
143
|
+
"\n\nFix: if comparing a type-holding variable against a type literal, use `is` "
|
|
144
|
+
"(e.g. `output_type is str`). If checking the type of a value, use `isinstance()` "
|
|
145
|
+
"(e.g. `isinstance(x, str)`)."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_CUSTOM_FORMAT: dict[str, Callable[[str, int, str], str]] = {
|
|
150
|
+
"E701": _format_E701,
|
|
151
|
+
"E721": _format_E721,
|
|
152
|
+
"F401": _format_F401,
|
|
153
|
+
"F541": _format_F541,
|
|
154
|
+
"F821": _format_F821,
|
|
155
|
+
"F841": _format_F841,
|
|
156
|
+
}
|
|
19
157
|
|
|
20
158
|
|
|
21
159
|
class RuffParser(AbstractParser):
|
|
@@ -35,27 +173,33 @@ class RuffParser(AbstractParser):
|
|
|
35
173
|
m = _DIAG_RE.match(line)
|
|
36
174
|
if m:
|
|
37
175
|
path = m.group(1).replace("\\", "/")
|
|
38
|
-
files.setdefault(path, []).append(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
176
|
+
files.setdefault(path, []).append(
|
|
177
|
+
{
|
|
178
|
+
"line": int(m.group(2)),
|
|
179
|
+
"col": int(m.group(3)),
|
|
180
|
+
"code": m.group(4),
|
|
181
|
+
"message": m.group(5),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
43
184
|
score = score_logistic_variant(
|
|
44
185
|
sum(len(v) for v in files.values()), scale_factor=20
|
|
45
186
|
)
|
|
46
187
|
return ToolResult(raw=raw_result, metrics={"lint": score}, details=files)
|
|
47
188
|
|
|
48
|
-
def format_llm_message(
|
|
189
|
+
def format_llm_message(
|
|
190
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
191
|
+
) -> str:
|
|
49
192
|
"""Return the first lint violation as a defect description."""
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
file, issues = next(iter(tr.details.items()))
|
|
53
|
-
if not isinstance(issues, list) or not issues:
|
|
54
|
-
return "ruff reported issues (no details available)"
|
|
55
|
-
issue = issues[0]
|
|
56
|
-
if not isinstance(issue, dict):
|
|
193
|
+
result = extract_first_issue(tr.details)
|
|
194
|
+
if result is None:
|
|
57
195
|
return "ruff reported issues (no details available)"
|
|
196
|
+
file, issue = result
|
|
58
197
|
line = issue.get("line", "?")
|
|
59
198
|
code = issue.get("code", "")
|
|
60
199
|
message = issue.get("message", "")
|
|
61
|
-
|
|
200
|
+
fmt_fn = _CUSTOM_FORMAT.get(code)
|
|
201
|
+
if fmt_fn and isinstance(line, int):
|
|
202
|
+
return fmt_fn(file, line, message)
|
|
203
|
+
return format_issue_header(file, line, code, message) + format_source_context(
|
|
204
|
+
file, line, count=context_lines
|
|
205
|
+
)
|
py_cq/parsers/typarser.py
CHANGED
|
@@ -12,72 +12,204 @@ followed by a summary line ``Found N diagnostic`` or ``All checks passed!``.
|
|
|
12
12
|
Errors count more heavily than warnings toward the score."""
|
|
13
13
|
|
|
14
14
|
import re
|
|
15
|
+
from collections.abc import Callable
|
|
15
16
|
|
|
16
17
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
17
|
-
from py_cq.parsers.common import
|
|
18
|
+
from py_cq.parsers.common import (
|
|
19
|
+
extract_first_issue,
|
|
20
|
+
find_enclosing_function,
|
|
21
|
+
format_issue_header,
|
|
22
|
+
format_source_context,
|
|
23
|
+
resolve_path,
|
|
24
|
+
score_logistic_variant,
|
|
25
|
+
)
|
|
18
26
|
|
|
19
27
|
_DIAG_RE = re.compile(r"^(.+):(\d+):\d+:\s+(error|warning)\[([^\]]+)\] (.+)$")
|
|
28
|
+
_EXPECTED_FOUND_RE = re.compile(r"Expected `([^`]+)`, found `([^`]+)`")
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
|
|
31
|
+
def _format_invalid_argument_type(file: str, line: int, message: str) -> str:
|
|
32
|
+
"""Format a ty 'invalid-argument-type' diagnostic for LLM consumption.
|
|
33
|
+
|
|
34
|
+
Includes hint text when ty reports ``Unknown`` as the found type.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
file: Resolved file path.
|
|
38
|
+
line: Line number of the diagnostic.
|
|
39
|
+
message: The diagnostic message from ty.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Markdown-formatted issue report with fix hints.
|
|
43
|
+
"""
|
|
44
|
+
display_msg = re.sub(r": (Expected `)", r":\n\1", message)
|
|
45
|
+
base = format_issue_header(
|
|
46
|
+
file, line, "invalid-argument-type", display_msg
|
|
47
|
+
) + format_source_context(file, line)
|
|
48
|
+
m = _EXPECTED_FOUND_RE.search(message)
|
|
49
|
+
if not m or "Unknown" not in m.group(2):
|
|
50
|
+
return base
|
|
51
|
+
enclosing = find_enclosing_function(file, line)
|
|
52
|
+
note = "\n\n`Unknown` means ty cannot infer this argument's type - the variable has no annotation."
|
|
53
|
+
if enclosing:
|
|
54
|
+
note += (
|
|
55
|
+
f" Trace the argument back to its source and annotate its type:{enclosing}"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
note += " Annotate the variable's type or cast the argument explicitly."
|
|
59
|
+
return base + note
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_TYPE_NAME_RE = re.compile(r"Object of type `([^`]+)` is not callable")
|
|
63
|
+
_MODULE_RE = re.compile(r"Submodule `([^`]+)`")
|
|
64
|
+
_IMPORT_MODULE_RE = re.compile(r"Cannot resolve imported module `([^`]+)`")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_call_non_callable(file: str, line: int, message: str) -> str:
|
|
68
|
+
"""Format a ty 'call-non-callable' diagnostic for LLM consumption.
|
|
69
|
+
|
|
70
|
+
Provides fix advice about incomplete type stubs and Callable annotations.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
file: Resolved file path.
|
|
74
|
+
line: Line number of the diagnostic.
|
|
75
|
+
message: The diagnostic message from ty.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Markdown-formatted issue report with fix hints.
|
|
79
|
+
"""
|
|
80
|
+
base = format_issue_header(
|
|
81
|
+
file, line, "call-non-callable", message
|
|
82
|
+
) + format_source_context(file, line, context=3, count=8)
|
|
83
|
+
m = _TYPE_NAME_RE.search(message)
|
|
84
|
+
type_name = f"`{m.group(1)}`" if m else "this type"
|
|
85
|
+
return base + (
|
|
86
|
+
f"\n\nty cannot see a `__call__` declaration on {type_name} - common when a library's "
|
|
87
|
+
f"type stubs are incomplete. Fix: annotate the variable as `Callable[..., <ReturnType>]`, "
|
|
88
|
+
f"or suppress with `# type: ignore[call-non-callable]` if the call is correct at runtime."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_possibly_missing_submodule(file: str, line: int, message: str) -> str:
|
|
93
|
+
"""Format a ty 'possibly-missing-submodule' diagnostic for LLM consumption.
|
|
94
|
+
|
|
95
|
+
Advises adding an explicit import for the submodule before attribute access.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
file: Resolved file path.
|
|
99
|
+
line: Line number of the diagnostic.
|
|
100
|
+
message: The diagnostic message from ty.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Markdown-formatted issue report with fix hints.
|
|
104
|
+
"""
|
|
105
|
+
base = format_issue_header(
|
|
106
|
+
file, line, "possibly-missing-submodule", message
|
|
107
|
+
) + format_source_context(file, line, context=1, count=3)
|
|
108
|
+
m = _MODULE_RE.search(message)
|
|
109
|
+
submodule = m.group(1) if m else "the submodule"
|
|
110
|
+
return (
|
|
111
|
+
base
|
|
112
|
+
+ f"\n\nFix: add `import <package>.{submodule}` before accessing it as an attribute."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_unresolved_import(file: str, line: int, message: str) -> str:
|
|
117
|
+
"""Format a ty 'unresolved-import' diagnostic for LLM consumption.
|
|
118
|
+
|
|
119
|
+
Advises checking whether the module was renamed, deleted, or needs installation.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
file: Resolved file path.
|
|
123
|
+
line: Line number of the diagnostic.
|
|
124
|
+
message: The diagnostic message from ty.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Markdown-formatted issue report with fix hints.
|
|
128
|
+
"""
|
|
129
|
+
base = format_issue_header(
|
|
130
|
+
file, line, "unresolved-import", message
|
|
131
|
+
) + format_source_context(file, line, context=1, count=3)
|
|
132
|
+
m = _IMPORT_MODULE_RE.search(message)
|
|
133
|
+
module = f"`{m.group(1)}`" if m else "the module"
|
|
134
|
+
return base + (
|
|
135
|
+
f"\n\n{module} cannot be found. Check whether it has been renamed or deleted. "
|
|
136
|
+
f"If it is no longer needed, remove the import. "
|
|
137
|
+
f"If the module exists but is not installed, add it to your dependencies."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_CUSTOM_FORMAT: dict[str, Callable[[str, int, str], str]] = {
|
|
142
|
+
"call-non-callable": _format_call_non_callable,
|
|
143
|
+
"invalid-argument-type": _format_invalid_argument_type,
|
|
144
|
+
"possibly-missing-submodule": _format_possibly_missing_submodule,
|
|
145
|
+
"unresolved-import": _format_unresolved_import,
|
|
146
|
+
}
|
|
30
147
|
|
|
31
148
|
|
|
32
149
|
class TyParser(AbstractParser):
|
|
33
150
|
"""Parses raw output from ``ty check`` into a structured ToolResult."""
|
|
34
151
|
|
|
35
152
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
36
|
-
"""Parse concise ty output and return a ToolResult.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
raw_result: Raw output from ``ty check --output-format concise``.
|
|
40
|
-
|
|
41
|
-
Returns:
|
|
42
|
-
ToolResult with a ``type_check`` metric in [0, 1] and per-file diagnostics in details.
|
|
43
|
-
"""
|
|
44
153
|
files: dict[str, list] = {}
|
|
154
|
+
seen: set[tuple[str, int, str]] = set()
|
|
45
155
|
weighted = 0
|
|
46
156
|
for line in (raw_result.stdout or "").splitlines():
|
|
47
157
|
m = _DIAG_RE.match(line)
|
|
48
158
|
if m:
|
|
49
159
|
path = m.group(1).replace("\\", "/")
|
|
160
|
+
lineno = int(m.group(2))
|
|
161
|
+
code = m.group(4)
|
|
50
162
|
severity = m.group(3)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
163
|
+
key = (path, lineno, code)
|
|
164
|
+
if key in seen:
|
|
165
|
+
continue
|
|
166
|
+
seen.add(key)
|
|
167
|
+
files.setdefault(path, []).append(
|
|
168
|
+
{
|
|
169
|
+
"line": lineno,
|
|
170
|
+
"code": code,
|
|
171
|
+
"severity": severity,
|
|
172
|
+
"message": m.group(5),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
57
175
|
weighted += 3 if severity == "error" else 1
|
|
58
176
|
|
|
59
177
|
score = score_logistic_variant(weighted, scale_factor=10)
|
|
60
|
-
return ToolResult(
|
|
178
|
+
return ToolResult(
|
|
179
|
+
raw=raw_result,
|
|
180
|
+
metrics={"type_check": score},
|
|
181
|
+
details=files,
|
|
182
|
+
project_path=raw_result.project_path,
|
|
183
|
+
)
|
|
61
184
|
|
|
62
|
-
def format_llm_message(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
185
|
+
def format_llm_message(
|
|
186
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Return a markdown description of the most important ty defect.
|
|
189
|
+
|
|
190
|
+
Delegates to a custom formatter when the code has a registered handler
|
|
191
|
+
in ``_CUSTOM_FORMAT`` (e.g. ``call-non-callable``, ``invalid-argument-type``),
|
|
192
|
+
otherwise falls back to the standard header + source context format.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
tr: The parsed tool result containing details and raw output.
|
|
196
|
+
context_lines: Number of source context lines to show.
|
|
197
|
+
limit: Maximum number of issues to display (unused - always 1).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Markdown-formatted issue description.
|
|
201
|
+
"""
|
|
202
|
+
result = extract_first_issue(tr.details)
|
|
203
|
+
if result is None:
|
|
71
204
|
return "ty reported issues (no details available)"
|
|
205
|
+
file, issue = result
|
|
72
206
|
line = issue.get("line", "?")
|
|
73
207
|
code = issue.get("code", "")
|
|
74
208
|
message = issue.get("message", "")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
callee = format_callee_context(func_name, file)
|
|
83
|
-
return f"`{file}:{line}` — **{code}**: {message}{src_ctx}{callee}"
|
|
209
|
+
resolved_file = resolve_path(tr.project_path, file)
|
|
210
|
+
fmt_fn = _CUSTOM_FORMAT.get(code)
|
|
211
|
+
if fmt_fn and isinstance(line, int):
|
|
212
|
+
return fmt_fn(resolved_file, line, message)
|
|
213
|
+
return format_issue_header(
|
|
214
|
+
resolved_file, line, code, message
|
|
215
|
+
) + format_source_context(resolved_file, line, count=context_lines)
|
py_cq/parsers/vultureparser.py
CHANGED
|
@@ -12,7 +12,11 @@ score stored under the ``dead_code`` metric key.
|
|
|
12
12
|
import re
|
|
13
13
|
|
|
14
14
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
15
|
-
from py_cq.parsers.common import
|
|
15
|
+
from py_cq.parsers.common import (
|
|
16
|
+
extract_first_issue,
|
|
17
|
+
format_source_context,
|
|
18
|
+
score_logistic_variant,
|
|
19
|
+
)
|
|
16
20
|
|
|
17
21
|
_LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
|
|
18
22
|
|
|
@@ -21,32 +25,34 @@ class VultureParser(AbstractParser):
|
|
|
21
25
|
"""Parses raw text output from ``vulture`` into a ToolResult."""
|
|
22
26
|
|
|
23
27
|
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
28
|
+
"""Parses the raw text output from vulture."""
|
|
24
29
|
files: dict[str, list] = {}
|
|
25
30
|
for line in (raw_result.stdout or "").splitlines():
|
|
26
31
|
m = _LINE_RE.match(line)
|
|
27
32
|
if m:
|
|
28
33
|
path = m.group(1).replace("\\", "/")
|
|
29
|
-
files.setdefault(path, []).append(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
files.setdefault(path, []).append(
|
|
35
|
+
{
|
|
36
|
+
"line": int(m.group(2)),
|
|
37
|
+
"type": m.group(3),
|
|
38
|
+
"name": m.group(4),
|
|
39
|
+
"confidence": int(m.group(5)),
|
|
40
|
+
}
|
|
41
|
+
)
|
|
35
42
|
count = sum(len(v) for v in files.values())
|
|
36
43
|
score = score_logistic_variant(count, scale_factor=15)
|
|
37
44
|
return ToolResult(raw=raw_result, metrics={"dead_code": score}, details=files)
|
|
38
45
|
|
|
39
|
-
def format_llm_message(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
issue = issues[0]
|
|
46
|
-
if not isinstance(issue, dict):
|
|
46
|
+
def format_llm_message(
|
|
47
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Formats the LLM message from the ToolResult."""
|
|
50
|
+
result = extract_first_issue(tr.details)
|
|
51
|
+
if result is None:
|
|
47
52
|
return "vulture reported issues (no details available)"
|
|
53
|
+
file, issue = result
|
|
48
54
|
line = issue.get("line", "?")
|
|
49
55
|
kind = issue.get("type", "unused")
|
|
50
56
|
name = issue.get("name", "")
|
|
51
57
|
confidence = issue.get("confidence", "?")
|
|
52
|
-
return f"
|
|
58
|
+
return f"{file}:{line} - **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
|
py_cq/table_formatter.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""Rich table formatter for combined tool results."""
|
|
2
2
|
|
|
3
3
|
from rich.table import Table
|
|
4
|
+
from rich.text import Text
|
|
4
5
|
|
|
5
6
|
from py_cq.localtypes import CombinedToolResults, ToolConfig
|
|
6
7
|
|
|
7
8
|
|
|
8
|
-
def format_as_table(
|
|
9
|
+
def format_as_table(
|
|
10
|
+
data: CombinedToolResults,
|
|
11
|
+
registry: dict[str, ToolConfig],
|
|
12
|
+
*,
|
|
13
|
+
total_s: float | None = None,
|
|
14
|
+
) -> Table:
|
|
9
15
|
"""Format combined tool results into a Rich Table."""
|
|
10
16
|
table = Table(width=80)
|
|
11
17
|
table.add_column("Tool", justify="left", no_wrap=True)
|
|
@@ -25,5 +31,13 @@ def format_as_table(data: CombinedToolResults, registry: dict[str, ToolConfig])
|
|
|
25
31
|
status = "[green]OK[/]"
|
|
26
32
|
time_str = f"{tr.duration_s:.2f}s" if i == 0 else ""
|
|
27
33
|
table.add_row(tool_name, time_str, name, f"{value:0.3f}", status)
|
|
28
|
-
|
|
34
|
+
total_label = (
|
|
35
|
+
Text("Total", style="bold", justify="right")
|
|
36
|
+
if total_s is not None
|
|
37
|
+
else Text("")
|
|
38
|
+
)
|
|
39
|
+
total_time = f"[bold]{total_s:.2f}s[/]" if total_s is not None else ""
|
|
40
|
+
table.add_row(
|
|
41
|
+
total_label, total_time, "[bold]Score[/]", f"[bold]{data.score:0.3f}[/]", ""
|
|
42
|
+
)
|
|
29
43
|
return table
|
py_cq/tool_registry.py
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
"""Loads tool configurations from a
|
|
1
|
+
"""Loads tool configurations from a TOML file and builds a registry that maps tool names to their configuration objects, enabling efficient lookup and instantiation of tools throughout the application."""
|
|
2
2
|
|
|
3
|
+
import tomllib
|
|
3
4
|
from importlib import import_module
|
|
4
5
|
from importlib.resources import files
|
|
5
6
|
|
|
6
|
-
import yaml
|
|
7
|
-
|
|
8
7
|
from py_cq.localtypes import ToolConfig
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
def load_tool_configs() -> dict[str, ToolConfig]:
|
|
12
|
-
"""Load tool configurations from the bundled config.
|
|
11
|
+
"""Load tool configurations from the bundled config.toml and return a registry.
|
|
13
12
|
|
|
14
13
|
Returns:
|
|
15
14
|
dict[str, ToolConfig]: A mapping from tool ID to its configuration instance."""
|
|
16
|
-
|
|
17
|
-
config =
|
|
15
|
+
toml_bytes = files("py_cq.config").joinpath("config.toml").read_bytes()
|
|
16
|
+
config = tomllib.loads(toml_bytes.decode())
|
|
18
17
|
registry = {}
|
|
19
18
|
for tool_id, tool_data in config["python"].items():
|
|
20
19
|
# Dynamically import parser class
|
|
@@ -31,6 +30,8 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
31
30
|
extra_deps=tool_data.get("extra_deps", []),
|
|
32
31
|
parser_config=tool_data.get("parser_config", {}),
|
|
33
32
|
exclude_format=tool_data.get("exclude_format", ""),
|
|
33
|
+
scan_exclude_names=tool_data.get("scan_exclude_names", []),
|
|
34
|
+
skip_for_file=tool_data.get("skip_for_file", False),
|
|
34
35
|
)
|
|
35
36
|
return registry
|
|
36
37
|
|