Files
Marius CiepluchandClaude Fable 5.1 23aeeada10 feat(benchmark): combine --keep-verdicts drops hunter suspicions the classifier rejected
Opt-in filter on the classifier verdict for hunter-sourced defects; the default
keeps everything. Each combined record notes the kept verdicts and the counts.
Live check on the pilot: (a)+(b) filtered to 'vulnerable' gives recall 5/12 and
54.4 false alarms per 100 ordinary pushes, matching the scratch numbers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 08:21:59 +02:00

627 lines
29 KiB
Python

"""Offline coverage of the frozen benchmark hunter arm and replay join."""
import hashlib
import importlib
import json
import socket
import sys
from dataclasses import replace
from pathlib import Path
import pytest
from openultrasast import tool_hunter
from openultrasast.complexity.map import Hotspot
from openultrasast.config import load_config
from openultrasast.learn import program
from openultrasast.learn.program import ANSWER_FORMAT, PARSE_FAILED, Caller, Decision, aggregate
from openultrasast.model.endpoint import resolve_models
from openultrasast.model.taxonomy import FAMILY_IDS, load_families
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
hunter_arm = importlib.import_module("benchmarks.valid.hunter_arm")
catalog = importlib.import_module("benchmarks.valid.catalog")
score = importlib.import_module("benchmarks.unseen.score")
@pytest.fixture(autouse=True)
def offline(monkeypatch):
def forbidden(*args, **kwargs):
pytest.fail("network access attempted")
monkeypatch.setattr(socket.socket, "connect", forbidden)
monkeypatch.setattr(socket, "create_connection", forbidden)
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(tool_hunter, "resolve_hunter_client", lambda: tool_hunter.scripted_hunter_client())
monkeypatch.setattr(hunter_arm, "classify", lambda *args, **kwargs: decision("injection"))
@pytest.fixture
def fixture(tmp_path):
root = tmp_path / "repo"
root.mkdir()
git = catalog.local_git(root)
git.run("init", "-q")
git.run("config", "user.name", "Fixture")
git.run("config", "user.email", "[email protected]")
heads = []
for text in ('def search(q):\n return db.execute("select " + q)\n', "# Later checkout\n"):
(root / "app.py").write_text(text)
git.run("add", "app.py")
git.run("-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture")
heads.append(git.run("rev-parse", "HEAD").strip())
change = dict(
id="fixture-positive",
repo="fixture/app",
repository_url="https://example.invalid/app",
kind="vulnerability",
base=heads[1],
head=heads[0],
family="injection",
cwe="CWE-89",
files=[{"path": "app.py", "head_lines": [[2, 2]]}],
function="search",
function_lines=[1, 2],
)
manifest = tmp_path / "frozen.json"
manifest.write_text(
json.dumps({"changes": [change], "sha256": hashlib.sha256(json.dumps([change], sort_keys=True).encode()).hexdigest()})
)
repos = tmp_path / "repos.json"
repos.write_text(json.dumps({"fixture/app": str(root)}))
return git, change, manifest, repos, tmp_path / "out"
def flagged_client():
return tool_hunter.ScriptedChatClient(
[
tool_hunter.ChatResponse(tool_calls=(tool_hunter.ToolCall("read", "read_file", {"path": "app.py"}),)),
tool_hunter.ChatResponse(
content=json.dumps(
[
{
"path": "app.py",
"line": 2,
"function_name": "search",
"title": "SQL injection (CWE-89)",
"rationale": "Input reaches a SQL interpreter",
"family": "sql-injection",
}
]
)
),
]
)
def record_path(out, item_id):
return out / (hashlib.sha256(item_id.encode()).hexdigest() + ".json")
def decision(family):
return Decision("vulnerable", family, 0.9, {"vulnerable": 1}, "Protected operation has no guard at line 2", (2,), 0, ())
def raw_finding(*, line=2):
(finding,) = tool_hunter._findings_from_content(
Path("/tmp"),
[],
json.dumps(
[
{
"path": "app.py",
"line": line,
"title": "Read-only mode bypass",
"rationale": "A protected write is allowed",
"family": "sql-parser",
}
]
),
)
return finding
def test_positive_is_scoreable_and_checkout_is_removed(fixture, monkeypatch, capsys):
git, change, manifest, repos, out = fixture
client = flagged_client()
monkeypatch.setattr(tool_hunter, "resolve_hunter_client", lambda: client)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out), "--max-steps", "2"]) == 0
(row,) = score.load_records(out)
(defect,) = row["push"]["admission"]["defects"]
assert score.catches(row) == ["alerts"]
assert defect["family"] == "injection" and defect["cwe"] == "CWE-77"
assert defect["locations"] == [["app.py", 2]] and defect["function"] == "search"
assert defect["source"] == "hunter"
assert row["status"] == "hunter_covered" and row["done"]
assert row["instrument"]["hook_bytes_read"] == len(git.blob(change["head"], "app.py").encode())
assert score.coverage(row) != "instrument_failure"
assert row["input_digest"] == json.loads((out / "inputs.json").read_text())[0]["input_digest"]
assert client.calls[0]["model"] == resolve_models(load_config(dotenv=False))[0]
assert "fixture-positive app.py:" in capsys.readouterr().out
assert git.run("worktree", "list", "--porcelain").count("worktree ") == 1
assert (git.path / "app.py").read_text() == "# Later checkout\n"
@pytest.mark.parametrize("failure", ["empty", "unreadable", "hunter", "missing", "bad_head", "bad_base"])
def test_failed_input_never_emits_findings(fixture, monkeypatch, capsys, failure):
git, change, manifest, repos, out = fixture
if failure in {"empty", "missing"}:
change["files"] = [{"path": "empty.py" if failure == "empty" else "absent.py"}]
if failure == "empty":
(git.path / "empty.py").write_bytes(b"")
git.run("add", "empty.py")
git.run("-c", "core.hooksPath=/dev/null", "commit", "-qm", "empty")
change["head"] = git.run("rev-parse", "HEAD").strip()
if failure in {"bad_head", "bad_base"}:
change[failure.removeprefix("bad_")] = "f" * 40
monkeypatch.setattr(hunter_arm, "load_slice", lambda *args: [change])
if failure == "unreadable":
original = Path.read_bytes
def unreadable(path):
if path.name == "app.py":
raise PermissionError("fixture")
return original(path)
monkeypatch.setattr(Path, "read_bytes", unreadable)
def hunter(*args, **kwargs):
if failure == "hunter":
raise RuntimeError("private model key must not be logged")
pytest.fail("hunter must not run on unread input")
monkeypatch.setattr(tool_hunter, "run_tool_hunter", hunter)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) != 0
(row,) = score.load_records(out)
assert row["status"] == "instrument_failure"
assert list(score.findings(row)) == [] and row["instrument"]["failure"]
assert "instrument_failure" in capsys.readouterr().err
assert "private model key" not in record_path(out, change["id"]).read_text()
assert git.run("worktree", "list", "--porcelain").count("worktree ") == 1
def test_missing_file_skipped_and_hotspots_are_unlabelled(fixture, monkeypatch):
_, change, manifest, repos, out = fixture
change["files"].append({"path": "removed.py", "head_lines": []})
monkeypatch.setattr(hunter_arm, "load_slice", lambda *args: [change])
def hunter(root, hotspots, **kwargs):
(hotspot,) = hotspots
assert isinstance(hotspot, Hotspot) and hotspot.path == "app.py"
assert hotspot.function_name is None and "injection" not in hotspot.rationale
assert kwargs["max_steps"] == tool_hunter.DEFAULT_MAX_STEPS
return []
monkeypatch.setattr(tool_hunter, "run_tool_hunter", hunter)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
(row,) = score.load_records(out)
assert row["skipped"] == [{"path": "removed.py", "reason": "missing_at_head"}]
@pytest.mark.parametrize("with_inputs", [True, False])
def test_resume_skips_done_without_client_or_checkout(fixture, monkeypatch, with_inputs):
_, change, manifest, repos, out = fixture
out.mkdir()
existing = {"id": change["id"], "done": True, "input_digest": "existing-digest", "status": "hunter_covered"}
record_path(out, change["id"]).write_text(json.dumps(existing))
if with_inputs:
(out / "inputs.json").write_text(json.dumps([{"item_id": change["id"], "change": change, "input_digest": "existing-digest"}]))
def forbidden(*args, **kwargs):
pytest.fail("completed change was executed again")
monkeypatch.setattr(tool_hunter, "resolve_hunter_client", forbidden)
monkeypatch.setattr(hunter_arm, "local_git", forbidden)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
assert json.loads(record_path(out, change["id"]).read_text()) == existing
assert score.load_records(out)[0]["input_digest"] == "existing-digest"
def test_no_client_fails_clearly(fixture, monkeypatch, capsys):
_, _, manifest, repos, out = fixture
monkeypatch.setattr(tool_hunter, "resolve_hunter_client", lambda: None)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) != 0
assert "no hunter client" in capsys.readouterr().err.lower()
assert not list(out.glob("*.json"))
def test_classification_does_not_borrow_change_label(fixture):
_, change, _, _, _ = fixture
(finding,) = tool_hunter._findings_from_content(
Path("/tmp"),
[],
json.dumps(
[
{
"path": "app.py",
"line": 2,
"title": "Path traversal",
"family": "path",
}
]
),
)
defect = hunter_arm.finding_defect(finding)
assert defect["family"] == "path" and defect["cwe"] == "CWE-22"
row = {**change, "push": {"admission": {"defects": [defect]}}}
assert not score.catches(row)
unknown = hunter_arm.finding_defect(replace(finding, tags=[], title="Unclassified concern", rationale=""))
assert unknown["family"] == "unknown" and unknown["cwe"] is None
def test_free_text_family_is_classified_and_scoreable(fixture, monkeypatch):
_, change, manifest, repos, out = fixture
change["family"] = "access_control"
change["cwe"] = "CWE-284"
monkeypatch.setattr(hunter_arm, "load_slice", lambda *args: [change])
finding = raw_finding()
monkeypatch.setattr(tool_hunter, "run_tool_hunter", lambda *args, **kwargs: [finding, replace(finding, finding_id="second")])
client = tool_hunter.scripted_hunter_client()
monkeypatch.setattr(tool_hunter, "resolve_hunter_client", lambda: client)
prompts = []
def classify(prompt, caller, k, *, families):
assert isinstance(caller, Caller) and caller.client is client
assert caller.model == resolve_models(load_config(dotenv=False))[0]
assert k == 1 and tuple(families) == tuple(f for f in FAMILY_IDS if f != "unknown")
assert prompt.example_ids == prompt.demo_ids == ()
assert prompt.valid_lines == frozenset({1, 2})
system, user = prompt.messages
assert system["role"] == "system" and user["role"] == "user"
content = user["content"]
for family in families:
assert load_families().by_id(family).description in content
assert "sql-parser" in content and finding.title in content and finding.rationale in content
assert "app.py:2" in content and "1 def search(q):" in content and "2 return db.execute" in content
assert content.endswith(ANSWER_FORMAT)
assert change["id"] not in content and change["cwe"] not in content
prompts.append(prompt)
return decision("access_control")
monkeypatch.setattr(hunter_arm, "classify", classify)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
(row,) = score.load_records(out)
assert score.catches(row) == ["alerts"]
assert len(prompts) == 2
assert row["classifier"] == {"calls": 2, "parse_failed": 0, "families": {"access_control": 2}}
for defect in row["push"]["admission"]["defects"]:
assert defect["family"] == "access_control" and defect["cwe"] == "CWE-284"
assert defect["hunter_family"] == "sql-parser" and defect["title"] == finding.title
assert defect["rationale"] == finding.rationale
assert defect["classifier"] == {
"family": "access_control",
"verdict": "vulnerable",
"confidence": 0.9,
"rationale": decision("access_control").rationale,
"cited_lines": [2],
"parse_failed": 0,
}
@pytest.mark.parametrize("result", [decision("none"), aggregate([PARSE_FAILED])])
def test_classifier_abstention_is_unknown_and_done(fixture, monkeypatch, result):
_, _, manifest, repos, out = fixture
monkeypatch.setattr(tool_hunter, "run_tool_hunter", lambda *args, **kwargs: [raw_finding()])
monkeypatch.setattr(hunter_arm, "classify", lambda *args, **kwargs: result)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
(row,) = score.load_records(out)
(defect,) = row["push"]["admission"]["defects"]
assert defect["family"] == "unknown" and defect["cwe"] is None
assert defect["classifier"]["parse_failed"] == result.parse_failed
assert row["classifier"] == {"calls": 1, "parse_failed": result.parse_failed, "families": {"unknown": 1}}
assert row["done"] and row["status"] == "hunter_covered"
assert score.coverage(row) != "instrument_failure" and score.catches(row) == []
def test_classifier_exception_is_a_scoreable_miss(fixture, monkeypatch):
_, _, manifest, repos, out = fixture
monkeypatch.setattr(tool_hunter, "run_tool_hunter", lambda *args, **kwargs: [raw_finding()])
def classify(*args, **kwargs):
raise RuntimeError("private model key must not be logged")
monkeypatch.setattr(hunter_arm, "classify", classify)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
(row,) = score.load_records(out)
(defect,) = row["push"]["admission"]["defects"]
assert defect["family"] == "unknown" and defect["classifier"]["error"] == "RuntimeError"
assert row["done"] and row["status"] == "hunter_covered"
assert score.coverage(row) != "instrument_failure" and score.catches(row) == []
assert row["classifier"] == {"calls": 1, "parse_failed": 0, "families": {"unknown": 1}}
assert "private model key" not in json.dumps(row)
def test_no_classify_retains_heuristic_and_audit(fixture, monkeypatch):
_, _, manifest, repos, out = fixture
finding = replace(raw_finding(), title="SQL injection (CWE-89)")
monkeypatch.setattr(tool_hunter, "run_tool_hunter", lambda *args, **kwargs: [finding])
def forbidden(*args, **kwargs):
pytest.fail("classifier called with --no-classify")
monkeypatch.setattr(hunter_arm, "classify", forbidden)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out), "--no-classify"]) == 0
(row,) = score.load_records(out)
(defect,) = row["push"]["admission"]["defects"]
assert defect["family"] == "injection" and defect["cwe"] == "CWE-89"
assert score.catches(row) == ["alerts"]
assert defect["hunter_family"] == "sql-parser" and defect["title"] == finding.title
assert defect["rationale"] == finding.rationale and defect["classifier"] is None
assert row["classifier"] == {"calls": 0, "parse_failed": 0, "families": {"injection": 1}}
@pytest.mark.parametrize("line, expected", [(1, range(1, 22)), (30, range(10, 51)), (60, range(40, 61))])
def test_classify_family_excerpt_is_numbered_and_clamped(tmp_path, monkeypatch, line, expected, capsys):
text = "".join(f"source_{n}\n" for n in range(1, 61))
(tmp_path / "app.py").write_text(text)
def classify(prompt, caller, k, *, families):
assert prompt.valid_lines == frozenset(expected)
content = prompt.messages[1]["content"]
for n in expected:
assert f"{n} source_{n}\n" in content
return decision("access_control")
monkeypatch.setattr(hunter_arm, "classify", classify)
result = hunter_arm.classify_family(raw_finding(line=line), tmp_path, caller=Caller(None, "offline"), families=("access_control",))
assert result["family"] == "access_control"
assert f"{len(text.encode())} bytes read" in capsys.readouterr().out
@pytest.mark.parametrize("failure", ["empty", "unreadable", "out_of_range"])
def test_missing_excerpt_is_unknown_without_classifying(fixture, monkeypatch, failure):
_, _, manifest, repos, out = fixture
def hunter(root, *args, **kwargs):
if failure == "empty":
(root / "app.py").write_text("")
if failure == "unreadable":
(root / "app.py").unlink()
return [raw_finding(line=200 if failure == "out_of_range" else 2)]
def forbidden(*args, **kwargs):
pytest.fail("classifier called without a readable excerpt")
monkeypatch.setattr(tool_hunter, "run_tool_hunter", hunter)
monkeypatch.setattr(hunter_arm, "classify", forbidden)
assert hunter_arm.main(["run", str(manifest), "--repos", str(repos), "--out", str(out)]) == 0
(row,) = score.load_records(out)
(defect,) = row["push"]["admission"]["defects"]
assert defect["family"] == "unknown"
assert defect["classifier"]["error"] == ("input_error:FileNotFoundError" if failure == "unreadable" else "empty_excerpt")
assert row["classifier"] == {"calls": 0, "parse_failed": 0, "families": {"unknown": 1}}
assert row["done"] and row["status"] == "hunter_covered" and score.catches(row) == []
@pytest.mark.parametrize("failure", ["off_list", "bad_citation", "retry_success"])
def test_classify_family_reuses_parser_and_retry(tmp_path, monkeypatch, failure):
(tmp_path / "app.py").write_text("def search(q):\n write(q)\n")
good = {
"verdict": "vulnerable",
"family": "access_control",
"confidence": 0.9,
"rationale": "Missing guard at line 2",
"cited_lines": [2],
}
bad = {**good, "family": "sql-parser"} if failure == "off_list" else {**good, "cited_lines": [1000]}
contents = [bad] if failure == "off_list" else [bad, good if failure == "retry_success" else bad]
client = tool_hunter.ScriptedChatClient([tool_hunter.ChatResponse(content=json.dumps(answer)) for answer in contents])
monkeypatch.setattr(hunter_arm, "classify", program.classify)
result = hunter_arm.classify_family(
raw_finding(), tmp_path, caller=Caller(client, "offline"), families=tuple(f for f in FAMILY_IDS if f != "unknown")
)
assert len(client.calls) == len(contents)
assert result["family"] == ("access_control" if failure == "retry_success" else "unknown")
assert result["parse_failed"] == (1 if failure == "bad_citation" else 0)
def write_arm(out, entries, records):
out.mkdir()
(out / "inputs.json").write_text(json.dumps(entries))
for item_id, record in records.items():
record_path(out, item_id).write_text(json.dumps(record))
@pytest.fixture
def combine_arms(tmp_path):
change = {"id": "combine-fixture", "kind": "ordinary"}
entry = {"item_id": change["id"], "change": change, "input_digest": "fixture-digest"}
replay_defect = {"defect_id": "replay", "source": "engine", "family": "injection", "locations": [["app.py", 1]]}
hunter_defects = [
{
"defect_id": verdict,
"source": "hunter",
"family": "injection",
"locations": [["app.py", line]],
"classifier": {"verdict": verdict},
}
for line, verdict in enumerate(("vulnerable", "not_vulnerable", "unsure"), start=2)
]
other_defect = {"defect_id": "other", "source": "engine", "family": "path", "locations": [["app.py", 5]]}
a = {
"status": "quick_only",
"input_digest": entry["input_digest"],
"push": {"admission": {"defects": [replay_defect], "dispositions": [{"sentinel": "keep"}]}},
"combine": {"sentinel": "keep"},
}
b = {
"status": "hunter_covered",
"input_digest": entry["input_digest"],
"push": {"admission": {"defects": [*hunter_defects, other_defect]}},
"combine": {"sentinel": "keep"},
}
replay_out, hunter_out, combined = tmp_path / "a", tmp_path / "b", tmp_path / "combined.json"
write_arm(replay_out, [entry], {change["id"]: a})
write_arm(hunter_out, [entry], {change["id"]: b})
return replay_out, hunter_out, combined, entry, a, b
def test_combine_default_keeps_all_verdicts(combine_arms):
replay_out, hunter_out, combined, _, a, b = combine_arms
assert hunter_arm.main(["combine", str(replay_out), str(hunter_out), "--out", str(combined)]) == 0
(row,) = score.load_records(combined)
assert row["push"]["admission"]["defects"] == a["push"]["admission"]["defects"] + b["push"]["admission"]["defects"]
assert row["combine"] == {"sentinel": "keep", "keep_verdicts": None, "hunter_defects_total": 3, "hunter_defects_kept": 3}
@pytest.mark.parametrize("with_replay", [True, False])
def test_combine_keeps_only_vulnerable_hunter_defects(combine_arms, with_replay):
replay_out, hunter_out, combined, entry, a, b = combine_arms
if not with_replay:
record_path(replay_out, entry["item_id"]).unlink()
assert hunter_arm.main(["combine", str(replay_out), str(hunter_out), "--out", str(combined), "--keep-verdicts", "vulnerable"]) == 0
(row,) = score.load_records(combined)
expected = a["push"]["admission"]["defects"] if with_replay else []
assert row["push"]["admission"]["defects"] == [
*expected,
b["push"]["admission"]["defects"][0],
b["push"]["admission"]["defects"][-1],
]
if with_replay:
assert row["push"]["admission"]["dispositions"] == a["push"]["admission"]["dispositions"]
assert row["combine"] == {"sentinel": "keep", "keep_verdicts": ["vulnerable"], "hunter_defects_total": 3, "hunter_defects_kept": 1}
@pytest.mark.parametrize("classification", [{}, {"classifier": None}, {"classifier": {"error": "empty_excerpt"}}])
@pytest.mark.parametrize("keep_verdicts", ["vulnerable", "unsure,not_vulnerable"])
def test_combine_missing_classifier_counts_as_unsure(combine_arms, classification, keep_verdicts):
replay_out, hunter_out, combined, entry, a, b = combine_arms
defect = {"defect_id": "unclassified", "source": "hunter", "family": "path", "locations": [["app.py", 6]], **classification}
b["push"]["admission"]["defects"] = [defect]
record_path(hunter_out, entry["item_id"]).write_text(json.dumps(b))
assert hunter_arm.main(["combine", str(replay_out), str(hunter_out), "--out", str(combined), "--keep-verdicts", keep_verdicts]) == 0
(row,) = score.load_records(combined)
kept = int("unsure" in keep_verdicts.split(","))
assert row["push"]["admission"]["defects"] == [*a["push"]["admission"]["defects"], *([defect] if kept else [])]
assert row["combine"] == {
"sentinel": "keep",
"keep_verdicts": keep_verdicts.split(","),
"hunter_defects_total": 1,
"hunter_defects_kept": kept,
}
def test_combine_preserves_a_and_scores_b(fixture, tmp_path):
_, change, _, _, _ = fixture
changes = [{**change, "id": name} for name in ("both", "a-only", "b-only")]
entries_a = [{"item_id": "replay-" + c["id"], "change": c, "input_digest": "a"} for c in changes]
entries_b = [{"item_id": c["id"], "change": c, "input_digest": "b"} for c in changes]
defect = {
"defect_id": "hunter",
"family": "injection",
"cwe": "CWE-89",
"function": "search",
"locations": [["app.py", 2]],
"source": "hunter",
}
a = {
"status": "quick_only",
"input_digest": "a",
"done": True,
"instrument": {"sentinel": "keep"},
"push": {"admission": {"defects": [], "dispositions": [{"sentinel": "keep"}]}, "quick_tier": [{"sentinel": "keep"}]},
}
b = {
"status": "hunter_covered",
"input_digest": "b",
"done": True,
"push": {"admission": {"defects": [defect], "dispositions": []}, "quick_tier": []},
}
replay_out, hunter_out, combined = tmp_path / "a", tmp_path / "b", tmp_path / "combined.json"
write_arm(replay_out, entries_a, {"replay-both": a, "replay-a-only": a})
write_arm(hunter_out, entries_b, {"both": b, "b-only": b})
assert hunter_arm.main(["combine", str(replay_out), str(hunter_out), "--out", str(combined)]) == 0
both, a_only, b_only = score.load_records(combined)
assert score.catches(both) == ["alerts"] and score.catches(b_only) == ["alerts"]
assert both["instrument"] == a["instrument"] and both["status"] == a["status"]
assert both["arms"] == {"a": "quick_only", "b": "hunter_covered"}
assert both["push"]["quick_tier"] == a["push"]["quick_tier"]
assert both["push"]["admission"]["dispositions"] == [{"sentinel": "keep"}]
assert a_only == {
**changes[1],
**a,
"arms": {"a": "quick_only", "b": "missing"},
"combine": {"keep_verdicts": None, "hunter_defects_total": 0, "hunter_defects_kept": 0},
}
assert b_only["arms"] == {"a": "missing", "b": "hunter_covered"}
a["push"]["admission"]["defects"] = [{**defect, "defect_id": "a-defect", "source": "engine"}]
record_path(replay_out, "replay-both").write_text(json.dumps(a))
hunter_arm.main(["combine", str(replay_out), str(hunter_out), "--out", str(combined)])
defects = score.load_records(combined)[0]["push"]["admission"]["defects"]
assert len(defects) == 1 and defects[0]["source"] == "engine"
@pytest.mark.parametrize("arm", ["a", "b"])
def test_combine_rejects_duplicate_ids(fixture, tmp_path, arm):
_, change, _, _, _ = fixture
entry = {"item_id": change["id"], "change": change, "input_digest": "digest"}
a, b = tmp_path / "a", tmp_path / "b"
write_arm(a, [entry, entry] if arm == "a" else [entry], {})
write_arm(b, [entry, entry] if arm == "b" else [entry], {})
with pytest.raises(ValueError, match="duplicate"):
hunter_arm.combine(a, b, tmp_path / "combined.json")
@pytest.mark.parametrize("directory_exists", [True, False])
def test_combine_without_hunter_index_keeps_replay(fixture, tmp_path, directory_exists):
_, change, _, _, _ = fixture
replay_out, hunter_out = tmp_path / "a", tmp_path / "b"
entry = {"item_id": "replay-item", "change": change, "input_digest": "replay-digest"}
a = {
"status": "quick_only",
"input_digest": "replay-digest",
"done": True,
"instrument": {"sentinel": "keep"},
"push": {"admission": {"defects": []}},
}
write_arm(replay_out, [entry], {"replay-item": a})
if directory_exists:
hunter_out.mkdir()
combined = tmp_path / "combined.json"
hunter_arm.combine(replay_out, hunter_out, combined)
assert score.load_records(combined) == [
{
**change,
**a,
"arms": {"a": "quick_only", "b": "missing"},
"combine": {"keep_verdicts": None, "hunter_defects_total": 0, "hunter_defects_kept": 0},
}
]
def test_build_caller_classifies_through_the_real_deepseek_wrapper(tmp_path, monkeypatch):
"""Regression: a bare DeepSeekChatClient keeps `usage` as a list, which made Caller.ask raise
ValueError on every live classification. build_caller must meter the client first."""
from types import SimpleNamespace
from openultrasast.model.endpoint import DeepSeekChatClient
answer = {
"verdict": "vulnerable",
"family": "access_control",
"confidence": 0.9,
"rationale": "The guard is skipped on line 2.",
"cited_lines": [2],
}
class Raw:
def complete_chat_raw(self, **kw):
return {"choices": [{"message": {"content": json.dumps(answer)}}], "usage": {"prompt_tokens": 3}}
(tmp_path / "app.py").write_text("def view(user):\n return secret()\n")
finding = SimpleNamespace(
path="app.py", line=2, title="guard skipped", rationale="no check", tags=["tool-hunter", "family:views"], function_name="view"
)
# The module-wide fixture stubs classify; this test must run the real parser and Caller.
monkeypatch.setattr(hunter_arm, "classify", program.classify)
caller = hunter_arm.build_caller(DeepSeekChatClient(Raw()), "test-model")
assert isinstance(caller.client, hunter_arm.MeteredClient)
# A scripted client has no usage list and must pass through unwrapped.
assert hunter_arm.build_caller(tool_hunter.scripted_hunter_client(), "m").client is not None
assert not isinstance(hunter_arm.build_caller(tool_hunter.scripted_hunter_client(), "m").client, hunter_arm.MeteredClient)
families = tuple(family for family in hunter_arm.FAMILY_IDS if family != "unknown")
result = hunter_arm.classify_family(finding, tmp_path, caller=caller, families=families)
assert result["family"] == "access_control" and "error" not in result
assert result["verdict"] == "vulnerable" and result["cited_lines"] == [2]