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/config.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Configuration loading for `.jitcatch.toml`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
CONFIG_FILENAMES = (".jitcatch.toml", "jitcatch.toml")
|
|
12
|
+
|
|
13
|
+
# Provider presets: fill in type/base_url/auth/api_key_env. `model` is always user-chosen.
|
|
14
|
+
PRESETS: dict[str, dict[str, Any]] = {
|
|
15
|
+
"anthropic": {
|
|
16
|
+
"type": "anthropic",
|
|
17
|
+
"base_url": "https://api.anthropic.com",
|
|
18
|
+
"api_key_env": "ANTHROPIC_API_KEY",
|
|
19
|
+
"auth": "x-api-key",
|
|
20
|
+
},
|
|
21
|
+
"openai": {
|
|
22
|
+
"type": "openai_responses",
|
|
23
|
+
"base_url": "https://api.openai.com/v1",
|
|
24
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
25
|
+
},
|
|
26
|
+
"openai-chat": {
|
|
27
|
+
"type": "openai_chat",
|
|
28
|
+
"base_url": "https://api.openai.com/v1",
|
|
29
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
30
|
+
},
|
|
31
|
+
# Z.ai GLM Coding Plan (dedicated coding endpoints; Coding Plan keys only work here).
|
|
32
|
+
# GLM-5.x always thinks (GLM-5.3/5.3-Flash cannot disable it) and reasoning tokens count against
|
|
33
|
+
# max_tokens, so use Z.ai's documented default budget (65536; max 131072) and recommended temperature 1.0.
|
|
34
|
+
"zai-coding": {
|
|
35
|
+
"type": "openai_chat",
|
|
36
|
+
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
|
37
|
+
"api_key_env": "ZAI_API_KEY",
|
|
38
|
+
"max_tokens": 65536,
|
|
39
|
+
"temperature": 1.0,
|
|
40
|
+
},
|
|
41
|
+
"zai-coding-anthropic": {
|
|
42
|
+
"type": "anthropic",
|
|
43
|
+
"base_url": "https://api.z.ai/api/anthropic",
|
|
44
|
+
"api_key_env": "ZAI_API_KEY",
|
|
45
|
+
"auth": "bearer",
|
|
46
|
+
"max_tokens": 65536,
|
|
47
|
+
"temperature": 1.0,
|
|
48
|
+
},
|
|
49
|
+
"zai-coding-responses": {
|
|
50
|
+
"type": "openai_responses",
|
|
51
|
+
"base_url": "https://api.z.ai/api/v1",
|
|
52
|
+
"api_key_env": "ZAI_API_KEY",
|
|
53
|
+
"max_tokens": 65536,
|
|
54
|
+
"temperature": 1.0,
|
|
55
|
+
},
|
|
56
|
+
# Local models via OpenAI-compatible servers.
|
|
57
|
+
"ollama": {
|
|
58
|
+
"type": "openai_chat",
|
|
59
|
+
"base_url": "http://localhost:11434/v1",
|
|
60
|
+
"api_key_env": "",
|
|
61
|
+
},
|
|
62
|
+
"lmstudio": {
|
|
63
|
+
"type": "openai_chat",
|
|
64
|
+
"base_url": "http://localhost:1234/v1",
|
|
65
|
+
"api_key_env": "",
|
|
66
|
+
},
|
|
67
|
+
"vllm": {
|
|
68
|
+
"type": "openai_chat",
|
|
69
|
+
"base_url": "http://localhost:8000/v1",
|
|
70
|
+
"api_key_env": "",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
DEFAULTS: dict[str, Any] = {
|
|
75
|
+
"providers": {},
|
|
76
|
+
"roles": {"generator": None, "judges": [], "logprob_judge": None},
|
|
77
|
+
"run": {
|
|
78
|
+
"workflows": ["intent_aware", "dodgy_diff"],
|
|
79
|
+
"max_target_files": 5,
|
|
80
|
+
"max_risks_per_file": 4,
|
|
81
|
+
"dodgy_tests_per_file": 2,
|
|
82
|
+
"max_fix_attempts": 2,
|
|
83
|
+
"check_mutants_with_existing_tests": True,
|
|
84
|
+
"flaky_reruns": 1,
|
|
85
|
+
"max_file_chars": 40000,
|
|
86
|
+
"max_patch_chars": 30000,
|
|
87
|
+
"test_timeout_s": 300,
|
|
88
|
+
"existing_tests_timeout_s": 900,
|
|
89
|
+
"min_risk_score": 0.0,
|
|
90
|
+
"report_threshold": 0.2,
|
|
91
|
+
"output_dir": ".jitcatch",
|
|
92
|
+
"link_paths": ["node_modules"],
|
|
93
|
+
"include": [],
|
|
94
|
+
"exclude": [],
|
|
95
|
+
},
|
|
96
|
+
"assessors": {
|
|
97
|
+
"weights": {"rules": 1.0, "llm_bucket": 1.0, "llm_logprob": 1.0},
|
|
98
|
+
"discard_on_high_fp_rule": True,
|
|
99
|
+
"rule_overrides": {},
|
|
100
|
+
},
|
|
101
|
+
"targeting": {
|
|
102
|
+
"sensitive_paths": [
|
|
103
|
+
"auth", "security", "permission", "acl", "rbac", "payment", "billing",
|
|
104
|
+
"crypto", "token", "session", "migration", "privacy",
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
"adapters": {
|
|
108
|
+
"python": {"enabled": True, "python": None, "extra_args": []},
|
|
109
|
+
"typescript": {"enabled": True, "runner": "auto", "extra_args": []},
|
|
110
|
+
"java": {"enabled": True, "build": "auto", "extra_args": []},
|
|
111
|
+
"go": {"enabled": True, "extra_args": []},
|
|
112
|
+
},
|
|
113
|
+
"hook": {"blocking": False, "default_branch": None},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ConfigError(ValueError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class ProviderConfig:
|
|
123
|
+
name: str
|
|
124
|
+
type: str
|
|
125
|
+
base_url: str
|
|
126
|
+
model: str
|
|
127
|
+
api_key_env: str = ""
|
|
128
|
+
api_key: str = ""
|
|
129
|
+
auth: str = "bearer"
|
|
130
|
+
max_tokens: int = 8192
|
|
131
|
+
temperature: float | None = 0.2
|
|
132
|
+
timeout_s: float = 300.0
|
|
133
|
+
max_retries: int = 4
|
|
134
|
+
logprobs: bool = False
|
|
135
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
136
|
+
extra_body: dict[str, Any] = field(default_factory=dict)
|
|
137
|
+
# Request-shape knobs for picky servers, e.g. OpenAI reasoning models on Chat Completions
|
|
138
|
+
# need max_tokens_field = "max_completion_tokens" and omit_params = ["temperature"].
|
|
139
|
+
max_tokens_field: str = ""
|
|
140
|
+
omit_params: list[str] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
def resolve_api_key(self) -> str:
|
|
143
|
+
if self.api_key:
|
|
144
|
+
return self.api_key
|
|
145
|
+
if self.api_key_env:
|
|
146
|
+
from .userconfig import stored_key
|
|
147
|
+
return os.environ.get(self.api_key_env, "") or stored_key(self.api_key_env)
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class Config:
|
|
153
|
+
raw: dict[str, Any]
|
|
154
|
+
providers: dict[str, ProviderConfig]
|
|
155
|
+
root: Path
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def run(self) -> dict[str, Any]:
|
|
159
|
+
return self.raw["run"]
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def roles(self) -> dict[str, Any]:
|
|
163
|
+
return self.raw["roles"]
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def assessors(self) -> dict[str, Any]:
|
|
167
|
+
return self.raw["assessors"]
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def targeting(self) -> dict[str, Any]:
|
|
171
|
+
return self.raw["targeting"]
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def adapters(self) -> dict[str, Any]:
|
|
175
|
+
return self.raw["adapters"]
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def hook(self) -> dict[str, Any]:
|
|
179
|
+
return self.raw["hook"]
|
|
180
|
+
|
|
181
|
+
def output_dir(self) -> Path:
|
|
182
|
+
p = Path(self.run["output_dir"])
|
|
183
|
+
return p if p.is_absolute() else self.root / p
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
out = copy.deepcopy(base)
|
|
188
|
+
for k, v in override.items():
|
|
189
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
190
|
+
out[k] = _deep_merge(out[k], v)
|
|
191
|
+
else:
|
|
192
|
+
out[k] = copy.deepcopy(v)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_config(start: Path) -> Path | None:
|
|
197
|
+
for d in [start, *start.parents]:
|
|
198
|
+
for name in CONFIG_FILENAMES:
|
|
199
|
+
p = d / name
|
|
200
|
+
if p.is_file():
|
|
201
|
+
return p
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_config(repo_root: Path, path: Path | None = None, overrides: dict[str, Any] | None = None) -> Config:
|
|
206
|
+
data: dict[str, Any] = {}
|
|
207
|
+
cfg_path = path or find_config(repo_root)
|
|
208
|
+
if cfg_path is not None:
|
|
209
|
+
with open(cfg_path, "rb") as f:
|
|
210
|
+
data = tomllib.load(f)
|
|
211
|
+
from .userconfig import load_user_config
|
|
212
|
+
merged = _deep_merge(_deep_merge(DEFAULTS, load_user_config()), data)
|
|
213
|
+
if overrides:
|
|
214
|
+
merged = _deep_merge(merged, overrides)
|
|
215
|
+
providers = {name: _build_provider(name, spec) for name, spec in merged["providers"].items()}
|
|
216
|
+
cfg = Config(raw=merged, providers=providers, root=repo_root)
|
|
217
|
+
_validate(cfg)
|
|
218
|
+
return cfg
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _build_provider(name: str, spec: dict[str, Any]) -> ProviderConfig:
|
|
222
|
+
spec = dict(spec)
|
|
223
|
+
preset = spec.pop("preset", None)
|
|
224
|
+
if preset is not None:
|
|
225
|
+
if preset not in PRESETS:
|
|
226
|
+
raise ConfigError(f"provider {name!r}: unknown preset {preset!r}; known: {sorted(PRESETS)}")
|
|
227
|
+
spec = {**PRESETS[preset], **spec}
|
|
228
|
+
for required in ("type", "base_url", "model"):
|
|
229
|
+
if not spec.get(required):
|
|
230
|
+
raise ConfigError(f"provider {name!r}: missing required field {required!r}")
|
|
231
|
+
if spec["type"] not in ("anthropic", "openai_chat", "openai_responses"):
|
|
232
|
+
raise ConfigError(f"provider {name!r}: type must be anthropic, openai_chat or openai_responses")
|
|
233
|
+
if spec["type"] == "anthropic":
|
|
234
|
+
spec.setdefault("auth", "x-api-key")
|
|
235
|
+
known = set(ProviderConfig.__dataclass_fields__) - {"name"}
|
|
236
|
+
unknown = set(spec) - known
|
|
237
|
+
if unknown:
|
|
238
|
+
raise ConfigError(f"provider {name!r}: unknown fields {sorted(unknown)}")
|
|
239
|
+
return ProviderConfig(name=name, **spec)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _validate(cfg: Config) -> None:
|
|
243
|
+
roles = cfg.roles
|
|
244
|
+
names = set(cfg.providers)
|
|
245
|
+
refs = [roles.get("generator")] + list(roles.get("judges") or []) + [roles.get("logprob_judge")]
|
|
246
|
+
for r in refs:
|
|
247
|
+
if r is not None and r not in names:
|
|
248
|
+
raise ConfigError(f"roles reference unknown provider {r!r}; defined: {sorted(names)}")
|
|
249
|
+
lp = roles.get("logprob_judge")
|
|
250
|
+
if lp is not None:
|
|
251
|
+
p = cfg.providers[lp]
|
|
252
|
+
if p.type != "openai_chat" or not p.logprobs:
|
|
253
|
+
raise ConfigError(
|
|
254
|
+
f"logprob_judge {lp!r} must be an openai_chat provider with logprobs = true "
|
|
255
|
+
"(token log-probabilities are only read from Chat Completions responses)"
|
|
256
|
+
)
|
|
257
|
+
for wf in cfg.run["workflows"]:
|
|
258
|
+
if wf not in ("intent_aware", "dodgy_diff"):
|
|
259
|
+
raise ConfigError(f"unknown workflow {wf!r}")
|