reviewgate 0.1.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.
Files changed (48) hide show
  1. agentboard/__init__.py +62 -0
  2. agentboard/agents/__init__.py +0 -0
  3. agentboard/agents/critic_agent.py +174 -0
  4. agentboard/agents/fix_agent.py +98 -0
  5. agentboard/agents/fix_with_test_agent.py +135 -0
  6. agentboard/agents/gap_auditor.py +166 -0
  7. agentboard/agents/openai_agent.py +140 -0
  8. agentboard/agents/openai_explainer.py +77 -0
  9. agentboard/agents/peer_reviewer.py +184 -0
  10. agentboard/agents/reviewer_agent.py +253 -0
  11. agentboard/cli.py +771 -0
  12. agentboard/config.py +296 -0
  13. agentboard/dataset.py +192 -0
  14. agentboard/demo/__init__.py +10 -0
  15. agentboard/demo/target/demo.test.js +14 -0
  16. agentboard/demo/target/order_tool.js +22 -0
  17. agentboard/demo/target/package.json +7 -0
  18. agentboard/fingerprint.py +62 -0
  19. agentboard/graph.py +186 -0
  20. agentboard/ingestion/__init__.py +0 -0
  21. agentboard/ingestion/intent.py +48 -0
  22. agentboard/ingestion/output_invariants.py +113 -0
  23. agentboard/ingestion/pr_diff.py +111 -0
  24. agentboard/ingestion/repo_adapter.py +36 -0
  25. agentboard/ingestion/tech_detect.py +119 -0
  26. agentboard/ingestion/text_adapter.py +27 -0
  27. agentboard/interfaces.py +85 -0
  28. agentboard/loop.py +203 -0
  29. agentboard/personas.py +103 -0
  30. agentboard/proposal_cache.py +147 -0
  31. agentboard/review.py +185 -0
  32. agentboard/state.py +121 -0
  33. agentboard/verifiers/__init__.py +0 -0
  34. agentboard/verifiers/assertion_lint.py +59 -0
  35. agentboard/verifiers/finding_verifier.py +575 -0
  36. agentboard/verifiers/pytest_verifier.py +114 -0
  37. agentboard/verifiers/schema_verifier.py +53 -0
  38. agentboard/verifiers/transition_verifier.py +208 -0
  39. agentboard/verifiers/vitest_verifier.py +353 -0
  40. agentboard/whiteboards/__init__.py +0 -0
  41. agentboard/whiteboards/flow_adapter.py +314 -0
  42. agentboard/whiteboards/html_adapter.py +104 -0
  43. agentboard/whiteboards/tldraw_adapter.py +28 -0
  44. reviewgate-0.1.0.dist-info/METADATA +305 -0
  45. reviewgate-0.1.0.dist-info/RECORD +48 -0
  46. reviewgate-0.1.0.dist-info/WHEEL +4 -0
  47. reviewgate-0.1.0.dist-info/entry_points.txt +2 -0
  48. reviewgate-0.1.0.dist-info/licenses/LICENSE +5 -0
