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.
@@ -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())