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
jitcatch/models.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Core data structures shared across the pipeline.
|
|
2
|
+
|
|
3
|
+
Terminology follows Becker et al., "Just-in-Time Catching Test Generation at Meta" (FSE '26):
|
|
4
|
+
* weak catch -- a generated test that passes on the parent revision and fails on the change
|
|
5
|
+
* strong catch -- a weak catch whose failure reveals a real bug (true positive)
|
|
6
|
+
* strictly weak catch -- a weak catch whose failure is a false positive
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Outcome(str, Enum):
|
|
16
|
+
PASS = "pass"
|
|
17
|
+
FAIL = "fail" # test ran, at least one assertion/runtime failure
|
|
18
|
+
ERROR = "error" # test could not build / collect / compile
|
|
19
|
+
INFRA = "infra" # runner itself broken, timeout, missing tool
|
|
20
|
+
NO_TESTS = "no_tests" # runner found no tests in the generated file
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TestRun:
|
|
25
|
+
__test__ = False # not a pytest test class
|
|
26
|
+
outcome: Outcome
|
|
27
|
+
exit_code: int
|
|
28
|
+
output: str
|
|
29
|
+
duration_s: float = 0.0
|
|
30
|
+
command: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def passed(self) -> bool:
|
|
34
|
+
return self.outcome == Outcome.PASS
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ChangedFile:
|
|
39
|
+
path: str # repo-relative path at head
|
|
40
|
+
old_path: str # repo-relative path at base (same as path unless renamed)
|
|
41
|
+
status: str # M, A, D, R
|
|
42
|
+
patch: str # unified diff for this file
|
|
43
|
+
added_lines: int = 0
|
|
44
|
+
removed_lines: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChangeContext:
|
|
49
|
+
base_sha: str
|
|
50
|
+
head_sha: str
|
|
51
|
+
title: str
|
|
52
|
+
description: str
|
|
53
|
+
files: list[ChangedFile]
|
|
54
|
+
full_patch: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Risk:
|
|
59
|
+
id: str
|
|
60
|
+
description: str
|
|
61
|
+
file: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Intent:
|
|
66
|
+
summary: str
|
|
67
|
+
change_kind: str # refactor | dead_code_removal | additive | bugfix | feature | other
|
|
68
|
+
risks: list[Risk]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Mutant:
|
|
73
|
+
id: str
|
|
74
|
+
risk: Risk
|
|
75
|
+
file: str
|
|
76
|
+
original: str
|
|
77
|
+
mutated: str
|
|
78
|
+
survived_existing_tests: bool | None = None
|
|
79
|
+
note: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class GeneratedTest:
|
|
84
|
+
id: str
|
|
85
|
+
workflow: str # intent_aware | dodgy_diff
|
|
86
|
+
target_file: str # source file under test (repo-relative)
|
|
87
|
+
test_path: str # where the test is written (repo-relative)
|
|
88
|
+
code: str
|
|
89
|
+
risk: Risk | None = None
|
|
90
|
+
mutant_id: str | None = None
|
|
91
|
+
base_run: TestRun | None = None
|
|
92
|
+
base_rerun: TestRun | None = None
|
|
93
|
+
mutant_run: TestRun | None = None
|
|
94
|
+
head_run: TestRun | None = None
|
|
95
|
+
head_rerun: TestRun | None = None
|
|
96
|
+
attempts: int = 0
|
|
97
|
+
discarded_reason: str = ""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_weak_catch(self) -> bool:
|
|
101
|
+
return (
|
|
102
|
+
self.base_run is not None and self.base_run.passed
|
|
103
|
+
and self.head_run is not None and not self.head_run.passed
|
|
104
|
+
and self.head_run.outcome != Outcome.INFRA
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_hardening(self) -> bool:
|
|
109
|
+
return (
|
|
110
|
+
self.base_run is not None and self.base_run.passed
|
|
111
|
+
and self.head_run is not None and self.head_run.passed
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_flaky(self) -> bool:
|
|
116
|
+
for a, b in ((self.base_run, self.base_rerun), (self.head_run, self.head_rerun)):
|
|
117
|
+
if a is not None and b is not None and a.outcome != b.outcome:
|
|
118
|
+
return True
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class AssessorScore:
|
|
124
|
+
name: str
|
|
125
|
+
score: float | None # -1.0 (false positive) .. 1.0 (true positive); None = no information
|
|
126
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Assessment:
|
|
131
|
+
test_id: str
|
|
132
|
+
scores: list[AssessorScore]
|
|
133
|
+
combined: float
|
|
134
|
+
discarded: bool
|
|
135
|
+
discard_reason: str = ""
|
|
136
|
+
behavior_change: str = "" # plain-language "used to X, now Y"
|
|
137
|
+
question: str = "" # "is this expected?" message for the author
|
|
138
|
+
rationales: list[dict[str, str]] = field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def to_jsonable(obj: Any) -> Any:
|
|
142
|
+
if isinstance(obj, Enum):
|
|
143
|
+
return obj.value
|
|
144
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
145
|
+
return {k: to_jsonable(v) for k, v in asdict(obj).items()}
|
|
146
|
+
if isinstance(obj, dict):
|
|
147
|
+
return {k: to_jsonable(v) for k, v in obj.items()}
|
|
148
|
+
if isinstance(obj, (list, tuple)):
|
|
149
|
+
return [to_jsonable(v) for v in obj]
|
|
150
|
+
return obj
|
jitcatch/prompts.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Prompt templates for every LLM step in the pipeline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
GEN_SYSTEM = (
|
|
5
|
+
"You are a meticulous senior software engineer specialising in finding regressions in code changes "
|
|
6
|
+
"before they are merged. You follow output-format instructions exactly."
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
INTENT_USER = """\
|
|
10
|
+
A developer submitted the code change below. Infer what the change is trying to do (its intent), classify it,
|
|
11
|
+
and list the concrete *risks*: specific ways an attempt to implement this intent could introduce a bug that
|
|
12
|
+
changes observable behaviour of the code that existed BEFORE the change and that a unit test could detect.
|
|
13
|
+
Prioritise risks with severe consequences (data loss, wrong results, security/permission mistakes, crashes).
|
|
14
|
+
Do not list style issues or risks that only concern brand-new code paths.
|
|
15
|
+
|
|
16
|
+
## Title
|
|
17
|
+
{title}
|
|
18
|
+
|
|
19
|
+
## Description
|
|
20
|
+
{description}
|
|
21
|
+
|
|
22
|
+
## Diff (parent -> change)
|
|
23
|
+
```diff
|
|
24
|
+
{patch}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Changed files after the change
|
|
28
|
+
{files}
|
|
29
|
+
|
|
30
|
+
Respond with ONLY a JSON object:
|
|
31
|
+
{{
|
|
32
|
+
"intent": "<one or two sentences>",
|
|
33
|
+
"change_kind": "refactor" | "dead_code_removal" | "additive" | "bugfix" | "feature" | "other",
|
|
34
|
+
"risks": [{{"file": "<repo-relative path of a changed file listed above>", "description": "<specific risk>"}}]
|
|
35
|
+
}}
|
|
36
|
+
List at most {max_risks} risks per file, most severe first.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
MUTANT_USER = """\
|
|
40
|
+
We simulate a bug that could be introduced while implementing a change. Below is the PARENT version (before the
|
|
41
|
+
change) of `{path}`. Create a *mutant*: a minimal, realistic modification of this parent file that exhibits the
|
|
42
|
+
risk described. The mutant must still compile/parse, must change observable behaviour, and must look like a
|
|
43
|
+
plausible developer mistake (not a deliberate sabotage like `return None` everywhere).
|
|
44
|
+
|
|
45
|
+
## Intended change
|
|
46
|
+
{intent}
|
|
47
|
+
|
|
48
|
+
## Risk to simulate
|
|
49
|
+
{risk}
|
|
50
|
+
|
|
51
|
+
## Parent version of `{path}`
|
|
52
|
+
```{lang}
|
|
53
|
+
{source}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Respond with ONLY a JSON object:
|
|
57
|
+
{{
|
|
58
|
+
"explanation": "<what the mutant changes and why it reflects the risk>",
|
|
59
|
+
"edits": [{{"search": "<exact, unique snippet copied verbatim from the parent file>", "replace": "<new snippet>"}}]
|
|
60
|
+
}}
|
|
61
|
+
Each "search" string must appear exactly once in the parent file. Keep edits small.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
TEST_FOR_MUTANT_USER = """\
|
|
65
|
+
Write a unit test file for `{path}` that PASSES on the parent version shown below and FAILS on the mutant
|
|
66
|
+
(the mutant diff is shown). The test must assert the parent's correct behaviour on the code path the mutant
|
|
67
|
+
affects, so that the risk "{risk}" would be caught. Prefer simple, deterministic inputs and precise assertions
|
|
68
|
+
on returned values or observable state. Test only the public behaviour.
|
|
69
|
+
|
|
70
|
+
## Test-file constraints
|
|
71
|
+
{instructions}
|
|
72
|
+
The file will be written to `{test_path}`.
|
|
73
|
+
|
|
74
|
+
## Parent version of `{path}`
|
|
75
|
+
```{lang}
|
|
76
|
+
{source}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Mutant diff (parent -> mutant)
|
|
80
|
+
```diff
|
|
81
|
+
{mutant_diff}
|
|
82
|
+
```
|
|
83
|
+
{example}
|
|
84
|
+
Respond with ONLY the complete test file in a single ```{lang} code block.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
TEST_DODGY_USER = """\
|
|
88
|
+
Treat the change below as if it were a mutant of its parent. Write a unit test file for `{path}` that PASSES on
|
|
89
|
+
the parent version (shown in full) and would FAIL if the code behaved the way the changed version behaves.
|
|
90
|
+
Focus on the behaviour of the functions/branches touched by the diff. Assert precise parent-version outputs for
|
|
91
|
+
concrete inputs. Test only the public behaviour; keep it deterministic.
|
|
92
|
+
|
|
93
|
+
## Test-file constraints
|
|
94
|
+
{instructions}
|
|
95
|
+
The file will be written to `{test_path}`.
|
|
96
|
+
|
|
97
|
+
## Parent version of `{path}`
|
|
98
|
+
```{lang}
|
|
99
|
+
{source}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Diff (parent -> change)
|
|
103
|
+
```diff
|
|
104
|
+
{patch}
|
|
105
|
+
```
|
|
106
|
+
{example}{variation}
|
|
107
|
+
Respond with ONLY the complete test file in a single ```{lang} code block.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
TEST_FIX_USER = """\
|
|
111
|
+
The test file below was supposed to PASS on the parent version of `{path}` but it did not.
|
|
112
|
+
Fix the test so that it passes on the parent version while still asserting meaningful, precise behaviour on the
|
|
113
|
+
same code path (do not delete the assertions that matter or make the test trivial). If an assertion encodes a
|
|
114
|
+
wrong expectation, correct the expected value to the parent's real behaviour.
|
|
115
|
+
|
|
116
|
+
## Test-file constraints
|
|
117
|
+
{instructions}
|
|
118
|
+
|
|
119
|
+
## Parent version of `{path}`
|
|
120
|
+
```{lang}
|
|
121
|
+
{source}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Current test file (`{test_path}`)
|
|
125
|
+
```{lang}
|
|
126
|
+
{test_code}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Output when run on the parent version (outcome: {outcome})
|
|
130
|
+
```
|
|
131
|
+
{output}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Respond with ONLY the complete corrected test file in a single ```{lang} code block.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
EXAMPLE_BLOCK = """
|
|
138
|
+
## Existing test in this repository (follow its imports, setup and style)
|
|
139
|
+
`{path}`:
|
|
140
|
+
```{lang}
|
|
141
|
+
{code}
|
|
142
|
+
```
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
JUDGE_SYSTEM = (
|
|
146
|
+
"You are an experienced code reviewer judging whether an automatically generated test failure reveals a "
|
|
147
|
+
"real bug introduced by a code change, or whether the behaviour change is intended / the test is wrong. "
|
|
148
|
+
"You are careful, concrete and sceptical of brittle tests."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
JUDGE_USER = """\
|
|
152
|
+
A generated test PASSES on the parent revision and FAILS on the changed revision.
|
|
153
|
+
Decide how likely it is that this failure reveals an UNEXPECTED behaviour change (a bug) introduced by the change,
|
|
154
|
+
as opposed to an intended behaviour change or a problem with the test itself (brittle assertions on
|
|
155
|
+
implementation details, wrong mocks, API/signature changes, private members, ordering of unordered data, etc.).
|
|
156
|
+
|
|
157
|
+
## Inferred intent of the change
|
|
158
|
+
{intent}
|
|
159
|
+
|
|
160
|
+
## Title / description supplied by the author
|
|
161
|
+
{title}
|
|
162
|
+
{description}
|
|
163
|
+
|
|
164
|
+
## Diff (parent -> change)
|
|
165
|
+
```diff
|
|
166
|
+
{patch}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Generated test (`{test_path}`)
|
|
170
|
+
```{lang}
|
|
171
|
+
{test_code}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Test execution on the changed revision (outcome: {outcome})
|
|
175
|
+
```
|
|
176
|
+
{output}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Respond with ONLY a JSON object:
|
|
180
|
+
{{
|
|
181
|
+
"likelihood": "High" | "Medium" | "Low",
|
|
182
|
+
"behavior_change": "<one sentence: what the code did before the change vs what it does now, in concrete terms>",
|
|
183
|
+
"question": "<a short, friendly message to the change author describing the observed behaviour change and ending with 'Is this expected?'>",
|
|
184
|
+
"rationale": "<2-4 sentences: why this is or is not likely an unintended bug, given the diff and intent>"
|
|
185
|
+
}}
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
LOGPROB_USER = JUDGE_USER.split("Respond with ONLY")[0] + (
|
|
189
|
+
"Question: Is this test failure most likely an UNEXPECTED bug introduced by the change?\n"
|
|
190
|
+
"Answer with exactly one word: Yes or No."
|
|
191
|
+
)
|
jitcatch/report.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""JSON + Markdown reports and on-disk artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from .engine import RunResult
|
|
9
|
+
from .models import Assessment, GeneratedTest, to_jsonable
|
|
10
|
+
|
|
11
|
+
COMMENT_MARKER = "<!-- jitcatch-report -->"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def to_dict(r: RunResult) -> dict:
|
|
15
|
+
return {
|
|
16
|
+
"base_sha": r.ctx.base_sha,
|
|
17
|
+
"head_sha": r.ctx.head_sha,
|
|
18
|
+
"title": r.ctx.title,
|
|
19
|
+
"risk": to_jsonable(r.risk),
|
|
20
|
+
"intent": to_jsonable(r.intent),
|
|
21
|
+
"targets": r.targets,
|
|
22
|
+
"skipped": r.skipped,
|
|
23
|
+
"threshold": r.threshold,
|
|
24
|
+
"summary": summary_counts(r),
|
|
25
|
+
"candidates": [
|
|
26
|
+
{"test_id": t.id, "target_file": t.target_file, "score": a.combined, "question": a.question,
|
|
27
|
+
"behavior_change": a.behavior_change}
|
|
28
|
+
for t, a in r.candidates()
|
|
29
|
+
],
|
|
30
|
+
"mutants": [to_jsonable(_mutant_summary(m)) for m in r.mutants],
|
|
31
|
+
"tests": [to_jsonable(t) for t in r.tests],
|
|
32
|
+
"assessments": {k: to_jsonable(v) for k, v in r.assessments.items()},
|
|
33
|
+
"errors": r.errors,
|
|
34
|
+
"llm_calls": r.llm_calls,
|
|
35
|
+
"duration_s": round((r.finished_at or time.time()) - r.started_at, 1),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mutant_summary(m):
|
|
40
|
+
return {"id": m.id, "risk": m.risk, "file": m.file, "survived_existing_tests": m.survived_existing_tests,
|
|
41
|
+
"note": m.note, "changed": m.original != m.mutated}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def summary_counts(r: RunResult) -> dict:
|
|
45
|
+
tests = r.tests
|
|
46
|
+
return {
|
|
47
|
+
"generated_tests": len(tests),
|
|
48
|
+
"passed_on_parent": sum(1 for t in tests if t.base_run is not None and t.base_run.passed and not t.discarded_reason),
|
|
49
|
+
"weak_catches": len(r.weak_catches),
|
|
50
|
+
"discarded_by_rules": sum(1 for a in r.assessments.values() if a.discarded),
|
|
51
|
+
"reported": len(r.candidates()),
|
|
52
|
+
"hardening_tests": len(r.hardening),
|
|
53
|
+
"mutants_used": sum(1 for m in r.mutants if not m.note.startswith("invalid") and m.survived_existing_tests is not False),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_artifacts(r: RunResult, out_root: Path) -> Path:
|
|
58
|
+
run_dir = out_root / "runs" / f"{time.strftime('%Y%m%d-%H%M%S')}-{r.ctx.head_sha[:8]}"
|
|
59
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
(run_dir / "report.json").write_text(json.dumps(to_dict(r), indent=2), encoding="utf-8")
|
|
61
|
+
(run_dir / "report.md").write_text(markdown(r), encoding="utf-8")
|
|
62
|
+
for t in r.weak_catches:
|
|
63
|
+
_save_test(run_dir / "weak_catches", t)
|
|
64
|
+
for t in r.hardening:
|
|
65
|
+
_save_test(run_dir / "hardening", t)
|
|
66
|
+
return run_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _save_test(d: Path, t: GeneratedTest) -> None:
|
|
70
|
+
dest = d / t.test_path
|
|
71
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
dest.write_text(t.code, encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fmt(score) -> str:
|
|
76
|
+
return "n/a" if score is None else f"{score:+.2f}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lang_for(path: str) -> str:
|
|
80
|
+
ext = PurePosixPath(path).suffix
|
|
81
|
+
return {".py": "python", ".ts": "ts", ".tsx": "tsx", ".js": "js", ".jsx": "jsx", ".mjs": "js",
|
|
82
|
+
".java": "java", ".go": "go"}.get(ext, "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _candidate_md(i: int, t: GeneratedTest, a: Assessment) -> str:
|
|
86
|
+
lines = [f"#### {i}. `{t.target_file}` — confidence {a.combined:+.2f}"]
|
|
87
|
+
if a.question:
|
|
88
|
+
lines.append(f"> {a.question}")
|
|
89
|
+
elif a.behavior_change:
|
|
90
|
+
lines.append(f"> {a.behavior_change} Is this expected?")
|
|
91
|
+
if t.risk:
|
|
92
|
+
lines.append(f"\n_Risk probed:_ {t.risk.description}")
|
|
93
|
+
lines.append("\n<details><summary>Evidence</summary>\n")
|
|
94
|
+
for s in a.scores:
|
|
95
|
+
extra = ""
|
|
96
|
+
if s.name == "rules":
|
|
97
|
+
fps = [h["rule"] for h in s.details.get("false_positive_patterns", [])]
|
|
98
|
+
tps = [h["rule"] for h in s.details.get("true_positive_patterns", [])]
|
|
99
|
+
extra = f" — TP patterns: {', '.join(tps) or 'none'}; FP patterns: {', '.join(fps) or 'none'}"
|
|
100
|
+
elif s.name == "llm_logprob" and s.score is not None:
|
|
101
|
+
extra = f" — P(yes)={s.details.get('p_yes')}, P(no)={s.details.get('p_no')}"
|
|
102
|
+
lines.append(f"- **{s.name}**: {_fmt(s.score)}{extra}")
|
|
103
|
+
for rat in a.rationales:
|
|
104
|
+
if rat.get("error"):
|
|
105
|
+
lines.append(f"- _{rat.get('judge')}_: error — {rat['error'][:200]}")
|
|
106
|
+
else:
|
|
107
|
+
lines.append(f"- _{rat.get('judge')}_ ({rat.get('likelihood')}): {rat.get('rationale', '')}")
|
|
108
|
+
lines.append(f"\nWorkflow: `{t.workflow}` · test id `{t.id}` · passes on parent, "
|
|
109
|
+
f"`{t.head_run.outcome.value if t.head_run else '?'}` on this change"
|
|
110
|
+
+ (f" · {'kills' if t.mutant_run and not t.mutant_run.passed else 'does not kill'} its mutant"
|
|
111
|
+
if t.mutant_id else ""))
|
|
112
|
+
out = (t.head_run.output if t.head_run else "").strip()
|
|
113
|
+
if len(out) > 3000:
|
|
114
|
+
out = "…\n" + out[-3000:]
|
|
115
|
+
lines.append(f"\n**Failure on this change**\n```\n{out}\n```")
|
|
116
|
+
lines.append(f"\n**Generated test** (`{t.test_path}`)\n```{_lang_for(t.test_path)}\n{t.code.rstrip()}\n```")
|
|
117
|
+
lines.append("\n</details>\n")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def markdown(r: RunResult, for_comment: bool = False) -> str:
|
|
122
|
+
c = summary_counts(r)
|
|
123
|
+
cands = r.candidates()
|
|
124
|
+
md = [COMMENT_MARKER if for_comment else "", "## JiTCatch: just-in-time catching tests", ""]
|
|
125
|
+
md.append(f"Change `{r.ctx.base_sha[:8]}..{r.ctx.head_sha[:8]}`"
|
|
126
|
+
+ (f" · risk score {r.risk.score:.2f}" if r.risk else ""))
|
|
127
|
+
if r.intent:
|
|
128
|
+
md.append(f"\n**Inferred intent** ({r.intent.change_kind}): {r.intent.summary}")
|
|
129
|
+
md.append("")
|
|
130
|
+
if cands:
|
|
131
|
+
md.append(f"### {len(cands)} behaviour change(s) worth a quick check")
|
|
132
|
+
md.append("Each item is a generated test that passes on the parent revision but fails with this change. "
|
|
133
|
+
"If the change is intended, just dismiss it.\n")
|
|
134
|
+
for i, (t, a) in enumerate(cands, 1):
|
|
135
|
+
md.append(_candidate_md(i, t, a))
|
|
136
|
+
elif r.tests:
|
|
137
|
+
md.append("No likely regressions found by the generated tests.")
|
|
138
|
+
else:
|
|
139
|
+
md.append("No tests were generated for this change.")
|
|
140
|
+
md.append("\n| generated | pass on parent | weak catches | auto-discarded (rules) | reported | hardening |")
|
|
141
|
+
md.append("|---:|---:|---:|---:|---:|---:|")
|
|
142
|
+
md.append(f"| {c['generated_tests']} | {c['passed_on_parent']} | {c['weak_catches']} | "
|
|
143
|
+
f"{c['discarded_by_rules']} | {c['reported']} | {c['hardening_tests']} |")
|
|
144
|
+
low = [(a, next(t for t in r.tests if t.id == a.test_id)) for a in r.assessments.values()
|
|
145
|
+
if a.discarded or a.combined < r.threshold]
|
|
146
|
+
if low:
|
|
147
|
+
md.append("\n<details><summary>Weak catches judged unlikely to be bugs "
|
|
148
|
+
f"({len(low)})</summary>\n")
|
|
149
|
+
for a, t in sorted(low, key=lambda x: -x[0].combined):
|
|
150
|
+
why = a.discard_reason or f"score {a.combined:+.2f} < threshold {r.threshold:+.2f}"
|
|
151
|
+
md.append(f"- `{t.target_file}` test `{t.id}` ({t.workflow}): {why}")
|
|
152
|
+
md.append("\n</details>")
|
|
153
|
+
if r.skipped:
|
|
154
|
+
md.append("\n<details><summary>Skipped files</summary>\n")
|
|
155
|
+
for s in r.skipped:
|
|
156
|
+
md.append(f"- `{s['path']}`: {s['reason']}")
|
|
157
|
+
md.append("\n</details>")
|
|
158
|
+
if r.errors:
|
|
159
|
+
md.append("\n<details><summary>Errors</summary>\n")
|
|
160
|
+
for e in r.errors:
|
|
161
|
+
md.append(f"- {e[:300]}")
|
|
162
|
+
md.append("\n</details>")
|
|
163
|
+
return "\n".join(x for x in md if x is not None).strip() + "\n"
|
jitcatch/targeting.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Change-risk targeting.
|
|
2
|
+
|
|
3
|
+
The paper targets diffs with a learned Diff Risk Score model trained on historical changes. Without
|
|
4
|
+
that history, this module uses a transparent heuristic (size, sensitive paths, untested change,
|
|
5
|
+
spread, deletion ratio). Set `run.min_risk_score` to skip low-risk changes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .models import ChangeContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RiskScore:
|
|
18
|
+
score: float
|
|
19
|
+
reasons: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def score_change(ctx: ChangeContext, sensitive_paths: list[str], is_test_file) -> RiskScore:
|
|
23
|
+
reasons: list[str] = []
|
|
24
|
+
files = [f for f in ctx.files if f.status != "D"]
|
|
25
|
+
src = [f for f in files if not is_test_file(f.path)]
|
|
26
|
+
tests_touched = any(is_test_file(f.path) for f in files)
|
|
27
|
+
changed = sum(f.added_lines + f.removed_lines for f in src)
|
|
28
|
+
removed = sum(f.removed_lines for f in src)
|
|
29
|
+
|
|
30
|
+
size = min(1.0, math.log10(1 + changed) / 3) # ~1000 changed lines => 1.0
|
|
31
|
+
reasons.append(f"{changed} changed source lines")
|
|
32
|
+
sens_hits = sorted({p for f in src for p in sensitive_paths if re.search(rf"(^|[/_.\-]){re.escape(p)}", f.path, re.I)})
|
|
33
|
+
sensitive = 1.0 if sens_hits else 0.0
|
|
34
|
+
if sens_hits:
|
|
35
|
+
reasons.append("touches sensitive areas: " + ", ".join(sens_hits))
|
|
36
|
+
untested = 1.0 if src and not tests_touched else 0.0
|
|
37
|
+
if untested:
|
|
38
|
+
reasons.append("no tests changed alongside source changes")
|
|
39
|
+
spread = min(1.0, len(src) / 10)
|
|
40
|
+
deletion = removed / changed if changed else 0.0
|
|
41
|
+
if deletion > 0.5:
|
|
42
|
+
reasons.append(f"{deletion:.0%} of changed lines are deletions")
|
|
43
|
+
score = 0.35 * size + 0.25 * sensitive + 0.2 * untested + 0.1 * spread + 0.1 * deletion
|
|
44
|
+
return RiskScore(round(score, 3), reasons)
|
jitcatch/templates.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
CONFIG_TEMPLATE = '''\
|
|
2
|
+
# JiTCatch configuration. API keys are read from the environment variables named below.
|
|
3
|
+
|
|
4
|
+
# ---------------------------------------------------------------- LLM providers
|
|
5
|
+
# type: "anthropic" | "openai_chat" | "openai_responses" (or use a preset)
|
|
6
|
+
# presets: anthropic, openai, openai-chat, zai-coding, zai-coding-anthropic,
|
|
7
|
+
# zai-coding-responses, ollama, lmstudio, vllm
|
|
8
|
+
|
|
9
|
+
[providers.claude]
|
|
10
|
+
preset = "anthropic" # ANTHROPIC_API_KEY
|
|
11
|
+
model = "claude-sonnet-5-5"
|
|
12
|
+
|
|
13
|
+
[providers.glm]
|
|
14
|
+
preset = "zai-coding" # https://api.z.ai/api/coding/paas/v4, ZAI_API_KEY
|
|
15
|
+
model = "glm-5.3-flash" # preset sets max_tokens = 65536, temperature = 1.0 (Z.ai defaults)
|
|
16
|
+
# GLM-5.3 / 5.3-Flash always think; trade depth for Coding Plan quota with:
|
|
17
|
+
# extra_body = { reasoning_effort = "high" } # "max" (default) | "high" | "low"
|
|
18
|
+
|
|
19
|
+
# [providers.gpt]
|
|
20
|
+
# preset = "openai" # Responses API, OPENAI_API_KEY
|
|
21
|
+
# model = "gpt-5"
|
|
22
|
+
# omit_params = ["temperature"] # for models that reject temperature
|
|
23
|
+
|
|
24
|
+
# [providers.local]
|
|
25
|
+
# preset = "ollama" # http://localhost:11434/v1
|
|
26
|
+
# model = "qwen2.5-coder:32b"
|
|
27
|
+
# logprobs = true # enables the token-probability judge (if the server supports it)
|
|
28
|
+
|
|
29
|
+
# [providers.proxy] # any Anthropic-compatible endpoint
|
|
30
|
+
# type = "anthropic"
|
|
31
|
+
# base_url = "https://my-gateway.example.com"
|
|
32
|
+
# api_key_env = "GATEWAY_KEY"
|
|
33
|
+
# auth = "bearer" # or "x-api-key"
|
|
34
|
+
# model = "claude-sonnet-5-5"
|
|
35
|
+
|
|
36
|
+
[roles]
|
|
37
|
+
generator = "claude" # writes intents, mutants and tests
|
|
38
|
+
judges = ["claude", "glm"] # LLM-as-judge ensemble (median of High/Medium/Low)
|
|
39
|
+
# logprob_judge = "local" # optional; must be openai_chat with logprobs = true
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- pipeline
|
|
42
|
+
[run]
|
|
43
|
+
workflows = ["intent_aware", "dodgy_diff"]
|
|
44
|
+
max_target_files = 5
|
|
45
|
+
max_risks_per_file = 4
|
|
46
|
+
dodgy_tests_per_file = 2
|
|
47
|
+
max_fix_attempts = 2
|
|
48
|
+
check_mutants_with_existing_tests = true
|
|
49
|
+
flaky_reruns = 1
|
|
50
|
+
test_timeout_s = 300
|
|
51
|
+
min_risk_score = 0.0 # raise to only run on riskier changes
|
|
52
|
+
report_threshold = 0.2 # combined score (-1..1) needed to report a catch
|
|
53
|
+
link_paths = ["node_modules"] # symlinked into the temporary worktrees
|
|
54
|
+
exclude = ["**/generated/**"]
|
|
55
|
+
|
|
56
|
+
[assessors]
|
|
57
|
+
weights = { rules = 1.0, llm_bucket = 1.0, llm_logprob = 1.0 }
|
|
58
|
+
discard_on_high_fp_rule = true
|
|
59
|
+
# rule_overrides = { flakiness = "off", changed_bool = "0.5" }
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------- languages
|
|
62
|
+
[adapters.python]
|
|
63
|
+
# python = ".venv/bin/python" # interpreter with pytest + project deps
|
|
64
|
+
|
|
65
|
+
[adapters.typescript]
|
|
66
|
+
runner = "auto" # auto | jest | vitest
|
|
67
|
+
|
|
68
|
+
[adapters.java]
|
|
69
|
+
build = "auto" # auto | maven | gradle
|
|
70
|
+
|
|
71
|
+
[adapters.go]
|
|
72
|
+
|
|
73
|
+
[hook]
|
|
74
|
+
blocking = false # true: a reported catch blocks the push
|
|
75
|
+
# default_branch = "main"
|
|
76
|
+
'''
|