jitcatch 0.2.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.
- jitcatch/__init__.py +2 -0
- jitcatch/__main__.py +5 -0
- jitcatch/adapters/__init__.py +36 -0
- jitcatch/adapters/base.py +137 -0
- jitcatch/adapters/golang.py +88 -0
- jitcatch/adapters/java.py +136 -0
- jitcatch/adapters/javascript.py +165 -0
- jitcatch/adapters/python.py +187 -0
- jitcatch/assessors/__init__.py +94 -0
- jitcatch/assessors/failure_parse.py +126 -0
- jitcatch/assessors/llm_judge.py +75 -0
- jitcatch/assessors/rules.py +343 -0
- jitcatch/cli.py +338 -0
- jitcatch/config.py +259 -0
- jitcatch/engine.py +521 -0
- jitcatch/github.py +90 -0
- jitcatch/gitutil.py +231 -0
- jitcatch/hook.py +98 -0
- jitcatch/jsonutil.py +87 -0
- jitcatch/llm/__init__.py +21 -0
- jitcatch/llm/base.py +100 -0
- jitcatch/llm/providers.py +127 -0
- jitcatch/models.py +150 -0
- jitcatch/prompts.py +191 -0
- jitcatch/report.py +163 -0
- jitcatch/targeting.py +44 -0
- jitcatch/templates.py +76 -0
- jitcatch/userconfig.py +188 -0
- jitcatch-0.2.0.dist-info/METADATA +164 -0
- jitcatch-0.2.0.dist-info/RECORD +34 -0
- jitcatch-0.2.0.dist-info/WHEEL +5 -0
- jitcatch-0.2.0.dist-info/entry_points.txt +2 -0
- jitcatch-0.2.0.dist-info/licenses/LICENSE +21 -0
- jitcatch-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""RubFake-style rule-based assessor.
|
|
2
|
+
|
|
3
|
+
Implements the false-positive patterns (paper Table 2) and true-positive patterns (Table 3) as
|
|
4
|
+
deterministic checks over three sources: the code change, the generated test, and the execution
|
|
5
|
+
log of the test on the changed revision. Output is a score in [-1, 1].
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from ..models import AssessorScore, GeneratedTest, Intent
|
|
14
|
+
from . import failure_parse as fp
|
|
15
|
+
|
|
16
|
+
FP_LIKELIHOOD = {"high": -1.0, "medium": -0.6, "low": -0.3}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuleInput:
|
|
21
|
+
test: GeneratedTest
|
|
22
|
+
output: str # execution log on the changed revision
|
|
23
|
+
patch: str # diff for the target file (falls back to the whole change)
|
|
24
|
+
intent: Intent | None
|
|
25
|
+
title: str
|
|
26
|
+
language: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def code(self) -> str:
|
|
30
|
+
return self.test.code
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def changed(self) -> list[str]:
|
|
34
|
+
return fp.changed_lines(self.patch)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def changed_text(self) -> str:
|
|
38
|
+
return "\n".join(self.changed)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Rule:
|
|
43
|
+
name: str
|
|
44
|
+
kind: str # "fp" or "tp"
|
|
45
|
+
level: str # fp: high/medium/low ; tp: score as string-able float
|
|
46
|
+
sources: str
|
|
47
|
+
check: Callable[[RuleInput], str | None] # returns evidence string when the pattern applies
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _search(rx: str, text: str, flags: int = 0) -> str | None:
|
|
51
|
+
m = re.search(rx, text, flags)
|
|
52
|
+
return m.group(0)[:200] if m else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------------------
|
|
56
|
+
# False-positive patterns (Table 2)
|
|
57
|
+
# --------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _broken_test_runner(r: RuleInput) -> str | None:
|
|
60
|
+
if r.test.head_run is not None and r.test.head_run.outcome.value == "infra":
|
|
61
|
+
return "test runner reported an infrastructure outcome"
|
|
62
|
+
return _search(r"INTERNALERROR|Test runner .* crashed|Could not find or load main class|"
|
|
63
|
+
r"jest-worker|worker process has failed to exit|Error: spawn .* ENOENT|"
|
|
64
|
+
r"Unable to start the daemon|OutOfMemoryError", r.output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reflection(r: RuleInput) -> str | None:
|
|
68
|
+
return _search(r"\bgetDeclared(Field|Method|Constructor)s?\(|setAccessible\(true\)|\breflect\.\w+|"
|
|
69
|
+
r"\binspect\.(getsource|getmembers|signature)|\b__dict__\b|object\.__setattr__|"
|
|
70
|
+
r"Reflect\.(get|set|ownKeys)|Object\.getOwnPropertyNames|_[A-Z]\w*__\w+", r.code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _type_mismatch(r: RuleInput) -> str | None:
|
|
74
|
+
return _search(r"TypeError: .*(takes \d+ positional|missing \d+ required|unexpected keyword argument|"
|
|
75
|
+
r"positional arguments? but)|error TS(2345|2322|2554|2339|2741|2353)|incompatible types|"
|
|
76
|
+
r"cannot be applied to given types|method .* in class .* cannot be applied|"
|
|
77
|
+
r"cannot use .* as .* (value|type)|(too many|not enough) arguments in call", r.output)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bad_mock_smell(r: RuleInput) -> str | None:
|
|
81
|
+
if not re.search(r"\b(Mock|MagicMock|patch\(|monkeypatch|jest\.(fn|mock|spyOn)|vi\.(fn|mock|spyOn)|"
|
|
82
|
+
r"Mockito|mock\(|gomock|testify/mock)", r.code):
|
|
83
|
+
return None
|
|
84
|
+
return _search(r"(Mock|mock)\w*.{0,80}(Error|Exception|not callable|has no attribute|undefined)|"
|
|
85
|
+
r"(Error|Exception).{0,80}\b(Mock|mock)\w*", r.output)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _mock_broken(r: RuleInput) -> str | None:
|
|
89
|
+
return _search(r"UnnecessaryStubbing|WrongTypeOfReturnValue|InvalidUseOfMatchers|"
|
|
90
|
+
r"MissingMethodInvocation|mockImplementation is not a function|"
|
|
91
|
+
r"vi\.mock.*hoist|jest\.mock\(\) .* not allowed to reference|"
|
|
92
|
+
r"AttributeError: Mock object has no attribute|missing call\(s\) to|"
|
|
93
|
+
r"Unexpected call to \*mock|aborting test due to missing call", r.output)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _data_provider_broken(r: RuleInput) -> str | None:
|
|
97
|
+
return _search(r"ParameterResolutionException|PreconditionViolationException.*(MethodSource|ValueSource)|"
|
|
98
|
+
r"In (test_\w+|\w+): function uses no argument|parametrize.*(wrong|got)|"
|
|
99
|
+
r"each\(\) .* (must|expects)|\.each is not a function|Invalid parametrize", r.output)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _should_be_private(r: RuleInput) -> str | None:
|
|
103
|
+
if r.language == "python":
|
|
104
|
+
m = re.search(r"\.\_(?!_)[a-z]\w*", r.code)
|
|
105
|
+
if m and re.search(r"AttributeError|has no attribute", r.output):
|
|
106
|
+
return f"test touches private member {m.group(0)}"
|
|
107
|
+
return None
|
|
108
|
+
return _search(r"error TS2341|has private access|is private and only accessible|"
|
|
109
|
+
r"cannot refer to unexported", r.output)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _must_be_protected(r: RuleInput) -> str | None:
|
|
113
|
+
return _search(r"error TS2445|has protected access|is protected and only accessible", r.output)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _not_implemented(r: RuleInput) -> str | None:
|
|
117
|
+
hit = _search(r"NotImplementedError|UnsupportedOperationException|[Nn]ot implemented|TODO: implement|"
|
|
118
|
+
r"panic: (not implemented|unimplemented)", r.output)
|
|
119
|
+
if hit and re.search(r"NotImplemented|UnsupportedOperation|not implemented|unimplemented", r.changed_text, re.I):
|
|
120
|
+
return hit
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _key_value_pair_change(r: RuleInput) -> str | None:
|
|
125
|
+
for c in fp.comparisons(r.output):
|
|
126
|
+
if fp.same_elements_different_order(c.actual, c.expected):
|
|
127
|
+
return f"same elements, different order: {c.actual[:80]} vs {c.expected[:80]}"
|
|
128
|
+
return _search(r"Compared values (have no visual difference|serialize to the same structure)|"
|
|
129
|
+
r"Received: serializes to the same string", r.output)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _undefined_variable(r: RuleInput) -> str | None:
|
|
133
|
+
return _search(r"NameError: name '\w+' is not defined|ReferenceError: \w+ is not defined|"
|
|
134
|
+
r"error: cannot find symbol|undefined: \w+|error TS2304|"
|
|
135
|
+
r"ImportError: cannot import name|has no exported member|is not exported by|"
|
|
136
|
+
r"SyntaxError: The requested module .* does not provide an export", r.output)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _expecting_calls(r: RuleInput) -> str | None:
|
|
140
|
+
code_hit = re.search(r"assert_called(_once)?(_with)?|assert_not_called|call_count|call_args|"
|
|
141
|
+
r"toHaveBeenCalled(Times|With)?|toBeCalled(Times|With)?|verify\(.*times\(|"
|
|
142
|
+
r"\.Times\(\d+\)|AssertNumberOfCalls|AssertCalled", r.code)
|
|
143
|
+
if not code_hit:
|
|
144
|
+
return None
|
|
145
|
+
return _search(r"(Expected|expected) .{0,40}(call|called|invocation)|call_count|Number of calls|"
|
|
146
|
+
r"Wanted but not invoked|Too (many|few) actual invocations|toHaveBeenCalled|"
|
|
147
|
+
r"Expected number of calls|assert_called", r.output)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _web_server_down(r: RuleInput) -> str | None:
|
|
151
|
+
return _search(r"ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|Connection refused|ConnectionError|"
|
|
152
|
+
r"Max retries exceeded with url|getaddrinfo|dial tcp|503 Service Unavailable|"
|
|
153
|
+
r"502 Bad Gateway|socket hang up|UnknownHostException|ConnectException", r.output)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _flakiness(r: RuleInput) -> str | None:
|
|
157
|
+
if r.test.is_flaky:
|
|
158
|
+
return "test outcome differed across reruns"
|
|
159
|
+
return _search(r"\b(time\.sleep|random\.|uuid\.uuid4|datetime\.now|Date\.now|new Date\(\)|Math\.random|"
|
|
160
|
+
r"setTimeout|Thread\.sleep|System\.currentTimeMillis|time\.Now\(|rand\.)", r.code)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
FP_RULES: list[Rule] = [
|
|
164
|
+
Rule("broken_test_runner", "fp", "high", "execution log", _broken_test_runner),
|
|
165
|
+
Rule("reflection", "fp", "high", "test code", _reflection),
|
|
166
|
+
Rule("type_mismatch", "fp", "high", "execution log", _type_mismatch),
|
|
167
|
+
Rule("bad_mock_smell", "fp", "medium", "execution log, test code", _bad_mock_smell),
|
|
168
|
+
Rule("should_be_private_smell", "fp", "high", "execution log, test code", _should_be_private),
|
|
169
|
+
Rule("method_must_be_protected_smell", "fp", "high", "execution log", _must_be_protected),
|
|
170
|
+
Rule("mock_broken", "fp", "high", "execution log", _mock_broken),
|
|
171
|
+
Rule("data_provider_broken", "fp", "high", "execution log", _data_provider_broken),
|
|
172
|
+
Rule("not_implemented_exception", "fp", "high", "execution log, change", _not_implemented),
|
|
173
|
+
Rule("key_value_pair_change", "fp", "high", "execution log", _key_value_pair_change),
|
|
174
|
+
Rule("undefined_variable", "fp", "high", "execution log", _undefined_variable),
|
|
175
|
+
Rule("expecting_particular_calls_to_functions", "fp", "high", "execution log, test code", _expecting_calls),
|
|
176
|
+
Rule("web_server_down", "fp", "high", "execution log", _web_server_down),
|
|
177
|
+
Rule("flakiness", "fp", "low", "execution log, test code", _flakiness),
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------------------
|
|
182
|
+
# True-positive patterns (Table 3). Each requires an execution-trace sign plus corroboration
|
|
183
|
+
# that the change did not obviously intend the observed behaviour.
|
|
184
|
+
# --------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _unexpected_key_change(r: RuleInput) -> str | None:
|
|
187
|
+
for name, msg in fp.exceptions(r.output):
|
|
188
|
+
if name in ("KeyError", "IndexError", "ArrayIndexOutOfBoundsException", "IndexOutOfBoundsException",
|
|
189
|
+
"NoSuchElementException") or (name == "runtime error" and "index out of range" in msg):
|
|
190
|
+
key = msg.strip("'\"") if msg else ""
|
|
191
|
+
if key and key in r.changed_text:
|
|
192
|
+
return None
|
|
193
|
+
if re.search(r"\b(key|keys|index|idx)\b", r.changed_text) and not key:
|
|
194
|
+
return None
|
|
195
|
+
return f"{name}: {msg}"[:200]
|
|
196
|
+
m = re.search(r"Cannot read propert(?:y|ies) of undefined \(reading '([^']+)'\)", r.output)
|
|
197
|
+
if m and m.group(1) not in r.changed_text:
|
|
198
|
+
return m.group(0)
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _empty_container(r: RuleInput) -> str | None:
|
|
203
|
+
for c in fp.comparisons(r.output):
|
|
204
|
+
if fp.is_empty_container(c.actual) and not fp.is_empty_container(c.expected):
|
|
205
|
+
if re.search(r"\.clear\(\)|=\s*(\[\]|\{\}|new (ArrayList|HashMap)|make\(|set\(\)|list\(\)|dict\(\))|"
|
|
206
|
+
r"\.filter\(|\bdelete\b|\.pop\(|\.remove\(", r.changed_text):
|
|
207
|
+
return None
|
|
208
|
+
return f"container became empty: expected {c.expected[:80]}"
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _create_failure(r: RuleInput) -> str | None:
|
|
213
|
+
hit = _search(r"InstantiationException|is not a constructor|__init__\(\) (missing|got|takes)|"
|
|
214
|
+
r"cannot instantiate|Can't instantiate|NoSuchMethodException: .*<init>|"
|
|
215
|
+
r"could not (create|construct)", r.output)
|
|
216
|
+
if hit and not re.search(r"def __init__|constructor\s*\(|\bclass\s+\w+|func New\w*\(|public \w+\(", r.changed_text):
|
|
217
|
+
return hit
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _changed_bool(r: RuleInput) -> str | None:
|
|
222
|
+
for c in fp.comparisons(r.output):
|
|
223
|
+
a, e = fp.as_bool(c.actual), fp.as_bool(c.expected)
|
|
224
|
+
if a is not None and e is not None and a != e:
|
|
225
|
+
return f"boolean flipped: expected {c.expected}, got {c.actual}"
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _kind(r: RuleInput) -> str:
|
|
230
|
+
return (r.intent.change_kind if r.intent else "") or ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_REFACTOR_TITLE = re.compile(r"\b(refactor|rename|restructure|cleanup|clean up|tidy|reorganis|reorganiz|"
|
|
234
|
+
r"extract|simplif|no functional change|nfc|move)\w*", re.I)
|
|
235
|
+
_DEAD_CODE_TITLE = re.compile(r"\b(remove|delete|drop)\w*\b.*\b(unused|dead|obsolete|deprecated)\b", re.I)
|
|
236
|
+
_ADDITIVE_TITLE = re.compile(r"\b(add|introduce|log|logging|metric|telemetry|instrument)\w*", re.I)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _behaviour_failure(r: RuleInput) -> bool:
|
|
240
|
+
"""Failure is a behaviour difference (assertion/runtime), not a build error."""
|
|
241
|
+
return r.test.head_run is not None and r.test.head_run.outcome.value == "fail"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _refactor(r: RuleInput) -> str | None:
|
|
245
|
+
if _behaviour_failure(r) and (_kind(r) == "refactor" or _REFACTOR_TITLE.search(r.title or "")):
|
|
246
|
+
return "behaviour changed in a change intended as a refactoring"
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _dead_code_removal(r: RuleInput) -> str | None:
|
|
251
|
+
if _behaviour_failure(r) and (_kind(r) == "dead_code_removal" or _DEAD_CODE_TITLE.search(r.title or "")):
|
|
252
|
+
return "behaviour changed in a change intended as dead-code removal"
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _null_value(r: RuleInput) -> str | None:
|
|
257
|
+
for c in fp.comparisons(r.output):
|
|
258
|
+
if fp.is_null(c.actual) and not fp.is_null(c.expected):
|
|
259
|
+
if re.search(r"\breturn\s+(None|null|nil|undefined)\b|=\s*(None|null|nil|undefined)\b|\?\.", r.changed_text):
|
|
260
|
+
return None
|
|
261
|
+
return f"value became null: expected {c.expected[:80]}"
|
|
262
|
+
hit = _search(r"NullPointerException|'NoneType' object|nil pointer dereference|"
|
|
263
|
+
r"Cannot read propert(?:y|ies) of null", r.output)
|
|
264
|
+
if hit and not re.search(r"\b(None|null|nil)\b", r.changed_text):
|
|
265
|
+
return hit
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _monotonic_change(r: RuleInput) -> str | None:
|
|
270
|
+
if not _behaviour_failure(r):
|
|
271
|
+
return None
|
|
272
|
+
if _kind(r) == "additive" or (_ADDITIVE_TITLE.search(r.title or "") and not re.search(r"\bfix", r.title or "", re.I)):
|
|
273
|
+
return "existing behaviour changed in a change intended only to add behaviour"
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _rbac(r: RuleInput) -> str | None:
|
|
278
|
+
hit = _search(r"\b(permission|forbidden|unauthori[sz]ed|access denied|not allowed|role|403|401|acl|rbac)\b",
|
|
279
|
+
r.output, re.I)
|
|
280
|
+
if hit and not re.search(r"\b(permission|role|acl|rbac|authori[sz]|access|allow|deny)\w*", r.changed_text, re.I):
|
|
281
|
+
return hit
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
TP_RULES: list[Rule] = [
|
|
286
|
+
Rule("unexpected_key_change", "tp", "0.6", "execution log, change", _unexpected_key_change),
|
|
287
|
+
Rule("empty_container", "tp", "0.6", "execution log, change", _empty_container),
|
|
288
|
+
Rule("create_failure", "tp", "0.6", "execution log, change", _create_failure),
|
|
289
|
+
Rule("changed_bool", "tp", "0.4", "execution log", _changed_bool),
|
|
290
|
+
Rule("refactor", "tp", "0.7", "execution log, intent", _refactor),
|
|
291
|
+
Rule("dead_code_removal", "tp", "0.7", "execution log, intent", _dead_code_removal),
|
|
292
|
+
Rule("null_value", "tp", "0.6", "execution log, change", _null_value),
|
|
293
|
+
Rule("monotonic_change", "tp", "0.6", "execution log, intent", _monotonic_change),
|
|
294
|
+
Rule("RBAC", "tp", "0.6", "execution log, change", _rbac),
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ease_of_dismissal(r: RuleInput) -> float:
|
|
299
|
+
"""Small bonus for failures that are quick for an author to confirm/dismiss (paper §5.1):
|
|
300
|
+
short scalar comparisons are cheap; large structure diffs are expensive."""
|
|
301
|
+
comps = fp.comparisons(r.output)
|
|
302
|
+
if not comps:
|
|
303
|
+
return 0.0
|
|
304
|
+
longest = max(len(c.actual) + len(c.expected) for c in comps)
|
|
305
|
+
if longest <= 40:
|
|
306
|
+
return 0.1
|
|
307
|
+
if longest >= 400:
|
|
308
|
+
return -0.1
|
|
309
|
+
return 0.0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def assess(r: RuleInput, overrides: dict[str, str] | None = None) -> AssessorScore:
|
|
313
|
+
overrides = overrides or {}
|
|
314
|
+
fp_hits, tp_hits = [], []
|
|
315
|
+
for rule in FP_RULES:
|
|
316
|
+
level = overrides.get(rule.name, rule.level)
|
|
317
|
+
if level == "off":
|
|
318
|
+
continue
|
|
319
|
+
ev = rule.check(r)
|
|
320
|
+
if ev:
|
|
321
|
+
fp_hits.append({"rule": rule.name, "level": level, "evidence": ev, "sources": rule.sources})
|
|
322
|
+
for rule in TP_RULES:
|
|
323
|
+
if overrides.get(rule.name) == "off":
|
|
324
|
+
continue
|
|
325
|
+
ev = rule.check(r)
|
|
326
|
+
if ev:
|
|
327
|
+
tp_hits.append({"rule": rule.name, "strength": float(overrides.get(rule.name, rule.level)),
|
|
328
|
+
"evidence": ev, "sources": rule.sources})
|
|
329
|
+
fp_score = min((FP_LIKELIHOOD[h["level"]] for h in fp_hits), default=0.0)
|
|
330
|
+
tp_score = max((h["strength"] for h in tp_hits), default=0.0)
|
|
331
|
+
if fp_score <= -1.0:
|
|
332
|
+
score = -1.0
|
|
333
|
+
elif not fp_hits and not tp_hits:
|
|
334
|
+
score = None # no information
|
|
335
|
+
else:
|
|
336
|
+
score = tp_score + fp_score
|
|
337
|
+
if tp_hits:
|
|
338
|
+
score += ease_of_dismissal(r)
|
|
339
|
+
score = max(-1.0, min(1.0, round(score, 3)))
|
|
340
|
+
return AssessorScore(name="rules", score=score, details={
|
|
341
|
+
"false_positive_patterns": fp_hits, "true_positive_patterns": tp_hits,
|
|
342
|
+
"high_fp": any(h["level"] == "high" for h in fp_hits),
|
|
343
|
+
})
|
jitcatch/cli.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .adapters import build_adapters
|
|
13
|
+
from .config import ConfigError, load_config
|
|
14
|
+
from .engine import Engine, RunResult
|
|
15
|
+
from .gitutil import GitError, auto_range, change_context, ensure_excluded, repo_root
|
|
16
|
+
from .llm import LLMError, build_client
|
|
17
|
+
from .report import markdown, to_dict, write_artifacts
|
|
18
|
+
from .templates import CONFIG_TEMPLATE
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("jitcatch")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _setup_logging(verbose: int) -> None:
|
|
24
|
+
level = logging.WARNING if verbose < 0 else logging.INFO if verbose == 0 else logging.DEBUG
|
|
25
|
+
logging.basicConfig(level=level, format="[jitcatch] %(message)s", stream=sys.stderr)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _clients(cfg):
|
|
29
|
+
roles = cfg.roles
|
|
30
|
+
gen_name = roles.get("generator")
|
|
31
|
+
if not gen_name:
|
|
32
|
+
if len(cfg.providers) == 1:
|
|
33
|
+
gen_name = next(iter(cfg.providers))
|
|
34
|
+
else:
|
|
35
|
+
raise ConfigError("set [roles] generator = \"<provider name>\" (or define exactly one provider)")
|
|
36
|
+
generator = build_client(cfg.providers[gen_name])
|
|
37
|
+
judge_names = list(roles.get("judges") or []) or [gen_name]
|
|
38
|
+
judges = [build_client(cfg.providers[n]) for n in judge_names]
|
|
39
|
+
lp = roles.get("logprob_judge")
|
|
40
|
+
return generator, judges, (build_client(cfg.providers[lp]) if lp else None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_keys(cfg) -> list[str]:
|
|
44
|
+
missing = []
|
|
45
|
+
for p in cfg.providers.values():
|
|
46
|
+
if p.api_key_env and not p.resolve_api_key():
|
|
47
|
+
missing.append(f"{p.name}: no API key ({p.api_key_env} is not set and none is saved; "
|
|
48
|
+
"run `jitcatch setup`)")
|
|
49
|
+
return missing
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_pipeline(repo: Path, base: str, head: str, cfg, title: str = "", description: str = "",
|
|
53
|
+
force: bool = False) -> RunResult:
|
|
54
|
+
ctx = change_context(repo, base, head, title, description)
|
|
55
|
+
generator, judges, lp = _clients(cfg)
|
|
56
|
+
used = {generator.name, *(j.name for j in judges), *([lp.name] if lp else [])}
|
|
57
|
+
problems = [m for m in _check_keys(cfg) if m.split(":")[0] in used]
|
|
58
|
+
if problems:
|
|
59
|
+
raise ConfigError("; ".join(problems))
|
|
60
|
+
adapters = build_adapters(cfg.adapters, repo)
|
|
61
|
+
return Engine(cfg, repo, ctx, generator, judges, lp, adapters, force=force).run()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit(result: RunResult, cfg, args) -> int:
|
|
65
|
+
run_dir = write_artifacts(result, cfg.output_dir())
|
|
66
|
+
md = markdown(result)
|
|
67
|
+
if getattr(args, "markdown", None):
|
|
68
|
+
Path(args.markdown).write_text(md, encoding="utf-8")
|
|
69
|
+
if getattr(args, "json", None):
|
|
70
|
+
Path(args.json).write_text(json.dumps(to_dict(result), indent=2), encoding="utf-8")
|
|
71
|
+
if not getattr(args, "quiet", False):
|
|
72
|
+
print(md)
|
|
73
|
+
print(f"[jitcatch] artifacts: {run_dir}", file=sys.stderr)
|
|
74
|
+
return 1 if (getattr(args, "fail_on_catch", False) and result.candidates()) else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------- commands
|
|
78
|
+
|
|
79
|
+
def _ready_config(repo: Path, args):
|
|
80
|
+
"""Load the config, running the (at most 3-question) first-time setup if something is missing."""
|
|
81
|
+
from .userconfig import ensure_setup
|
|
82
|
+
return ensure_setup(lambda: load_config(repo, Path(args.config) if args.config else None))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _exclude_output(repo: Path, cfg) -> None:
|
|
86
|
+
try:
|
|
87
|
+
rel = cfg.output_dir().resolve().relative_to(repo.resolve())
|
|
88
|
+
except ValueError:
|
|
89
|
+
return # output outside the repository
|
|
90
|
+
try:
|
|
91
|
+
ensure_excluded(repo, rel.as_posix().rstrip("/") + "/")
|
|
92
|
+
except (GitError, OSError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_run(args) -> int:
|
|
97
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
98
|
+
cfg = _ready_config(repo, args)
|
|
99
|
+
_exclude_output(repo, cfg)
|
|
100
|
+
title = args.title or ""
|
|
101
|
+
if args.base is None:
|
|
102
|
+
base, head, what = auto_range(repo)
|
|
103
|
+
print(f"[jitcatch] testing {what}", file=sys.stderr)
|
|
104
|
+
if head != "HEAD" and not title:
|
|
105
|
+
title = "Uncommitted changes"
|
|
106
|
+
else:
|
|
107
|
+
base, head = args.base, args.head
|
|
108
|
+
result = run_pipeline(repo, base, head, cfg, title, args.description or "", args.force)
|
|
109
|
+
return _emit(result, cfg, args)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_setup(args) -> int:
|
|
113
|
+
from .userconfig import user_config_path
|
|
114
|
+
repo_dir = Path(args.repo).resolve()
|
|
115
|
+
if args.reset and user_config_path().exists():
|
|
116
|
+
user_config_path().unlink()
|
|
117
|
+
try:
|
|
118
|
+
repo = repo_root(repo_dir)
|
|
119
|
+
except GitError:
|
|
120
|
+
repo = repo_dir # setup is per user; a repository is not required
|
|
121
|
+
cfg = _ready_config(repo, args)
|
|
122
|
+
gen = cfg.roles.get("generator") or next(iter(cfg.providers))
|
|
123
|
+
p = cfg.providers[gen]
|
|
124
|
+
print(f"jitcatch is set up: {p.name} ({p.model}). Run `jitcatch` inside a git project; "
|
|
125
|
+
"`jitcatch setup --reset` to change provider or model.")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_github(args) -> int:
|
|
130
|
+
from .github import GitHubAPI, read_event, set_output, write_step_summary
|
|
131
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
132
|
+
cfg = load_config(repo, Path(args.config) if args.config else None)
|
|
133
|
+
pr = read_event()
|
|
134
|
+
if pr is None:
|
|
135
|
+
print("[jitcatch] not a pull_request event; nothing to do", file=sys.stderr)
|
|
136
|
+
return 0
|
|
137
|
+
result = run_pipeline(repo, pr.base_sha, pr.head_sha, cfg, pr.title, pr.body, args.force)
|
|
138
|
+
code = _emit(result, cfg, args)
|
|
139
|
+
md_comment = markdown(result, for_comment=True)
|
|
140
|
+
write_step_summary(md_comment)
|
|
141
|
+
set_output("catches", str(len(result.candidates())))
|
|
142
|
+
set_output("weak_catches", str(len(result.weak_catches)))
|
|
143
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
144
|
+
if args.comment and token and pr.repo:
|
|
145
|
+
try:
|
|
146
|
+
status = GitHubAPI(token).upsert_comment(pr.repo, pr.number, md_comment,
|
|
147
|
+
create_if_missing=bool(result.candidates()) or args.always_comment)
|
|
148
|
+
print(f"[jitcatch] PR comment {status}", file=sys.stderr)
|
|
149
|
+
except Exception as e: # never fail the job because of the comment
|
|
150
|
+
print(f"[jitcatch] could not post PR comment: {e}", file=sys.stderr)
|
|
151
|
+
return code
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_init(args) -> int:
|
|
155
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
156
|
+
path = repo / ".jitcatch.toml"
|
|
157
|
+
if path.exists() and not args.force:
|
|
158
|
+
print(f"{path} already exists (use --force to overwrite)", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
|
|
161
|
+
gi = repo / ".gitignore"
|
|
162
|
+
lines = gi.read_text(encoding="utf-8").splitlines() if gi.exists() else []
|
|
163
|
+
if ".jitcatch/" not in lines:
|
|
164
|
+
with open(gi, "a", encoding="utf-8") as f:
|
|
165
|
+
f.write(("\n" if lines and lines[-1] else "") + ".jitcatch/\n")
|
|
166
|
+
print(f"wrote {path}; edit providers/models, then: jitcatch doctor")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_doctor(args) -> int:
|
|
171
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
172
|
+
cfg = load_config(repo, Path(args.config) if args.config else None)
|
|
173
|
+
ok = True
|
|
174
|
+
print(f"config OK ({len(cfg.providers)} providers)")
|
|
175
|
+
for m in _check_keys(cfg):
|
|
176
|
+
ok = False
|
|
177
|
+
print(f" ✗ {m}")
|
|
178
|
+
try:
|
|
179
|
+
generator, judges, lp = _clients(cfg)
|
|
180
|
+
print(f"generator: {generator.name}; judges: {[j.name for j in judges]}; logprob judge: {lp.name if lp else '-'}")
|
|
181
|
+
except ConfigError as e:
|
|
182
|
+
print(f" ✗ {e}")
|
|
183
|
+
return 2
|
|
184
|
+
for ad in build_adapters(cfg.adapters, repo):
|
|
185
|
+
good, why = ad.available()
|
|
186
|
+
print(f" {'✓' if good else '·'} {ad.name}: {'available' if good else why}")
|
|
187
|
+
if args.ping:
|
|
188
|
+
seen = set()
|
|
189
|
+
for c in [generator, *judges, *([lp] if lp else [])]:
|
|
190
|
+
if c.name in seen:
|
|
191
|
+
continue
|
|
192
|
+
seen.add(c.name)
|
|
193
|
+
try:
|
|
194
|
+
r = c.complete("Reply with the single word: pong", "ping", max_tokens=1024,
|
|
195
|
+
want_logprobs=(lp is not None and c.name == lp.name))
|
|
196
|
+
extra = f", logprobs={'yes' if r.logprobs else 'NO'}" if lp is not None and c.name == lp.name else ""
|
|
197
|
+
print(f" ✓ {c.name} ({r.model}): {r.text.strip()[:40]!r}{extra}")
|
|
198
|
+
except LLMError as e:
|
|
199
|
+
ok = False
|
|
200
|
+
print(f" ✗ {c.name}: {e}")
|
|
201
|
+
return 0 if ok else 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_install_hook(args) -> int:
|
|
205
|
+
from .hook import install
|
|
206
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
207
|
+
path = install(repo, blocking=args.blocking, force=args.force)
|
|
208
|
+
print(f"installed {path} ({'blocking' if args.blocking else 'non-blocking'}); bypass once with JITCATCH_SKIP=1")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_uninstall_hook(args) -> int:
|
|
213
|
+
from .hook import uninstall
|
|
214
|
+
repo = repo_root(Path(args.repo).resolve())
|
|
215
|
+
print("removed" if uninstall(repo) else "no jitcatch pre-push hook found")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_hook_run(args) -> int:
|
|
220
|
+
from .hook import env_skip, ranges_from_stdin, stdin_text
|
|
221
|
+
if env_skip():
|
|
222
|
+
return 0
|
|
223
|
+
try:
|
|
224
|
+
repo = repo_root(Path.cwd())
|
|
225
|
+
cfg = load_config(repo)
|
|
226
|
+
ranges = ranges_from_stdin(repo, args.remote or "origin", cfg.hook.get("default_branch"), stdin_text())
|
|
227
|
+
blocking = args.blocking or bool(cfg.hook.get("blocking"))
|
|
228
|
+
found = 0
|
|
229
|
+
for base, head in ranges:
|
|
230
|
+
print(f"[jitcatch] checking {base[:12]}..{head[:12]} (bypass with JITCATCH_SKIP=1)", file=sys.stderr)
|
|
231
|
+
result = run_pipeline(repo, base, head, cfg)
|
|
232
|
+
write_artifacts(result, cfg.output_dir())
|
|
233
|
+
print(markdown(result), file=sys.stderr)
|
|
234
|
+
found += len(result.candidates())
|
|
235
|
+
if found and blocking:
|
|
236
|
+
print(f"[jitcatch] {found} possible regression(s); push blocked. Re-run with JITCATCH_SKIP=1 to override.",
|
|
237
|
+
file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
return 0
|
|
240
|
+
except (ConfigError, GitError, LLMError) as e:
|
|
241
|
+
print(f"[jitcatch] skipped: {e}", file=sys.stderr)
|
|
242
|
+
return 0 # never block a push on tool problems
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------- parser
|
|
246
|
+
|
|
247
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
248
|
+
p = argparse.ArgumentParser(prog="jitcatch", description="Just-in-Time catching test generation")
|
|
249
|
+
p.add_argument("--version", action="version", version=f"jitcatch {__version__}")
|
|
250
|
+
p.add_argument("-v", "--verbose", action="count", default=0)
|
|
251
|
+
p.add_argument("-q", "--quiet-logs", action="store_true", help="only warnings on stderr")
|
|
252
|
+
sub = p.add_subparsers(dest="cmd", metavar="{run,setup,github,init,doctor,install-hook,uninstall-hook}")
|
|
253
|
+
|
|
254
|
+
def common(sp):
|
|
255
|
+
sp.add_argument("--repo", default=".", help="path inside the target git repository")
|
|
256
|
+
sp.add_argument("--config", help="path to config file (default: .jitcatch.toml)")
|
|
257
|
+
|
|
258
|
+
r = sub.add_parser("run", help="generate catching tests for a change (default command)")
|
|
259
|
+
common(r)
|
|
260
|
+
r.add_argument("--base", help="parent revision (merge-base with head is used). Default: automatic: "
|
|
261
|
+
"uncommitted changes vs HEAD, else commits not on the default branch, "
|
|
262
|
+
"else the last commit")
|
|
263
|
+
r.add_argument("--head", default="HEAD", help="changed revision")
|
|
264
|
+
r.add_argument("--title", help="change title (default: from commit messages)")
|
|
265
|
+
r.add_argument("--description", help="change description (default: from commit messages)")
|
|
266
|
+
r.add_argument("--force", action="store_true", help="ignore min_risk_score")
|
|
267
|
+
r.add_argument("--json", help="also write the JSON report here")
|
|
268
|
+
r.add_argument("--markdown", help="also write the Markdown report here")
|
|
269
|
+
r.add_argument("--quiet", action="store_true", help="don't print the report to stdout")
|
|
270
|
+
r.add_argument("--fail-on-catch", action="store_true", help="exit 1 if any catch is reported")
|
|
271
|
+
r.set_defaults(func=cmd_run)
|
|
272
|
+
|
|
273
|
+
g = sub.add_parser("github", help="run on a GitHub pull_request event and comment on the PR")
|
|
274
|
+
common(g)
|
|
275
|
+
g.add_argument("--force", action="store_true")
|
|
276
|
+
g.add_argument("--json")
|
|
277
|
+
g.add_argument("--markdown")
|
|
278
|
+
g.add_argument("--quiet", action="store_true")
|
|
279
|
+
g.add_argument("--fail-on-catch", action="store_true")
|
|
280
|
+
g.add_argument("--no-comment", dest="comment", action="store_false")
|
|
281
|
+
g.add_argument("--always-comment", action="store_true", help="comment even when nothing is reported")
|
|
282
|
+
g.set_defaults(func=cmd_github)
|
|
283
|
+
|
|
284
|
+
s = sub.add_parser("setup", help="choose provider, model and API key (asked once per machine)")
|
|
285
|
+
common(s)
|
|
286
|
+
s.add_argument("--reset", action="store_true", help="ask for provider and model again")
|
|
287
|
+
s.set_defaults(func=cmd_setup)
|
|
288
|
+
|
|
289
|
+
i = sub.add_parser("init", help="write a starter .jitcatch.toml")
|
|
290
|
+
common(i)
|
|
291
|
+
i.add_argument("--force", action="store_true")
|
|
292
|
+
i.set_defaults(func=cmd_init)
|
|
293
|
+
|
|
294
|
+
d = sub.add_parser("doctor", help="validate config, API keys and toolchains")
|
|
295
|
+
common(d)
|
|
296
|
+
d.add_argument("--ping", action="store_true", help="send a tiny request to each provider")
|
|
297
|
+
d.set_defaults(func=cmd_doctor)
|
|
298
|
+
|
|
299
|
+
h = sub.add_parser("install-hook", help="install a git pre-push hook")
|
|
300
|
+
common(h)
|
|
301
|
+
h.add_argument("--blocking", action="store_true", help="block the push when a catch is reported")
|
|
302
|
+
h.add_argument("--force", action="store_true", help="overwrite an existing pre-push hook")
|
|
303
|
+
h.set_defaults(func=cmd_install_hook)
|
|
304
|
+
|
|
305
|
+
u = sub.add_parser("uninstall-hook", help="remove the jitcatch pre-push hook")
|
|
306
|
+
common(u)
|
|
307
|
+
u.set_defaults(func=cmd_uninstall_hook)
|
|
308
|
+
|
|
309
|
+
hr = sub.add_parser("hook-run")
|
|
310
|
+
hr.add_argument("--remote")
|
|
311
|
+
hr.add_argument("--blocking", action="store_true")
|
|
312
|
+
hr.set_defaults(func=cmd_hook_run)
|
|
313
|
+
return p
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: list[str] | None = None) -> int:
|
|
317
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
318
|
+
parser = build_parser()
|
|
319
|
+
args = parser.parse_args(argv)
|
|
320
|
+
if args.cmd is None: # plain `jitcatch` = `jitcatch run` with automatic base/head
|
|
321
|
+
args = parser.parse_args([*argv, "run"])
|
|
322
|
+
_setup_logging(-1 if args.quiet_logs else args.verbose)
|
|
323
|
+
try:
|
|
324
|
+
return args.func(args)
|
|
325
|
+
except (ConfigError, GitError, FileExistsError) as e:
|
|
326
|
+
print(f"jitcatch: error: {e}", file=sys.stderr)
|
|
327
|
+
return 2
|
|
328
|
+
except LLMError as e:
|
|
329
|
+
print(f"jitcatch: LLM error: {e}", file=sys.stderr)
|
|
330
|
+
return 3
|
|
331
|
+
except KeyboardInterrupt:
|
|
332
|
+
# Context managers have already restored swapped sources and removed temporary worktrees.
|
|
333
|
+
print("\njitcatch: interrupted; temporary worktrees cleaned up, no report written", file=sys.stderr)
|
|
334
|
+
return 130
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
sys.exit(main())
|