jittest 0.2.1__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.
- jittest/__init__.py +13 -0
- jittest/_minirunner.py +121 -0
- jittest/assess.py +89 -0
- jittest/cli.py +232 -0
- jittest/config.py +127 -0
- jittest/diff.py +272 -0
- jittest/execute.py +234 -0
- jittest/github.py +129 -0
- jittest/ledger.py +213 -0
- jittest/llm.py +365 -0
- jittest/pipeline.py +341 -0
- jittest/prompts.py +161 -0
- jittest/report.py +130 -0
- jittest/risk.py +103 -0
- jittest/safety.py +89 -0
- jittest-0.2.1.dist-info/METADATA +248 -0
- jittest-0.2.1.dist-info/RECORD +20 -0
- jittest-0.2.1.dist-info/WHEEL +4 -0
- jittest-0.2.1.dist-info/entry_points.txt +2 -0
- jittest-0.2.1.dist-info/licenses/LICENSE +201 -0
jittest/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""jittest - just-in-time catching tests for pull requests.
|
|
2
|
+
|
|
3
|
+
The open-source reference implementation of Just-in-Time catching test
|
|
4
|
+
generation, the method published by Meta in 2026 (arXiv 2601.22832).
|
|
5
|
+
|
|
6
|
+
A catching test is not a test that passes. It is a test that PASSES on the
|
|
7
|
+
base commit and FAILS on the head commit, proving the change broke something.
|
|
8
|
+
That distinction is the whole product.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.1"
|
|
13
|
+
__all__ = ["__version__"]
|
jittest/_minirunner.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""A ~100 line test runner, used only when pytest is not importable.
|
|
2
|
+
|
|
3
|
+
Why this exists: jittest must be able to answer the question "does this test
|
|
4
|
+
pass here?" in environments where we cannot install anything - hardened CI
|
|
5
|
+
images, air-gapped runners, or a first-time user who has not created a virtual
|
|
6
|
+
environment yet. Refusing to run is a worse answer than running a smaller
|
|
7
|
+
runner.
|
|
8
|
+
|
|
9
|
+
Exit codes deliberately mirror pytest so the oracle needs no special cases:
|
|
10
|
+
|
|
11
|
+
0 all tests passed
|
|
12
|
+
1 at least one test failed
|
|
13
|
+
2 the file could not be imported or collected
|
|
14
|
+
5 no tests were collected
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import importlib.util
|
|
20
|
+
import inspect
|
|
21
|
+
import sys
|
|
22
|
+
import traceback
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_FAILED = 1
|
|
27
|
+
EXIT_COLLECTION_ERROR = 2
|
|
28
|
+
EXIT_NO_TESTS = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(path: Path):
|
|
32
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise ImportError(f"cannot load {path}")
|
|
35
|
+
module = importlib.util.module_from_spec(spec)
|
|
36
|
+
sys.modules[path.stem] = module
|
|
37
|
+
spec.loader.exec_module(module)
|
|
38
|
+
return module
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _call(fn) -> None:
|
|
42
|
+
if inspect.iscoroutinefunction(fn):
|
|
43
|
+
asyncio.run(fn())
|
|
44
|
+
else:
|
|
45
|
+
fn()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _collect(module) -> list[tuple[str, object]]:
|
|
49
|
+
tests: list[tuple[str, object]] = []
|
|
50
|
+
for name in dir(module):
|
|
51
|
+
obj = getattr(module, name)
|
|
52
|
+
if name.startswith("test_") and callable(obj):
|
|
53
|
+
tests.append((name, obj))
|
|
54
|
+
elif name.startswith("Test") and inspect.isclass(obj):
|
|
55
|
+
for method_name in dir(obj):
|
|
56
|
+
if method_name.startswith("test_"):
|
|
57
|
+
tests.append((f"{name}.{method_name}", (obj, method_name)))
|
|
58
|
+
return tests
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_file(path: Path) -> int:
|
|
62
|
+
try:
|
|
63
|
+
module = _load(path)
|
|
64
|
+
except BaseException:
|
|
65
|
+
print(f"COLLECTION ERROR in {path.name}", file=sys.stderr)
|
|
66
|
+
traceback.print_exc()
|
|
67
|
+
return EXIT_COLLECTION_ERROR
|
|
68
|
+
|
|
69
|
+
tests = _collect(module)
|
|
70
|
+
if not tests:
|
|
71
|
+
print("no tests collected", file=sys.stderr)
|
|
72
|
+
return EXIT_NO_TESTS
|
|
73
|
+
|
|
74
|
+
failures = 0
|
|
75
|
+
for label, target in tests:
|
|
76
|
+
try:
|
|
77
|
+
if isinstance(target, tuple):
|
|
78
|
+
cls, method_name = target
|
|
79
|
+
instance = cls()
|
|
80
|
+
if hasattr(instance, "setup_method"):
|
|
81
|
+
instance.setup_method(getattr(instance, method_name))
|
|
82
|
+
_call(getattr(instance, method_name))
|
|
83
|
+
if hasattr(instance, "teardown_method"):
|
|
84
|
+
instance.teardown_method(getattr(instance, method_name))
|
|
85
|
+
else:
|
|
86
|
+
sig = inspect.signature(target)
|
|
87
|
+
if sig.parameters:
|
|
88
|
+
print(f"SKIP {label}: fixtures are not supported by the "
|
|
89
|
+
"mini-runner; install pytest", file=sys.stderr)
|
|
90
|
+
continue
|
|
91
|
+
_call(target)
|
|
92
|
+
print(f"PASS {label}")
|
|
93
|
+
except BaseException as exc:
|
|
94
|
+
failures += 1
|
|
95
|
+
print(f"FAIL {label}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
96
|
+
traceback.print_exc()
|
|
97
|
+
|
|
98
|
+
return EXIT_FAILED if failures else EXIT_OK
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: list[str] | None = None) -> int:
|
|
102
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
103
|
+
paths = [Path(a) for a in args if not a.startswith("-")]
|
|
104
|
+
if not paths:
|
|
105
|
+
print("usage: python -m jittest._minirunner <test_file.py>", file=sys.stderr)
|
|
106
|
+
return EXIT_COLLECTION_ERROR
|
|
107
|
+
|
|
108
|
+
worst = EXIT_NO_TESTS
|
|
109
|
+
for path in paths:
|
|
110
|
+
code = run_file(path)
|
|
111
|
+
if code == EXIT_COLLECTION_ERROR:
|
|
112
|
+
return code
|
|
113
|
+
if code == EXIT_FAILED:
|
|
114
|
+
worst = EXIT_FAILED
|
|
115
|
+
elif code == EXIT_OK and worst != EXIT_FAILED:
|
|
116
|
+
worst = EXIT_OK
|
|
117
|
+
return worst
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
raise SystemExit(main())
|
jittest/assess.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The assessor layer.
|
|
2
|
+
|
|
3
|
+
Meta reports that assessor agents cut reviewer load by roughly 70% (arXiv
|
|
4
|
+
2601.22832). That number is the difference between a tool people keep switched
|
|
5
|
+
on and one they mute in week two: independent 2026 measurements put 15-30% of
|
|
6
|
+
AI review comments in the low-value or incorrect bucket.
|
|
7
|
+
|
|
8
|
+
The oracle decides whether a test is *true*. The assessor decides whether it is
|
|
9
|
+
*worth saying*. Only a confident real_regression is reported by default.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
__all__ = ["Assessment", "parse_assessment", "MIN_CONFIDENCE", "VALID_VERDICTS"]
|
|
16
|
+
|
|
17
|
+
MIN_CONFIDENCE = 0.70
|
|
18
|
+
VALID_VERDICTS = ("real_regression", "intended_change", "unclear")
|
|
19
|
+
VALID_SEVERITY = ("low", "medium", "high")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _coerce_confidence(raw: object) -> float:
|
|
23
|
+
try:
|
|
24
|
+
value = float(raw) # type: ignore[arg-type]
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return 0.0
|
|
27
|
+
if value > 1.0: # models like answering "85"
|
|
28
|
+
value = value / 100.0
|
|
29
|
+
return round(min(1.0, max(0.0, value)), 3)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Assessment:
|
|
34
|
+
verdict: str = "unclear"
|
|
35
|
+
confidence: float = 0.0
|
|
36
|
+
severity: str = "medium"
|
|
37
|
+
summary: str = ""
|
|
38
|
+
reviewer_question: str = ""
|
|
39
|
+
raw: str = ""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def should_report(self) -> bool:
|
|
43
|
+
return self.verdict == "real_regression" and self.confidence >= MIN_CONFIDENCE
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def badge(self) -> str:
|
|
47
|
+
return {
|
|
48
|
+
"real_regression": "likely regression",
|
|
49
|
+
"intended_change": "looks intended",
|
|
50
|
+
"unclear": "needs a human",
|
|
51
|
+
}.get(self.verdict, "needs a human")
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"verdict": self.verdict,
|
|
56
|
+
"confidence": self.confidence,
|
|
57
|
+
"severity": self.severity,
|
|
58
|
+
"summary": self.summary,
|
|
59
|
+
"reviewer_question": self.reviewer_question,
|
|
60
|
+
"should_report": self.should_report,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_assessment(payload: dict | None, raw: str = "") -> Assessment:
|
|
65
|
+
"""Never raise. A malformed assessor reply degrades to `unclear`, which is
|
|
66
|
+
not reported - failing closed is the whole point of this layer."""
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
return Assessment(summary="assessor returned unparseable output", raw=raw)
|
|
69
|
+
|
|
70
|
+
verdict = str(payload.get("verdict", "")).strip().lower()
|
|
71
|
+
if verdict not in VALID_VERDICTS:
|
|
72
|
+
verdict = "unclear"
|
|
73
|
+
|
|
74
|
+
severity = str(payload.get("severity", "medium")).strip().lower()
|
|
75
|
+
if severity not in VALID_SEVERITY:
|
|
76
|
+
severity = "medium"
|
|
77
|
+
|
|
78
|
+
confidence = _coerce_confidence(payload.get("confidence", 0.0))
|
|
79
|
+
if verdict == "unclear":
|
|
80
|
+
confidence = min(confidence, MIN_CONFIDENCE - 0.01)
|
|
81
|
+
|
|
82
|
+
return Assessment(
|
|
83
|
+
verdict=verdict,
|
|
84
|
+
confidence=confidence,
|
|
85
|
+
severity=severity,
|
|
86
|
+
summary=str(payload.get("summary", "")).strip()[:300],
|
|
87
|
+
reviewer_question=str(payload.get("reviewer_question", "")).strip()[:300],
|
|
88
|
+
raw=raw,
|
|
89
|
+
)
|
jittest/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Command line interface. argparse, because a CI tool has no business dragging
|
|
2
|
+
Typer, Click and Rich into somebody else's dependency graph.
|
|
3
|
+
|
|
4
|
+
jittest run --base main --head HEAD
|
|
5
|
+
jittest run --dry-run # whole pipeline, no model, no key, no cost
|
|
6
|
+
jittest doctor # can this environment run jittest?
|
|
7
|
+
jittest stats # what the ledger has learned
|
|
8
|
+
jittest outcome <hash> fixed_code
|
|
9
|
+
jittest export corpus.jsonl
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .config import load_config
|
|
22
|
+
from .execute import detect_runner
|
|
23
|
+
from .ledger import HUMAN_OUTCOMES, Ledger
|
|
24
|
+
from .llm import LLMError, build_llm
|
|
25
|
+
from .pipeline import run as run_pipeline
|
|
26
|
+
from .report import to_markdown, to_terminal
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _add_run_args(p: argparse.ArgumentParser) -> None:
|
|
30
|
+
p.add_argument("--repo", default=".", help="path to the git repository")
|
|
31
|
+
p.add_argument("--base", default=os.getenv("JITTEST_BASE", "origin/main"))
|
|
32
|
+
p.add_argument("--head", default=os.getenv("JITTEST_HEAD", "HEAD"))
|
|
33
|
+
p.add_argument("--model", default=None)
|
|
34
|
+
p.add_argument("--candidates", type=int, default=None,
|
|
35
|
+
help="candidate tests generated per target")
|
|
36
|
+
p.add_argument("--max-targets", type=int, default=None)
|
|
37
|
+
p.add_argument("--risk-threshold", type=float, default=None)
|
|
38
|
+
p.add_argument("--budget", type=float, default=None, help="hard USD cap")
|
|
39
|
+
p.add_argument("--timeout", type=int, default=None, help="per test run, seconds")
|
|
40
|
+
p.add_argument("--reruns", type=int, default=None,
|
|
41
|
+
help="flakiness reruns on head (default 2)")
|
|
42
|
+
p.add_argument("--latent", action="store_true",
|
|
43
|
+
help="also report faults that fail on base too")
|
|
44
|
+
p.add_argument("--dry-run", action="store_true",
|
|
45
|
+
help="run everything with a stub model: no API key, no cost")
|
|
46
|
+
p.add_argument("--comment", action="store_true", help="upsert a PR comment")
|
|
47
|
+
p.add_argument("--fail-on-regression", action="store_true",
|
|
48
|
+
help="exit 1 when a confident regression is found")
|
|
49
|
+
p.add_argument("--json", dest="as_json", action="store_true")
|
|
50
|
+
p.add_argument("--markdown", metavar="PATH", default=None,
|
|
51
|
+
help="also write the markdown report to this file")
|
|
52
|
+
p.add_argument("--quiet", action="store_true")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="jittest",
|
|
58
|
+
description="Generate tests that FAIL on your pull request and PASS on main.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version=f"jittest {__version__}")
|
|
61
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
62
|
+
|
|
63
|
+
_add_run_args(sub.add_parser("run", help="analyse a diff"))
|
|
64
|
+
|
|
65
|
+
st = sub.add_parser("stats", help="summarise the local ledger")
|
|
66
|
+
st.add_argument("--repo", default=".")
|
|
67
|
+
st.add_argument("--json", dest="as_json", action="store_true")
|
|
68
|
+
|
|
69
|
+
ex = sub.add_parser("export", help="export the corpus as JSONL")
|
|
70
|
+
ex.add_argument("out")
|
|
71
|
+
ex.add_argument("--repo", default=".")
|
|
72
|
+
ex.add_argument("--with-source", action="store_true",
|
|
73
|
+
help="include repo names and test code (off by default)")
|
|
74
|
+
|
|
75
|
+
oc = sub.add_parser("outcome", help="label what a human did with a finding")
|
|
76
|
+
oc.add_argument("test_hash")
|
|
77
|
+
oc.add_argument("outcome", choices=list(HUMAN_OUTCOMES))
|
|
78
|
+
oc.add_argument("--note", default="")
|
|
79
|
+
oc.add_argument("--repo", default=".")
|
|
80
|
+
|
|
81
|
+
dr = sub.add_parser("doctor", help="check that this environment can run jittest")
|
|
82
|
+
dr.add_argument("--repo", default=".")
|
|
83
|
+
|
|
84
|
+
sub.add_parser("version", help="print the version")
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
89
|
+
repo = Path(args.repo).resolve()
|
|
90
|
+
cfg = load_config(repo, overrides={
|
|
91
|
+
"model": args.model,
|
|
92
|
+
"candidates_per_target": args.candidates,
|
|
93
|
+
"max_targets": args.max_targets,
|
|
94
|
+
"risk_threshold": args.risk_threshold,
|
|
95
|
+
"budget_usd": args.budget,
|
|
96
|
+
"timeout_s": args.timeout,
|
|
97
|
+
"reruns": args.reruns,
|
|
98
|
+
"latent_mode": True if args.latent else None,
|
|
99
|
+
"fail_on_regression": True if args.fail_on_regression else None,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
from .github import pr_context, upsert_pr_comment
|
|
103
|
+
pr_title, pr_body = pr_context()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
llm = build_llm(cfg.model, dry_run=args.dry_run, budget_usd=cfg.budget_usd,
|
|
107
|
+
temperature=cfg.temperature, cache_path=repo / cfg.cache_path)
|
|
108
|
+
except LLMError as exc:
|
|
109
|
+
print(f"jittest: {exc}", file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
emit = (lambda m: None) if args.quiet else (lambda m: print(f" {m}", file=sys.stderr))
|
|
113
|
+
report = run_pipeline(repo, args.base, args.head, cfg, llm,
|
|
114
|
+
pr_title=pr_title, pr_body=pr_body,
|
|
115
|
+
pr_ref=os.getenv("JITTEST_PR_NUMBER", ""),
|
|
116
|
+
on_event=emit)
|
|
117
|
+
|
|
118
|
+
markdown = to_markdown(report)
|
|
119
|
+
if args.as_json:
|
|
120
|
+
print(json.dumps(report.as_dict(), indent=2))
|
|
121
|
+
else:
|
|
122
|
+
print(to_terminal(report))
|
|
123
|
+
|
|
124
|
+
if args.markdown:
|
|
125
|
+
Path(args.markdown).write_text(markdown or "", encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
if args.comment:
|
|
128
|
+
print(f" github: {upsert_pr_comment(markdown)}", file=sys.stderr)
|
|
129
|
+
|
|
130
|
+
if os.getenv("GITHUB_OUTPUT"):
|
|
131
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
|
132
|
+
fh.write(f"regressions={'true' if report.has_regression else 'false'}\n")
|
|
133
|
+
fh.write(f"findings={len(report.findings)}\n")
|
|
134
|
+
fh.write(f"cost_usd={report.cost_usd:.4f}\n")
|
|
135
|
+
|
|
136
|
+
if (args.fail_on_regression or cfg.fail_on_regression) and report.has_regression:
|
|
137
|
+
return 1
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
142
|
+
cfg = load_config(Path(args.repo))
|
|
143
|
+
with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
|
|
144
|
+
stats = ledger.stats()
|
|
145
|
+
if args.as_json:
|
|
146
|
+
print(json.dumps(stats, indent=2))
|
|
147
|
+
return 0
|
|
148
|
+
print("jittest ledger")
|
|
149
|
+
for key, value in stats.items():
|
|
150
|
+
print(f" {key:22} {value}")
|
|
151
|
+
if not stats["labelled"]:
|
|
152
|
+
print("\n No human outcomes recorded yet, so the precision numbers above")
|
|
153
|
+
print(" are unknown rather than good. Label findings with `jittest outcome`.")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
158
|
+
cfg = load_config(Path(args.repo))
|
|
159
|
+
with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
|
|
160
|
+
n = ledger.export_jsonl(args.out, anonymise=not args.with_source)
|
|
161
|
+
print(f"wrote {n} record(s) to {args.out} "
|
|
162
|
+
f"({'with source' if args.with_source else 'anonymised'})")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cmd_outcome(args: argparse.Namespace) -> int:
|
|
167
|
+
cfg = load_config(Path(args.repo))
|
|
168
|
+
with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
|
|
169
|
+
n = ledger.mark_outcome_by_hash(args.test_hash, args.outcome, args.note)
|
|
170
|
+
print(f"labelled {n} record(s) as {args.outcome}")
|
|
171
|
+
return 0 if n else 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
175
|
+
repo = Path(args.repo).resolve()
|
|
176
|
+
cfg = load_config(repo)
|
|
177
|
+
ok = True
|
|
178
|
+
|
|
179
|
+
def check(label: str, passed: bool, detail: str = "") -> None:
|
|
180
|
+
nonlocal ok
|
|
181
|
+
ok = ok and passed
|
|
182
|
+
print(f" [{'ok ' if passed else 'FAIL'}] {label}"
|
|
183
|
+
f"{(' - ' + detail) if detail else ''}")
|
|
184
|
+
|
|
185
|
+
print(f"jittest {__version__} doctor")
|
|
186
|
+
check("python >= 3.11", sys.version_info >= (3, 11), sys.version.split()[0])
|
|
187
|
+
|
|
188
|
+
git = subprocess.run(["git", "--version"], capture_output=True, text=True)
|
|
189
|
+
check("git available", git.returncode == 0, git.stdout.strip())
|
|
190
|
+
|
|
191
|
+
inside = subprocess.run(
|
|
192
|
+
["git", "-C", str(repo), "rev-parse", "--is-inside-work-tree"],
|
|
193
|
+
capture_output=True, text=True)
|
|
194
|
+
check("inside a git repository", inside.stdout.strip() == "true", str(repo))
|
|
195
|
+
|
|
196
|
+
runner = detect_runner()
|
|
197
|
+
using_pytest = "pytest" in " ".join(runner)
|
|
198
|
+
print(f" [ok ] test runner: "
|
|
199
|
+
f"{'pytest' if using_pytest else 'built-in mini-runner'}")
|
|
200
|
+
if not using_pytest:
|
|
201
|
+
print(" pytest is not importable here, so the oracle will use the")
|
|
202
|
+
print(" stdlib fallback, which does not support fixtures.")
|
|
203
|
+
|
|
204
|
+
has_key = any(os.getenv(k) for k in
|
|
205
|
+
("JITTEST_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"))
|
|
206
|
+
print(f" [{'ok ' if has_key else 'warn'}] model API key present"
|
|
207
|
+
f"{'' if has_key else ' - only --dry-run will work'}")
|
|
208
|
+
print(f" [ok ] model {cfg.model}, budget ${cfg.budget_usd:.2f}, "
|
|
209
|
+
f"max targets {cfg.max_targets}")
|
|
210
|
+
print(f" [ok ] ledger {repo / cfg.ledger_path}")
|
|
211
|
+
print(f" [ok ] {len(cfg.ignore)} ignore pattern(s)")
|
|
212
|
+
return 0 if ok else 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: list[str] | None = None) -> int:
|
|
216
|
+
args = build_parser().parse_args(argv)
|
|
217
|
+
if args.command == "run":
|
|
218
|
+
return _cmd_run(args)
|
|
219
|
+
if args.command == "stats":
|
|
220
|
+
return _cmd_stats(args)
|
|
221
|
+
if args.command == "export":
|
|
222
|
+
return _cmd_export(args)
|
|
223
|
+
if args.command == "outcome":
|
|
224
|
+
return _cmd_outcome(args)
|
|
225
|
+
if args.command == "doctor":
|
|
226
|
+
return _cmd_doctor(args)
|
|
227
|
+
print(__version__)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
raise SystemExit(main())
|
jittest/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Configuration with a boring, predictable precedence chain:
|
|
2
|
+
|
|
3
|
+
dataclass defaults -> .jittest.toml -> [tool.jittest] in pyproject.toml
|
|
4
|
+
-> JITTEST_* environment variables -> command line flags
|
|
5
|
+
|
|
6
|
+
Read with stdlib `tomllib`. No PyYAML, no pydantic.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from fnmatch import fnmatch
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
__all__ = ["Config", "load_config", "DEFAULT_IGNORES"]
|
|
17
|
+
|
|
18
|
+
# Generated, vendored or throwaway code. Testing it wastes money and reviewer
|
|
19
|
+
# patience in equal measure.
|
|
20
|
+
DEFAULT_IGNORES = [
|
|
21
|
+
"*/migrations/*", "*/vendor/*", "*/vendored/*", "*/node_modules/*",
|
|
22
|
+
"*/.venv/*", "*/venv/*", "*/build/*", "*/dist/*", "*/__pycache__/*",
|
|
23
|
+
"*_pb2.py", "*_pb2_grpc.py", "*.pyi", "setup.py", "conftest.py",
|
|
24
|
+
"*/docs/*", "*/examples/*",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_ENV = {
|
|
28
|
+
"model": ("JITTEST_MODEL", str),
|
|
29
|
+
"budget_usd": ("JITTEST_BUDGET_USD", float),
|
|
30
|
+
"max_targets": ("JITTEST_MAX_TARGETS", int),
|
|
31
|
+
"candidates_per_target": ("JITTEST_CANDIDATES", int),
|
|
32
|
+
"risk_threshold": ("JITTEST_RISK_THRESHOLD", float),
|
|
33
|
+
"timeout_s": ("JITTEST_TIMEOUT", int),
|
|
34
|
+
"reruns": ("JITTEST_RERUNS", int),
|
|
35
|
+
"min_confidence": ("JITTEST_MIN_CONFIDENCE", float),
|
|
36
|
+
"ledger_path": ("JITTEST_LEDGER", str),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Config:
|
|
42
|
+
model: str = "anthropic/claude-sonnet-4-5"
|
|
43
|
+
budget_usd: float = 1.00
|
|
44
|
+
max_targets: int = 5
|
|
45
|
+
candidates_per_target: int = 4
|
|
46
|
+
risk_threshold: float = 0.35
|
|
47
|
+
timeout_s: int = 120
|
|
48
|
+
reruns: int = 2
|
|
49
|
+
temperature: float = 0.8
|
|
50
|
+
min_confidence: float = 0.70
|
|
51
|
+
repair_attempts: int = 1
|
|
52
|
+
latent_mode: bool = False
|
|
53
|
+
fail_on_regression: bool = False
|
|
54
|
+
ledger_path: str = ".jittest/ledger.db"
|
|
55
|
+
cache_path: str = ".jittest/cache.db"
|
|
56
|
+
ignore: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORES))
|
|
57
|
+
|
|
58
|
+
def is_ignored(self, path: str) -> bool:
|
|
59
|
+
p = path.replace("\\", "/")
|
|
60
|
+
name = p.split("/")[-1]
|
|
61
|
+
for pattern in self.ignore:
|
|
62
|
+
if fnmatch(p, pattern) or fnmatch(name, pattern) or fnmatch("/" + p, pattern):
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def as_dict(self) -> dict:
|
|
67
|
+
return asdict(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _from_toml(repo: Path) -> dict:
|
|
71
|
+
for filename, section in ((".jittest.toml", None), ("pyproject.toml", "jittest")):
|
|
72
|
+
path = repo / filename
|
|
73
|
+
if not path.exists():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
with path.open("rb") as fh:
|
|
77
|
+
data = tomllib.load(fh)
|
|
78
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
79
|
+
continue
|
|
80
|
+
if section:
|
|
81
|
+
data = data.get("tool", {}).get(section, {})
|
|
82
|
+
elif "jittest" in data and isinstance(data["jittest"], dict):
|
|
83
|
+
data = data["jittest"]
|
|
84
|
+
if data:
|
|
85
|
+
return dict(data)
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _from_env() -> dict:
|
|
90
|
+
out: dict = {}
|
|
91
|
+
for key, (env_name, caster) in _ENV.items():
|
|
92
|
+
raw = os.getenv(env_name)
|
|
93
|
+
if raw is None or raw == "":
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
out[key] = caster(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config(repo: Path | str = ".", overrides: dict | None = None) -> Config:
|
|
103
|
+
repo = Path(repo)
|
|
104
|
+
values: dict = {}
|
|
105
|
+
values.update(_from_toml(repo))
|
|
106
|
+
values.update(_from_env())
|
|
107
|
+
for key, value in (overrides or {}).items():
|
|
108
|
+
if value is not None:
|
|
109
|
+
values[key] = value
|
|
110
|
+
|
|
111
|
+
valid = {f for f in Config.__dataclass_fields__}
|
|
112
|
+
extra_ignores = values.pop("ignore", None)
|
|
113
|
+
cfg = Config(**{k: v for k, v in values.items() if k in valid})
|
|
114
|
+
|
|
115
|
+
if extra_ignores:
|
|
116
|
+
cfg.ignore = list(DEFAULT_IGNORES) + list(extra_ignores)
|
|
117
|
+
|
|
118
|
+
ignore_file = repo / ".jittestignore"
|
|
119
|
+
if ignore_file.exists():
|
|
120
|
+
try:
|
|
121
|
+
lines = ignore_file.read_text(encoding="utf-8").splitlines()
|
|
122
|
+
except OSError:
|
|
123
|
+
lines = []
|
|
124
|
+
cfg.ignore += [ln.strip() for ln in lines
|
|
125
|
+
if ln.strip() and not ln.strip().startswith("#")]
|
|
126
|
+
|
|
127
|
+
return cfg
|