use anyhow::Result;
use serde::Serialize;
use crate::agent::agentic_review;
use crate::backend::{OpenRouterBackend, ReviewBackend, ReviewContext};
use crate::config::Config;
use crate::diff::parse_valid_lines;
use crate::llm::{Finding, Review, ReviewResult, Usage};
use crate::providers::{InlineComment, PrMeta, Provider, ReviewPost};
use crate::repo::Workspace;
use crate::repo_config;
pub struct RunReviewInput {
pub provider: String,
pub repo: String,
pub pr: u64,
pub dry_run: bool,
pub placeholder: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunReviewOutput {
pub provider: String,
pub repo: String,
pub pr: u64,
pub model: String,
pub recommendation: String,
pub findings: usize,
#[serde(default)]
pub findings_detail: Vec<Finding>,
pub inline_posted: usize,
pub posted: bool,
pub comment_url: Option<String>,
pub summary_markdown: String,
pub usage: Option<Usage>,
}
pub(crate) fn severity_rank(sev: &str) -> u8 {
match sev.to_uppercase().as_str() {
"BLOCKING" => 3,
"HIGH" => 2,
"MEDIUM" => 1,
"LOW" => 0,
_ => 0,
}
}
pub(crate) fn severity_emoji(sev: &str) -> &'static str {
match sev.to_uppercase().as_str() {
"BLOCKING" => "🚨",
"HIGH" => "⚠️",
"MEDIUM" => "ℹ️",
"LOW" => "💡",
_ => "•",
}
}
fn recommendation_rank(rec: &str) -> u8 {
let r = rec.to_uppercase();
if r.contains("BLOCK") {
2
} else if r.contains("CHANGES") {
1
} else {
0
}
}
fn effective_recommendation(model_rec: &str, findings: &[Finding]) -> String {
let max_sev = findings
.iter()
.map(|f| severity_rank(&f.severity))
.max()
.unwrap_or(0);
let floor = match max_sev {
3 => "BLOCK", 2 | 1 => "APPROVE WITH CHANGES", _ => "APPROVE", };
if recommendation_rank(model_rec) >= recommendation_rank(floor) {
model_rec.trim().to_string()
} else {
floor.to_string()
}
}
const BUILD_CLAIM_MARKERS: &[&str] = &[
"breaks the build",
"break the build",
"fails the build",
"fail the build",
"fails to compile",
"won't compile",
"will not compile",
"ci would go red",
"ci will go red",
"fails ci",
"fail ci",
];
const WEAK_FAILURE_PHRASES: &[&str] = &["will fail", "would fail", "fails when", "would break"];
const CHECK_CONTEXT_WORDS: &[&str] = &[
"ci",
"build",
"builds",
"compile",
"compiles",
"compilation",
"clippy",
"rustfmt",
"fmt",
"lint",
"linter",
"eslint",
"pipeline",
"workflow",
];
const CHECK_CONTEXT_PHRASES: &[&str] = &[
"--check",
"cargo fmt",
"cargo test",
"cargo clippy",
"test suite",
"check job",
"the check",
"check will",
"check would",
];
fn asserts_check_outcome(body_lower: &str) -> bool {
if BUILD_CLAIM_MARKERS.iter().any(|m| body_lower.contains(m)) {
return true;
}
if !WEAK_FAILURE_PHRASES.iter().any(|m| body_lower.contains(m)) {
return false;
}
if CHECK_CONTEXT_PHRASES.iter().any(|p| body_lower.contains(p)) {
return true;
}
body_lower
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|w| CHECK_CONTEXT_WORDS.contains(&w))
}
fn ci_is_all_green(ci_status: Option<&str>) -> bool {
let Some(ci) = ci_status.map(str::trim).filter(|s| !s.is_empty()) else {
return false;
};
if ci.contains("NOT shown") {
return false;
}
let states: Vec<&str> = ci
.lines()
.filter_map(|l| l.rsplit_once(':').map(|(_, s)| s.trim()))
.collect();
!states.is_empty()
&& states
.iter()
.all(|s| s.eq_ignore_ascii_case("success") || s.eq_ignore_ascii_case("successful"))
}
fn demote_falsified_build_claims(findings: &mut [Finding], ci_status: Option<&str>) {
if !ci_is_all_green(ci_status) {
return;
}
for f in findings.iter_mut() {
if severity_rank(&f.severity) == 0 {
continue; }
if !asserts_check_outcome(&f.body.to_lowercase()) {
continue;
}
tracing::warn!(
"demoting a {} finding on {} to LOW: it asserts a check outcome CI reports green",
f.severity,
f.file
);
f.severity = "LOW".to_string();
f.body = format!(
"{}\n\n(Every CI check on the reviewed commit passed, which contradicts the \
claim that this fails a check — capped at LOW. The underlying observation may \
still be worth acting on; the predicted failure is not.)",
f.body.trim_end()
);
}
}
const BURST_THRESHOLD: usize = 3;
fn burst_key(f: &Finding) -> String {
let mut out = String::new();
let mut in_tick = false;
for c in f.body.chars() {
match c {
'`' => in_tick = !in_tick,
_ if in_tick || c.is_ascii_digit() => {}
_ => out.extend(c.to_lowercase()),
}
}
out.split_whitespace()
.take(12)
.collect::<Vec<_>>()
.join(" ")
}
fn collapse_bursts(findings: Vec<Finding>) -> Vec<Finding> {
let mut groups: Vec<(String, Vec<Finding>)> = Vec::new();
for f in findings {
let key = format!("{}|{}", f.severity.to_uppercase(), burst_key(&f));
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, g)) => g.push(f),
None => groups.push((key, vec![f])),
}
}
let mut out = Vec::new();
for (key, mut group) in groups {
if group.len() < BURST_THRESHOLD {
out.append(&mut group);
continue;
}
let max_rank = group
.iter()
.map(|f| severity_rank(&f.severity))
.max()
.unwrap_or(0);
if max_rank >= severity_rank("HIGH") {
tracing::info!(
"not collapsing {} findings at HIGH+ despite a shared claim: \"{key}\"",
group.len()
);
out.append(&mut group);
continue;
}
let n = group.len();
tracing::warn!("collapsed {n} findings that make the same claim: \"{key}\"");
let best = group
.iter()
.enumerate()
.max_by_key(|(i, f)| (severity_rank(&f.severity), std::cmp::Reverse(*i)))
.map_or(0, |(i, _)| i);
let mut rep = group.remove(best);
const MAX_NAMED: usize = 10;
let mut named: Vec<String> = group
.iter()
.take(MAX_NAMED)
.map(|f| format!("`{}`", f.file))
.collect();
if group.len() > MAX_NAMED {
named.push(format!("and {} more", group.len() - MAX_NAMED));
}
rep.body = format!(
"{} \n\nThe same applies to {} other file(s) in this change ({}) — reported once \
rather than {n} times. If that pattern is expected here (vendored or generated \
code, a mechanical refactor), treat this as one decision, not {n}.",
rep.body.trim_end(),
group.len(),
named.join(", ")
);
out.push(rep);
}
out
}
fn inline_body(f: &Finding) -> String {
format!(
"{} **{}** — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.body.trim()
)
}
const REANCHOR_WINDOW: i64 = 3;
const MIN_ANCHOR_SYMBOL_LEN: usize = 4;
fn idents(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for ch in s.chars() {
if ch.is_alphanumeric() || ch == '_' {
cur.push(ch);
} else if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn line_symbols(text: &str) -> Vec<String> {
const KW: &[&str] = &[
"const",
"let",
"var",
"function",
"return",
"import",
"export",
"from",
"class",
"interface",
"type",
"public",
"private",
"protected",
"static",
"async",
"await",
"for",
"while",
"new",
"void",
"null",
"true",
"false",
"this",
"self",
"def",
"func",
"pub",
"use",
"mod",
"struct",
"enum",
"impl",
"package",
];
idents(text)
.into_iter()
.filter(|w| w.len() >= 3 && !KW.contains(&w.as_str()))
.collect()
}
fn reanchor(
line: u64,
valid: &std::collections::HashSet<u64>,
texts: &std::collections::HashMap<u64, String>,
body: &str,
) -> Option<u64> {
let body_words: std::collections::HashSet<String> = line_symbols(body).into_iter().collect();
if body_words.is_empty() {
return None;
}
let mut cands: Vec<u64> = valid
.iter()
.copied()
.filter(|&c| c != line && (c as i64 - line as i64).abs() <= REANCHOR_WINDOW)
.collect();
cands.sort_by_key(|&c| ((c as i64 - line as i64).abs(), c));
for c in cands {
if let Some(text) = texts.get(&c) {
if line_symbols(text)
.iter()
.any(|s| s.len() >= MIN_ANCHOR_SYMBOL_LEN && body_words.contains(s))
{
return Some(c);
}
}
}
None
}
fn render_summary(
review: &Review,
recommendation: &str,
unanchored: &[&Finding],
inline_count: usize,
) -> String {
let mut s = format!(
"🤖 **Automated review**\n\n{}\n\n**Recommendation:** {}",
review.summary.trim(),
recommendation.trim()
);
if inline_count > 0 {
s.push_str(&format!("\n\n_{inline_count} inline comment(s) below._"));
}
if unanchored.is_empty() {
if inline_count == 0 {
s.push_str("\n\nNo blocking issues found.");
}
} else {
s.push_str("\n\n## Findings");
for f in unanchored {
let loc = match f.line {
Some(l) => format!("`{}` (~{l})", f.file),
None => format!("`{}`", f.file),
};
s.push_str(&format!(
"\n- {} **{}** — {loc} — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.body.trim()
));
}
}
s.push_str("\n\n_Automated advisory review — a human still owns the merge decision._");
s
}
fn render_pending() -> String {
"🤖 **Automated review**\n\n⏳ _Reviewing this PR… (this comment will update shortly)_"
.to_string()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_agentic(
provider: &Provider,
client: &reqwest::Client,
cfg: &Config,
meta: &PrMeta,
diff: &str,
omitted_note: Option<&str>,
structural_context: Option<&str>,
repo: &str,
system_prompt: &str,
) -> Result<ReviewResult> {
let url = provider.clone_url(cfg, repo)?;
let sha = meta.head_sha.clone();
let ws = tokio::task::spawn_blocking(move || Workspace::clone(&url, sha.as_deref())).await??;
agentic_review(
client,
cfg,
meta,
diff,
omitted_note,
structural_context,
&ws,
system_prompt,
)
.await
}
pub(crate) async fn load_repo_config(
provider: &Provider,
client: &reqwest::Client,
base: &Config,
repo: &str,
meta: &PrMeta,
) -> Config {
let git_ref = match (meta.head_sha.as_deref(), meta.base_branch.as_deref()) {
(Some(sha), _) if !sha.is_empty() => sha,
(_, Some(branch)) if !branch.is_empty() => branch,
_ => return base.clone(),
};
match provider
.get_file_contents(client, base, repo, git_ref, ".prbot.toml")
.await
{
Ok(Some(text)) => match repo_config::parse(&text) {
Ok(rc) => {
tracing::info!("applied .prbot.toml overrides for {repo}");
base.with_repo_overrides(&rc)
}
Err(e) => {
tracing::warn!("ignoring invalid .prbot.toml for {repo}: {e:#}");
base.clone()
}
},
Ok(None) => base.clone(),
Err(e) => {
tracing::warn!("could not fetch .prbot.toml for {repo}: {e:#}");
base.clone()
}
}
}
fn render_no_review_summary(
advisories: &[crate::deps::DepAdvisory],
hygiene: &[Finding],
) -> String {
let mut s = String::from(
"🤖 **Automated review**\n\nNo reviewable source changes (all files excluded by filters).",
);
if !advisories.is_empty() {
s.push_str("\n\n");
s.push_str(&crate::deps::render_advisories(advisories));
}
if !hygiene.is_empty() {
s.push_str("\n\n## Findings");
for f in hygiene {
s.push_str(&format!(
"\n- {} **{}** — `{}` — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.file,
f.body.trim()
));
}
}
s.push_str("\n\n_Automated advisory review — a human still owns the merge decision._");
s
}
async fn post_advisory_only(
provider: &Provider,
client: &reqwest::Client,
cfg: &Config,
meta: &PrMeta,
input: &RunReviewInput,
advisories: Vec<crate::deps::DepAdvisory>,
hygiene: Vec<Finding>,
) -> Result<RunReviewOutput> {
let summary = render_no_review_summary(&advisories, &hygiene);
let baseline = if advisories.is_empty() {
"APPROVE"
} else {
"APPROVE WITH CHANGES"
};
let recommendation = effective_recommendation(baseline, &hygiene);
let post = ReviewPost {
summary: summary.clone(),
inline: Vec::new(),
};
let mut out = RunReviewOutput {
provider: provider.name().to_string(),
repo: input.repo.clone(),
pr: input.pr,
model: cfg.openrouter_model.clone(),
recommendation,
findings: hygiene.len(),
findings_detail: hygiene,
inline_posted: 0,
posted: false,
comment_url: None,
summary_markdown: summary,
usage: None,
};
if !input.dry_run {
out.comment_url = provider.post_review(client, cfg, meta, &post).await?;
out.posted = true;
}
Ok(out)
}
struct PreparedDiff {
diff: String,
hygiene: Vec<Finding>,
omitted_note: Option<String>,
}
fn prepare_diff(cfg: &Config, raw_diff: &str) -> PreparedDiff {
let (diff, dropped) =
crate::diff::filter_diff_by_globs(raw_diff, &cfg.include_globs, &cfg.exclude_globs);
if !dropped.is_empty() {
tracing::info!("skipped {} file(s) by glob: {:?}", dropped.len(), dropped);
}
let hygiene: Vec<Finding> = crate::diff::diff_hygiene_with(raw_diff, &cfg.vendored_globs)
.into_iter()
.map(|h| Finding {
severity: h.severity.to_string(),
file: h.file,
line: None,
body: h.body,
confidence: Some(100),
})
.collect();
if diff.trim().is_empty() {
return PreparedDiff {
diff,
hygiene,
omitted_note: None,
};
}
let (diff, packed_dropped) = if cfg.file_bundling {
crate::diff::pack_diff_bundled(&diff, cfg.max_diff_chars)
} else {
crate::diff::pack_diff(&diff, cfg.max_diff_chars)
};
if !packed_dropped.is_empty() {
tracing::info!(
"packed diff: omitted {} lower-priority file(s) to fit budget: {:?}",
packed_dropped.len(),
packed_dropped
);
}
let omitted_note = (!packed_dropped.is_empty()).then(|| {
format!(
"{} file(s) were omitted to fit the size limit and were NOT reviewed: {}",
packed_dropped.len(),
packed_dropped.join(", ")
)
});
PreparedDiff {
diff,
hygiene,
omitted_note,
}
}
struct FinishedReview {
findings: Vec<Finding>,
recommendation: String,
summary: String,
inline: Vec<InlineComment>,
}
async fn finish_review(
cfg: &Config,
backend: &dyn ReviewBackend,
meta: &PrMeta,
diff: &str,
result: &ReviewResult,
hygiene: Vec<Finding>,
) -> FinishedReview {
let mut findings = result.review.findings.clone();
if cfg.self_critique && !findings.is_empty() {
findings = match crate::llm::critique_findings(cfg, backend, meta, diff, &findings).await {
Ok(f) => f,
Err(e) => {
tracing::warn!("self-critique failed ({e:#}); keeping original findings");
findings
}
};
}
findings.retain(|f| f.confidence.unwrap_or(100) >= cfg.min_confidence);
findings.extend(hygiene);
demote_falsified_build_claims(&mut findings, meta.ci_status.as_deref());
findings = collapse_bursts(findings);
findings.sort_by(|a, b| {
severity_rank(&b.severity)
.cmp(&severity_rank(&a.severity))
.then(b.confidence.unwrap_or(0).cmp(&a.confidence.unwrap_or(0)))
});
let recommendation = effective_recommendation(&result.review.recommendation, &findings);
findings.truncate(cfg.max_findings);
let valid = parse_valid_lines(diff);
let line_texts = if cfg.reanchor_findings {
crate::diff::diff_line_texts(diff)
} else {
std::collections::HashMap::new()
};
let mut inline: Vec<InlineComment> = Vec::new();
let mut unanchored: Vec<&Finding> = Vec::new();
for f in &findings {
let mut anchor = f
.line
.filter(|l| valid.get(&f.file).is_some_and(|s| s.contains(l)));
if anchor.is_none() && cfg.reanchor_findings {
if let (Some(l), Some(v), Some(t)) =
(f.line, valid.get(&f.file), line_texts.get(&f.file))
{
anchor = reanchor(l, v, t, &f.body);
}
}
match anchor {
Some(line) => inline.push(InlineComment {
path: f.file.clone(),
line,
body: inline_body(f),
}),
None => unanchored.push(f),
}
}
let summary = render_summary(&result.review, &recommendation, &unanchored, inline.len());
FinishedReview {
findings,
recommendation,
summary,
inline,
}
}
pub async fn run_review(cfg: &Config, input: RunReviewInput) -> Result<RunReviewOutput> {
run_review_with(cfg, input, &OpenRouterBackend).await
}
pub async fn run_review_with(
cfg: &Config,
input: RunReviewInput,
backend: &dyn ReviewBackend,
) -> Result<RunReviewOutput> {
let provider = Provider::from_name(&input.provider)?;
let client = reqwest::Client::new();
let meta = provider
.get_meta(&client, cfg, &input.repo, input.pr)
.await?;
let effective = load_repo_config(&provider, &client, cfg, &input.repo, &meta).await;
let cfg = &effective;
if input.placeholder && !input.dry_run {
let pending = ReviewPost {
summary: render_pending(),
inline: Vec::new(),
};
if let Err(e) = provider.post_review(&client, cfg, &meta, &pending).await {
tracing::warn!(
"placeholder comment failed for {}#{}: {e:#}",
input.repo,
input.pr
);
}
}
let raw_diff = provider
.get_diff(&client, cfg, &input.repo, input.pr)
.await?;
let advisories = crate::deps::scan(&client, cfg, &raw_diff).await;
if !advisories.is_empty() {
tracing::info!(
"OSV: {} dependency advisor(y/ies) for {}#{}",
advisories.len(),
input.repo,
input.pr
);
}
let prepared = prepare_diff(cfg, &raw_diff);
if prepared.diff.trim().is_empty() {
if !advisories.is_empty() || !prepared.hygiene.is_empty() {
return post_advisory_only(
&provider,
&client,
cfg,
&meta,
&input,
advisories,
prepared.hygiene,
)
.await;
}
anyhow::bail!(
"PR diff is empty (all files excluded by globs, or no changes) — nothing to review."
);
}
let PreparedDiff {
diff,
hygiene,
omitted_note,
} = prepared;
let structural = if cfg.structural_context {
crate::structure::structural_context(&provider, &client, cfg, &input.repo, &meta, &diff)
.await
} else {
String::new()
};
if !structural.is_empty() {
tracing::info!(
"structural context for {}#{}: {} line(s)",
input.repo,
input.pr,
structural.lines().count()
);
}
let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
let injected_rules = crate::prompt::injected_rules(cfg);
let ctx = ReviewContext {
client: &client,
cfg,
provider: Some(&provider),
local_root: None,
repo: &input.repo,
meta: &meta,
diff: &diff,
omitted_note: omitted_note.as_deref(),
structural_context: structural_opt,
injected_rules: &injected_rules,
};
let result = backend.review(&ctx).await?;
let mut finished = finish_review(cfg, backend, &meta, &diff, &result, hygiene).await;
if !advisories.is_empty() {
finished.summary.push_str("\n\n");
finished
.summary
.push_str(&crate::deps::render_advisories(&advisories));
}
let FinishedReview {
findings,
recommendation,
summary,
inline,
} = finished;
let inline_count = inline.len();
let post = ReviewPost {
summary: summary.clone(),
inline,
};
let mut out = RunReviewOutput {
provider: provider.name().to_string(),
repo: input.repo.clone(),
pr: input.pr,
model: result.model,
recommendation,
findings: findings.len(),
findings_detail: findings,
inline_posted: inline_count,
posted: false,
comment_url: None,
summary_markdown: summary,
usage: result.usage,
};
if !input.dry_run {
out.comment_url = provider.post_review(&client, cfg, &meta, &post).await?;
out.posted = true;
}
Ok(out)
}
pub struct LocalReviewInput {
pub diff: String,
pub repo_root: Option<std::path::PathBuf>,
pub label: String,
}
pub async fn run_review_local(
cfg: &Config,
input: LocalReviewInput,
backend: &dyn ReviewBackend,
) -> Result<RunReviewOutput> {
let client = reqwest::Client::new();
let meta = PrMeta {
repo: input.label.clone(),
pr: 0,
title: (!input.label.trim().is_empty()).then(|| input.label.clone()),
base_branch: None,
head_sha: None,
body: None,
ci_status: None,
};
let prepared = prepare_diff(cfg, &input.diff);
if prepared.diff.trim().is_empty() {
if !prepared.hygiene.is_empty() {
let summary = render_no_review_summary(&[], &prepared.hygiene);
let recommendation = effective_recommendation("APPROVE", &prepared.hygiene);
return Ok(RunReviewOutput {
provider: LOCAL_PROVIDER.to_string(),
repo: input.label,
pr: 0,
model: String::new(),
recommendation,
findings: prepared.hygiene.len(),
findings_detail: prepared.hygiene,
inline_posted: 0,
posted: false,
comment_url: None,
summary_markdown: summary,
usage: None,
});
}
anyhow::bail!(
"Diff is empty (all files excluded by globs, or no changes) — nothing to review."
);
}
let PreparedDiff {
diff,
hygiene,
omitted_note,
} = prepared;
let structural = match (cfg.structural_context, input.repo_root.as_deref()) {
(true, Some(root)) => crate::structure::structural_context_local(cfg, root, &diff),
(true, None) => crate::structure::hunk_context(&diff),
(false, _) => String::new(),
};
if !structural.is_empty() {
tracing::info!(
"structural context for {}: {} line(s)",
input.label,
structural.lines().count()
);
}
let injected_rules = crate::prompt::injected_rules(cfg);
let ctx = ReviewContext {
client: &client,
cfg,
provider: None,
local_root: input.repo_root.as_deref(),
repo: &input.label,
meta: &meta,
diff: &diff,
omitted_note: omitted_note.as_deref(),
structural_context: (!structural.is_empty()).then_some(structural.as_str()),
injected_rules: &injected_rules,
};
let result = backend.review(&ctx).await?;
let finished = finish_review(cfg, backend, &meta, &diff, &result, hygiene).await;
Ok(RunReviewOutput {
provider: LOCAL_PROVIDER.to_string(),
repo: input.label,
pr: 0,
model: result.model,
recommendation: finished.recommendation,
findings: finished.findings.len(),
findings_detail: finished.findings,
inline_posted: finished.inline.len(),
posted: false,
comment_url: None,
summary_markdown: finished.summary,
usage: result.usage,
})
}
pub const LOCAL_PROVIDER: &str = "local";
#[cfg(test)]
mod local_review_tests {
use std::sync::{Arc, Mutex};
use anyhow::Result;
use async_trait::async_trait;
use super::{run_review_local, LocalReviewInput, LOCAL_PROVIDER};
use crate::backend::{ReviewBackend, ReviewContext};
use crate::config::Config;
use crate::llm::{Finding, Review, ReviewResult};
const DIFF: &str = "diff --git a/src/order.rs b/src/order.rs\n\
--- a/src/order.rs\n\
+++ b/src/order.rs\n\
@@ -1,3 +1,5 @@\n\
\x20fn total(items: &[u32]) -> u32 {\n\
- items.iter().sum()\n\
+ let mut t = 0;\n\
+ for i in items { t += i; }\n\
+ t\n\
\x20}\n";
const NEW_SIDE: &str = "fn total(items: &[u32]) -> u32 {\n let mut t = 0;\n for i in items { t += i; }\n t\n}\n";
struct SeenCtx {
structural: Option<String>,
had_provider: bool,
}
type Seen = Arc<Mutex<Vec<SeenCtx>>>;
struct LocalSpy {
seen: Seen,
}
#[async_trait]
impl ReviewBackend for LocalSpy {
async fn review(&self, ctx: &ReviewContext<'_>) -> Result<ReviewResult> {
self.seen.lock().unwrap().push(SeenCtx {
structural: ctx.structural_context.map(str::to_string),
had_provider: ctx.provider.is_some(),
});
Ok(ReviewResult {
review: Review {
summary: "an accumulator replaced a fold".to_string(),
recommendation: "APPROVE WITH CHANGES".to_string(),
findings: vec![Finding {
severity: "MEDIUM".to_string(),
file: "src/order.rs".to_string(),
line: Some(3),
body: "`t` can overflow on a long list. Fix: use checked_add.".to_string(),
confidence: Some(90),
}],
},
model: "spy".to_string(),
usage: None,
})
}
}
fn cfg() -> Config {
let mut c = Config::from_env();
c.self_critique = false; c.min_confidence = 0;
c.extra_system_prompt = String::new();
c
}
fn spy() -> (LocalSpy, Seen) {
let seen = Arc::new(Mutex::new(Vec::new()));
(
LocalSpy {
seen: Arc::clone(&seen),
},
seen,
)
}
#[tokio::test]
async fn a_local_diff_returns_anchored_findings() {
let (backend, seen) = spy();
let out = run_review_local(
&cfg(),
LocalReviewInput {
diff: DIFF.to_string(),
repo_root: None,
label: "vexar/session-7".to_string(),
},
&backend,
)
.await
.expect("the local review runs");
assert_eq!(out.findings_detail.len(), 1);
assert_eq!(
out.inline_posted, 1,
"the finding anchored to a real diff line"
);
assert_eq!(out.recommendation, "APPROVE WITH CHANGES");
assert_eq!(out.provider, LOCAL_PROVIDER);
assert_eq!(out.repo, "vexar/session-7");
assert_eq!(out.pr, 0);
assert!(!out.posted, "a local review has nothing to post to");
assert!(out
.summary_markdown
.contains("an accumulator replaced a fold"));
let seen = seen.lock().unwrap();
assert!(
!seen[0].had_provider,
"no provider is invented for a local diff"
);
}
#[tokio::test]
async fn a_finding_off_the_diff_folds_into_the_summary() {
struct OffDiff;
#[async_trait]
impl ReviewBackend for OffDiff {
async fn review(&self, _ctx: &ReviewContext<'_>) -> Result<ReviewResult> {
Ok(ReviewResult {
review: Review {
summary: "s".to_string(),
recommendation: "APPROVE".to_string(),
findings: vec![Finding {
severity: "LOW".to_string(),
file: "src/order.rs".to_string(),
line: Some(900),
body: "something far away entirely".to_string(),
confidence: Some(50),
}],
},
model: "spy".to_string(),
usage: None,
})
}
}
let out = run_review_local(
&cfg(),
LocalReviewInput {
diff: DIFF.to_string(),
repo_root: None,
label: "branch".to_string(),
},
&OffDiff,
)
.await
.expect("the local review runs");
assert_eq!(out.inline_posted, 0);
assert!(out.summary_markdown.contains("something far away entirely"));
}
#[tokio::test]
async fn a_repo_root_supplies_tree_sitter_structural_context() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/order.rs"), NEW_SIDE).unwrap();
let (backend, seen) = spy();
let mut c = cfg();
c.structural_context = true;
run_review_local(
&c,
LocalReviewInput {
diff: DIFF.to_string(),
repo_root: Some(dir.path().to_path_buf()),
label: "branch".to_string(),
},
&backend,
)
.await
.expect("the local review runs");
let seen = seen.lock().unwrap();
let structural = seen[0].structural.as_deref().unwrap_or_default();
assert!(
structural.contains("Changed symbols") && structural.contains("total"),
"Tier B did not name the enclosing function from the checkout: {structural:?}"
);
}
#[tokio::test]
async fn an_all_excluded_diff_never_reaches_the_backend() {
let (backend, seen) = spy();
let lockfile = "diff --git a/Cargo.lock b/Cargo.lock\n\
--- a/Cargo.lock\n\
+++ b/Cargo.lock\n\
@@ -1,2 +1,3 @@\n\
\x20[[package]]\n\
+name = \"new-dep\"\n";
let out = run_review_local(
&cfg(),
LocalReviewInput {
diff: lockfile.to_string(),
repo_root: None,
label: "branch".to_string(),
},
&backend,
)
.await;
assert!(
seen.lock().unwrap().is_empty(),
"the backend was not called"
);
if let Ok(o) = out {
assert!(!o.findings_detail.is_empty());
assert_eq!(o.inline_posted, 0);
}
}
#[tokio::test]
async fn an_empty_diff_is_an_error_not_an_empty_review() {
let (backend, _seen) = spy();
let err = run_review_local(
&cfg(),
LocalReviewInput {
diff: String::new(),
repo_root: None,
label: "branch".to_string(),
},
&backend,
)
.await
.expect_err("an empty diff has nothing to review");
assert!(err.to_string().contains("nothing to review"));
}
}
#[cfg(test)]
mod orchestrator_tests {
use std::sync::{Arc, Mutex};
use anyhow::Result;
use async_trait::async_trait;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{run_review_with, RunReviewInput};
use crate::backend::{ReviewBackend, ReviewContext};
use crate::config::Config;
use crate::llm::{Review, ReviewResult};
const DIFF: &str = "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,2 +1,3 @@\n fn alpha() {}\n+fn beta() {}\n";
struct SpyBackend {
seen: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl ReviewBackend for SpyBackend {
async fn review(&self, ctx: &ReviewContext<'_>) -> Result<ReviewResult> {
self.seen
.lock()
.unwrap()
.push(ctx.system_prompt("MY OWN RUBRIC."));
Ok(ReviewResult {
review: Review {
summary: "nothing to report".to_string(),
recommendation: "APPROVE".to_string(),
findings: Vec::new(),
},
model: "spy".to_string(),
usage: None,
})
}
}
async fn github_stub() -> MockServer {
let s = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/o/r/pulls/1"))
.and(header("accept", "application/vnd.github.diff"))
.respond_with(ResponseTemplate::new(200).set_body_string(DIFF))
.mount(&s)
.await;
Mock::given(method("GET"))
.and(path("/repos/o/r/pulls/1"))
.and(header("accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"title": "a change",
"body": null,
"base": { "ref": "main" },
"head": { "sha": "deadbeef" },
})))
.mount(&s)
.await;
s
}
fn cfg_for(base: &str) -> Config {
let mut c = Config::from_env();
c.github_api_base = base.to_string();
c.github_token = "test-token".to_string();
c.extra_system_prompt = String::new(); c.ci_status = false;
c.cve_scan = false;
c.structural_context = false;
c.self_critique = false;
c.agentic = false;
c
}
fn input() -> RunReviewInput {
RunReviewInput {
provider: "github".to_string(),
repo: "o/r".to_string(),
pr: 1,
dry_run: true,
placeholder: false,
}
}
#[tokio::test]
async fn the_orchestrator_hands_the_review_rules_to_every_backend() {
let srv = github_stub().await;
let seen = Arc::new(Mutex::new(Vec::new()));
let backend = SpyBackend {
seen: Arc::clone(&seen),
};
run_review_with(&cfg_for(&srv.uri()), input(), &backend)
.await
.expect("the review runs");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1, "the backend was called once");
let prompt = &seen[0];
assert!(
prompt.starts_with("MY OWN RUBRIC."),
"the backend's own rubric leads; the rules follow it"
);
for rule in [
"THROWS is never LOW",
"config that ENFORCES it",
"breaks the build",
"vendored",
"raise it ONCE",
"middleware, guard, decorator",
] {
assert!(
prompt.contains(rule),
"the backend never received the rule containing {rule:?}"
);
}
}
struct CritiqueSpy {
asked: Arc<Mutex<Vec<(String, String)>>>,
reply: Option<&'static str>,
}
fn proposed(file: &str, body: &str) -> crate::llm::Finding {
crate::llm::Finding {
severity: "MEDIUM".to_string(),
file: file.to_string(),
line: None,
body: body.to_string(),
confidence: Some(80),
}
}
#[async_trait]
impl ReviewBackend for CritiqueSpy {
async fn review(&self, _ctx: &ReviewContext<'_>) -> Result<ReviewResult> {
Ok(ReviewResult {
review: Review {
summary: "two things".to_string(),
recommendation: "APPROVE WITH CHANGES".to_string(),
findings: vec![
proposed("src/a.rs", "a real problem"),
proposed("src/a.rs", "a noisy nit"),
],
},
model: "spy".to_string(),
usage: None,
})
}
async fn complete(&self, _cfg: &Config, system: &str, user: &str) -> Result<String> {
self.asked
.lock()
.unwrap()
.push((system.to_string(), user.to_string()));
match self.reply {
Some(r) => Ok(r.to_string()),
None => anyhow::bail!("backend is down"),
}
}
}
fn critique_cfg(base: &str) -> Config {
let mut c = cfg_for(base);
c.self_critique = true;
c.min_confidence = 0; c
}
#[tokio::test]
async fn the_critique_runs_on_the_backend_that_produced_the_review() {
let srv = github_stub().await;
let asked = Arc::new(Mutex::new(Vec::new()));
let backend = CritiqueSpy {
asked: Arc::clone(&asked),
reply: Some(
r#"[{"severity":"MEDIUM","file":"src/a.rs","body":"a real problem","confidence":90}]"#,
),
};
let out = run_review_with(&critique_cfg(&srv.uri()), input(), &backend)
.await
.expect("the review runs");
let asked = asked.lock().unwrap();
assert_eq!(asked.len(), 1, "complete() carried the critique");
let (system, user) = &asked[0];
assert!(
system.contains("skeptical senior reviewer"),
"the critique system prompt reached the backend: {system}"
);
assert!(
user.contains("PROPOSED FINDINGS") && user.contains("a noisy nit"),
"the proposed findings reached the backend"
);
assert_eq!(
out.findings_detail.len(),
1,
"the critique's pruning is honored"
);
assert!(out.findings_detail[0].body.contains("a real problem"));
}
#[tokio::test]
async fn a_failed_critique_keeps_the_original_findings() {
let srv = github_stub().await;
let backend = CritiqueSpy {
asked: Arc::new(Mutex::new(Vec::new())),
reply: None, };
let out = run_review_with(&critique_cfg(&srv.uri()), input(), &backend)
.await
.expect("the review still succeeds");
assert_eq!(out.findings_detail.len(), 2, "both proposals survive");
}
#[tokio::test]
async fn an_unparseable_critique_keeps_the_original_findings() {
let srv = github_stub().await;
let backend = CritiqueSpy {
asked: Arc::new(Mutex::new(Vec::new())),
reply: Some(r#"[{"nonsense":true},{"also":"nonsense"}]"#),
};
let out = run_review_with(&critique_cfg(&srv.uri()), input(), &backend)
.await
.expect("the review still succeeds");
assert_eq!(out.findings_detail.len(), 2, "both proposals survive");
}
#[tokio::test]
async fn an_empty_critique_verdict_is_obeyed() {
let srv = github_stub().await;
let backend = CritiqueSpy {
asked: Arc::new(Mutex::new(Vec::new())),
reply: Some("[]"),
};
let out = run_review_with(&critique_cfg(&srv.uri()), input(), &backend)
.await
.expect("the review still succeeds");
assert_eq!(out.findings_detail.len(), 0, "all findings were noise");
}
#[tokio::test]
async fn the_consumers_extra_prompt_rides_along_with_the_rules() {
let srv = github_stub().await;
let mut cfg = cfg_for(&srv.uri());
cfg.extra_system_prompt = "House rule: never widen a public API.".to_string();
let seen = Arc::new(Mutex::new(Vec::new()));
let backend = SpyBackend {
seen: Arc::clone(&seen),
};
run_review_with(&cfg, input(), &backend)
.await
.expect("the review runs");
let seen = seen.lock().unwrap();
let prompt = &seen[0];
assert!(
prompt.contains("THROWS is never LOW"),
"rules still present"
);
assert!(
prompt.contains("House rule: never widen a public API."),
"a consumer's extra prompt reaches the backend too"
);
}
}
#[cfg(test)]
mod tests {
use super::{
burst_key, collapse_bursts, demote_falsified_build_claims, effective_recommendation,
idents, line_symbols, reanchor, render_no_review_summary,
};
use crate::llm::Finding;
use std::collections::{HashMap, HashSet};
fn f(severity: &str, file: &str, body: &str) -> Finding {
Finding {
severity: severity.to_string(),
file: file.to_string(),
line: None,
body: body.to_string(),
confidence: Some(100),
}
}
#[test]
fn a_repeated_claim_collapses_into_one_finding() {
let findings = vec![
f("LOW", "a.cxx", "`a.cxx` adds 2192 lines in one new file."),
f("LOW", "b.cxx", "`b.cxx` adds 1868 lines in one new file."),
f("LOW", "c.cxx", "`c.cxx` adds 1268 lines in one new file."),
f("HIGH", "d.rs", "Unvalidated input reaches the query."),
];
let out = collapse_bursts(findings);
assert_eq!(out.len(), 2, "three same-shape findings become one");
assert!(out[0].body.contains("2 other file(s)"));
assert!(out[0].body.contains("`b.cxx`"));
assert_eq!(out[1].file, "d.rs");
assert!(!out[1].body.contains("other file(s)"));
}
#[test]
fn two_of_a_kind_are_left_alone() {
let findings = vec![
f("LOW", "a.cxx", "`a.cxx` adds 2192 lines in one new file."),
f("LOW", "b.cxx", "`b.cxx` adds 1868 lines in one new file."),
];
assert_eq!(collapse_bursts(findings).len(), 2);
}
#[test]
fn serious_findings_are_never_collapsed() {
let same_claim = "SQL built by string concatenation reaches the driver.";
let findings = vec![
f("HIGH", "a.rs", same_claim),
f("HIGH", "b.rs", same_claim),
f("HIGH", "c.rs", same_claim),
];
let out = collapse_bursts(findings);
assert_eq!(out.len(), 3, "each HIGH keeps its own anchor and fix");
assert!(out.iter().all(|f| !f.body.contains("other file(s)")));
let mixed = vec![
f("LOW", "a.rs", same_claim),
f("LOW", "b.rs", same_claim),
f("BLOCKING", "c.rs", same_claim),
];
assert_eq!(collapse_bursts(mixed).len(), 3);
}
#[test]
fn a_uniform_group_collapses_at_its_own_severity() {
let findings = vec![
f("MEDIUM", "a.zip", "A binary file `a.zip` was added."),
f("MEDIUM", "b.zip", "A binary file `b.zip` was added."),
f("MEDIUM", "c.zip", "A binary file `c.zip` was added."),
];
let out = collapse_bursts(findings);
assert_eq!(out.len(), 1);
assert_eq!(out[0].severity, "MEDIUM");
assert_eq!(out[0].file, "a.zip");
}
#[test]
fn a_mixed_severity_group_is_not_collapsed() {
let findings = vec![
f("LOW", "a.zip", "A binary file `a.zip` was added."),
f("MEDIUM", "b.zip", "A binary file `b.zip` was added."),
f("LOW", "c.zip", "A binary file `c.zip` was added."),
];
let out = collapse_bursts(findings);
assert_eq!(out.len(), 3, "2 LOW + 1 MEDIUM: neither reaches 3");
assert!(out.iter().all(|f| !f.body.contains("other file(s)")));
}
#[test]
fn a_build_claim_is_capped_at_low_when_ci_is_green() {
let green = "- fmt + clippy + test: success\n- cargo package: success";
let mut findings = vec![f(
"BLOCKING",
"src/blast.rs",
"This line is 118 chars, so `cargo fmt --check` will fail on this commit.",
)];
demote_falsified_build_claims(&mut findings, Some(green));
assert_eq!(findings[0].severity, "LOW");
assert!(findings[0]
.body
.contains("Every CI check on the reviewed commit passed"));
assert!(findings[0].body.contains("118 chars"));
}
#[test]
fn a_build_claim_survives_when_ci_is_not_green() {
let body = "`cargo fmt --check` will fail on this commit.";
for ci in [
None, Some("- fmt: failure\n- test: success"), Some("- fmt: in_progress"), Some(""), Some("- a: success\n- [12 further check(s) NOT shown — this commit has 312.]"),
] {
let mut findings = vec![f("BLOCKING", "src/x.rs", body)];
demote_falsified_build_claims(&mut findings, ci);
assert_eq!(
findings[0].severity, "BLOCKING",
"wrongly demoted with ci={ci:?}"
);
}
}
#[test]
fn a_runtime_failure_claim_is_not_a_check_claim() {
let green = "- fmt + clippy + test: success";
for body in [
"This will fail at runtime when the list is empty.",
"The request would fail if the token has expired.",
"`parse()` fails when the header is absent, and the error is swallowed.",
"This would break existing callers that pass a null id.",
] {
let mut findings = vec![f("HIGH", "src/x.rs", body)];
demote_falsified_build_claims(&mut findings, Some(green));
assert_eq!(
findings[0].severity, "HIGH",
"green CI wrongly capped a runtime finding: {body:?}"
);
}
}
#[test]
fn a_weak_phrase_counts_once_a_check_is_named() {
let green = "- fmt + clippy + test: success";
for body in [
"This line is 118 chars, so `cargo fmt --check` will fail on this commit.",
"The clippy job will fail on this unwrap.",
"CI will fail because the lockfile is stale.",
"This breaks the build on MSVC.",
] {
let mut findings = vec![f("BLOCKING", "src/x.rs", body)];
demote_falsified_build_claims(&mut findings, Some(green));
assert_eq!(findings[0].severity, "LOW", "not capped: {body:?}");
}
}
#[test]
fn check_context_words_match_whole_words_only() {
let green = "- test: success";
let mut findings = vec![f(
"HIGH",
"src/x.rs",
"The specific decision here will fail for efficient callers.",
)];
demote_falsified_build_claims(&mut findings, Some(green));
assert_eq!(
findings[0].severity, "HIGH",
"'ci' inside specific/decision/efficient must not count as a check"
);
}
#[test]
fn an_ordinary_finding_is_untouched_by_green_ci() {
let green = "- test: success";
let mut findings = vec![
f("HIGH", "a.rs", "Unvalidated input reaches the SQL query."),
f(
"MEDIUM",
"b.rs",
"This handler can panic on an empty slice.",
),
];
demote_falsified_build_claims(&mut findings, Some(green));
assert_eq!(findings[0].severity, "HIGH");
assert_eq!(findings[1].severity, "MEDIUM");
assert!(!findings[0].body.contains("CI check"));
}
#[test]
fn burst_key_ignores_paths_and_numbers_but_not_the_claim() {
let a = f("LOW", "a.rs", "`a.rs` adds 2192 lines in one new file.");
let b = f("LOW", "b.rs", "`b.rs` adds 17 lines in one new file.");
let c = f("LOW", "c.rs", "`c.rs` leaks a handle on the error path.");
assert_eq!(burst_key(&a), burst_key(&b));
assert_ne!(burst_key(&a), burst_key(&c));
}
fn finding(severity: &str) -> Finding {
Finding {
severity: severity.to_string(),
file: "assets/ime.zip".to_string(),
line: None,
body: "A binary file `assets/ime.zip` was added. Fix: drop it.".to_string(),
confidence: Some(100),
}
}
#[test]
fn recommendation_upgrades_but_never_downgrades() {
assert_eq!(
effective_recommendation("APPROVE", &[finding("MEDIUM")]),
"APPROVE WITH CHANGES"
);
assert_eq!(
effective_recommendation("APPROVE", &[finding("BLOCKING")]),
"BLOCK"
);
assert_eq!(
effective_recommendation("APPROVE", &[finding("LOW")]),
"APPROVE"
);
assert_eq!(
effective_recommendation("BLOCK", &[finding("LOW")]),
"BLOCK"
);
assert_eq!(effective_recommendation("APPROVE", &[]), "APPROVE");
}
#[test]
fn no_review_summary_still_names_a_swept_in_binary() {
let s = render_no_review_summary(&[], &[finding("MEDIUM")]);
assert!(s.contains("assets/ime.zip"));
assert!(s.contains("MEDIUM"));
assert!(s.contains("## Findings"));
}
#[test]
fn idents_extracts_tokens() {
assert_eq!(idents("foo.bar(baz_qux)"), vec!["foo", "bar", "baz_qux"]);
}
#[test]
fn line_symbols_drops_keywords_and_short_tokens() {
let s = line_symbols("export function calcTotal(o) {");
assert!(s.contains(&"calcTotal".to_string()));
assert!(!s
.iter()
.any(|w| w == "export" || w == "function" || w == "o"));
}
#[test]
fn reanchor_snaps_to_the_matching_diff_line() {
let valid: HashSet<u64> = [8, 10, 12].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " const subtotal = sum(items);".to_string());
texts.insert(10, " return calcTotal(order, tax);".to_string());
texts.insert(12, "}".to_string());
let got = reanchor(
9,
&valid,
&texts,
"`calcTotal` now needs a tax arg. Fix: pass it.",
);
assert_eq!(got, Some(10));
}
#[test]
fn reanchor_declines_without_a_content_match() {
let valid: HashSet<u64> = [8, 10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " const x = 1;".to_string());
texts.insert(10, " const y = 2;".to_string());
assert_eq!(
reanchor(9, &valid, &texts, "Missing null check on user.roles"),
None
);
}
#[test]
fn reanchor_declines_on_a_short_shared_token() {
let valid: HashSet<u64> = [10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(10, " const total = sum(items);".to_string());
assert_eq!(reanchor(9, &valid, &texts, "sum is off by one"), None);
}
#[test]
fn reanchor_ties_break_on_lower_line_number() {
let valid: HashSet<u64> = [8, 10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " calcTotal(order);".to_string());
texts.insert(10, " calcTotal(basket);".to_string());
assert_eq!(
reanchor(9, &valid, &texts, "calcTotal needs a tax arg"),
Some(8)
);
}
#[test]
fn reanchor_ignores_lines_outside_the_window() {
let valid: HashSet<u64> = [20].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(20, " calcTotal();".to_string());
assert_eq!(reanchor(9, &valid, &texts, "calcTotal issue"), None);
}
}