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/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}")