python-code-quality 0.2.3__py3-none-any.whl → 0.3.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/api.py +9 -3
- py_cq/cli.py +12 -14
- py_cq/config/config.toml +13 -0
- py_cq/context_hash.py +58 -15
- py_cq/execution_engine.py +172 -61
- py_cq/localtypes.py +4 -2
- py_cq/parsers/common.py +2 -7
- py_cq/parsers/coverageparser.py +7 -18
- py_cq/parsers/interrogateparser.py +31 -24
- py_cq/parsers/pytestparser.py +1 -1
- py_cq/parsers/ruffformatparser.py +62 -0
- py_cq/source_file.py +199 -0
- py_cq/table_formatter.py +1 -1
- py_cq/tool_registry.py +1 -0
- {python_code_quality-0.2.3.dist-info → python_code_quality-0.3.1.dist-info}/METADATA +136 -112
- {python_code_quality-0.2.3.dist-info → python_code_quality-0.3.1.dist-info}/RECORD +18 -16
- {python_code_quality-0.2.3.dist-info → python_code_quality-0.3.1.dist-info}/WHEEL +1 -1
- {python_code_quality-0.2.3.dist-info → python_code_quality-0.3.1.dist-info}/entry_points.txt +0 -0
py_cq/parsers/coverageparser.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Parses raw coverage tool output into structured ToolResult instances with per-function granularity."""
|
|
2
2
|
|
|
3
|
-
import ast
|
|
4
3
|
import logging
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
7
6
|
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
8
7
|
from py_cq.parsers.common import find_function_source, resolve_path
|
|
8
|
+
from py_cq.source_file import get_source
|
|
9
9
|
|
|
10
10
|
log = logging.getLogger("cq")
|
|
11
11
|
|
|
@@ -29,30 +29,19 @@ def _parse_line_ranges(s: str) -> set[int]:
|
|
|
29
29
|
return result
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _get_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
33
|
-
"""Return the signature of the function definition as a string."""
|
|
34
|
-
prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
35
|
-
args = ast.unparse(node.args)
|
|
36
|
-
returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
|
|
37
|
-
return f"{prefix} {node.name}({args}){returns}"
|
|
38
|
-
|
|
39
|
-
|
|
40
32
|
def _extract_functions(file: str, missing_lines_str: str) -> list[tuple[str, int, str]]:
|
|
41
33
|
"""Return (name, lineno, signature) for functions whose bodies overlap with the missing line ranges."""
|
|
42
34
|
try:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
except (OSError, SyntaxError, ValueError):
|
|
35
|
+
funcs = get_source(file).functions
|
|
36
|
+
except OSError:
|
|
46
37
|
return []
|
|
47
38
|
missing = _parse_line_ranges(missing_lines_str)
|
|
48
39
|
seen: set[str] = set()
|
|
49
40
|
result: list[tuple[str, int, str]] = []
|
|
50
|
-
for
|
|
51
|
-
if
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
seen.add(node.name)
|
|
55
|
-
result.append((node.name, node.lineno, _get_signature(node)))
|
|
41
|
+
for fn in funcs:
|
|
42
|
+
if missing & set(range(fn.lineno, fn.end_lineno + 1)) and fn.name not in seen:
|
|
43
|
+
seen.add(fn.name)
|
|
44
|
+
result.append((fn.name, fn.lineno, fn.signature))
|
|
56
45
|
return result
|
|
57
46
|
|
|
58
47
|
|
|
@@ -21,6 +21,7 @@ from py_cq.parsers.common import (
|
|
|
21
21
|
format_issue_header,
|
|
22
22
|
format_source_context,
|
|
23
23
|
)
|
|
24
|
+
from py_cq.source_file import get_source
|
|
24
25
|
|
|
25
26
|
_SKIP_PARAMS = {"self", "cls"}
|
|
26
27
|
|
|
@@ -31,12 +32,16 @@ def _format_missing_docstring(file_str: str, line: int, code: str, message: str)
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
if code == "D100":
|
|
34
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
base
|
|
37
|
+
+ "\n\nInsert a module-level docstring as the very first statement in the file."
|
|
38
|
+
)
|
|
35
39
|
|
|
36
40
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
tree = get_source(file_str).tree
|
|
42
|
+
except OSError:
|
|
43
|
+
tree = None
|
|
44
|
+
if tree is None:
|
|
40
45
|
return base + "\n\nInsert a docstring as the first statement in the body."
|
|
41
46
|
|
|
42
47
|
for node in ast.walk(tree):
|
|
@@ -72,6 +77,7 @@ def _format_missing_docstring(file_str: str, line: int, code: str, message: str)
|
|
|
72
77
|
|
|
73
78
|
return base + "\n\nInsert a docstring as the first statement in the body."
|
|
74
79
|
|
|
80
|
+
|
|
75
81
|
_ROW_RE = re.compile(
|
|
76
82
|
r"^\|\s+(.+?)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+\d+\s+\|\s+(\d+(?:\.\d+)?)%\s*\|"
|
|
77
83
|
)
|
|
@@ -83,12 +89,14 @@ def _load_interrogate_cfg(context_path: str) -> dict:
|
|
|
83
89
|
"""Read [tool.interrogate] from the nearest pyproject.toml."""
|
|
84
90
|
p = Path(context_path).resolve()
|
|
85
91
|
for candidate in [p, *p.parents]:
|
|
86
|
-
pyproject = (
|
|
92
|
+
pyproject = (
|
|
93
|
+
candidate if candidate.is_dir() else candidate.parent
|
|
94
|
+
) / "pyproject.toml"
|
|
87
95
|
if pyproject.exists():
|
|
88
96
|
try:
|
|
89
97
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
90
98
|
return data.get("tool", {}).get("interrogate", {})
|
|
91
|
-
except
|
|
99
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
|
92
100
|
return {}
|
|
93
101
|
return {}
|
|
94
102
|
|
|
@@ -104,12 +112,12 @@ def _skip_node(name: str, cfg: dict) -> bool:
|
|
|
104
112
|
return True
|
|
105
113
|
if is_private and cfg.get("ignore-private"):
|
|
106
114
|
return True
|
|
107
|
-
|
|
108
|
-
return True
|
|
109
|
-
return False
|
|
115
|
+
return bool(is_semiprivate and cfg.get("ignore-semiprivate"))
|
|
110
116
|
|
|
111
117
|
|
|
112
|
-
def _missing_docstrings(
|
|
118
|
+
def _missing_docstrings(
|
|
119
|
+
file_path: Path, cfg: dict | None = None
|
|
120
|
+
) -> list[tuple[int, str, str]]:
|
|
113
121
|
"""Return (line, kind, source_line) for each node missing a docstring.
|
|
114
122
|
|
|
115
123
|
kind is 'module', 'def', or 'class'. source_line is the verbatim text of
|
|
@@ -117,11 +125,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
|
|
|
117
125
|
the module.
|
|
118
126
|
"""
|
|
119
127
|
try:
|
|
120
|
-
|
|
121
|
-
tree =
|
|
122
|
-
except
|
|
128
|
+
sf = get_source(str(file_path))
|
|
129
|
+
tree = sf.tree
|
|
130
|
+
except OSError:
|
|
131
|
+
return []
|
|
132
|
+
if tree is None:
|
|
123
133
|
return []
|
|
124
|
-
src_lines =
|
|
134
|
+
src_lines = sf.text.splitlines()
|
|
125
135
|
|
|
126
136
|
def src_line(lineno: int) -> str:
|
|
127
137
|
"""Return the stripped content of the source line at the given line number."""
|
|
@@ -137,16 +147,13 @@ def _missing_docstrings(file_path: Path, cfg: dict | None = None) -> list[tuple[
|
|
|
137
147
|
|
|
138
148
|
effective_cfg = cfg or {}
|
|
139
149
|
results = []
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
elif isinstance(node, ast.ClassDef):
|
|
148
|
-
if not ast.get_docstring(node) and not _skip_node(node.name, effective_cfg):
|
|
149
|
-
results.append((node.lineno, "class", src_line(node.lineno)))
|
|
150
|
+
if not sf.module_has_docstring:
|
|
151
|
+
results.append((0, "module", first_code_line()))
|
|
152
|
+
for d in sf.definitions:
|
|
153
|
+
if d.has_docstring or _skip_node(d.name, effective_cfg):
|
|
154
|
+
continue
|
|
155
|
+
kind = "def" if d.kind == "function" else "class"
|
|
156
|
+
results.append((d.lineno, kind, src_line(d.lineno)))
|
|
150
157
|
results.sort(key=lambda x: x[0])
|
|
151
158
|
return results
|
|
152
159
|
|
py_cq/parsers/pytestparser.py
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Parses output from ruff format --check into a standardized ToolResult.
|
|
2
|
+
|
|
3
|
+
The output is one line per unformatted file. ruff >= 0.16 honours
|
|
4
|
+
``--output-format concise``::
|
|
5
|
+
|
|
6
|
+
<file>:<line>:<col>: unformatted: File would be reformatted
|
|
7
|
+
|
|
8
|
+
while older versions ignore it (it needs preview mode) and print::
|
|
9
|
+
|
|
10
|
+
Would reformat: <file>
|
|
11
|
+
|
|
12
|
+
Either is followed by a summary line such as
|
|
13
|
+
``2 files would be reformatted, 30 files already formatted`` or
|
|
14
|
+
``32 files already formatted``.
|
|
15
|
+
|
|
16
|
+
The score is the fraction of checked files that are already formatted,
|
|
17
|
+
taken from the summary so it stays correct if the per-file format changes."""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from py_cq.localtypes import AbstractParser, RawResult, ToolResult
|
|
22
|
+
from py_cq.parsers.common import extract_first_issue
|
|
23
|
+
|
|
24
|
+
_FILE_RE = re.compile(r"^(?:Would reformat: (.+)|(.+):\d+:\d+: unformatted: .*)$")
|
|
25
|
+
_FORMATTED_RE = re.compile(r"(\d+) files? already formatted")
|
|
26
|
+
_UNFORMATTED_RE = re.compile(r"(\d+) files? would be reformatted")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RuffFormatParser(AbstractParser):
|
|
30
|
+
"""Parses ``ruff format --check --output-format concise`` output into a ToolResult."""
|
|
31
|
+
|
|
32
|
+
def parse(self, raw_result: RawResult) -> ToolResult:
|
|
33
|
+
"""Collect unformatted files and score the fraction already formatted."""
|
|
34
|
+
stdout = raw_result.stdout or ""
|
|
35
|
+
# One issue per file, with no line: the line ruff reports is the first
|
|
36
|
+
# diff hunk, which would make the fingerprint unstable across edits.
|
|
37
|
+
files: dict[str, list] = {}
|
|
38
|
+
for line in stdout.splitlines():
|
|
39
|
+
m = _FILE_RE.match(line)
|
|
40
|
+
if m:
|
|
41
|
+
files[(m.group(1) or m.group(2)).replace("\\", "/")] = [{}]
|
|
42
|
+
m = _FORMATTED_RE.search(stdout)
|
|
43
|
+
formatted = int(m.group(1)) if m else 0
|
|
44
|
+
m = _UNFORMATTED_RE.search(stdout)
|
|
45
|
+
unformatted = max(int(m.group(1)) if m else 0, len(files))
|
|
46
|
+
total = formatted + unformatted
|
|
47
|
+
score = formatted / total if total else 1.0
|
|
48
|
+
return ToolResult(raw=raw_result, metrics={"formatted": score}, details=files)
|
|
49
|
+
|
|
50
|
+
def format_llm_message(
|
|
51
|
+
self, tr: ToolResult, *, context_lines: int = 15, limit: int = 1
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Ask for the formatter to be run rather than a hand edit."""
|
|
54
|
+
result = extract_first_issue(tr.details)
|
|
55
|
+
if result is None:
|
|
56
|
+
return "ruff format reported issues (no details available)"
|
|
57
|
+
file, _ = result
|
|
58
|
+
return (
|
|
59
|
+
f"{file} - **not formatted**. Run the formatter, do not edit by hand:\n\n"
|
|
60
|
+
f"```bash\nruff format {file}\n```\n\n"
|
|
61
|
+
"Other files may also need formatting; `ruff format .` fixes them all."
|
|
62
|
+
)
|
py_cq/source_file.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Process-scoped cache of parsed source files, shared across one ``cq check``.
|
|
2
|
+
|
|
3
|
+
Within a single run the same ``.py`` file is otherwise read and parsed several
|
|
4
|
+
times: by the normalized context hash, the coverage parser, the interrogate
|
|
5
|
+
parser, and the LLM formatters. :class:`SourceFile` parses each file at most
|
|
6
|
+
once and exposes lazy projections (functions, classes, docstring presence) that
|
|
7
|
+
downstream consumers walk instead of re-parsing.
|
|
8
|
+
|
|
9
|
+
The cache is keyed on ``(path, size, mtime)`` so a content change (which bumps
|
|
10
|
+
``mtime``) misses and forces a fresh parse; correctness never depends on a hit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FuncInfo:
|
|
22
|
+
"""A function or class definition discovered in a source file.
|
|
23
|
+
|
|
24
|
+
``signature`` is the rendered ``def`` line for functions and ``""`` for
|
|
25
|
+
classes (no consumer needs a class signature). ``kind`` is ``"function"``
|
|
26
|
+
or ``"class"``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
kind: str
|
|
31
|
+
lineno: int
|
|
32
|
+
end_lineno: int
|
|
33
|
+
has_docstring: bool
|
|
34
|
+
signature: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _docstring_invariant_digest(tree: ast.AST) -> str:
|
|
38
|
+
"""Return an md5 of ``tree`` with docstrings removed, leaving ``tree`` intact.
|
|
39
|
+
|
|
40
|
+
Docstring statements are temporarily detached, the tree is dumped and
|
|
41
|
+
hashed, then they are reattached - so the digest is docstring-invariant
|
|
42
|
+
without parsing a second time or mutating the tree that other consumers
|
|
43
|
+
share. Safe because the hash pass runs single-threaded before the tool pool.
|
|
44
|
+
"""
|
|
45
|
+
detached = []
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if isinstance(
|
|
48
|
+
node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
49
|
+
):
|
|
50
|
+
body = node.body
|
|
51
|
+
if (
|
|
52
|
+
body
|
|
53
|
+
and isinstance(body[0], ast.Expr)
|
|
54
|
+
and isinstance(body[0].value, ast.Constant)
|
|
55
|
+
and isinstance(body[0].value.value, str)
|
|
56
|
+
):
|
|
57
|
+
detached.append((node, body[0]))
|
|
58
|
+
node.body = body[1:]
|
|
59
|
+
digest = hashlib.md5(ast.dump(tree).encode()).hexdigest() # nosec
|
|
60
|
+
for node, doc in detached:
|
|
61
|
+
node.body = [doc, *node.body]
|
|
62
|
+
return digest
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
66
|
+
"""Return the signature of the function definition as a string."""
|
|
67
|
+
prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
68
|
+
args = ast.unparse(node.args)
|
|
69
|
+
returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
|
|
70
|
+
return f"{prefix} {node.name}({args}){returns}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SourceFile:
|
|
74
|
+
"""Lazily-realized view of one source file: text, AST, and projections.
|
|
75
|
+
|
|
76
|
+
Fields are computed on first access and cached. ``.tree`` stays pristine
|
|
77
|
+
(docstrings intact) so multiple consumers can share it; ``.norm_digest``
|
|
78
|
+
derives its docstring-stripped digest from a throwaway reparse rather than
|
|
79
|
+
mutating ``.tree``.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, path: str) -> None:
|
|
83
|
+
self.path = path
|
|
84
|
+
self._raw: bytes | None = None
|
|
85
|
+
self._text: str | None = None
|
|
86
|
+
self._tree: ast.AST | None = None
|
|
87
|
+
self._parsed = False
|
|
88
|
+
self._norm_digest: str | None = None
|
|
89
|
+
self._definitions: list[FuncInfo] | None = None
|
|
90
|
+
|
|
91
|
+
def _read_bytes(self) -> bytes:
|
|
92
|
+
if self._raw is None:
|
|
93
|
+
self._raw = Path(self.path).read_bytes()
|
|
94
|
+
return self._raw
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def text(self) -> str:
|
|
98
|
+
"""Return the file contents decoded as UTF-8 (undecodable bytes replaced)."""
|
|
99
|
+
if self._text is None:
|
|
100
|
+
self._text = self._read_bytes().decode("utf-8", "replace")
|
|
101
|
+
return self._text
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def tree(self) -> ast.AST | None:
|
|
105
|
+
"""Return the parsed AST, or ``None`` if the file can't be parsed."""
|
|
106
|
+
if not self._parsed:
|
|
107
|
+
self._parsed = True
|
|
108
|
+
try:
|
|
109
|
+
self._tree = ast.parse(self.text)
|
|
110
|
+
except (SyntaxError, ValueError):
|
|
111
|
+
self._tree = None
|
|
112
|
+
return self._tree
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def norm_digest(self) -> str:
|
|
116
|
+
"""Return a docstring/comment/format-invariant digest of the file.
|
|
117
|
+
|
|
118
|
+
Falls back to a byte digest when the file can't be parsed (e.g. a
|
|
119
|
+
mid-edit syntax error), so callers always get a usable digest.
|
|
120
|
+
"""
|
|
121
|
+
if self._norm_digest is None:
|
|
122
|
+
tree = self.tree
|
|
123
|
+
if tree is None:
|
|
124
|
+
self._norm_digest = hashlib.md5(self._read_bytes()).hexdigest() # nosec
|
|
125
|
+
else:
|
|
126
|
+
self._norm_digest = _docstring_invariant_digest(tree)
|
|
127
|
+
return self._norm_digest
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def definitions(self) -> list[FuncInfo]:
|
|
131
|
+
"""Return every function and class definition, in source order."""
|
|
132
|
+
if self._definitions is None:
|
|
133
|
+
defs: list[FuncInfo] = []
|
|
134
|
+
tree = self.tree
|
|
135
|
+
if tree is not None:
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
138
|
+
defs.append(
|
|
139
|
+
FuncInfo(
|
|
140
|
+
node.name,
|
|
141
|
+
"function",
|
|
142
|
+
node.lineno,
|
|
143
|
+
getattr(node, "end_lineno", node.lineno),
|
|
144
|
+
ast.get_docstring(node) is not None,
|
|
145
|
+
_signature(node),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
elif isinstance(node, ast.ClassDef):
|
|
149
|
+
defs.append(
|
|
150
|
+
FuncInfo(
|
|
151
|
+
node.name,
|
|
152
|
+
"class",
|
|
153
|
+
node.lineno,
|
|
154
|
+
getattr(node, "end_lineno", node.lineno),
|
|
155
|
+
ast.get_docstring(node) is not None,
|
|
156
|
+
"",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
defs.sort(key=lambda d: d.lineno)
|
|
160
|
+
self._definitions = defs
|
|
161
|
+
return self._definitions
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def functions(self) -> list[FuncInfo]:
|
|
165
|
+
"""Return the function definitions only (no classes), in source order."""
|
|
166
|
+
return [d for d in self.definitions if d.kind == "function"]
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def module_has_docstring(self) -> bool:
|
|
170
|
+
"""Return True if the module has a docstring."""
|
|
171
|
+
tree = self.tree
|
|
172
|
+
return isinstance(tree, ast.Module) and ast.get_docstring(tree) is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Process-scoped memo: (path, size, mtime) -> SourceFile. A stale mtime only
|
|
176
|
+
# forces a re-parse that reproduces the same projections, so correctness never
|
|
177
|
+
# depends on this cache (matches the lock-free design: a duplicate parse is
|
|
178
|
+
# harmless and GIL-serialized).
|
|
179
|
+
_source_cache: dict[tuple[str, int, float], SourceFile] = {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_source(path: str) -> SourceFile:
|
|
183
|
+
"""Return the cached :class:`SourceFile` for ``path``, creating it on miss.
|
|
184
|
+
|
|
185
|
+
Keyed on ``(canonical_path, size, mtime)``; a content change misses and
|
|
186
|
+
re-parses. The path is canonicalized (absolute + OS case folding) so the
|
|
187
|
+
hash pass and the parsers - which spell the same file differently (relative
|
|
188
|
+
vs. resolved/posix) - share one cache entry.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
OSError: If ``path`` cannot be stat'd.
|
|
192
|
+
"""
|
|
193
|
+
s = os.stat(path)
|
|
194
|
+
key = (os.path.normcase(os.path.abspath(path)), s.st_size, s.st_mtime)
|
|
195
|
+
sf = _source_cache.get(key)
|
|
196
|
+
if sf is None:
|
|
197
|
+
sf = SourceFile(path)
|
|
198
|
+
_source_cache[key] = sf
|
|
199
|
+
return sf
|
py_cq/table_formatter.py
CHANGED
|
@@ -21,7 +21,7 @@ def format_as_table(
|
|
|
21
21
|
table.add_column("Status")
|
|
22
22
|
for tr in data.tool_results:
|
|
23
23
|
tool_name = tr.raw.tool_name
|
|
24
|
-
config = next(
|
|
24
|
+
config = next(t for t in registry.values() if t.name == tool_name)
|
|
25
25
|
for i, (name, value) in enumerate(tr.metrics.items()):
|
|
26
26
|
if value < config.error_threshold:
|
|
27
27
|
status = "[bold red]Error[/]"
|
py_cq/tool_registry.py
CHANGED
|
@@ -34,6 +34,7 @@ def load_tool_configs() -> dict[str, ToolConfig]:
|
|
|
34
34
|
skip_for_file=tool_data.get("skip_for_file", False),
|
|
35
35
|
gate_strict=tool_data.get("gate_strict", True),
|
|
36
36
|
skip_if=tool_data.get("skip_if", ""),
|
|
37
|
+
cache_invariant=tool_data.get("cache_invariant", ""),
|
|
37
38
|
)
|
|
38
39
|
return registry
|
|
39
40
|
|