python-code-quality 0.3.1__py3-none-any.whl → 0.4.0__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 CHANGED
@@ -107,7 +107,7 @@ def callback(
107
107
  help="Show version and dependencies",
108
108
  ),
109
109
  ) -> None:
110
- """Feed the results from 12 code quality tools to an LLM. Try: cq check . -o llm"""
110
+ """Feed the results from 13 code quality tools to an LLM. Try: cq check . -o llm"""
111
111
  import sys
112
112
 
113
113
  if isinstance(sys.stdout, io.TextIOWrapper):
@@ -172,7 +172,7 @@ def check(
172
172
  help="Issue selection order for -o llm/llm-json: severity (most severe first) or phase (first non-clean phase, in dependency order)",
173
173
  ),
174
174
  ):
175
- """Feed the results from 12 code quality tools to an LLM. Try: cq check . -o llm""" # --help
175
+ """Feed the results from 13 code quality tools to an LLM. Try: cq check . -o llm""" # --help
176
176
  path_obj = Path(path)
177
177
  if not path_obj.exists():
178
178
  raise typer.BadParameter(f"Path does not exist: {path}")
py_cq/config/config.toml CHANGED
@@ -7,8 +7,8 @@ error_threshold = 0.9999
7
7
  cache_invariant = "ast"
8
8
 
9
9
  [python.ruff]
10
- command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
11
- exclude_format = " --exclude {path}"
10
+ command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude{exclude}"
11
+ exclude_format = " --extend-exclude {path}"
12
12
  parser = "RuffParser"
13
13
  order = 2
14
14
  warning_threshold = 0.9999
@@ -33,11 +33,27 @@ order = 4
33
33
  warning_threshold = 0.9999
34
34
  error_threshold = 0.8
35
35
 
36
+ [python.kingfisher]
37
+ command = "kingfisher scan \"{context_path}\" --no-validate --no-update-check --git-history none --no-extract-archives --redact -q -f json --exclude .venv --exclude .git --exclude .hypothesis --exclude dist --exclude \"*_cache\" --exclude __pycache__ --exclude .claude{exclude}"
38
+ exclude_format = " --exclude {path}"
39
+ parser = "KingfisherParser"
40
+ order = 5
41
+ warning_threshold = 0.9999
42
+ error_threshold = 0.9999
43
+
44
+ [python.uv-audit]
45
+ command = "uv audit --project \"{abs_context_path}\" --frozen --output-format json"
46
+ parser = "UvAuditParser"
47
+ order = 6
48
+ warning_threshold = 0.9999
49
+ error_threshold = 0.8
50
+ skip_for_file = true
51
+
36
52
  [python.pytest]
37
53
  command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
38
54
  exclude_format = " --ignore {path}"
39
55
  parser = "PytestParser"
40
- order = 5
56
+ order = 7
41
57
  warning_threshold = 1.0
42
58
  error_threshold = 1.0
43
59
  run_in_target_env = true
@@ -46,10 +62,10 @@ skip_for_file = true
46
62
  cache_invariant = "ast"
47
63
 
48
64
  [python.coverage]