agentboard/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """agentboard — a code review gate that verifies by executing tests.
2
+
3
+ A model proposes edge-case tests from your intent and your change; a
4
+ deterministic harness runs each one against the real code in a clean
5
+ checkout; a behavior is a gap only if its test runs and fails. No model is in
6
+ the pass/fail decision. The entry point is the `agentboard` CLI
7
+ (`agentboard.cli:main`).
8
+
9
+ The legacy whiteboard/loop API (built on LangGraph) is still importable from
10
+ this package, but only when the optional `whiteboard` extra is installed
11
+ (`pip install "agentboard[whiteboard]"`). It is not needed for the review
12
+ gate, so a lean install does not pull LangGraph, and the imports below degrade
13
+ to absent rather than crashing `import agentboard`.
14
+ """
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ # Legacy whiteboard exports — available only with the [whiteboard] extra.
19
+ # Wrapped so a lean install (review gate only) imports cleanly without
20
+ # LangGraph. If you need these symbols, install the extra.
21
+ try: # pragma: no cover - exercised by the [whiteboard] install path
22
+ from .loop import build_loop, initial_board
23
+ from .personas import DEFAULT_PERSONAS, StubAgent
24
+ from .state import (
25
+ Board,
26
+ CodeChange,
27
+ Conflict,
28
+ Node,
29
+ Proposal,
30
+ Rejection,
31
+ Snapshot,
32
+ )
33
+ from .verifiers.schema_verifier import SchemaVerifier
34
+ from .whiteboards.html_adapter import HtmlWhiteboardAdapter
35
+ from .whiteboards.flow_adapter import FlowWhiteboardAdapter
36
+ from .ingestion.text_adapter import TextIngestionAdapter
37
+ from .ingestion.repo_adapter import RepoIngestionAdapter
38
+ from .verifiers.pytest_verifier import PytestVerifier
39
+ from .agents.openai_agent import OpenAIAgent
40
+
41
+ __all__ = [
42
+ "build_loop",
43
+ "initial_board",
44
+ "DEFAULT_PERSONAS",
45
+ "StubAgent",
46
+ "SchemaVerifier",
47
+ "HtmlWhiteboardAdapter",
48
+ "FlowWhiteboardAdapter",
49
+ "TextIngestionAdapter",
50
+ "RepoIngestionAdapter",
51
+ "PytestVerifier",
52
+ "OpenAIAgent",
53
+ "Board",
54
+ "Node",
55
+ "Proposal",
56
+ "CodeChange",
57
+ "Rejection",
58
+ "Conflict",
59
+ "Snapshot",
60
+ ]
61
+ except ImportError: # langgraph (the [whiteboard] extra) not installed
62
+ __all__ = []
File without changes
@@ -0,0 +1,174 @@
1
+ """CriticAgent — the second seat, a DIFFERENT model (Claude by default).
2
+
3
+ The first agent (ReviewerAgent, GPT) derives the behaviors the intent promises
4
+ and tests them — it produces a competent *feature checklist*. Empirically it
5
+ does NOT reach for adversarial schema shapes on its own.
6
+
7
+ This agent's job is different, and it MUST be a different model family, because a
8
+ same-model second seat is a clone with the same blind spots (proven). It receives
9
+ the first agent's coverage as a finished artifact and asks only: what does this
10
+ MISS? What inputs would break these features? It proposes tests for the gaps.
11
+
12
+ Why this is collaboration and not a vote:
13
+ - The critic does not judge whether the first agent's tests are "correct" — the
14
+ gate already runs those.
15
+ - It ADDS candidate gap-tests the first agent didn't think of.
16
+ - Every candidate it proposes still goes through the deterministic FindingVerifier.
17
+ Two models agreeing "composite FKs break this" proves nothing until the test
18
+ fails on the branch. Collaboration raises candidate quality; the gate decides
19
+ truth.
20
+
21
+ Structure mirrors reviewer_agent: model call split from pure parsing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+
29
+ from ..review import ReviewFinding
30
+
31
+ _SYSTEM = """You are a staff engineer doing a second-pass review. Another engineer already reviewed a code change against this intent and wrote tests for the behaviors it promises:
32
+
33
+ INTENT:
34
+ {intent}
35
+
36
+ Their tests (the coverage so far) are given to you. Your job is NOT to re-check what they covered. Your job is to find what their coverage MISSES — the realistic inputs and data shapes that would break this tool but that their tests never exercise.
37
+
38
+ Think like someone trying to find the bug they didn't think of. Consider unusual but legitimate shapes of the data this tool processes. For each gap you find, write ONE test that asserts the CORRECT behavior for that shape.
39
+
40
+ Constraints:
41
+ - Only propose gaps that are within the intent's scope — real inputs the tool is supposed to handle, not invented features.
42
+ - Do NOT try to force a failure. Assert what SHOULD happen for that input and let it run.
43
+ - MANDATORY SETUP: reproduce the exact project setup the existing tests use (create org, create project, set active status, load schema via the project's db exec) before calling the tool. Never call the tool with a project_id you did not create this way. Copy the setup verbatim from the existing tests; change only the schema you load and the assertions.
44
+ - Reuse the existing test harness exactly (same imports, helpers, style). The test must compile.
45
+ - EXACTNESS FOR PRODUCED COLLECTIONS (mandatory). Whenever the tool returns a collection and the intent implies it is the complete, authoritative answer, assert it EXACTLY: assert the count AND deep-equal the full expected set — nothing missing and nothing extra. A consumer relies on this as truth; an item that should not be there is false information it will act on, a correctness failure. Presence-only matchers (arrayContaining, toContain, toContainEqual, objectContaining, stringContaining) are FORBIDDEN when the collection is meant to be complete — they pass even when the tool over-produces. Assert length (toHaveLength(N)) plus a full deep-equal; sort both sides by a stable key first if output order is not guaranteed.
46
+
47
+ Output ONLY JSON:
48
+ {{"gaps": [
49
+ {{"behavior": "<the uncovered case, one sentence>",
50
+ "axis": "correctness" | "consistency",
51
+ "test_path": "<repo-root-relative path to the test file>",
52
+ "test_code": "<a complete test in the existing harness style>"
53
+ }}
54
+ ]}}"""
55
+
56
+
57
+ def _loads_gaps_lenient(text: str) -> dict:
58
+ try:
59
+ return json.loads(text)
60
+ except json.JSONDecodeError:
61
+ pass
62
+ objs, stack, instr, esc = [], [], False, False
63
+ for i, ch in enumerate(text):
64
+ if instr:
65
+ if esc:
66
+ esc = False
67
+ elif ch == "\\":
68
+ esc = True
69
+ elif ch == '"':
70
+ instr = False
71
+ continue
72
+ if ch == '"':
73
+ instr = True
74
+ elif ch == "{":
75
+ stack.append(i)
76
+ elif ch == "}" and stack:
77
+ start = stack.pop()
78
+ try:
79
+ o = json.loads(text[start : i + 1])
80
+ if isinstance(o, dict) and "behavior" in o and "test_code" in o:
81
+ objs.append(o)
82
+ except json.JSONDecodeError:
83
+ pass
84
+ return {"gaps": objs}
85
+
86
+
87
+ def parse_gaps(data: dict) -> list[ReviewFinding]:
88
+ findings: list[ReviewFinding] = []
89
+ for g in data.get("gaps", []) or []:
90
+ behavior = str(g.get("behavior", "")).strip()
91
+ tc = g.get("test_code")
92
+ if not behavior or not (isinstance(tc, str) and tc.strip()):
93
+ continue
94
+ axis = g.get("axis", "correctness")
95
+ if axis not in ("correctness", "consistency"):
96
+ axis = "correctness"
97
+ findings.append(
98
+ ReviewFinding(
99
+ behavior=behavior,
100
+ axis=axis,
101
+ covered_by_existing=False,
102
+ coverage_note="proposed by critic (2nd-pass, different model)",
103
+ test_path=g.get("test_path"),
104
+ test_code=tc,
105
+ )
106
+ )
107
+ return findings
108
+
109
+
110
+ def _coverage_digest(prior: list[ReviewFinding]) -> str:
111
+ lines = []
112
+ for f in prior:
113
+ lines.append(f"- ({f.axis}) {f.behavior}")
114
+ return "\n".join(lines) if lines else "(no prior coverage)"
115
+
116
+
117
+ class CriticAgent:
118
+ def __init__(
119
+ self, model: str = "claude-opus-4-8", client=None, max_tokens: int = 3000
120
+ ):
121
+ self.model = model
122
+ self._client = client
123
+ self.max_tokens = max_tokens
124
+ self._is_openai = model.startswith("gpt") or model.startswith("o")
125
+
126
+ def _client_lazy(self):
127
+ if self._client is None:
128
+ if self._is_openai:
129
+ from openai import OpenAI
130
+
131
+ self._client = OpenAI()
132
+ else:
133
+ from anthropic import Anthropic
134
+
135
+ self._client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
136
+ return self._client
137
+
138
+ def critique(
139
+ self, intent: str, source: str, existing_tests: str, prior: list[ReviewFinding]
140
+ ) -> list[ReviewFinding]:
141
+ user = (
142
+ f"SOURCE FILE:\n```\n{source[:9000]}\n```\n\n"
143
+ f"EXISTING TESTS (harness to reuse):\n```\n{existing_tests[:9000]}\n```\n\n"
144
+ f"COVERAGE SO FAR (the first reviewer's behaviors — find what these MISS):\n"
145
+ f"{_coverage_digest(prior)}\n\nRespond ONLY with the JSON specified."
146
+ )
147
+ try:
148
+ client = self._client_lazy()
149
+ if self._is_openai:
150
+ resp = client.chat.completions.create(
151
+ model=self.model,
152
+ response_format={"type": "json_object"},
153
+ messages=[
154
+ {"role": "system", "content": _SYSTEM.format(intent=intent)},
155
+ {"role": "user", "content": user},
156
+ ],
157
+ )
158
+ data = json.loads(resp.choices[0].message.content or "{}")
159
+ else:
160
+ resp = client.messages.create(
161
+ model=self.model,
162
+ max_tokens=8000,
163
+ system=_SYSTEM.format(intent=intent)
164
+ + "\n\nRespond with ONLY the JSON object, no prose before or after.",
165
+ messages=[{"role": "user", "content": user}],
166
+ )
167
+ text = "".join(
168
+ b.text for b in resp.content if getattr(b, "type", None) == "text"
169
+ )
170
+ data = _loads_gaps_lenient(text)
171
+ except Exception as ex: # never crash the loop
172
+ print(f" [warn] critic: {ex}")
173
+ return []
174
+ return parse_gaps(data)
@@ -0,0 +1,98 @@
1
+ """FixAgent — proposes a code fix for a confirmed_gap. It NEVER judges.
2
+
3
+ The judge is TransitionVerifier.verify_transition: the finding's own red test must
4
+ go green under the fix, with zero regressions. This agent only turns
5
+ (behavior, observed failure, source) into a candidate CodeChange.
6
+
7
+ Deterministic pre-checks before anything expensive runs:
8
+ - `find` must be non-empty and occur EXACTLY ONCE in the target file
9
+ (ambiguous or missing anchors are rejected here, not by a test run).
10
+ - `replace` must differ from `find`.
11
+ These reject malformed proposals for free; they do not judge correctness.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+
18
+ from ..state import CodeChange
19
+
20
+ _SYSTEM = """You are a senior engineer proposing a MINIMAL fix for a verified bug.
21
+
22
+ You are given:
23
+ - the intended behavior that a failing test proved is violated,
24
+ - the observed assertion failure,
25
+ - the failing test's code,
26
+ - the full content of one source file, with its repo-relative path.
27
+
28
+ Propose ONE minimal edit to THAT file that makes the intended behavior hold.
29
+
30
+ Rules:
31
+ - Smallest change that fixes the cause. No refactors, no style changes, no
32
+ drive-by improvements.
33
+ - `find` must be an EXACT, UNIQUE substring of the file as given (copy it
34
+ verbatim, whitespace included). `replace` is its replacement.
35
+ - Do not touch the test.
36
+
37
+ Respond ONLY with JSON:
38
+ {"find": "<exact snippet>", "replace": "<replacement>", "note": "<one line: what/why>"}
39
+ If you cannot propose a defensible fix in this file, respond {"find": null}."""
40
+
41
+
42
+ def check_change_applies(source: str, find: str | None, replace: str | None):
43
+ """Deterministic applicability check. Returns (ok, reason)."""
44
+ if not find:
45
+ return False, "agent proposed no fix"
46
+ if source.count(find) == 0:
47
+ return False, "proposed `find` snippet not present in file"
48
+ if source.count(find) > 1:
49
+ return False, f"proposed `find` snippet ambiguous ({source.count(find)} occurrences)"
50
+ if replace is None or replace == find:
51
+ return False, "replace missing or identical to find"
52
+ return True, ""
53
+
54
+
55
+ class FixAgent:
56
+ def __init__(self, repo_root: str, model: str = "gpt-5.5"):
57
+ self.repo_root = repo_root
58
+ self.model = model
59
+ self._client = None
60
+
61
+ def _client_lazy(self):
62
+ if self._client is None:
63
+ from openai import OpenAI
64
+ self._client = OpenAI()
65
+ return self._client
66
+
67
+ def propose(self, behavior: str, observed: str, test_code: str,
68
+ target_path: str) -> tuple[CodeChange | None, str]:
69
+ """Returns (CodeChange, note) or (None, reason)."""
70
+ src_file = os.path.join(self.repo_root, target_path)
71
+ try:
72
+ source = open(src_file, encoding="utf-8").read()
73
+ except OSError as e:
74
+ return None, f"cannot read target file: {e}"
75
+
76
+ user = (
77
+ f"INTENDED BEHAVIOR (a test proved this is violated):\n{behavior}\n\n"
78
+ f"OBSERVED FAILURE:\n{observed}\n\n"
79
+ f"FAILING TEST:\n{test_code}\n\n"
80
+ f"FILE: {target_path}\n----- FILE CONTENT -----\n{source}"
81
+ )
82
+ resp = self._client_lazy().chat.completions.create(
83
+ model=self.model,
84
+ messages=[{"role": "system", "content": _SYSTEM},
85
+ {"role": "user", "content": user}],
86
+ response_format={"type": "json_object"},
87
+ )
88
+ try:
89
+ data = json.loads(resp.choices[0].message.content or "{}")
90
+ except (json.JSONDecodeError, IndexError):
91
+ return None, "agent returned unparseable output"
92
+
93
+ find, replace = data.get("find"), data.get("replace")
94
+ ok, why = check_change_applies(source, find, replace)
95
+ if not ok:
96
+ return None, why
97
+ note = (data.get("note") or "").strip()[:200]
98
+ return CodeChange(path=target_path, find=find, replace=replace), note
@@ -0,0 +1,135 @@
1
+ """FixWithTestAgent — proposes a change AND the test that proves it.
2
+
3
+ The plain OpenAIAgent emits a fix (find/replace) but no test, so it can't feed
4
+ the TransitionVerifier, which needs a NEW test to check red-on-baseline /
5
+ green-after. This agent asks the model for both, in one shot, and packs them
6
+ into a single Proposal (``change`` + ``test_change``).
7
+
8
+ Same discipline as openai_agent: the network call and the parsing are split, so
9
+ ``parse_fix_with_test`` is a pure, offline-testable function.
10
+
11
+ It does NOT try to guarantee the test is good — that's not its job and it
12
+ couldn't if it tried. The TransitionVerifier is the judge: if the model's test
13
+ doesn't actually fail-before-and-pass-after, the proposal is rejected. The agent
14
+ proposes; the world disposes.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+
22
+ from ..state import CodeChange, Proposal
23
+
24
+ _SYSTEM = (
25
+ "You are a senior engineer. You are given one source file and a goal. Propose "
26
+ "ONE small, surgical change that advances the goal, AND a NEW test that proves "
27
+ "it.\n\n"
28
+ "CRITICAL constraint on the test — it will be checked mechanically:\n"
29
+ " * The test MUST FAIL on the current (unchanged) code.\n"
30
+ " * The test MUST PASS once your change is applied.\n"
31
+ "A test that passes on the current code will be rejected as worthless.\n\n"
32
+ "Output ONLY JSON with this shape:\n"
33
+ '{{"issue": {{"text": "<one sentence>", "severity": "low|medium|high"}},\n'
34
+ ' "fix": {{"text": "<one sentence>", "find": "<exact substring from the file, or null>", '
35
+ '"replace": "<replacement, or null>", "append": "<code to append, or null>"}},\n'
36
+ ' "test": {{"path": "<repo-root-relative path to a test file>", '
37
+ '"append": "<a complete vitest test, including any imports it needs>"}}}}\n'
38
+ "Rules: use EITHER find+replace (find must be copied verbatim and occur once) "
39
+ "OR append, not both. The test `path` may be a new file. Keep the change minimal."
40
+ )
41
+
42
+
43
+ def _slug(s: str) -> str:
44
+ return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:24]
45
+
46
+
47
+ def parse_fix_with_test(persona: str, node_id: str, data: dict) -> list[Proposal]:
48
+ """Turn the model's JSON into one Proposal carrying change + test_change.
49
+ Pure and defensive: anything malformed yields no proposal rather than a crash."""
50
+ mod = _slug(node_id.split("/")[-1])
51
+ issue = data.get("issue") or {}
52
+ fix = data.get("fix") or {}
53
+ test = data.get("test") or {}
54
+
55
+ text = str(fix.get("text") or issue.get("text") or "").strip()
56
+ if not text:
57
+ return []
58
+ sev = issue.get("severity", "medium")
59
+ if sev not in ("low", "medium", "high"):
60
+ sev = "medium"
61
+
62
+ # implementation change: find/replace OR append
63
+ find, replace, append = fix.get("find"), fix.get("replace"), fix.get("append")
64
+ change: CodeChange | None = None
65
+ if isinstance(find, str) and find and isinstance(replace, str):
66
+ change = CodeChange(path=node_id, find=find, replace=replace)
67
+ elif isinstance(append, str) and append.strip():
68
+ change = CodeChange(path=node_id, append=append)
69
+ if change is None:
70
+ return []
71
+
72
+ # the test that proves it
73
+ tpath, tappend = test.get("path"), test.get("append")
74
+ test_change: CodeChange | None = None
75
+ if isinstance(tpath, str) and tpath and isinstance(tappend, str) and tappend.strip():
76
+ test_change = CodeChange(path=tpath, append=tappend)
77
+ if test_change is None:
78
+ return [] # no test -> nothing for the TransitionVerifier to check; skip
79
+
80
+ gid = f"{persona}:{mod}:fix"
81
+ return [Proposal(gid, persona, "fix", node_id, text, severity=sev,
82
+ change=change, test_change=test_change)]
83
+
84
+
85
+ class FixWithTestAgent:
86
+ """Implements the ``Agent`` protocol; proposes change + proving test."""
87
+
88
+ def __init__(self, repo_root: str, model: str = "gpt-4o",
89
+ focus_modules: list[str] | None = None, max_source_chars: int = 9000,
90
+ client=None, temperature: float = 0.2):
91
+ self.repo_root = repo_root
92
+ self.model = model
93
+ self.focus_modules = focus_modules
94
+ self.max_source_chars = max_source_chars
95
+ self._client = client
96
+ self.temperature = temperature
97
+
98
+ def _client_lazy(self):
99
+ if self._client is None:
100
+ from openai import OpenAI
101
+ self._client = OpenAI()
102
+ return self._client
103
+
104
+ def _ask(self, persona: str, goal: str, node_id: str, source: str) -> list[Proposal]:
105
+ user = f"Goal: {goal}\nFile: {node_id}\n\n```\n{source}\n```"
106
+ try:
107
+ resp = self._client_lazy().chat.completions.create(
108
+ model=self.model,
109
+ temperature=self.temperature,
110
+ response_format={"type": "json_object"},
111
+ messages=[{"role": "system", "content": _SYSTEM},
112
+ {"role": "user", "content": user}],
113
+ )
114
+ data = json.loads(resp.choices[0].message.content or "{}")
115
+ except Exception as e: # never crash the loop on a bad call/parse
116
+ print(f" [warn] {persona} on {node_id}: {e}")
117
+ return []
118
+ return parse_fix_with_test(persona, node_id, data)
119
+
120
+ def propose(self, persona, goal, nodes, prior_committed, iteration):
121
+ if iteration != 1:
122
+ return []
123
+ ids = [n.id for n in nodes]
124
+ targets = self.focus_modules or ids
125
+ proposals: list[Proposal] = []
126
+ for node_id in targets:
127
+ if node_id not in ids:
128
+ continue
129
+ try:
130
+ with open(os.path.join(self.repo_root, node_id), encoding="utf-8") as f:
131
+ source = f.read()[: self.max_source_chars]
132
+ except OSError:
133
+ continue
134
+ proposals += self._ask(persona, goal, node_id, source)
135
+ return proposals
@@ -0,0 +1,166 @@
1
+ """GapAuditor — the precision layer. ADVISORY, never a verdict.
2
+
3
+ Tonight's proven problem: every `confirmed_gap` the pipeline produced was a FALSE
4
+ POSITIVE — the agent asserted its own ASSUMPTION, and the tool's real contract
5
+ (often in code the PR didn't touch) disagreed. Only a human reading source caught
6
+ them.
7
+
8
+ This does that source-reading step and hands the human the evidence:
9
+ for each confirmed_gap, a DIFFERENT model reads the source file + the agent's test
10
+ + the observed assertion failure, and judges: does the code's ACTUAL behavior match
11
+ what the test asserted?
12
+
13
+ CRITICAL THESIS GUARD:
14
+ - This NEVER changes the gate's verdict. The gate already ran the code; that
15
+ result stands. The auditor only ANNOTATES: likely_real / likely_false_positive
16
+ / uncertain, with a one-line reason and the source lines it relied on.
17
+ - It is triage, not truth. It tells the human WHICH gaps to check first and WHERE
18
+ to look. The human still decides. An LLM does not get to rule a real failing
19
+ test "not a bug" and suppress it.
20
+ - Because it's a judgment (not a deterministic check), it must show its evidence
21
+ so the human can overrule it in seconds.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+
28
+ from ..review import ReviewFinding
29
+
30
+ Assessment = str # "likely_real" | "likely_false_positive" | "uncertain"
31
+
32
+ _SYSTEM = """You are auditing a code review finding for FALSE POSITIVES. Another agent claimed a tool has a bug because a test it wrote failed. Your job is NOT to decide if the tool is good — it is to decide whether the failing test actually reflects a real defect, or whether the agent simply ASSERTED something the code was never contracted to do.
33
+
34
+ You are given: the source file, the test the agent wrote, and the assertion that failed.
35
+
36
+ Reason strictly from the SOURCE:
37
+ - Find the code that produces the value under test. Quote the specific lines.
38
+ - Ask: is the agent's assertion the code's ACTUAL contract, or the agent's assumption about how it *should* behave?
39
+ - A test can fail for three reasons: (1) the tool has a real defect, (2) the agent asserted a behavior the tool never promised (design disagreement / wrong assumption), or (3) the agent's test setup didn't create what it assumed (so the tool correctly returned empty/less).
40
+ - Cases (2) and (3) are FALSE POSITIVES. Only (1) is a real gap.
41
+
42
+ Be skeptical of the agent, but be MORE skeptical of dismissing a real bug. Calling a genuine defect a false positive is the worst error you can make here: the failing test already EXECUTED against real code, so the burden of proof is on the dismissal, not on the bug. A test that ran and failed is presumed to reflect a real defect UNTIL you can quote the exact source line or rule that proves the agent asserted something the code never promised.
43
+
44
+ HARD BAR FOR likely_false_positive: you may only answer likely_false_positive if you can QUOTE the specific line(s) of source that establish the code's actual contract and show the agent's assertion contradicts that contract (a design disagreement) OR show the test's setup did not create what it assumed. Put that quoted line in "evidence". If you cannot quote such a line, you are NOT permitted to say likely_false_positive — answer likely_real or, only if the code path is genuinely untraceable, uncertain.
45
+
46
+ WATCH FOR REAL BUGS THAT LOOK BENIGN: if the observed failure shows actual state being lost, mutated, or fabricated (e.g. a prototype changed, a value dropped, a count off by one, "expected true to be false" on a pollution check), lean likely_real — those are the exact signatures of the defects this tool exists to catch, and they are easy to wave away as "the agent's assumption."
47
+
48
+ COMMIT to a call. "uncertain" is ONLY for when you genuinely cannot find the relevant code or the source is truly ambiguous — it is NOT a safe default. If you can trace the code path that produces the value under test, you MUST decide likely_real or likely_false_positive:
49
+ - If the code plainly produces a WRONG result for a valid input the intent covers (loses, duplicates, or fabricates information), say likely_real.
50
+ - If the code behaves consistently by its own clear rule AND you can quote that rule, and the agent merely assumed a different rule, say likely_false_positive.
51
+ Do not hide correct analysis behind "uncertain." If your reasoning has identified the mechanism, state the verdict that reasoning implies.
52
+
53
+ You MUST always fill in "reason" and "evidence" with specifics from the source (name the behavior and the lines/logic). An empty reason is not acceptable; a likely_false_positive with no quoted source line is not acceptable.
54
+
55
+ Output ONLY JSON:
56
+ {"assessment": "likely_real" | "likely_false_positive" | "uncertain",
57
+ "reason": "<one sentence, plain — required, never empty>",
58
+ "evidence": "<the specific source behavior/lines you relied on — required, never empty>"}"""
59
+
60
+
61
+ def _loads_one(text: str) -> dict:
62
+ try:
63
+ return json.loads(text)
64
+ except json.JSONDecodeError:
65
+ s, e = text.find("{"), text.rfind("}")
66
+ if s >= 0 and e > s:
67
+ try:
68
+ return json.loads(text[s:e + 1])
69
+ except json.JSONDecodeError:
70
+ pass
71
+ return {}
72
+
73
+
74
+ class GapAuditor:
75
+ def __init__(self, model: str = "gpt-5.5", client=None, max_source_chars: int = 16000):
76
+ self.model = model
77
+ self._client = client
78
+ self.max_source_chars = max_source_chars
79
+ self._is_openai = model.startswith("gpt") or model.startswith("o")
80
+
81
+ def _client_lazy(self):
82
+ if self._client is None:
83
+ if self._is_openai:
84
+ from openai import OpenAI
85
+ self._client = OpenAI()
86
+ else:
87
+ from anthropic import Anthropic
88
+ self._client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
89
+ return self._client
90
+
91
+ def _ask(self, source: str, finding: ReviewFinding) -> dict:
92
+ user = (
93
+ f"SOURCE FILE:\n```\n{source[:self.max_source_chars]}\n```\n\n"
94
+ f"THE AGENT'S CLAIMED GAP: {finding.behavior}\n\n"
95
+ f"THE TEST IT WROTE:\n```\n{finding.test_code or '(none)'}\n```\n\n"
96
+ f"THE ASSERTION THAT FAILED:\n{finding.observed or '(none)'}\n\n"
97
+ f"Audit this for a false positive. Respond ONLY with the JSON."
98
+ )
99
+ client = self._client_lazy()
100
+ if self._is_openai:
101
+ resp = client.chat.completions.create(
102
+ model=self.model,
103
+ response_format={"type": "json_object"},
104
+ messages=[{"role": "system", "content": _SYSTEM},
105
+ {"role": "user", "content": user}],
106
+ )
107
+ return _loads_one(resp.choices[0].message.content or "{}")
108
+ resp = client.messages.create(
109
+ model=self.model, max_tokens=2500,
110
+ system=_SYSTEM + "\n\nRespond with ONLY the JSON object, nothing before it.",
111
+ messages=[{"role": "user", "content": user}],
112
+ )
113
+ text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
114
+ return _loads_one(text)
115
+
116
+ def audit(self, source: str, finding: ReviewFinding) -> ReviewFinding:
117
+ """Annotate a confirmed_gap with an advisory assessment. Verdict UNCHANGED.
118
+
119
+ Two guards against the auditor's own failure modes, both learned from a
120
+ benchmark run:
121
+ * empty reason -> retry once. Sonnet hedged 'uncertain' with a blank
122
+ reason on ~6 gaps (concentrated on prototype-semantics questions); a
123
+ single retry recovers most into a committed, reasoned call.
124
+ * likely_false_positive with no quoted source line -> DOWNGRADE to
125
+ uncertain. Dismissing a real, executed failure is the costliest
126
+ error here (it once flagged a genuine strict catch as FP), so a
127
+ dismissal that cannot cite the contract it relies on is not trusted.
128
+ """
129
+ if finding.status != "confirmed_gap":
130
+ return finding
131
+ try:
132
+ data = self._ask(source, finding)
133
+ reason = str(data.get("reason", "")).strip()
134
+ if not reason: # empty-reason hedge -> one retry
135
+ data = self._ask(source, finding)
136
+ except KeyError as e: # missing API key — make it LOUD, not a silent skip
137
+ finding.audit = "not_audited"
138
+ finding.audit_reason = f"auditor could not run: missing {e} — gap is UNVERIFIED"
139
+ print(f" [AUDITOR DID NOT RUN] missing {e}; gaps are unverified")
140
+ return finding
141
+ except Exception as e:
142
+ finding.audit = "not_audited"
143
+ finding.audit_reason = f"auditor error: {e} — gap is UNVERIFIED"
144
+ print(f" [warn] auditor: {e}")
145
+ return finding
146
+ assessment = data.get("assessment", "uncertain")
147
+ if assessment not in ("likely_real", "likely_false_positive", "uncertain"):
148
+ assessment = "uncertain"
149
+ reason = str(data.get("reason", "")).strip()[:200]
150
+ evidence = str(data.get("evidence", "")).strip()[:300]
151
+ # A dismissal must cite the contract it relies on. No evidence quote ->
152
+ # the auditor has not earned the FP call; downgrade so a real bug is
153
+ # never buried under an unsupported "false positive".
154
+ if assessment == "likely_false_positive" and not evidence:
155
+ assessment = "uncertain"
156
+ reason = ("(downgraded from false-positive: no source line cited) "
157
+ + reason)[:200]
158
+ finding.audit = assessment
159
+ finding.audit_reason = reason
160
+ finding.audit_evidence = evidence
161
+ return finding
162
+
163
+ def audit_all(self, source: str, findings: list[ReviewFinding]) -> list[ReviewFinding]:
164
+ for f in findings:
165
+ self.audit(source, f)
166
+ return findings