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.
Files changed (45) hide show
  1. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/PKG-INFO +88 -3
  2. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/README.md +86 -1
  3. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/pyproject.toml +15 -2
  4. python_code_quality-0.2.1/src/py_cq/__init__.py +5 -0
  5. python_code_quality-0.2.1/src/py_cq/api.py +248 -0
  6. python_code_quality-0.2.1/src/py_cq/cli.py +413 -0
  7. python_code_quality-0.2.1/src/py_cq/config/config.toml +95 -0
  8. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/context_hash.py +18 -8
  9. python_code_quality-0.2.1/src/py_cq/execution_engine.py +356 -0
  10. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/language_detector.py +4 -1
  11. python_code_quality-0.2.1/src/py_cq/llm_formatter.py +230 -0
  12. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/localtypes.py +53 -7
  13. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/main.py +1 -1
  14. python_code_quality-0.2.1/src/py_cq/parsers/__init__.py +1 -0
  15. python_code_quality-0.2.1/src/py_cq/parsers/banditparser.py +83 -0
  16. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/common.py +187 -25
  17. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/compileparser.py +21 -9
  18. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/complexityparser.py +40 -4
  19. python_code_quality-0.2.1/src/py_cq/parsers/coverageparser.py +202 -0
  20. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/exitcodeparser.py +11 -2
  21. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/halsteadparser.py +42 -14
  22. python_code_quality-0.2.1/src/py_cq/parsers/interrogateparser.py +294 -0
  23. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/linecountparser.py +10 -2
  24. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/maintainabilityparser.py +34 -4
  25. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/pytestparser.py +77 -20
  26. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/regexcountparser.py +13 -3
  27. python_code_quality-0.2.1/src/py_cq/parsers/ruffparser.py +205 -0
  28. python_code_quality-0.2.1/src/py_cq/parsers/typarser.py +215 -0
  29. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/parsers/vultureparser.py +22 -12
  30. python_code_quality-0.2.1/src/py_cq/table_formatter.py +43 -0
  31. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/tool_registry.py +7 -6
  32. python_code_quality-0.1.15/src/py_cq/__init__.py +0 -6
  33. python_code_quality-0.1.15/src/py_cq/cli.py +0 -324
  34. python_code_quality-0.1.15/src/py_cq/config/config.yaml +0 -94
  35. python_code_quality-0.1.15/src/py_cq/execution_engine.py +0 -200
  36. python_code_quality-0.1.15/src/py_cq/llm_formatter.py +0 -48
  37. python_code_quality-0.1.15/src/py_cq/parsers/__init__.py +0 -1
  38. python_code_quality-0.1.15/src/py_cq/parsers/banditparser.py +0 -54
  39. python_code_quality-0.1.15/src/py_cq/parsers/coverageparser.py +0 -88
  40. python_code_quality-0.1.15/src/py_cq/parsers/interrogateparser.py +0 -58
  41. python_code_quality-0.1.15/src/py_cq/parsers/ruffparser.py +0 -57
  42. python_code_quality-0.1.15/src/py_cq/parsers/typarser.py +0 -79
  43. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/config/__init__.py +0 -0
  44. {python_code_quality-0.1.15 → python_code_quality-0.2.1}/src/py_cq/metric_aggregator.py +0 -0
  45. {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.15
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}\" -e tests{exclude} -v --fail-under 0"
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}\" -e tests{exclude} -v --fail-under 0"
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.15"
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,5 @@
1
+ """Py cq"""
2
+
3
+ from py_cq.api import CQ
4
+
5
+ __all__ = ["CQ"]
@@ -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