49
- command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
65
+ command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py,*/py_cq/runners/* \"{pytest_serial}\" \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py,*/py_cq/runners/*"
50
66
  exclude_format = " --ignore {path}"
51
67
  parser = "CoverageParser"
52
- order = 6
68
+ order = 8
53
69
  warning_threshold = 0.9
54
70
  error_threshold = 0.5
55
71
  run_in_target_env = true
@@ -59,19 +75,19 @@ skip_if = "pytest"
59
75
  cache_invariant = "ast"
60
76
 
61
77
  [python.vulture]
62
- command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
78
+ command = "{python} -m vulture \"{context_path}\" --min-confidence 60 --ignore-decorators \"@*.command,@*.callback,@*.route,@*.get,@*.post,@*.put,@*.delete,@*.patch,@*fixture,@*.task,@*hookimpl,@*validator,@*.errorhandler\" --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
63
79
  exclude_format = ",{path}"
64
80
  parser = "VultureParser"
65
- order = 7
81
+ order = 9
66
82
  warning_threshold = 0.9999
67
83
  error_threshold = 0.8
68
84
  cache_invariant = "ast"
69
85
 
70
86
  [python.interrogate]
71
- command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
87
+ command = "{python} -m interrogate \"{context_path}\" -e .claude -e \"{context_path}/tests\"{exclude} -v --fail-under 0"
72
88
  exclude_format = " -e {path}"
73
89
  parser = "InterrogateParser"
74
- order = 8
90
+ order = 10
75
91
  warning_threshold = 0.8
76
92
  error_threshold = 0.5
77
93
 
@@ -81,32 +97,28 @@ skip_empty_init = true
81
97
  [python.radon-cc]
82
98
  command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
83
99
  parser = "ComplexityParser"
84
- order = 9
85
- gate_strict = false
100
+ order = 11
86
101
  warning_threshold = 0.6
87
102
  error_threshold = 0.4
88
103
 
89
104
  [python.radon-mi]
90
105
  command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
91
106
  parser = "MaintainabilityParser"
92
- order = 10
93
- gate_strict = false
107
+ order = 12
94
108
  warning_threshold = 0.6
95
109
  error_threshold = 0.4
96
110
 
97
111
  [python.radon-hal]
98
112
  command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
99
113
  parser = "HalsteadParser"
100
- order = 11
101
- gate_strict = false
114
+ order = 13
102
115
  warning_threshold = 0.35
103
116
  error_threshold = 0.3
104
117
 
105
118
  [python.ruff-format]
106
- command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
107
- exclude_format = " --exclude {path}"
119
+ command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
120
+ exclude_format = " --extend-exclude {path}"
108
121
  parser = "RuffFormatParser"
109
- order = 12
110
- gate_strict = false
122
+ order = 14
111
123
  warning_threshold = 0.9999
112
124
  error_threshold = 0.5
py_cq/execution_engine.py CHANGED
@@ -38,6 +38,9 @@ _cache = Cache(
38
38
  Path.home() / ".cache" / "cq", size_limit=100 * 1024 * 1024, disk=JSONDisk
39
39
  )
40
40
 
41
+ # Script that runs pytest in one process under coverage; see its docstring.
42
+ _PYTEST_SERIAL = str(Path(__file__).parent / "runners" / "pytest_serial.py")
43
+
41
44
 
42
45
  def _find_project_root(path: Path) -> Path | None:
43
46
  """Walk up from path to find the nearest directory containing pyproject.toml."""
@@ -271,6 +274,7 @@ def run_tool(
271
274
  scan_targets=scan_targets,
272
275
  python=python,
273
276
  exclude=exclude,
277
+ pytest_serial=_PYTEST_SERIAL,
274
278
  )
275
279
  if precomputed_hash is not None:
276
280
  context_hash = precomputed_hash
@@ -328,6 +332,10 @@ def run_tool(
328
332
  timestamp=timestamp,
329
333
  project_path=project_dir,
330
334
  )
335
+ if return_code != 0 and not stdout.strip():
336
+ # Failed without a report: the tool is missing from PATH or offline.
337
+ # Caching this would hide real results until the files change.
338
+ return raw_result
331
339
  _cache.set(
332
340
  cache_key, raw_result.to_dict(), expire=5 * 24 * 60 * 60, tag=project_tag
333
341
  )
py_cq/localtypes.py CHANGED
@@ -60,9 +60,6 @@ class ToolConfig:
60
60
  default_factory=list
61
61
  ) # Top-level dir/file names to omit from {scan_targets}
62
62
  skip_for_file: bool = False # If True, skip when context_path is a single file
63
- gate_strict: bool = (
64
- True # If False, small regressions above warning_threshold are accepted
65
- )
66
63
  skip_if: str = "" # Tool name; if that tool's score is below its error_threshold, skip this tool
67
64
  cache_invariant: str = "" # "ast": key the cache on the docstring-stripped AST, ignoring docstrings/comments/format
68
65
 
@@ -0,0 +1,73 @@
1
+ """Parses output from the Kingfisher secret scanner into a standardized ToolResult.
2
+
3
+ Kingfisher (``uv tool install kingfisher-bin``) is invoked with ``-f json`` and
4
+ ``--redact``, so the report on stdout never contains the secret itself, only a
5
+ hash. ``--no-validate`` keeps it from sending findings to provider APIs.
6
+
7
+ Each finding is listed against its file. The count becomes a logistic-variant
8
+ score stored under the ``secrets`` metric key.
9
+
10
+ When no report can be read (``kingfisher`` not on PATH) the tool reports no
11
+ metric instead of a failing one.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+
17
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
18
+ from py_cq.parsers.common import extract_first_issue, score_logistic_variant
19
+
20
+ log = logging.getLogger("cq")
21
+
22
+
23
+ class KingfisherParser(AbstractParser):
24
+ """Parses raw JSON output from ``kingfisher scan -f json`` into a ToolResult."""
25
+
26
+ def parse(self, raw_result: RawResult) -> ToolResult:
27
+ """Parses the raw Kingfisher JSON report into a ToolResult."""
28
+ try:
29
+ findings = json.loads(raw_result.stdout)["findings"]
30
+ except (ValueError, KeyError, TypeError):
31
+ log.warning(
32
+ "kingfisher produced no report (return_code=%s), skipping. "
33
+ "Install it with `uv tool install kingfisher-bin`.",
34
+ raw_result.return_code,
35
+ )
36
+ return ToolResult(raw=raw_result)
37
+
38
+ files: dict[str, list] = {}
39
+ for entry in findings:
40
+ rule = entry.get("rule") or {}
41
+ finding = entry.get("finding") or {}
42
+ path = str(finding.get("path", "")).replace("\\", "/")
43
+ files.setdefault(path, []).append(
44
+ {
45
+ "line": finding.get("line", 0),
46
+ "code": rule.get("id", ""),
47
+ "confidence": finding.get("confidence", ""),
48
+ "message": rule.get("description", ""),
49
+ }
50
+ )
51
+
52
+ count = sum(len(issues) for issues in files.values())
53
+ score = score_logistic_variant(count, scale_factor=5)
54
+ return ToolResult(raw=raw_result, metrics={"secrets": score}, details=files)
55
+
56
+ def format_llm_message(
57
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
58
+ ) -> str:
59
+ """Formats the first secret finding into an LLM-friendly message."""
60
+ result = extract_first_issue(tr.details)
61
+ if result is None:
62
+ return "kingfisher reported secrets (no details available)"
63
+ file, issue = result
64
+ line = issue.get("line", "?")
65
+ # No source excerpt: it would put the plaintext secret into the LLM prompt.
66
+ return (
67
+ f"{file}:{line} - {issue.get('code', '')}: "
68
+ f"[{issue.get('confidence', '')} confidence] {issue.get('message', '')}\n\n"
69
+ "Move the secret out of the source (environment variable or an untracked "
70
+ "file) and rotate it if it was ever committed. Only if it is an intentional "
71
+ "test fixture or a false positive, add a `kingfisher:ignore` comment on "
72
+ "that line."
73
+ )
@@ -0,0 +1,88 @@
1
+ """Parses output from ``uv audit`` into a standardized ToolResult.
2
+
3
+ ``uv audit --output-format json`` checks the packages pinned in ``uv.lock``
4
+ against the OSV advisory database and prints a JSON report on stdout.
5
+
6
+ Only advisories with a fix version are counted: those are the ones an upgrade
7
+ resolves. The same vulnerability is often listed once per advisory database
8
+ (GHSA, PYSEC), so entries that alias one already seen for a package are
9
+ dropped. The count becomes a logistic-variant score stored under the
10
+ ``dependencies`` metric key.
11
+
12
+ When no report can be read (``uv`` missing, no ``pyproject.toml`` or
13
+ ``uv.lock``, no network) the tool reports no metric instead of a failing one.
14
+ """
15
+
16
+ import json
17
+ import logging
18
+
19
+ from py_cq.localtypes import AbstractParser, RawResult, ToolResult
20
+ from py_cq.parsers.common import extract_first_issue, score_logistic_variant
21
+
22
+ log = logging.getLogger("cq")
23
+
24
+ _LOCKFILE = "uv.lock"
25
+
26
+
27
+ class UvAuditParser(AbstractParser):
28
+ """Parses raw JSON output from ``uv audit`` into a ToolResult."""
29
+
30
+ def parse(self, raw_result: RawResult) -> ToolResult:
31
+ """Parses the raw ``uv audit`` JSON report into a ToolResult."""
32
+ try:
33
+ vulnerabilities = json.loads(raw_result.stdout)["vulnerabilities"]
34
+ except (ValueError, KeyError, TypeError):
35
+ reason = (raw_result.stderr or "").strip().splitlines()[-1:]
36
+ log.warning(
37
+ "uv audit produced no report (return_code=%s), skipping: %s",
38
+ raw_result.return_code,
39
+ reason[0] if reason else "no output",
40
+ )
41
+ return ToolResult(raw=raw_result)
42
+
43
+ seen: set[tuple[str, str]] = set()
44
+ issues: list[dict] = []
45
+ for vuln in vulnerabilities:
46
+ fix_versions = [str(v) for v in vuln.get("fix_versions") or []]
47
+ if not fix_versions:
48
+ continue
49
+ dependency = vuln.get("dependency") or {}
50
+ package = dependency.get("name", "")
51
+ ids = {vuln.get("id", ""), *(vuln.get("aliases") or [])}
52
+ if any((package, i) in seen for i in ids):
53
+ continue
54
+ seen.update((package, i) for i in ids)
55
+ issues.append(
56
+ {
57
+ "code": vuln.get("id", ""),
58
+ "package": package,
59
+ "version": dependency.get("version", ""),
60
+ "fix_versions": fix_versions,
61
+ "message": vuln.get("summary") or "",
62
+ "link": vuln.get("link") or "",
63
+ }
64
+ )
65
+
66
+ score = score_logistic_variant(len(issues), scale_factor=5)
67
+ details = {_LOCKFILE: issues} if issues else {}
68
+ return ToolResult(
69
+ raw=raw_result, metrics={"dependencies": score}, details=details
70
+ )
71
+
72
+ def format_llm_message(
73
+ self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
74
+ ) -> str:
75
+ """Formats the first vulnerable dependency into an LLM-friendly message."""
76
+ result = extract_first_issue(tr.details)
77
+ if result is None:
78
+ return "uv audit reported issues (no details available)"
79
+ file, issue = result
80
+ package = issue.get("package", "")
81
+ fixed = ", ".join(issue.get("fix_versions", []))
82
+ message = (
83
+ f"{file} - {issue.get('code', '')}: **{package} {issue.get('version', '')}** "
84
+ f"has a known vulnerability: {issue.get('message', '')}\n\n"
85
+ f"Fixed in {fixed}. Upgrade with `uv lock --upgrade-package {package}`."
86
+ )
87
+ link = issue.get("link")
88
+ return f"{message}\n\n{link}" if link else message
@@ -1,9 +1,14 @@
1
1
  """Parses output from vulture dead-code detector into a standardized ToolResult.
2
2
 
3
- Vulture is invoked with ``--min-confidence 80``, producing one text line
3
+ Vulture is invoked with ``--min-confidence 60``, producing one text line
4
4
  per unused symbol::
5
5
 
6
- src/foo.py:10: unused function 'bar' (80% confidence)
6
+ src/foo.py:10: unused function 'bar' (60% confidence)
7
+
8
+ 60% is the confidence vulture gives unused functions, classes, methods and
9
+ variables, so anything higher only reports unused imports and unreachable
10
+ code. At that level vulture cannot see dynamic use, so the LLM message for a
11
+ finding below 80% says how to keep the symbol instead of deleting it.
7
12
 
8
13
  The parser counts violations and converts the count into a logistic-variant
9
14
  score stored under the ``dead_code`` metric key.
@@ -20,6 +25,13 @@ from py_cq.parsers.common import (
20
25
 
21
26
  _LINE_RE = re.compile(r"^(.+):(\d+): (unused \S+) '(.+)' \((\d+)% confidence\)$")
22
27
 
28
+ _CERTAIN = 80
29
+ _KEEP_HINT = (
30
+ "\n\nRemove it if nothing uses it. If it is public API or is used dynamically,"
31
+ ' keep it and add `"{name}"` to `ignore_names` under `[tool.vulture]` in'
32
+ " `pyproject.toml`."
33
+ )
34
+
23
35
 
24
36
  class VultureParser(AbstractParser):
25
37
  """Parses raw text output from ``vulture`` into a ToolResult."""
@@ -55,4 +67,7 @@ class VultureParser(AbstractParser):
55
67
  kind = issue.get("type", "unused")
56
68
  name = issue.get("name", "")
57
69
  confidence = issue.get("confidence", "?")
58
- return f"{file}:{line} - **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
70
+ message = f"{file}:{line} - **{kind}** `{name}` ({confidence}% confidence){format_source_context(file, line, count=context_lines)}"
71
+ if isinstance(confidence, int) and confidence < _CERTAIN:
72
+ message += _KEEP_HINT.format(name=name)
73
+ return message
@@ -0,0 +1,27 @@
1
+ """Run pytest in a single process, for the coverage tool.
2
+
3
+ cq runs this script under ``coverage run``. Coverage only measures the process
4
+ it starts, so if the target project enables pytest-xdist through its own
5
+ ``addopts``, the tests run in worker processes that coverage never sees and the
6
+ coverage score collapses. Passing ``-n 0`` keeps the tests in this process. It
7
+ is only passed when xdist is installed, because pytest rejects ``-n`` otherwise.
8
+
9
+ The script runs in the target project's environment, so it imports only the
10
+ standard library and pytest. It lives in its own directory because running a
11
+ script puts that directory first on ``sys.path``; that entry is replaced with
12
+ the working directory so imports behave as they do under ``python -m pytest``.
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ from importlib.util import find_spec
18
+
19
+ if __name__ == "__main__":
20
+ sys.path[0] = os.getcwd()
21
+
22
+ import pytest
23
+
24
+ args = sys.argv[1:]
25
+ if find_spec("xdist") is not None:
26
+ args += ["-n", "0"]
27
+ sys.exit(pytest.main(args))
py_cq/tool_registry.py CHANGED
@@ -32,7 +32,6 @@ def load_tool_configs() -> dict[str, ToolConfig]:
32
32
  exclude_format=tool_data.get("exclude_format", ""),
33
33
  scan_exclude_names=tool_data.get("scan_exclude_names", []),
34
34
  skip_for_file=tool_data.get("skip_for_file", False),
35
- gate_strict=tool_data.get("gate_strict", True),
36
35
  skip_if=tool_data.get("skip_if", ""),
37
36
  cache_invariant=tool_data.get("cache_invariant", ""),
38
37
  )
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-code-quality
3
- Version: 0.3.1
4
- Summary: Python Code Quality Analysis Tool - feed the results from 12 CQ tools straight into an LLM. Minimal tokens.
3
+ Version: 0.4.0
4
+ Summary: Python Code Quality Analysis Tool - feed the results from 14 CQ tools straight into an LLM. Minimal tokens.
5
5
  Author: Chris Kilner
6
6
  Author-email: Chris Kilner <chris@rhiza.fr>
7
7
  License-Expression: MIT
@@ -35,7 +35,7 @@ Description-Content-Type: text/markdown
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/python-code-quality?)](https://pypi.org/project/python-code-quality/)
36
36
  [![License](https://img.shields.io/github/license/rhiza-fr/py-cq)](LICENSE)
37
37
 
38
- Run 12 code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
38
+ Run 14 code quality tools, aggregate results into one score, and surface the single most critical defect as a focused markdown prompt — ready to pipe to any LLM.
39
39
 
40
40
  This can dramatically reduce the amount of noise for LLMs (and humans) and remove the need for them to know about these tools.
41
41
 
@@ -74,16 +74,24 @@ When running '-o llm', we run sequentially and exit early at the first error.
74
74
  | 2 | ruff | Lint / style |
75
75
  | 3 | ty | Type errors |
76
76
  | 4 | bandit | Security vulnerabilities |
77
- | 5 | pytest | Test pass rate |
78
- | 6 | coverage | Test coverage |
79
- | 7 | vulture | Dead code |
80
- | 8 | interrogate | Docstring coverage |
81
- | 9 | radon cc | Cyclomatic complexity |
82
- | 10 | radon mi | Maintainability index |
83
- | 11 | radon hal | Halstead volume / bug estimate |
84
- | 12 | ruff format | Formatting (fraction of files already formatted) |
77
+ | 5 | kingfisher | Hardcoded secrets (API keys, tokens, private keys) |
78
+ | 6 | uv audit | Known vulnerabilities in locked dependencies |
79
+ | 7 | pytest | Test pass rate |
80
+ | 8 | coverage | Test coverage |
81
+ | 9 | vulture | Dead code |
82
+ | 10 | interrogate | Docstring coverage |
83
+ | 11 | radon cc | Cyclomatic complexity |
84
+ | 12 | radon mi | Maintainability index |
85
+ | 13 | radon hal | Halstead volume / bug estimate |
86
+ | 14 | ruff format | Formatting (fraction of files already formatted) |
85
87
 
86
- Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, No pickle risk.
88
+ `kingfisher` scans the working tree for hardcoded secrets with [Kingfisher](https://github.com/mongodb/kingfisher) (502 rules, Rust). It is not a Python dependency of cq: install it once with `uv tool install kingfisher-bin` (about 41 MB). When it is not on the `PATH` the tool is skipped with a warning. cq runs it offline (`--no-validate --no-update-check`) and with `--redact`, so secrets are never sent to provider APIs and never stored in the cache; the LLM message gives the file, line and rule but no source excerpt. Silence a test fixture or false positive with a `kingfisher:ignore` comment on that line.
89
+
90
+ `uv audit` checks the versions pinned in `uv.lock` against the OSV advisory database. It needs `uv` on the `PATH`, a `uv.lock` and network access; when any is missing the tool is skipped with a warning instead of failing. Only advisories that have a fix version are reported, since those are the ones an upgrade resolves. Results are cached like every other tool, so an advisory published after a run shows up when the project changes or the cache entry expires.
91
+
92
+ `vulture` runs at `--min-confidence 60`, the level at which it reports unused functions, classes, methods and variables. It cannot see dynamic use, so common framework decorators (`@app.command`, `@app.route`, `@pytest.fixture`, validators and similar) are ignored. For anything else that is used but never referenced by name, such as public API, add it to `ignore_names` under `[tool.vulture]` in your `pyproject.toml`.
93
+
94
+ Diskcache is used to cache tool output for lightning fast re-runs. Sane defaults: <100 Mb, <5 days, stored as JSON (cq never writes pickles).
87
95
 
88
96
 
89
97
  ## Usage
@@ -249,6 +257,7 @@ Then invoke it with `/cq-fix` in Claude Code. The `$(...)` embeds the live `cq`
249
257
  │ ruff │ 0.17s │ lint │ 1.000 │ OK │
250
258
  │ ty │ 0.33s │ type_check │ 1.000 │ OK │
251
259
  │ bandit │ 0.56s │ security │ 1.000 │ OK │
260
+ │ uv-audit │ 0.41s │ dependencies │ 1.000 │ OK │
252
261
  │ pytest │ 0.91s │ tests │ 1.000 │ OK │
253
262
  │ coverage │ 1.26s │ coverage │ 0.910 │ OK │
254
263
  │ vulture │ 0.32s │ dead_code │ 1.000 │ OK │
@@ -341,7 +350,7 @@ warning = 0.9
341
350
  error = 0.7
342
351
  ```
343
352
 
344
- Tool IDs match the keys in `src/py_cq/config/config.toml`: `compile`, `ruff`, `ty`, `bandit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`, `ruff-format`.
353
+ Tool IDs match the keys in `src/py_cq/config/config.toml`: `compile`, `ruff`, `ty`, `bandit`, `kingfisher`, `uv-audit`, `pytest`, `coverage`, `radon-cc`, `radon-mi`, `radon-hal`, `vulture`, `interrogate`, `ruff-format`.
345
354
 
346
355
 
347
356
  ### Default config
@@ -358,8 +367,8 @@ error_threshold = 0.9999
358
367
  cache_invariant = "ast"
359
368
 
360
369
  [python.ruff]
361
- command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --exclude .claude{exclude}"
362
- exclude_format = " --exclude {path}"
370
+ command = "{python} -m ruff check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude{exclude}"
371
+ exclude_format = " --extend-exclude {path}"
363
372
  parser = "RuffParser"
364
373
  order = 2
365
374
  warning_threshold = 0.9999
@@ -384,11 +393,27 @@ order = 4
384
393
  warning_threshold = 0.9999
385
394
  error_threshold = 0.8
386
395
 
396
+ [python.kingfisher]
397
+ command = "kingfisher scan \"{context_path}\" --no-validate --no-update-check --git-history none --no-extract-archives --redact -q -f json --exclude .venv --exclude .git --exclude .hypothesis --exclude dist --exclude \"*_cache\" --exclude __pycache__ --exclude .claude{exclude}"
398
+ exclude_format = " --exclude {path}"
399
+ parser = "KingfisherParser"
400
+ order = 5
401
+ warning_threshold = 0.9999
402
+ error_threshold = 0.9999
403
+
404
+ [python.uv-audit]
405
+ command = "uv audit --project \"{abs_context_path}\" --frozen --output-format json"
406
+ parser = "UvAuditParser"
407
+ order = 6
408
+ warning_threshold = 0.9999
409
+ error_threshold = 0.8
410
+ skip_for_file = true
411
+
387
412
  [python.pytest]
388
413
  command = "{python} -m pytest -vv \"{context_path}\" --ignore .claude{exclude}"
389
414
  exclude_format = " --ignore {path}"
390
415
  parser = "PytestParser"
391
- order = 5
416
+ order = 7
392
417
  warning_threshold = 1.0
393
418
  error_threshold = 1.0
394
419
  run_in_target_env = true
@@ -397,10 +422,10 @@ skip_for_file = true
397
422
  cache_invariant = "ast"
398
423
 
399
424
  [python.coverage]
400
- command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py -m pytest \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py"
425
+ command = "{python} -m coverage run --omit=*/tests/*,*/test_*.py,*/py_cq/runners/* \"{pytest_serial}\" \"{context_path}\" --ignore .claude{exclude} && {python} -m coverage report --show-missing --omit=*/tests/*,*/test_*.py,*/py_cq/runners/*"
401
426
  exclude_format = " --ignore {path}"
402
427
  parser = "CoverageParser"
403
- order = 6
428
+ order = 8
404
429
  warning_threshold = 0.9
405
430
  error_threshold = 0.5
406
431
  run_in_target_env = true
@@ -410,19 +435,19 @@ skip_if = "pytest"
410
435
  cache_invariant = "ast"
411
436
 
412
437
  [python.vulture]
413
- command = "{python} -m vulture \"{context_path}\" --min-confidence 80 --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
438
+ command = "{python} -m vulture \"{context_path}\" --min-confidence 60 --ignore-decorators \"@*.command,@*.callback,@*.route,@*.get,@*.post,@*.put,@*.delete,@*.patch,@*fixture,@*.task,@*hookimpl,@*validator,@*.errorhandler\" --exclude .venv,dist,.*_cache,docs,.git,.claude{exclude}"
414
439
  exclude_format = ",{path}"
415
440
  parser = "VultureParser"
416
- order = 7
441
+ order = 9
417
442
  warning_threshold = 0.9999
418
443
  error_threshold = 0.8
419
444
  cache_invariant = "ast"
420
445
 
421
446
  [python.interrogate]
422
- command = "{python} -m interrogate \"{context_path}\" -e .claude{exclude} -v --fail-under 0"
447
+ command = "{python} -m interrogate \"{context_path}\" -e .claude -e \"{context_path}/tests\"{exclude} -v --fail-under 0"
423
448
  exclude_format = " -e {path}"
424
449
  parser = "InterrogateParser"
425
- order = 8
450
+ order = 10
426
451
  warning_threshold = 0.8
427
452
  error_threshold = 0.5
428
453
 
@@ -432,33 +457,29 @@ skip_empty_init = true
432
457
  [python.radon-cc]
433
458
  command = "{python} -m radon cc --json --exclude '.claude/**' \"{context_path}\""
434
459
  parser = "ComplexityParser"
435
- order = 9
436
- gate_strict = false
460
+ order = 11
437
461
  warning_threshold = 0.6
438
462
  error_threshold = 0.4
439
463
 
440
464
  [python.radon-mi]
441
465
  command = "{python} -m radon mi -s --json --exclude '.claude/**' \"{context_path}\""
442
466
  parser = "MaintainabilityParser"
443
- order = 10
444
- gate_strict = false
467
+ order = 12
445
468
  warning_threshold = 0.6
446
469
  error_threshold = 0.4
447
470
 
448
471
  [python.radon-hal]
449
472
  command = "{python} -m radon hal -f --json --exclude '.claude/**' \"{context_path}\""
450
473
  parser = "HalsteadParser"
451
- order = 11
452
- gate_strict = false
474
+ order = 13
453
475
  warning_threshold = 0.35
454
476
  error_threshold = 0.3
455
477
 
456
478
  [python.ruff-format]
457
- command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
458
- exclude_format = " --exclude {path}"
479
+ command = "{python} -m ruff format --check --output-format concise --no-cache \"{context_path}\" --extend-exclude .claude --config \"include=['*.py','*.pyi','*.ipynb']\"{exclude}"
480
+ exclude_format = " --extend-exclude {path}"
459
481
  parser = "RuffFormatParser"
460
- order = 12
461
- gate_strict = false
482
+ order = 14
462
483
  warning_threshold = 0.9999
463
484
  error_threshold = 0.5
464
485
  ```
@@ -1,13 +1,13 @@
1
1
  py_cq/__init__.py,sha256=-C6_QzQeydxft3ndlExTgWnvlqH8fJNPJxfySXULQmU,56
2
2
  py_cq/api.py,sha256=u0ajk6ZYWFGjSsLYkRfUBBtL_Vw0HHZbWpa1AM79y8Y,10172
3
- py_cq/cli.py,sha256=zciffdqyz_TXO8xfTlItWBS8s8eFGhG7Iv0I2M9eBK4,13827
3
+ py_cq/cli.py,sha256=i_-VCXVzrj2kpzbx8lEJxWmQNFyiFa37nsCMMkxqvas,13827
4
4
  py_cq/config/__init__.py,sha256=f0wc51O_3kGDTZUnCbGv8_zWnC5yYGl4NWcf2buSImQ,670
5
- py_cq/config/config.toml,sha256=TnUzP2cKKsyfN9GbnGeveBnqKTQl_PEYggOrpqBPg3s,3357
5
+ py_cq/config/config.toml,sha256=iV0kUp6ifZvHu_3fyNyC6C3Ggv7ts1zhopU0-3LH1U4,4170
6
6
  py_cq/context_hash.py,sha256=uKMkN394v6QYMukZj9usbxnq8px3i1khyIRPKwe09DE,5614
7
- py_cq/execution_engine.py,sha256=nuW-N8X1ZmtxzkohFxxYvVPBrBIR2TPExMsPQCTAZ6A,20865
7
+ py_cq/execution_engine.py,sha256=1-AM1PbIAdizj-PGf3cmePMKGmsV4pk6K2YgDHyerdA,21279
8
8
  py_cq/language_detector.py,sha256=rh5bAA3LyCQR3RODmx_EalqRNYvSYK2SJzh2hIkxOUo,1139
9
9
  py_cq/llm_formatter.py,sha256=nCV3nFdxJAi2CnH9FKb5tB-0w-yjLWmjywQI5GJ2cSU,8412
10
- py_cq/localtypes.py,sha256=_g6RpV-JS6T0lD_Uqq4iA5e2Fw6RM56Lqrv_GiP4gm8,7907
10
+ py_cq/localtypes.py,sha256=-XxoasKVOQ6hLzNX2KdpTxxHu3F2c_9RV3DCLPPj0D0,7794
11
11
  py_cq/main.py,sha256=5dgr8Zk1LNK5M-0wncYVksBp0qE-jWZYqmOXfb1eOhk,400
12
12
  py_cq/metric_aggregator.py,sha256=M2ymo62S7p7qPUqjjoiPg4IVyXQhLMuTr9-jxLiFjCY,853
13
13
  py_cq/parsers/__init__.py,sha256=uCy7R0cnvDPNvB5aWMLgM3FB9nDcu1jwUmznFMRROSY,28
@@ -19,6 +19,7 @@ py_cq/parsers/coverageparser.py,sha256=QGjZ8JJF0G-0uiZ07BzxSnblGiysHtOwq0SYvWrtO
19
19
  py_cq/parsers/exitcodeparser.py,sha256=b4LWIwwklPwCX3MnqNsGQC1slQU56DKE05vJ0OdWVoA,957
20
20
  py_cq/parsers/halsteadparser.py,sha256=789c_WWfomskM4i7SUEbKvB67-F2y5nfqt48aFXOusY,10192
21
21
  py_cq/parsers/interrogateparser.py,sha256=KDqXTdnJ5JzWhN4ziRda90nZCQOJaKn3CCiq4Rh0IRw,11314
22
+ py_cq/parsers/kingfisherparser.py,sha256=-UbtZ4d_SAPoohgz9FwcVE4u33VB_8FtE1_dhAwJ1VM,3129
22
23
  py_cq/parsers/linecountparser.py,sha256=-hqebwG4j7_QugzGLUUQRcPukGHicV2nbIp1pR8ZcAw,1310
23
24
  py_cq/parsers/maintainabilityparser.py,sha256=3a4m93XNtUurw3IxEI1Iae9idgJY6qiEt7HMYl1wTu0,4793
24
25
  py_cq/parsers/pytestparser.py,sha256=cIaTs2XxgeJlVragLEVTZcrbB-bzfNYYMdUrakRa-rQ,11545
@@ -26,12 +27,14 @@ py_cq/parsers/regexcountparser.py,sha256=zGzHEYDIori6aiWnVkRFixdE7fWt66npAAWjzvp
26
27
  py_cq/parsers/ruffformatparser.py,sha256=UXff5Qwh9i0goiSTJ_AaaIsmMSssUjNY2O_dERXjsFw,2636
27
28
  py_cq/parsers/ruffparser.py,sha256=uE0QEaZ4Ch7IsWY_2ZkfHXPU0TYPGsGPfv66hGe5G34,7634
28
29
  py_cq/parsers/typarser.py,sha256=x835OrapG1aUteW2NhC5U0bhj1w1pMtbfmbvb74-1AI,8077
29
- py_cq/parsers/vultureparser.py,sha256=-yGAXzRY4_fzLRTk7dfwDrrRZ-n97j1Ph5_xo3c6TfU,2229
30
+ py_cq/parsers/uvauditparser.py,sha256=rnNLp4UD48utALr-ut1fCt3CKIu5Q80x75ws5s6ArqI,3652
31
+ py_cq/parsers/vultureparser.py,sha256=C3teaMYtROHqFrXHBvTvLMK9jbDK5Q8X4gAq5ruNTGM,2889
30
32
  py_cq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ py_cq/runners/pytest_serial.py,sha256=lhhkaKdwrgPzP3uKnHAfNgEE0iETjbvVh9xc23pbkXo,1026
31
34
  py_cq/source_file.py,sha256=Uyv-CSVeliFnE6TpRB4Y15ykFQs4n0r9-amzxXXlqN0,7562
32
35
  py_cq/table_formatter.py,sha256=-22nzVIOaDe3QOBmZ2iBPH7ewIEIPEn5WYSjCoB4Hj4,1608
33
- py_cq/tool_registry.py,sha256=0s0sD9_iQW-HU0SIKSS8CFDlrzW0c-vlYrAFODEHiSc,1894
34
- python_code_quality-0.3.1.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
35
- python_code_quality-0.3.1.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
36
- python_code_quality-0.3.1.dist-info/METADATA,sha256=E3HJ8qZESv7gyGqBQrSiQeeyDTyFYe9v7cm6Kt8x8_8,16739
37
- python_code_quality-0.3.1.dist-info/RECORD,,
36
+ py_cq/tool_registry.py,sha256=1LkaBzWu5NzbaEZdZp1JLGFXZpEyke4pHHe01UvcfPo,1834
37
+ python_code_quality-0.4.0.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
38
+ python_code_quality-0.4.0.dist-info/entry_points.txt,sha256=cfWbTw7eYO6Trv1-Z_odL6Zta9CqYU6Vk9lAHdfB60Q,40
39
+ python_code_quality-0.4.0.dist-info/METADATA,sha256=nHEPvS1bYCSgE-ev1QYGHOfdUehL8cEqv8CMCtVr89Y,19336
40
+ python_code_quality-0.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.13.0
2
+ Generator: uv 0.13.8
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any