python-code-quality 0.1.15__tar.gz → 0.2.1__tar.gz
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.
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/PKG-INFO +88 -3
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/README.md +86 -1
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/pyproject.toml +15 -2
- python_code_quality-0.2.1/src/py_cq/__init__.py +5 -0
- python_code_quality-0.2.1/src/py_cq/api.py +248 -0
- python_code_quality-0.2.1/src/py_cq/cli.py +413 -0
- python_code_quality-0.2.1/src/py_cq/config/config.toml +95 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/context_hash.py +18 -8
- python_code_quality-0.2.1/src/py_cq/execution_engine.py +356 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/language_detector.py +4 -1
- python_code_quality-0.2.1/src/py_cq/llm_formatter.py +230 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/localtypes.py +53 -7
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/main.py +1 -1
- python_code_quality-0.2.1/src/py_cq/parsers/__init__.py +1 -0
- python_code_quality-0.2.1/src/py_cq/parsers/banditparser.py +83 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/common.py +187 -25
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/compileparser.py +21 -9
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/complexityparser.py +40 -4
- python_code_quality-0.2.1/src/py_cq/parsers/coverageparser.py +202 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/exitcodeparser.py +11 -2
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/halsteadparser.py +42 -14
- python_code_quality-0.2.1/src/py_cq/parsers/interrogateparser.py +294 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/linecountparser.py +10 -2
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/maintainabilityparser.py +34 -4
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/pytestparser.py +77 -20
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/regexcountparser.py +13 -3
- python_code_quality-0.2.1/src/py_cq/parsers/ruffparser.py +205 -0
- python_code_quality-0.2.1/src/py_cq/parsers/typarser.py +215 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/vultureparser.py +22 -12
- python_code_quality-0.2.1/src/py_cq/table_formatter.py +43 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/tool_registry.py +7 -6
- python_code_quality-0.1.15/src/py_cq/__init__.py +0 -6
- python_code_quality-0.1.15/src/py_cq/cli.py +0 -324
- python_code_quality-0.1.15/src/py_cq/config/config.yaml +0 -94
- python_code_quality-0.1.15/src/py_cq/execution_engine.py +0 -200
- python_code_quality-0.1.15/src/py_cq/llm_formatter.py +0 -48
- python_code_quality-0.1.15/src/py_cq/parsers/__init__.py +0 -1
- python_code_quality-0.1.15/src/py_cq/parsers/banditparser.py +0 -54
- python_code_quality-0.1.15/src/py_cq/parsers/coverageparser.py +0 -88
- python_code_quality-0.1.15/src/py_cq/parsers/interrogateparser.py +0 -58
- python_code_quality-0.1.15/src/py_cq/parsers/ruffparser.py +0 -57
- python_code_quality-0.1.15/src/py_cq/parsers/typarser.py +0 -79
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/config/__init__.py +0 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/metric_aggregator.py +0 -0
- {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-code-quality
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens.
|
|
5
5
|
Author: Chris Kilner
|
|
6
6
|
Author-email: Chris Kilner <chris@rhiza.fr>
|
|
@@ -15,10 +15,10 @@ Requires-Dist: interrogate>=1.7.0
|
|
|
15
15
|
Requires-Dist: pytest>=8.4.0
|
|
16
16
|
Requires-Dist: pytest-cov>=6.1.1
|
|
17
17
|
Requires-Dist: pytest-json-report>=1.5.0
|
|
18
|
-
Requires-Dist: pyyaml>=6.0.2
|
|
19
18
|
Requires-Dist: radon>=6.0.1
|
|
20
19
|
Requires-Dist: rich>=14.0.0
|
|
21
20
|
Requires-Dist: ruff>=0.14.1
|
|
21
|
+
Requires-Dist: tomlkit>=0.13.0
|
|
22
22
|
Requires-Dist: ty>=0.0.17
|
|
23
23
|
Requires-Dist: typer>=0.16.0
|
|
24
24
|
Requires-Dist: vulture>=2.14
|
|
@@ -88,6 +88,7 @@ Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults
|
|
|
88
88
|
```bash
|
|
89
89
|
cq check . # Table overview of scores for humans
|
|
90
90
|
cq check . -o llm # Top defect as markdown for LLMs
|
|
91
|
+
cq check . -o llm-json # Top defect as JSON with fingerprint (for automation)
|
|
91
92
|
cq check . -o score # Numeric score only for CI
|
|
92
93
|
cq check . -o json # Detailed parsed JSON output for jq
|
|
93
94
|
cq check . -o raw # Raw tool output for debug
|
|
@@ -97,7 +98,9 @@ cq check . --skip bandit # Skip specific tools
|
|
|
97
98
|
cq check . --exclude demo # Exclude paths from all tools
|
|
98
99
|
cq check . --workers 1 # Run sequentially if you like things slow
|
|
99
100
|
cq check . --clear-cache # Clear cached results before running (rarely needed)
|
|
101
|
+
cq check . -o llm --hint # Append "run cq again to verify" (for human workflows)
|
|
100
102
|
cq config path/to/project/ # Show effective tool configuration
|
|
103
|
+
cq is-fixed <fingerprint> # Check whether a specific issue has been resolved
|
|
101
104
|
```
|
|
102
105
|
|
|
103
106
|
**Exit codes:** `cq check` exits with code `1` if any tool metric falls below its `error_threshold`, making it suitable as a CI gate:
|
|
@@ -107,6 +110,88 @@ cq check . && deploy # block deploy on errors
|
|
|
107
110
|
cq check . -o score # print score, exit 1 on errors
|
|
108
111
|
```
|
|
109
112
|
|
|
113
|
+
## Fingerprint-based verification
|
|
114
|
+
|
|
115
|
+
`-o llm-json` returns a JSON object with a stable `id` fingerprint you can pass back to `cq is-fixed` to check whether a specific issue has been resolved — without re-running all tools:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Get the top defect as JSON
|
|
119
|
+
cq check . -o llm-json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"id": "ruff::my-project::src/foo.py::42::E501",
|
|
125
|
+
"file": "src/foo.py",
|
|
126
|
+
"project": "/home/user/my-project",
|
|
127
|
+
"message": "..."
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# After fixing, verify only that issue (fast — reruns one tool on one file)
|
|
133
|
+
cq is-fixed "ruff::my-project::src/foo.py::42::E501"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This is most useful in automation: fix an issue, confirm it's gone, then fetch the next one. Note that this only verifies the original issue is no longer present — a full project-wide scan is still needed to guarantee no regressions, assuming you have sufficient tests.
|
|
137
|
+
|
|
138
|
+
## Python Library
|
|
139
|
+
|
|
140
|
+
`py_cq` can be used as a library — no subprocess required. Instantiate `CQ` with a project root; config is loaded once from `pyproject.toml`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from py_cq import CQ
|
|
144
|
+
|
|
145
|
+
cq = CQ(".") # load config from ./pyproject.toml
|
|
146
|
+
cq = CQ(".", skip=["bandit"]) # skip specific tools
|
|
147
|
+
cq = CQ(".", only=["ruff", "ty"]) # run only specific tools
|
|
148
|
+
cq = CQ(".", workers=4) # control parallelism
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Methods mirror the CLI and return data objects:**
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# list[ToolResult] — all parsed results before aggregation (-o raw / -o json)
|
|
155
|
+
results = cq.raw()
|
|
156
|
+
|
|
157
|
+
# CombinedToolResults — aggregated score and per-tool results (-o score / table)
|
|
158
|
+
combined = cq.check()
|
|
159
|
+
print(combined.score) # float
|
|
160
|
+
print(combined.tool_results) # list[ToolResult]
|
|
161
|
+
|
|
162
|
+
# dict — top defect as JSON, equivalent to -o llm-json
|
|
163
|
+
issue = cq.check_llm_json()
|
|
164
|
+
issue["id"] # fingerprint: "ruff::project::src/foo.py::42::E501"
|
|
165
|
+
issue["file"] # "src/foo.py"
|
|
166
|
+
issue["message"] # markdown prompt ready to send to an LLM
|
|
167
|
+
issue["project"] # absolute project root path
|
|
168
|
+
|
|
169
|
+
# bool — True if the fingerprinted issue is gone
|
|
170
|
+
# Reruns only the affected tool on the affected file — much faster than a full check.
|
|
171
|
+
# Pass the id from check_llm_json; also available as `cq is-fixed <id>` on the CLI.
|
|
172
|
+
fixed = cq.is_fixed(issue["id"])
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`check_llm_json` accepts the same options as `cq check . -o llm-json`:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
issue = cq.check_llm_json(limit=3, silence=["src/generated.py"], hint=True)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Typical automation loop:**
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from py_cq import CQ
|
|
185
|
+
|
|
186
|
+
cq = CQ(".")
|
|
187
|
+
while True:
|
|
188
|
+
issue = cq.check_llm_json()
|
|
189
|
+
if issue["id"] is None:
|
|
190
|
+
break # all clear
|
|
191
|
+
fix(issue["message"]) # call your LLM
|
|
192
|
+
assert cq.is_fixed(issue["id"])
|
|
193
|
+
```
|
|
194
|
+
|
|
110
195
|
## Claude Code Integration
|
|
111
196
|
|
|
112
197
|
Add a stop hook to your project's `.claude/settings.json` so Claude automatically checks quality after each session and loops until clean:
|
|
@@ -346,7 +431,7 @@ python:
|
|
|
346
431
|
error_threshold: 0.8
|
|
347
432
|
|
|
348
433
|
interrogate:
|
|
349
|
-
command: "{python} -m interrogate \"{context_path}\"
|
|
434
|
+
command: "{python} -m interrogate \"{context_path}\"{exclude} -v --fail-under 0"
|
|
350
435
|
exclude_format: " -e {path}"
|
|
351
436
|
parser: "InterrogateParser"
|
|
352
437
|
order: 11
|
|
@@ -59,6 +59,7 @@ Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults
|
|
|
59
59
|
```bash
|
|
60
60
|
cq check . # Table overview of scores for humans
|
|
61
61
|
cq check . -o llm # Top defect as markdown for LLMs
|
|
62
|
+
cq check . -o llm-json # Top defect as JSON with fingerprint (for automation)
|
|
62
63
|
cq check . -o score # Numeric score only for CI
|
|
63
64
|
cq check . -o json # Detailed parsed JSON output for jq
|
|
64
65
|
cq check . -o raw # Raw tool output for debug
|
|
@@ -68,7 +69,9 @@ cq check . --skip bandit # Skip specific tools
|
|
|
68
69
|
cq check . --exclude demo # Exclude paths from all tools
|
|
69
70
|
cq check . --workers 1 # Run sequentially if you like things slow
|
|
70
71
|
cq check . --clear-cache # Clear cached results before running (rarely needed)
|
|
72
|
+
cq check . -o llm --hint # Append "run cq again to verify" (for human workflows)
|
|
71
73
|
cq config path/to/project/ # Show effective tool configuration
|
|
74
|
+
cq is-fixed <fingerprint> # Check whether a specific issue has been resolved
|
|
72
75
|
```
|
|
73
76
|
|
|
74
77
|
**Exit codes:** `cq check` exits with code `1` if any tool metric falls below its `error_threshold`, making it suitable as a CI gate:
|
|
@@ -78,6 +81,88 @@ cq check . && deploy # block deploy on errors
|
|
|
78
81
|
cq check . -o score # print score, exit 1 on errors
|
|
79
82
|
```
|
|
80
83
|
|
|
84
|
+
## Fingerprint-based verification
|
|
85
|
+
|
|
86
|
+
`-o llm-json` returns a JSON object with a stable `id` fingerprint you can pass back to `cq is-fixed` to check whether a specific issue has been resolved — without re-running all tools:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Get the top defect as JSON
|
|
90
|
+
cq check . -o llm-json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"id": "ruff::my-project::src/foo.py::42::E501",
|
|
96
|
+
"file": "src/foo.py",
|
|
97
|
+
"project": "/home/user/my-project",
|
|
98
|
+
"message": "..."
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# After fixing, verify only that issue (fast — reruns one tool on one file)
|
|
104
|
+
cq is-fixed "ruff::my-project::src/foo.py::42::E501"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This is most useful in automation: fix an issue, confirm it's gone, then fetch the next one. Note that this only verifies the original issue is no longer present — a full project-wide scan is still needed to guarantee no regressions, assuming you have sufficient tests.
|
|
108
|
+
|
|
109
|
+
## Python Library
|
|
110
|
+
|
|
111
|
+
`py_cq` can be used as a library — no subprocess required. Instantiate `CQ` with a project root; config is loaded once from `pyproject.toml`.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from py_cq import CQ
|
|
115
|
+
|
|
116
|
+
cq = CQ(".") # load config from ./pyproject.toml
|
|
117
|
+
cq = CQ(".", skip=["bandit"]) # skip specific tools
|
|
118
|
+
cq = CQ(".", only=["ruff", "ty"]) # run only specific tools
|
|
119
|
+
cq = CQ(".", workers=4) # control parallelism
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Methods mirror the CLI and return data objects:**
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# list[ToolResult] — all parsed results before aggregation (-o raw / -o json)
|
|
126
|
+
results = cq.raw()
|
|
127
|
+
|
|
128
|
+
# CombinedToolResults — aggregated score and per-tool results (-o score / table)
|
|
129
|
+
combined = cq.check()
|
|
130
|
+
print(combined.score) # float
|
|
131
|
+
print(combined.tool_results) # list[ToolResult]
|
|
132
|
+
|
|
133
|
+
# dict — top defect as JSON, equivalent to -o llm-json
|
|
134
|
+
issue = cq.check_llm_json()
|
|
135
|
+
issue["id"] # fingerprint: "ruff::project::src/foo.py::42::E501"
|
|
136
|
+
issue["file"] # "src/foo.py"
|
|
137
|
+
issue["message"] # markdown prompt ready to send to an LLM
|
|
138
|
+
issue["project"] # absolute project root path
|
|
139
|
+
|
|
140
|
+
# bool — True if the fingerprinted issue is gone
|
|
141
|
+
# Reruns only the affected tool on the affected file — much faster than a full check.
|
|
142
|
+
# Pass the id from check_llm_json; also available as `cq is-fixed <id>` on the CLI.
|
|
143
|
+
fixed = cq.is_fixed(issue["id"])
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`check_llm_json` accepts the same options as `cq check . -o llm-json`:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
issue = cq.check_llm_json(limit=3, silence=["src/generated.py"], hint=True)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Typical automation loop:**
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from py_cq import CQ
|
|
156
|
+
|
|
157
|
+
cq = CQ(".")
|
|
158
|
+
while True:
|
|
159
|
+
issue = cq.check_llm_json()
|
|
160
|
+
if issue["id"] is None:
|
|
161
|
+
break # all clear
|
|
162
|
+
fix(issue["message"]) # call your LLM
|
|
163
|
+
assert cq.is_fixed(issue["id"])
|
|
164
|
+
```
|
|
165
|
+
|
|
81
166
|
## Claude Code Integration
|
|
82
167
|
|
|
83
168
|
Add a stop hook to your project's `.claude/settings.json` so Claude automatically checks quality after each session and loops until clean:
|
|
@@ -317,7 +402,7 @@ python:
|
|
|
317
402
|
error_threshold: 0.8
|
|
318
403
|
|
|
319
404
|
interrogate:
|
|
320
|
-
command: "{python} -m interrogate \"{context_path}\"
|
|
405
|
+
command: "{python} -m interrogate \"{context_path}\"{exclude} -v --fail-under 0"
|
|
321
406
|
exclude_format: " -e {path}"
|
|
322
407
|
parser: "InterrogateParser"
|
|
323
408
|
order: 11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-code-quality"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Python Code Quality Analysis Tool - feed the results from 11 CQ tools straight into an LLM. Minimal tokens."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -20,10 +20,10 @@ dependencies = [
|
|
|
20
20
|
"pytest>=8.4.0",
|
|
21
21
|
"pytest-cov>=6.1.1",
|
|
22
22
|
"pytest-json-report>=1.5.0",
|
|
23
|
-
"pyyaml>=6.0.2",
|
|
24
23
|
"radon>=6.0.1",
|
|
25
24
|
"rich>=14.0.0",
|
|
26
25
|
"ruff>=0.14.1",
|
|
26
|
+
"tomlkit>=0.13.0",
|
|
27
27
|
"ty>=0.0.17",
|
|
28
28
|
"typer>=0.16.0",
|
|
29
29
|
"vulture>=2.14",
|
|
@@ -46,6 +46,19 @@ cq = "py_cq.main:main"
|
|
|
46
46
|
[tool.pytest.ini_options]
|
|
47
47
|
testpaths = ["tests"]
|
|
48
48
|
norecursedirs = [".venv", "dist"]
|
|
49
|
+
markers = [
|
|
50
|
+
"slow: tests that have real side effects (e.g. clearing disk cache)",
|
|
51
|
+
]
|
|
52
|
+
addopts = "-m 'not slow'"
|
|
53
|
+
|
|
54
|
+
[[tool.ty.overrides]]
|
|
55
|
+
include = ["demo/**"]
|
|
56
|
+
rules = {unresolved-import = "ignore"}
|
|
49
57
|
|
|
50
58
|
[tool.cq]
|
|
51
59
|
exclude = ["demo"]
|
|
60
|
+
|
|
61
|
+
[dependency-groups]
|
|
62
|
+
dev = [
|
|
63
|
+
"hypothesis>=6.151.10",
|
|
64
|
+
]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Library API for py-cq. Instantiate CQ with a project root, then call methods."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from py_cq.config import load_user_config
|
|
8
|
+
from py_cq.execution_engine import _cache, run_tools
|
|
9
|
+
from py_cq.llm_formatter import format_for_llm_json
|
|
10
|
+
from py_cq.localtypes import CombinedToolResults, Fingerprint, ToolConfig, ToolResult
|
|
11
|
+
from py_cq.metric_aggregator import aggregate_metrics
|
|
12
|
+
from py_cq.tool_registry import tool_registry
|
|
13
|
+
|
|
14
|
+
_KNOWN_PARSER_CLASSES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"CompileParser",
|
|
17
|
+
"RuffParser",
|
|
18
|
+
"TyParser",
|
|
19
|
+
"BanditParser",
|
|
20
|
+
"PytestParser",
|
|
21
|
+
"CoverageParser",
|
|
22
|
+
"ComplexityParser",
|
|
23
|
+
"MaintainabilityParser",
|
|
24
|
+
"HalsteadParser",
|
|
25
|
+
"VultureParser",
|
|
26
|
+
"InterrogateParser",
|
|
27
|
+
"ExitCodeParser",
|
|
28
|
+
"LineCountParser",
|
|
29
|
+
"RegexCountParser",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _apply_user_config(
|
|
35
|
+
base: dict[str, ToolConfig], user_cfg: dict
|
|
36
|
+
) -> dict[str, ToolConfig]:
|
|
37
|
+
"""Return a modified copy of base with user overrides applied.
|
|
38
|
+
|
|
39
|
+
Raises ValueError on invalid config (caller wraps for CLI context).
|
|
40
|
+
"""
|
|
41
|
+
registry = {k: copy.copy(v) for k, v in base.items()}
|
|
42
|
+
for tool_id in user_cfg.get("disable", []):
|
|
43
|
+
registry.pop(tool_id, None)
|
|
44
|
+
for tool_id, thresholds in user_cfg.get("thresholds", {}).items():
|
|
45
|
+
if tool_id in registry:
|
|
46
|
+
if "warning" in thresholds:
|
|
47
|
+
registry[tool_id].warning_threshold = float(thresholds["warning"])
|
|
48
|
+
if "error" in thresholds:
|
|
49
|
+
registry[tool_id].error_threshold = float(thresholds["error"])
|
|
50
|
+
for tool_id, tool_data in user_cfg.get("tools", {}).items():
|
|
51
|
+
if tool_id in base:
|
|
52
|
+
available = ", ".join(sorted(base))
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"[tool.cq.tools.{tool_id}] is a built-in tool and cannot be redefined via pyproject.toml. "
|
|
55
|
+
f"Use [tool.cq.thresholds.{tool_id}] to adjust thresholds instead. "
|
|
56
|
+
f"Available: {available}"
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
parser_name = tool_data["parser"]
|
|
60
|
+
except KeyError:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"[tool.cq.tools.{tool_id}] missing required field 'parser'"
|
|
63
|
+
)
|
|
64
|
+
if parser_name not in _KNOWN_PARSER_CLASSES:
|
|
65
|
+
allowed = ", ".join(sorted(_KNOWN_PARSER_CLASSES))
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"[tool.cq.tools.{tool_id}] unknown parser {parser_name!r}. "
|
|
68
|
+
f"Allowed parsers: {allowed}"
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
module = import_module(f"py_cq.parsers.{parser_name.lower()}")
|
|
72
|
+
parser_class = getattr(module, parser_name)
|
|
73
|
+
registry[tool_id] = ToolConfig(
|
|
74
|
+
name=tool_id,
|
|
75
|
+
command=tool_data["command"],
|
|
76
|
+
parser_class=parser_class,
|
|
77
|
+
order=tool_data["order"],
|
|
78
|
+
warning_threshold=tool_data["warning_threshold"],
|
|
79
|
+
error_threshold=tool_data["error_threshold"],
|
|
80
|
+
run_in_target_env=tool_data.get("run_in_target_env", False),
|
|
81
|
+
extra_deps=tool_data.get("extra_deps", []),
|
|
82
|
+
parser_config=tool_data.get("parser_config", {}),
|
|
83
|
+
exclude_format=tool_data.get("exclude_format", ""),
|
|
84
|
+
)
|
|
85
|
+
except (KeyError, ImportError, AttributeError) as e:
|
|
86
|
+
raise ValueError(f"[tool.cq.tools.{tool_id}] {e}")
|
|
87
|
+
return registry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CQ:
|
|
91
|
+
"""Run code quality checks against a project root.
|
|
92
|
+
|
|
93
|
+
Config is loaded once at construction from pyproject.toml [tool.cq].
|
|
94
|
+
All methods return data objects; formatting is left to the caller.
|
|
95
|
+
|
|
96
|
+
Example::
|
|
97
|
+
|
|
98
|
+
cq = CQ(".")
|
|
99
|
+
issue = cq.check_llm_json() # {"id": ..., "message": ..., "file": ..., "project": ...}
|
|
100
|
+
fixed = cq.is_fixed(issue["id"])
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
path: str | Path,
|
|
106
|
+
*,
|
|
107
|
+
skip: list[str] | None = None,
|
|
108
|
+
only: list[str] | None = None,
|
|
109
|
+
exclude: list[str] | None = None,
|
|
110
|
+
workers: int = 0,
|
|
111
|
+
clear_cache: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.path = Path(path)
|
|
114
|
+
self._workers = workers
|
|
115
|
+
|
|
116
|
+
user_cfg = load_user_config(self.path)
|
|
117
|
+
self._context_lines: int = int(user_cfg.get("context_lines", 15))
|
|
118
|
+
self._registry = _apply_user_config(tool_registry, user_cfg)
|
|
119
|
+
|
|
120
|
+
if self.path.is_file():
|
|
121
|
+
self._registry = {
|
|
122
|
+
k: v for k, v in self._registry.items() if not v.skip_for_file
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if only:
|
|
126
|
+
keep = set(only)
|
|
127
|
+
unknown = keep - set(self._registry)
|
|
128
|
+
if unknown:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
|
|
131
|
+
)
|
|
132
|
+
self._registry = {k: v for k, v in self._registry.items() if k in keep}
|
|
133
|
+
if skip:
|
|
134
|
+
drop = set(skip)
|
|
135
|
+
unknown = drop - set(self._registry)
|
|
136
|
+
if unknown:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Unknown tool(s): {', '.join(sorted(unknown))}. Available: {', '.join(sorted(self._registry))}"
|
|
139
|
+
)
|
|
140
|
+
self._registry = {k: v for k, v in self._registry.items() if k not in drop}
|
|
141
|
+
|
|
142
|
+
config_excludes: list[str] = user_cfg.get("exclude", [])
|
|
143
|
+
self._excludes = list(dict.fromkeys(config_excludes + (exclude or [])))
|
|
144
|
+
self._project_root = (
|
|
145
|
+
self.path.resolve() if self.path.is_dir() else self.path.resolve().parent
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if clear_cache:
|
|
149
|
+
_cache.clear()
|
|
150
|
+
|
|
151
|
+
def raw(self, *, early_exit: bool = False) -> list[ToolResult]:
|
|
152
|
+
"""Run all tools and return parsed results before aggregation."""
|
|
153
|
+
return run_tools(
|
|
154
|
+
self._registry.values(),
|
|
155
|
+
str(self.path),
|
|
156
|
+
self._workers,
|
|
157
|
+
early_exit=early_exit,
|
|
158
|
+
excludes=self._excludes,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def check(self) -> CombinedToolResults:
|
|
162
|
+
"""Run all tools and return aggregated results."""
|
|
163
|
+
return aggregate_metrics(str(self.path), self.raw())
|
|
164
|
+
|
|
165
|
+
def check_llm_json(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
limit: int = 1,
|
|
169
|
+
silence: list[str] | None = None,
|
|
170
|
+
hint: bool = False,
|
|
171
|
+
) -> dict:
|
|
172
|
+
"""Return the top defect as a dict with keys: id, file, project, message.
|
|
173
|
+
|
|
174
|
+
Stops running tools after the first error (early_exit) for speed.
|
|
175
|
+
"""
|
|
176
|
+
results = self.raw(early_exit=True)
|
|
177
|
+
combined = aggregate_metrics(str(self.path), results)
|
|
178
|
+
return format_for_llm_json(
|
|
179
|
+
self._registry,
|
|
180
|
+
combined,
|
|
181
|
+
context_lines=self._context_lines,
|
|
182
|
+
hint=hint,
|
|
183
|
+
limit=limit,
|
|
184
|
+
silence=silence or [],
|
|
185
|
+
project_root=self._project_root,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def is_fixed(self, fingerprint: str) -> bool:
|
|
189
|
+
"""Return True if the fingerprinted issue is no longer present.
|
|
190
|
+
|
|
191
|
+
Fingerprint format: ``tool::project::path[::line[::code]]`` (as returned by check_llm_json["id"])
|
|
192
|
+
"""
|
|
193
|
+
fp = Fingerprint.from_string(fingerprint)
|
|
194
|
+
if not fp.tool:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Expected tool::project::path[::line[::code]], got: {fingerprint!r}"
|
|
197
|
+
)
|
|
198
|
+
if fp.tool not in tool_registry:
|
|
199
|
+
raise ValueError(f"Unknown tool: {fp.tool!r}")
|
|
200
|
+
|
|
201
|
+
if fp.code and fp.path:
|
|
202
|
+
# Specific issue: target only the affected file for a fast re-check
|
|
203
|
+
file_path = Path(fp.path)
|
|
204
|
+
if not file_path.is_absolute():
|
|
205
|
+
base = Path(fp.project) if fp.project else self._project_root
|
|
206
|
+
file_path = (base / file_path) if base else file_path
|
|
207
|
+
target = str(file_path)
|
|
208
|
+
elif fp.project and Path(fp.project).is_dir():
|
|
209
|
+
target = fp.project
|
|
210
|
+
elif fp.path:
|
|
211
|
+
file_path = Path(fp.path)
|
|
212
|
+
if not file_path.is_absolute():
|
|
213
|
+
file_path = (
|
|
214
|
+
(self._project_root / file_path)
|
|
215
|
+
if self._project_root
|
|
216
|
+
else file_path
|
|
217
|
+
)
|
|
218
|
+
target = str(file_path)
|
|
219
|
+
else:
|
|
220
|
+
target = str(self.path)
|
|
221
|
+
|
|
222
|
+
only_registry = {fp.tool: tool_registry[fp.tool]}
|
|
223
|
+
tool_results = run_tools(
|
|
224
|
+
only_registry.values(), target, max_workers=1, early_exit=False, excludes=[],
|
|
225
|
+
project_root=str(self.path),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if not tool_results:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
tr = tool_results[0]
|
|
232
|
+
tc = tool_registry[fp.tool]
|
|
233
|
+
|
|
234
|
+
if not fp.code:
|
|
235
|
+
return bool(tr.metrics and min(tr.metrics.values()) >= tc.warning_threshold)
|
|
236
|
+
|
|
237
|
+
def _file_matches(detail_file: str) -> bool:
|
|
238
|
+
p = Path(detail_file).as_posix()
|
|
239
|
+
t = Path(fp.path).as_posix()
|
|
240
|
+
return p == t or p.endswith(f"/{t}") or t.endswith(f"/{p}")
|
|
241
|
+
|
|
242
|
+
still_present = any(
|
|
243
|
+
str(i.get("line", "")) == fp.line and i.get("code") == fp.code
|
|
244
|
+
for f, issues in tr.details.items()
|
|
245
|
+
if _file_matches(f) and isinstance(issues, list)
|
|
246
|
+
for i in issues
|
|
247
|
+
)
|
|
248
|
+
return not still_present
|