fix(review): correct diff-compression line numbers, prior-review dedupe, triage skip
Four defects, all found reviewing PR #9 (two of them by pragent-bot's own review of that PR, which the anchoring bug then misplaced): * compress_diff dropped context lines but copied the original `@@` hunk header verbatim, so the header no longer described the lines beneath it. parse_diff_anchors then walked stale headers and produced anchor sets shifted by the number of elided lines, misplacing inline comments or demoting them to bullets. Each surviving run of lines is now re-emitted as its own hunk with a recomputed `@@ -a,b +c,d @@`, so the output stays a valid unified diff whose numbers describe the real post-change file. The pseudo-marker `@@ … N context line(s) omitted … @@` is gone; it parsed as a hunk header and reset the anchor counter to 0. Anchoring additionally runs on the raw diff now, so the prompt window can never shrink the anchorable set. * compress_diff's `_FILE_HEADER` regex matched diff *body* lines: a removed YAML `---` separator or an added `++` line was read as a file header, truncating the hunk and dropping its `@@` header with it. Body detection is now prefix-based, with a full-shape hunk-header regex. * extract_finding_bullets could not match the bullets pragent itself posts: summary_bullets renders an emoji severity badge between the `-` and the `[SEV]` tag, which the regex rejected, so compact_prior_reviews always returned [] and every re-review repeated its previous findings. * triage returning `{"lenses":[]}` — documented in .opencode/agents/triage.md as "no lens has surface, skip the fan-out" — ran every lens instead, since _intersect_with_triage mapped an empty selection to "all" and the call site had a second `or reviewers` fallback. `[]` and None are now distinct outcomes: `[]` skips, None fails open. A roster naming only unknown lens ids now fails open rather than silencing the review. The skip path returns a well-formed empty-findings response instead of "", which had landed in ai_review's unparseable-output branch and posted "AI review produced no parseable output" — a malfunction message for a normal verdict. Also: non-URL references (a CVE id, a doc title) rendered as `[CVE-2024-1234](CVE-2024-1234)`, a broken relative link in Gitea — now plain text. PRAGENT_DIFF_CONTEXT and friends parse through _int_env, so a typo logs and falls back instead of killing a review mid-flight. Removed format_usage_section, dead since the collapsible usage block replaced it and carrying a duplicate copy of the price-target logic. Tests: 290 -> 301. New coverage for hunk-header fidelity before/after compression, header-shaped content lines, the bullet round-trip against the real renderer, and triage's three outcomes (previously untested). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
This commit is contained in:
@@ -19,7 +19,6 @@ from ai_review import ( # noqa: E402
|
||||
compute_attribution,
|
||||
findings_table,
|
||||
format_review_body,
|
||||
format_usage_section,
|
||||
inline_comment_body,
|
||||
parse_diff_anchors,
|
||||
parse_findings,
|
||||
@@ -604,6 +603,31 @@ def test_inline_comment_body_renders_reference():
|
||||
assert "🔗 **Reference:** [cve.example/X](https://cve.example/X)" in body
|
||||
|
||||
|
||||
def test_reference_non_url_renders_as_plain_text():
|
||||
# A CVE id or doc title is not a URL. `[CVE-2024-1](CVE-2024-1)` renders as
|
||||
# a broken *relative* link in Gitea, so bare text is the correct fallback.
|
||||
assert ai_review._format_reference("CVE-2024-1234") == "CVE-2024-1234"
|
||||
assert ai_review._format_reference("see OWASP A03") == "see OWASP A03"
|
||||
assert ai_review._format_reference("") == ""
|
||||
f = {"severity": "high", "path": "a", "line": 1, "problem": "p", "fix": "",
|
||||
"suggestion": "", "reference": "CVE-2024-1234"}
|
||||
body = inline_comment_body(f)
|
||||
assert "🔗 **Reference:** CVE-2024-1234" in body
|
||||
assert "](CVE-" not in body
|
||||
|
||||
|
||||
def test_int_env_falls_back_on_garbage(monkeypatch, capsys):
|
||||
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "two")
|
||||
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1
|
||||
assert "ignoring PRAGENT_DIFF_CONTEXT" in capsys.readouterr().err
|
||||
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", " 3 ")
|
||||
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 3
|
||||
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "")
|
||||
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1
|
||||
monkeypatch.delenv("PRAGENT_DIFF_CONTEXT")
|
||||
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", -1) == -1
|
||||
|
||||
|
||||
def test_inline_comment_body_no_reference_no_ref_line():
|
||||
f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "",
|
||||
"suggestion": "", "reference": ""}
|
||||
@@ -641,7 +665,7 @@ def test_format_review_body_with_summary_section():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI-USAGE: compute_attribution + format_usage_section + inline 🪙 line
|
||||
# AI-USAGE: compute_attribution + usage block + inline 🪙 line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -695,47 +719,30 @@ def test_inline_comment_body_no_attribution_no_coin_line():
|
||||
assert "🪙" not in inline_comment_body(f)
|
||||
|
||||
|
||||
def test_format_usage_section_renders_totals_and_table():
|
||||
fs = [
|
||||
{"severity": "critical", "path": "src/Foo.java", "line": 98,
|
||||
"problem": "p"*10, "fix": "f", "suggestion": "", "_tok_attrib": 180, "_tok_pct": 0.29},
|
||||
]
|
||||
def test_render_collapsible_usage_renders_totals():
|
||||
usage = {"input": 18420, "output": 612, "reasoning": 0, "cache_read": 15210,
|
||||
"cache_write": 0, "total": 19032, "cost": 0.0, "steps": 7, "duration_s": 142.0}
|
||||
sec = format_usage_section(usage, fs, "glm-5.2:cloud")
|
||||
assert "## 🔋 AI usage" in sec
|
||||
sec = _render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
||||
assert "🔋 AI Usage & Run Details" in sec
|
||||
assert "`glm-5.2:cloud`" in sec
|
||||
assert "agent steps: 7" in sec
|
||||
assert "duration: 142.0s" in sec
|
||||
assert "18420 in" in sec and "612 out" in sec and "19032 total" in sec
|
||||
assert "7 steps" in sec
|
||||
assert "142.0s" in sec
|
||||
assert "18420 in / 612 out" in sec and "19032 total" in sec
|
||||
assert "$0.00" in sec
|
||||
assert "whole-repo checkout" in sec
|
||||
assert "Whole-repo checkout" in sec
|
||||
assert "attributed" in sec
|
||||
# table
|
||||
assert "| severity | location | ≈out tok | % |" in sec
|
||||
assert "CRITICAL" in sec
|
||||
assert "`src/Foo.java:98`" in sec
|
||||
assert "180" in sec and "29%" in sec
|
||||
|
||||
|
||||
def test_format_usage_section_omits_table_when_no_attributed_rows():
|
||||
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
||||
"cache_write": 0, "total": 10, "cost": 0.0, "steps": 1, "duration_s": 1.0}
|
||||
sec = format_usage_section(usage, [], "glm-5.2:cloud")
|
||||
assert "## 🔋 AI usage" in sec
|
||||
assert "severity | location" not in sec # no rows → no table
|
||||
def test_render_collapsible_usage_none_returns_empty():
|
||||
assert _render_collapsible_usage(None, "m", config=None) == ""
|
||||
|
||||
|
||||
def test_format_usage_section_none_returns_empty():
|
||||
assert format_usage_section(None, [], "m") == ""
|
||||
|
||||
|
||||
def test_format_usage_section_cost_nonzero():
|
||||
def test_render_collapsible_usage_cost_nonzero_drops_free_tier_note():
|
||||
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
||||
"cache_write": 0, "total": 10, "cost": 0.0123, "steps": 1, "duration_s": 1.0}
|
||||
sec = format_usage_section(usage, [], "m")
|
||||
sec = _render_collapsible_usage(usage, "m", config=None)
|
||||
assert "$0.0123" in sec
|
||||
assert "billed by provider" in sec
|
||||
assert "free tier" not in sec
|
||||
|
||||
|
||||
def test_format_review_body_usage_section_below_findings():
|
||||
@@ -1022,7 +1029,7 @@ def test_salvage_summary_empty_when_nothing_to_salvage():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# equivalent_cost + format_usage_section equivalent-provider line
|
||||
# equivalent_cost + usage-block equivalent-provider line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1037,15 +1044,15 @@ def test_equivalent_cost_unknown_key_returns_zero():
|
||||
assert ai_review.equivalent_cost({"input": 100}, "bogus") == 0.0
|
||||
|
||||
|
||||
def test_format_usage_section_shows_equivalent_provider_cost():
|
||||
def test_usage_block_shows_equivalent_provider_cost():
|
||||
usage = {"input": 200000, "output": 4000, "reasoning": 0,
|
||||
"cache_read": 0, "cache_write": 0, "total": 204000,
|
||||
"cost": 0.0, "steps": 6, "duration_s": 100.0}
|
||||
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
|
||||
sec = ai_review._render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
||||
# Two cost lines now: an equivalent (default Sonnet 5) AND the $0 actual.
|
||||
assert "## 🔋 AI usage" in sec
|
||||
assert "est. cost on **Claude Sonnet 5**" in sec
|
||||
assert "actual: $0.00" in sec
|
||||
assert "🔋 AI Usage & Run Details" in sec
|
||||
assert "**Est. cost on Claude Sonnet 5**" in sec
|
||||
assert "**Actual**: $0.00" in sec
|
||||
assert "free tier" in sec
|
||||
# Equivalent should be > 0 for non-trivial token counts.
|
||||
assert "$0.00" in sec # the actual line
|
||||
@@ -1057,36 +1064,36 @@ def test_format_usage_section_shows_equivalent_provider_cost():
|
||||
assert "$0.00" not in cost_lines[0]
|
||||
|
||||
|
||||
def test_format_usage_section_honors_cost_target(monkeypatch):
|
||||
def test_usage_block_honors_cost_target(monkeypatch):
|
||||
monkeypatch.setenv("PRAGENT_PRICE_TARGET", "claude-haiku-4-5")
|
||||
usage = {"input": 1000, "output": 100, "reasoning": 0,
|
||||
"cache_read": 0, "cache_write": 0, "total": 1100,
|
||||
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
||||
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
|
||||
sec = ai_review._render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
||||
assert "Claude Haiku 4.5" in sec
|
||||
# 1k * $1/MTok + 100 * $5/MTok = 0.001 + 0.0005 = $0.0015
|
||||
assert "$0.0015" in sec
|
||||
|
||||
|
||||
def test_format_usage_section_respects_repo_config_cost_target(monkeypatch):
|
||||
def test_usage_block_respects_repo_config_cost_target(monkeypatch):
|
||||
monkeypatch.delenv("PRAGENT_PRICE_TARGET", raising=False)
|
||||
usage = {"input": 1000, "output": 100, "reasoning": 0,
|
||||
"cache_read": 0, "cache_write": 0, "total": 1100,
|
||||
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
||||
sec = ai_review.format_usage_section(
|
||||
usage, [], "glm-5.2:cloud", config={"cost_target": "claude-opus-5"}
|
||||
sec = ai_review._render_collapsible_usage(
|
||||
usage, "glm-5.2:cloud", config={"cost_target": "claude-opus-5"}
|
||||
)
|
||||
assert "Claude Opus 5" in sec
|
||||
# Opus 5 = $5/MTok input + $25/MTok output → 1000*5e-6 + 100*25e-6 = 0.0075
|
||||
assert "$0.0075" in sec
|
||||
|
||||
|
||||
def test_format_usage_section_reports_unknown_price_target():
|
||||
def test_usage_block_reports_unknown_price_target():
|
||||
usage = {"input": 100, "output": 100, "reasoning": 0,
|
||||
"cache_read": 0, "cache_write": 0, "total": 200,
|
||||
"cost": 0.0, "steps": 1, "duration_s": 1.0}
|
||||
sec = ai_review.format_usage_section(
|
||||
usage, [], "glm-5.2:cloud", config={"cost_target": "bogus-model"}
|
||||
sec = ai_review._render_collapsible_usage(
|
||||
usage, "glm-5.2:cloud", config={"cost_target": "bogus-model"}
|
||||
)
|
||||
# Falls back to default + surfaces the error in the line.
|
||||
assert "Claude Sonnet 5" in sec
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for pragent pilot diff_compress. No network."""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -20,7 +21,7 @@ diff --git a/src/a.py b/src/a.py
|
||||
index 1..2 100644
|
||||
--- a/src/a.py
|
||||
+++ b/src/a.py
|
||||
@@ -1,10 +1,11 @@
|
||||
@@ -1,20 +1,21 @@
|
||||
ctx1
|
||||
-removed
|
||||
+added
|
||||
@@ -30,8 +31,17 @@ index 1..2 100644
|
||||
ctx5
|
||||
ctx6
|
||||
ctx7
|
||||
+extra
|
||||
ctx8
|
||||
ctx9
|
||||
ctx10
|
||||
ctx11
|
||||
ctx12
|
||||
ctx13
|
||||
ctx14
|
||||
ctx15
|
||||
ctx16
|
||||
+extra
|
||||
ctx17
|
||||
@@ -20,3 +21,4 @@
|
||||
tail1
|
||||
tail2
|
||||
@@ -76,11 +86,12 @@ def test_compress_diff_negative_disables_compression():
|
||||
assert orig == kept
|
||||
|
||||
|
||||
def test_compress_diff_collapsed_gap_marker():
|
||||
# Two +/- lines separated by 14 context lines, context=2 — the gap between
|
||||
# them is 10 dropped lines (between the +/- windows), which exceeds the
|
||||
# 5-line marker threshold. The marker tells the reviewer there's more code
|
||||
# between the kept hunks.
|
||||
def test_compress_diff_collapsed_gap_splits_into_two_hunks():
|
||||
# Two +/- lines separated by 14 context lines, context=2. The dropped
|
||||
# middle is expressed by SPLITTING the hunk in two, each with a recomputed
|
||||
# `@@` header — not by a pseudo-marker line. `parse_diff_anchors` reads
|
||||
# `@@` headers to reset its line counter, so anything that looks like a
|
||||
# header but isn't one silently misanchors every following comment.
|
||||
middle = "\n".join(f" m{i}" for i in range(14)) + "\n" # trailing \n!
|
||||
diff = (
|
||||
"diff --git a/x.py b/x.py\n"
|
||||
@@ -95,7 +106,12 @@ def test_compress_diff_collapsed_gap_marker():
|
||||
)
|
||||
text, _, _ = compress_diff(diff, context=2)
|
||||
assert "+a" in text and "+b" in text
|
||||
assert "@@ …" in text and "context line(s) omitted" in text
|
||||
for m in ("m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "m10", "m11"):
|
||||
assert f" {m}\n" not in text # the gap itself is gone
|
||||
# Two hunks, and every emitted header is a real unified-diff header.
|
||||
headers = [ln for ln in text.splitlines() if ln.startswith("@@")]
|
||||
assert len(headers) == 2
|
||||
assert all(re.match(r"^@@ -\d+,\d+ \+\d+,\d+ @@", h) for h in headers)
|
||||
|
||||
|
||||
def test_compress_diff_strips_no_newline_marker():
|
||||
@@ -245,4 +261,78 @@ def test_compress_diff_preserves_anchors_for_post_change_lines():
|
||||
anchors = ai_review.parse_diff_anchors(text)
|
||||
assert 12 in anchors["x.py"] # +new
|
||||
# ctx_a is within 1 line of +new at line 12, so kept.
|
||||
assert 11 in anchors["x.py"]
|
||||
assert 11 in anchors["x.py"]
|
||||
|
||||
def test_compress_diff_keeps_post_change_line_numbers_exact():
|
||||
# The regression that motivated the hunk-header rewrite: dropping context
|
||||
# lines without renumbering shifted every anchor. Here `+new` really is
|
||||
# line 10 of the post-change file; compression must not move it.
|
||||
raw = (
|
||||
"diff --git a/x.py b/x.py\n"
|
||||
"--- a/x.py\n"
|
||||
"+++ b/x.py\n"
|
||||
"@@ -1,12 +1,12 @@\n"
|
||||
+ "".join(f" l{i}\n" for i in range(1, 10))
|
||||
+ "-old\n"
|
||||
+ "+new\n"
|
||||
+ " l11\n"
|
||||
)
|
||||
import ai_review
|
||||
raw_anchors = ai_review.parse_diff_anchors(raw)["x.py"]
|
||||
assert 10 in raw_anchors # +new
|
||||
text, _, _ = compress_diff(raw, context=1)
|
||||
comp_anchors = ai_review.parse_diff_anchors(text)["x.py"]
|
||||
# Compression only ever drops anchors; it never invents or moves one.
|
||||
assert comp_anchors <= raw_anchors
|
||||
assert 10 in comp_anchors # +new still anchors to its real line
|
||||
|
||||
|
||||
def test_compress_diff_content_line_starting_with_dashes_is_not_a_header():
|
||||
# A removed YAML document separator renders as `----`; an added one as
|
||||
# `+++new`. Treating those as file headers truncated the hunk body and
|
||||
# dropped the `@@` header with it.
|
||||
diff = (
|
||||
"diff --git a/x.yml b/x.yml\n"
|
||||
"--- a/x.yml\n"
|
||||
"+++ b/x.yml\n"
|
||||
"@@ -1,4 +1,4 @@\n"
|
||||
" a: 1\n"
|
||||
" b: 2\n"
|
||||
"----\n"
|
||||
"+++new\n"
|
||||
" c: 3\n"
|
||||
)
|
||||
text, _, _ = compress_diff(diff, context=1)
|
||||
assert "----" in text and "+++new" in text
|
||||
# The hunk header survives, so the body is still anchorable.
|
||||
headers = [ln for ln in text.splitlines() if _is_hunk_header(ln)]
|
||||
assert len(headers) == 1
|
||||
import ai_review
|
||||
assert ai_review.parse_diff_anchors(text)["x.yml"] == {2, 3, 4}
|
||||
|
||||
|
||||
def _is_hunk_header(line: str) -> bool:
|
||||
return bool(re.match(r"^@@ -\d+,\d+ \+\d+,\d+ @@", line))
|
||||
|
||||
|
||||
def test_extract_finding_bullets_matches_current_renderer_output():
|
||||
# The prior-review dedupe is only worth anything if it can read the
|
||||
# bullets pragent itself posts. `summary_bullets` renders an emoji badge
|
||||
# between the `-` and the `[SEV]` tag, which the original regex rejected.
|
||||
import ai_review
|
||||
findings = [
|
||||
{"path": "a.py", "line": 10, "severity": "high",
|
||||
"problem": "boom", "fix": "guard it", "suggestion": "", "reference": ""},
|
||||
{"path": "b.go", "line": 0, "severity": "low",
|
||||
"problem": "nit", "fix": "", "suggestion": "", "reference": ""},
|
||||
]
|
||||
body = ai_review.format_review_body(
|
||||
ai_review.summary_bullets(findings), "m", "abc123",
|
||||
findings_for_table=findings,
|
||||
)
|
||||
bullets = extract_finding_bullets(body)
|
||||
assert len(bullets) == 2
|
||||
assert any("a.py:10" in b and "boom" in b for b in bullets)
|
||||
# `**Fix:**` continuation lines are prose, not findings.
|
||||
assert all("**Fix:**" not in b for b in bullets)
|
||||
assert ai_review.compact_prior_reviews([body]) != []
|
||||
|
||||
@@ -750,10 +750,14 @@ def test_intersect_with_triage_preserves_order():
|
||||
assert [r.id for r in out] == ["security", "docs"]
|
||||
|
||||
|
||||
def test_intersect_with_triage_none_returns_all():
|
||||
def test_intersect_with_triage_none_fails_open_but_empty_selects_nothing():
|
||||
# The two must NOT be conflated: None is "triage gave no verdict, run
|
||||
# everything"; [] is "triage says no lens has surface", which the caller
|
||||
# short-circuits on. Returning all lenses for [] made a skip verdict run
|
||||
# every lens instead.
|
||||
reviewers = [oc.ReviewerSpec(id="security"), oc.ReviewerSpec(id="docs")]
|
||||
assert oc._intersect_with_triage(reviewers, None) == reviewers
|
||||
assert oc._intersect_with_triage(reviewers, []) == reviewers
|
||||
assert oc._intersect_with_triage(reviewers, []) == []
|
||||
|
||||
|
||||
def test_merge_usage_sums_tokens():
|
||||
@@ -772,3 +776,84 @@ def test_merge_usage_skips_none():
|
||||
merged = oc.merge_usage([a, None, None])
|
||||
assert merged["input"] == 100
|
||||
assert merged["steps"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# triage(): the empty-list verdict must survive as its own outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_triage_env(monkeypatch, agent_output: str):
|
||||
"""Make `triage()` runnable in-process: no opencode binary, no HOME setup."""
|
||||
class _Proc:
|
||||
stdout = "irrelevant — parse_opencode_events is stubbed"
|
||||
stderr = ""
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(oc, "_opencode_bin", lambda: "/bin/true")
|
||||
monkeypatch.setattr(oc, "_shared_home", lambda: "/tmp")
|
||||
monkeypatch.setattr(oc, "_warm_opencode", lambda home, model: None)
|
||||
monkeypatch.setattr(oc, "_build_env", lambda home: {})
|
||||
monkeypatch.setattr(oc.subprocess, "run", lambda *a, **k: _Proc())
|
||||
monkeypatch.setattr(oc, "parse_opencode_events", lambda raw: (agent_output, None))
|
||||
|
||||
|
||||
_TRIAGE_CFG = {"enabled": True, "model": "", "max_lenses": 5}
|
||||
|
||||
|
||||
def test_triage_empty_list_is_a_skip_verdict(monkeypatch):
|
||||
_stub_triage_env(monkeypatch, '{"lenses":[]}')
|
||||
reviewers = [oc.ReviewerSpec(id="security"), oc.ReviewerSpec(id="docs")]
|
||||
out = oc.triage("/tmp", _TRIAGE_CFG, reviewers, "m", "/tmp")
|
||||
# [] — NOT None. None would fail open and run every lens.
|
||||
assert out == []
|
||||
assert out is not None
|
||||
|
||||
|
||||
def test_triage_unknown_lens_ids_fail_open(monkeypatch):
|
||||
# A hallucinated roster is a bad answer, not a verdict of "nothing to
|
||||
# review" — it must fail open rather than silence the whole review.
|
||||
_stub_triage_env(monkeypatch, '{"lenses":["not-a-lens","also-fake"]}')
|
||||
reviewers = [oc.ReviewerSpec(id="security"), oc.ReviewerSpec(id="docs")]
|
||||
assert oc.triage("/tmp", _TRIAGE_CFG, reviewers, "m", "/tmp") is None
|
||||
|
||||
|
||||
def test_triage_valid_subset_selected(monkeypatch):
|
||||
_stub_triage_env(monkeypatch, '{"lenses":["docs","nope"]}')
|
||||
reviewers = [oc.ReviewerSpec(id="security"), oc.ReviewerSpec(id="docs")]
|
||||
assert oc.triage("/tmp", _TRIAGE_CFG, reviewers, "m", "/tmp") == ["docs"]
|
||||
|
||||
|
||||
def test_triage_disabled_fails_open(monkeypatch):
|
||||
_stub_triage_env(monkeypatch, '{"lenses":[]}')
|
||||
reviewers = [oc.ReviewerSpec(id="security")]
|
||||
cfg = {"enabled": False, "model": "", "max_lenses": 5}
|
||||
assert oc.triage("/tmp", cfg, reviewers, "m", "/tmp") is None
|
||||
|
||||
|
||||
def test_triage_malformed_output_fails_open(monkeypatch):
|
||||
_stub_triage_env(monkeypatch, "the agent wrote prose instead of JSON")
|
||||
reviewers = [oc.ReviewerSpec(id="security")]
|
||||
assert oc.triage("/tmp", _TRIAGE_CFG, reviewers, "m", "/tmp") is None
|
||||
|
||||
|
||||
def test_no_surface_response_parses_as_an_empty_review():
|
||||
# The skip path must return the same shape every other path returns.
|
||||
# A bare "" landed in ai_review's unparseable-output branch and posted
|
||||
# "AI review produced no parseable output" — a malfunction, not a verdict.
|
||||
import ai_review
|
||||
text, usage = oc._no_surface_response("o/r", "9", "abc12345", 3)
|
||||
assert usage is None
|
||||
summary, findings, _changes, _risks = ai_review.parse_review_output(text)
|
||||
assert findings == []
|
||||
assert summary # non-empty, so ai_review does NOT take the salvage branch
|
||||
assert "no review surface" in summary.lower()
|
||||
assert "3 configured lens" in summary
|
||||
|
||||
|
||||
def test_no_surface_response_zero_lenses_wording():
|
||||
import ai_review
|
||||
text, _ = oc._no_surface_response("o/r", "9", "abc12345", 0)
|
||||
summary, findings, _c, _r = ai_review.parse_review_output(text)
|
||||
assert findings == []
|
||||
assert "after path filtering" in summary
|
||||
|
||||
Reference in New Issue
Block a user