Files
tkmind_go/crates/goose-cli/src/commands/review/orchestrator.rs
T
2026-06-02 20:45:24 +00:00

1183 lines
42 KiB
Rust

//! Deterministic, Rust-driven orchestration for `goose review`.
//!
//! The default in-process review path lets the LLM decide whether to
//! dispatch each check as a real subagent (`delegate(... async: true)`)
//! or to inline the work itself. That decision is non-deterministic and
//! is the dominant source of variance we see between runs (16s in the
//! best case, 60s+ when the model dispatches everything as a separate
//! subagent).
//!
//! This module sidesteps that variance by orchestrating checks
//! deterministically from Rust:
//!
//! - One subprocess per check (`goose run -q -t <prompt>`)
//! - Concurrency capped at [`MAX_WORKERS`] via a Tokio semaphore
//! - Per-subprocess turn limit via `--max-turns` (see
//! [`resolve_main_turn_limit`] and [`Check::resolved_turn_limit`])
//! - Each check is given a strict, tool-free prompt and is required to
//! return only `{"findings": [...]}` JSON
//! - Findings are tagged with the originating `check` name in Rust, not
//! by the model
//!
//! Wall-clock for the orchestrated phase is therefore
//! `max(check_latency)` — bounded by the slowest single check — rather
//! than the sum of model-driven dispatch overhead.
//!
//! The main correctness pass still runs in-process via the existing
//! `session.headless()` path; the two phases are awaited concurrently
//! so the user sees both their findings as soon as the slower of the
//! two completes.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use super::handler::ReviewOptions;
use goose::checks::{Check, DEFAULT_CHECK_TURN_LIMIT};
/// Maximum number of check subprocesses we run concurrently. 4 is
/// empirically the sweet spot before LLM-side rate limits and local
/// resource contention start hurting wall-clock.
pub const MAX_WORKERS: usize = 4;
/// One review finding emitted by a check or by the main correctness
/// pass.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub severity: String,
pub path: String,
pub line_start: i64,
pub line_end: i64,
pub summary: String,
pub check: String,
}
/// Schema the check subprocess is required to emit.
#[derive(Debug, Deserialize)]
struct FindingsResponse {
findings: Vec<RawFinding>,
}
#[derive(Debug, Deserialize)]
struct RawFinding {
#[serde(default)]
severity: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
line_start: Option<i64>,
#[serde(default)]
line_end: Option<i64>,
#[serde(default)]
summary: Option<String>,
}
/// Run all discovered checks concurrently as `goose run` subprocesses.
///
/// Returns one `Vec<Finding>` per check, in the same order as `checks`.
/// A failed check (subprocess error, turn-limit exhaustion, malformed JSON) yields an
/// empty findings list and a warning on stderr; a single broken check
/// must never block the rest of the review.
pub async fn run_checks_in_parallel(
checks: &[Check],
diff: &str,
opts: &ReviewOptions,
) -> Vec<Vec<Finding>> {
let semaphore = Arc::new(Semaphore::new(MAX_WORKERS));
let mut set = JoinSet::new();
for (idx, check) in checks.iter().enumerate() {
let sem = semaphore.clone();
let check = check.clone();
let diff = diff.to_string();
let provider = opts.provider.clone();
let model = resolve_check_model(&check, opts);
let max_turns = check.resolved_turn_limit(opts.default_turn_limit);
let quiet = opts.quiet;
let instructions = opts.instructions.clone();
set.spawn(async move {
// Bounded concurrency: drop the permit only after the
// subprocess completes.
let _permit = sem.acquire().await.expect("semaphore is never closed");
let result = run_single_check_subprocess(
&check,
&diff,
provider.as_deref(),
model.as_deref(),
instructions.as_deref(),
Some(max_turns),
)
.await;
(idx, check, result, quiet)
});
}
// Pre-allocate so we can write results in source order.
let mut results: Vec<Vec<Finding>> = vec![Vec::new(); checks.len()];
while let Some(joined) = set.join_next().await {
let (idx, check, result, quiet) = match joined {
Ok(v) => v,
Err(e) => {
eprintln!("goose review: check task panicked: {e}");
continue;
}
};
match result {
Ok(findings) => {
if !quiet {
eprintln!(
"goose review: check '{}' completed: {} finding(s)",
check.name,
findings.len()
);
}
results[idx] = findings;
}
Err(e) => {
// Per-check failure must never abort the review — emit a
// warning and continue with empty findings for this check.
eprintln!("goose review: check '{}' failed: {e}", check.name);
results[idx] = Vec::new();
}
}
}
results
}
/// Resolve which model a check should run on.
///
/// Precedence (most specific wins):
/// 1. `--override-model` always wins.
/// 2. If the user picked an explicit `--provider` on the CLI, drop the
/// per-check `model:` declaration entirely. The per-check model is
/// almost always pinned to a specific provider (e.g. a check that
/// asks for `goose-claude-4-sonnet` would 404 against Google's API),
/// so silently inheriting it across providers makes targeted reruns
/// fail. Use `--model` if set, otherwise fall through to the
/// selected provider's default.
/// 3. Per-check `model:` from frontmatter.
/// 4. `--model` (or the agent default).
fn resolve_check_model(check: &Check, opts: &ReviewOptions) -> Option<String> {
if let Some(o) = opts.override_model.as_deref() {
return Some(o.to_string());
}
if opts.provider.is_some() {
return opts.default_model.clone();
}
if let Some(m) = check.model.as_deref() {
return Some(m.to_string());
}
opts.default_model.clone()
}
/// Resolve the turn limit for a main-pass subprocess.
///
/// Uses `goose review --turn-limit` when set, otherwise
/// [`DEFAULT_CHECK_TURN_LIMIT`].
fn resolve_main_turn_limit(default_turn_limit: Option<usize>) -> usize {
default_turn_limit.unwrap_or(DEFAULT_CHECK_TURN_LIMIT)
}
/// Spawn a single `goose run` subprocess for one check and parse its
/// output into [`Finding`]s.
async fn run_single_check_subprocess(
check: &Check,
diff: &str,
provider: Option<&str>,
model: Option<&str>,
instructions: Option<&str>,
max_turns: Option<usize>,
) -> Result<Vec<Finding>> {
let turns = max_turns.expect("check subprocess always has a resolved turn limit");
let prompt = build_check_prompt(check, diff, instructions, turns);
let raw = run_subprocess_for_findings(
&prompt,
&format!("check '{}'", check.name),
provider,
model,
max_turns,
)
.await?;
let default_sev = check.severity_default.as_deref().unwrap_or("medium");
Ok(raw
.into_iter()
.map(|r| Finding {
severity: r.severity.unwrap_or_else(|| default_sev.to_string()),
path: r.path.unwrap_or_default(),
line_start: r.line_start.unwrap_or(0),
line_end: r.line_end.unwrap_or(0),
summary: r.summary.unwrap_or_default(),
check: check.name.clone(),
})
.collect())
}
/// Generic `goose run` subprocess that hands a prompt to the model
/// and parses `{"findings": [...]}` JSON out of the response. Shared
/// by the per-check and per-file main-pass orchestrators so both get
/// the same robust JSON extraction and error reporting.
async fn run_subprocess_for_findings(
prompt: &str,
label: &str,
provider: Option<&str>,
model: Option<&str>,
max_turns: Option<usize>,
) -> Result<Vec<RawFinding>> {
let goose_bin = std::env::current_exe().context("locate current goose binary")?;
let mut cmd = Command::new(&goose_bin);
cmd.arg("run")
.arg("--no-session")
.arg("--quiet")
.arg("--no-profile")
.arg("-i")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If this future is dropped, kill the child so it does not keep
// running (and racking up tokens) in the background.
.kill_on_drop(true);
if let Some(p) = provider {
cmd.arg("--provider").arg(p);
}
if let Some(m) = model {
cmd.arg("--model").arg(m);
}
if let Some(t) = max_turns {
cmd.arg("--max-turns").arg(t.to_string());
}
let mut child = cmd
.spawn()
.with_context(|| format!("spawn subprocess for {label}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(prompt.as_bytes())
.await
.with_context(|| format!("write prompt to {label} stdin"))?;
// Closing stdin signals EOF to `goose run -i -`.
drop(stdin);
}
let output = child
.wait_with_output()
.await
.with_context(|| format!("wait on {label}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"{label} subprocess exited with status {}: {}",
output.status,
truncate(&stderr, 500)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_findings(&stdout)
}
/// Run the main correctness pass as N parallel subprocesses, one per
/// touched file. This replaces the older in-process `session.headless()`
/// path which:
///
/// 1. Streamed text-mode chatter to stdout (not JSONL) so findings were
/// sometimes lost in interleaved output.
/// 2. Sent the entire diff in a single prompt — large diffs (1000+
/// lines) reliably caused Gemini 3.x to short-circuit with `[]`
/// after ~30s instead of doing the work.
///
/// File-by-file fan-out keeps each subprocess context small enough that
/// the model actually walks every change, and runs them concurrently
/// so total wall clock stays close to the slowest single file rather
/// than scaling with diff size. Failures on one file never block the
/// others.
pub async fn run_main_pass_in_parallel(
diff: &str,
base_prompt: &str,
opts: &ReviewOptions,
) -> Vec<Finding> {
let per_file = split_diff_by_file(diff);
if per_file.is_empty() {
return Vec::new();
}
let semaphore = Arc::new(Semaphore::new(MAX_WORKERS));
let mut set: JoinSet<(usize, String, Result<Vec<RawFinding>>, bool)> = JoinSet::new();
let max_turns = resolve_main_turn_limit(opts.default_turn_limit);
for (idx, (path, file_diff)) in per_file.iter().enumerate() {
let sem = semaphore.clone();
let path = path.clone();
let file_diff = file_diff.clone();
let provider = opts.provider.clone();
let model = opts.default_model.clone();
let quiet = opts.quiet;
let instructions = opts.instructions.clone();
let base_prompt = base_prompt.to_string();
set.spawn(async move {
let _permit = sem.acquire().await.expect("semaphore is never closed");
let prompt = build_main_pass_prompt(
&path,
&file_diff,
&base_prompt,
instructions.as_deref(),
max_turns,
);
let label = format!("main:{path}");
let result = run_subprocess_for_findings(
&prompt,
&label,
provider.as_deref(),
model.as_deref(),
Some(max_turns),
)
.await;
(idx, path, result, quiet)
});
}
let mut per_file_results: Vec<Vec<Finding>> = vec![Vec::new(); per_file.len()];
while let Some(joined) = set.join_next().await {
let (idx, path, result, quiet) = match joined {
Ok(v) => v,
Err(e) => {
eprintln!("goose review: main-pass task panicked: {e}");
continue;
}
};
match result {
Ok(raw) => {
let findings: Vec<Finding> = raw
.into_iter()
.map(|r| Finding {
severity: r.severity.unwrap_or_else(|| "medium".to_string()),
path: r.path.unwrap_or_else(|| path.clone()),
line_start: r.line_start.unwrap_or(0),
line_end: r.line_end.unwrap_or(0),
summary: r.summary.unwrap_or_default(),
check: "main".to_string(),
})
.collect();
if !quiet {
eprintln!(
"goose review: main pass on '{}' completed: {} finding(s)",
path,
findings.len()
);
}
per_file_results[idx] = findings;
}
Err(e) => {
// A single broken file must not abort the entire main
// pass; surface a warning and continue.
eprintln!("goose review: main pass on '{}' failed: {e}", path);
per_file_results[idx] = Vec::new();
}
}
}
per_file_results.into_iter().flatten().collect()
}
/// Split a unified `git diff` into one chunk per file. Each chunk
/// starts at its `diff --git a/... b/...` header and runs to the
/// next file boundary. Returns `(repo_relative_path, file_diff)`
/// pairs in source order.
pub fn split_diff_by_file(diff: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let mut current_path: Option<String> = None;
let mut current_chunk = String::new();
for line in diff.split_inclusive('\n') {
if let Some(rest) = line.strip_prefix("diff --git ") {
// Flush previous file.
if let Some(p) = current_path.take() {
if !current_chunk.is_empty() {
out.push((p, std::mem::take(&mut current_chunk)));
} else {
current_chunk.clear();
}
}
current_chunk.clear();
current_chunk.push_str(line);
// Header is `a/<path> b/<path>`. Pull `b/` (post-image)
// because that's the path the model should reference for
// line numbers.
current_path = parse_diff_header_path(rest);
} else {
current_chunk.push_str(line);
}
}
if let Some(p) = current_path {
if !current_chunk.is_empty() {
out.push((p, current_chunk));
}
}
out
}
/// Parse the `a/<path> b/<path>` portion of a `diff --git` header line.
/// Returns the post-image path (`b/...`) when present; falls back to
/// the pre-image path (`a/...`) for deletions.
///
/// Also handles git's quoted form for paths with non-ASCII bytes,
/// spaces, or special chars (e.g. `"a/dir/\303\251.txt" "b/dir/\303\251.txt"`,
/// emitted whenever `core.quotePath` is on or the path contains
/// whitespace). Without quote handling, files under those paths are
/// silently dropped from the per-file main pass.
#[allow(clippy::string_slice)]
fn parse_diff_header_path(rest: &str) -> Option<String> {
let trimmed = rest.trim_end_matches('\n').trim();
if trimmed.starts_with('"') {
let (a_quoted, after_a) = take_quoted(trimmed)?;
let after_a = after_a.trim_start();
let post = if after_a.starts_with('"') {
let (b_quoted, _) = take_quoted(after_a)?;
b_quoted
} else {
// Mixed form: quoted a/, unquoted b/.
after_a.to_string()
};
return Some(
strip_diff_prefix(&post)
.unwrap_or_else(|| strip_diff_prefix(&a_quoted).unwrap_or(post)),
);
}
if let Some(idx) = trimmed.find(" b/") {
let post = &trimmed[idx + 3..];
// The post-image path may itself be quoted in mixed-form
// headers like `a/foo.txt "b/with space.txt"`.
if post.starts_with('"') {
let (q, _) = take_quoted(post)?;
return Some(strip_diff_prefix(&q).unwrap_or(q));
}
return Some(post.to_string());
}
if let Some(stripped) = trimmed.strip_prefix("a/") {
return Some(stripped.to_string());
}
None
}
/// Strip the leading `a/` or `b/` prefix that git puts on diff header
/// paths. Returns `None` if no such prefix exists.
fn strip_diff_prefix(s: &str) -> Option<String> {
s.strip_prefix("a/")
.or_else(|| s.strip_prefix("b/"))
.map(str::to_string)
}
/// Pull a single C-style quoted token off the start of `s` and return
/// `(decoded, remainder)`. Decodes the escape sequences git uses when
/// `core.quotePath` is on: `\\`, `\"`, `\a`, `\b`, `\t`, `\n`, `\v`,
/// `\f`, `\r`, and octal `\NNN` byte escapes (for non-ASCII paths).
fn take_quoted(s: &str) -> Option<(String, &str)> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'"') {
return None;
}
let mut decoded: Vec<u8> = Vec::with_capacity(s.len());
let mut i = 1;
while i < bytes.len() {
match bytes[i] {
b'"' => {
let rest = std::str::from_utf8(&bytes[i + 1..]).ok()?;
let s = String::from_utf8(decoded).ok()?;
return Some((s, rest));
}
b'\\' => {
if i + 1 >= bytes.len() {
return None;
}
match bytes[i + 1] {
b'\\' => {
decoded.push(b'\\');
i += 2;
}
b'"' => {
decoded.push(b'"');
i += 2;
}
b'a' => {
decoded.push(0x07);
i += 2;
}
b'b' => {
decoded.push(0x08);
i += 2;
}
b't' => {
decoded.push(b'\t');
i += 2;
}
b'n' => {
decoded.push(b'\n');
i += 2;
}
b'v' => {
decoded.push(0x0b);
i += 2;
}
b'f' => {
decoded.push(0x0c);
i += 2;
}
b'r' => {
decoded.push(b'\r');
i += 2;
}
c if (b'0'..=b'7').contains(&c) => {
if i + 3 >= bytes.len() {
return None;
}
let octal = &bytes[i + 1..i + 4];
if !octal.iter().all(|b| (b'0'..=b'7').contains(b)) {
return None;
}
let val = ((octal[0] - b'0') as u16) * 64
+ ((octal[1] - b'0') as u16) * 8
+ (octal[2] - b'0') as u16;
decoded.push(val as u8);
i += 4;
}
_ => return None,
}
}
b => {
decoded.push(b);
i += 1;
}
}
}
None
}
/// Prompt section telling review subprocesses about the `--max-turns`
/// cap enforced by goose. Without this, models routinely burn turns on
/// tool loops and return nothing when the limit stops the session.
fn build_subprocess_turn_budget_section(max_turns: usize) -> String {
format!(
"## Turn budget\n\n\
You may take at most {max_turns} agent turns (model/tool iterations) in this run. \
goose enforces this via `--max-turns`; when you exhaust it, the session stops and \
any findings not yet emitted as JSON are lost.\n\n\
Plan for the limit:\n\
- As turns run low, stop exploring and return JSON with the findings you have verified.\n\
- Always emit valid JSON (`{{\"findings\":[...]}}` or `{{\"findings\":[]}}`) before \
the turn limit — an empty or missing response counts as failure.\n\n"
)
}
/// Build the strict, JSON-only prompt sent to one main-pass
/// subprocess. The base prompt (custom or
/// [`DEFAULT_REVIEW_PROMPT`]) supplies the reviewer voice; we then
/// pin the file under review and force a `{"findings": [...]}`
/// response so the orchestrator's parser can pick it up reliably.
fn build_main_pass_prompt(
path: &str,
file_diff: &str,
base_prompt: &str,
instructions: Option<&str>,
max_turns: usize,
) -> String {
let mut s = String::new();
s.push_str(base_prompt.trim_end());
s.push_str("\n\n");
if let Some(text) = instructions {
let trimmed = text.trim();
if !trimmed.is_empty() {
s.push_str("## Reviewer instructions\n\n");
s.push_str(trimmed);
s.push_str("\n\n");
}
}
s.push_str(&build_subprocess_turn_budget_section(max_turns));
s.push_str("## File under review\n\n");
s.push_str(&format!("Path: `{path}`\n\n"));
s.push_str(
"Review ONLY the changes in this file. Walk every added/modified line. \
Do not flag pre-existing code shown for context (lines beginning with a space). \
Use post-change line numbers from the diff.\n\n",
);
s.push_str(
"## Output\n\nReturn ONLY valid JSON with this exact schema:\n\n\
{\n \"findings\": [\n {\n \"severity\": \"low|medium|high|critical\",\n \"path\": \"relative/path/to/file\",\n \"line_start\": 10,\n \"line_end\": 12,\n \"summary\": \"One-paragraph actionable explanation of the issue and the fix\"\n }\n ]\n}\n\nIf there are no real issues, return:\n{\"findings\":[]}\n\nDo NOT include any text before or after the JSON. Do NOT wrap the JSON in code fences.\n\n",
);
s.push_str("## Diff\n\n```diff\n");
s.push_str(file_diff.trim_end_matches('\n'));
s.push_str("\n```\n");
s
}
/// Build the strict, tool-free prompt sent to one check subprocess.
///
/// Shape matches the prompt format Amp-authored checks already expect,
/// so a check written for `amp review` runs the same way under
/// `goose review`.
fn build_check_prompt(
check: &Check,
diff: &str,
instructions: Option<&str>,
max_turns: usize,
) -> String {
let mut s = String::new();
s.push_str("You are running an automated code review check.\n\n");
s.push_str(&format!("Check name: {}\n", check.name));
if let Some(d) = check.description.as_deref() {
if !d.is_empty() {
s.push_str(&format!("Description: {}\n", d));
}
}
if let Some(sev) = check.severity_default.as_deref() {
if !sev.is_empty() {
s.push_str(&format!("Default severity: {}\n", sev));
}
}
if let Some(text) = instructions {
let trimmed = text.trim();
if !trimmed.is_empty() {
s.push_str("\nReviewer instructions:\n");
s.push_str(trimmed);
s.push('\n');
}
}
s.push('\n');
s.push_str(&build_subprocess_turn_budget_section(max_turns));
s.push_str("Review ONLY the git diff provided below.\n");
s.push_str("Do not ask for missing context.\n");
s.push_str("Use repo-relative file paths.\n");
s.push_str("Use post-change line numbers from the diff.\n");
s.push_str("If you cannot map an issue to a specific line range in the diff, use line_start: 0 and line_end: 0.\n");
// Amp's check prompt emphasizes this twice; without it, models
// routinely flag pre-existing code that just happens to appear in
// the diff context (lines starting with a space, not `+`).
s.push_str(
"Search for patterns described above ONLY in the changed lines (lines beginning with `+` in the diff).\n",
);
s.push_str("Report issues ONLY for code that was added or modified in this diff.\n");
s.push_str("Do NOT report issues for unchanged/pre-existing code shown for context.\n\n");
s.push_str(
"Return ONLY valid JSON with this exact schema:\n\
{\n \"findings\": [\n {\n \"severity\": \"low|medium|high|critical\",\n \"path\": \"relative/path/to/file\",\n \"line_start\": 10,\n \"line_end\": 12,\n \"summary\": \"One-sentence actionable issue\"\n }\n ]\n}\n\nIf there are no issues, return:\n{\"findings\":[]}\n\nDo NOT include any text before or after the JSON. Do NOT wrap the JSON in code fences.\n\n",
);
s.push_str("Check instructions:\n\n");
s.push_str(check.body.trim());
s.push_str("\n\nDiff:\n\n```diff\n");
s.push_str(diff.trim_end_matches('\n'));
s.push_str("\n```\n");
s
}
/// Pull the `findings` array out of an LLM response, tolerating code
/// fences and stray text the model occasionally inserts.
fn parse_findings(output: &str) -> Result<Vec<RawFinding>> {
let stripped = strip_code_fences(output.trim());
let json = extract_json_object(&stripped).unwrap_or(stripped);
let resp: FindingsResponse = serde_json::from_str(&json)
.with_context(|| format!("parse check JSON: {}", truncate(&json, 500)))?;
Ok(resp.findings)
}
fn strip_code_fences(s: &str) -> String {
let s = s.trim();
if let Some(after_open) = s.strip_prefix("```") {
let after_first_line = after_open
.split_once('\n')
.map(|(_, rest)| rest)
.unwrap_or("");
let trimmed_close = after_first_line
.rsplit_once("```")
.map(|(before, _)| before)
.unwrap_or(after_first_line);
return trimmed_close.trim().to_string();
}
s.to_string()
}
#[allow(clippy::string_slice)]
fn extract_json_object(s: &str) -> Option<String> {
// The structural characters we scan for (`"`, `\`, `{`, `}`) are
// single-byte ASCII, so iterating char-by-char with byte offsets via
// `char_indices` is safe even when the LLM's chatter around the JSON
// contains multi-byte characters. The two slice operations below
// (`s[start..]` and `s[start..=abs]`) only ever land on UTF-8 char
// boundaries because `start` is from `find('{')` and `abs` is from
// `char_indices`, both of which yield boundary offsets.
let start = s.find('{')?;
let mut depth = 0i32;
let mut in_string = false;
let mut escaped = false;
for (i, ch) in s[start..].char_indices() {
let abs = start + i;
if escaped {
escaped = false;
continue;
}
if in_string {
if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(s[start..=abs].to_string());
}
}
_ => {}
}
}
None
}
#[allow(clippy::string_slice)]
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
// Find the largest char boundary at or before `max` so we never
// bisect a multi-byte UTF-8 sequence (this string is usually a model
// error / response excerpt).
let mut cut = max;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}", &s[..cut])
}
/// Emit findings as JSONL (one object per line) to stdout, matching
/// the format the in-process path produces. Findings whose severity
/// ranks below `min_severity` are suppressed; this mirrors Amp's
/// behavior of hiding `low` from the review output by default.
pub fn emit_findings(findings: &[Finding], min_severity: Severity) -> usize {
let mut emitted = 0usize;
for f in findings {
if Severity::parse(&f.severity) < min_severity {
continue;
}
// serde_json::to_string never fails for these owned strings.
if let Ok(line) = serde_json::to_string(f) {
println!("{line}");
emitted += 1;
}
}
emitted
}
/// Severity floor for finding display. Mirrors Amp's CLI behavior of
/// hiding `low` by default; pass `--severity low` to surface them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Low = 0,
Medium = 1,
High = 2,
Critical = 3,
}
impl Severity {
/// Parse a severity string from a finding (`low`/`medium`/`high`/
/// `critical`). Unrecognized strings are treated as `Medium` so
/// odd-but-non-trivial findings still surface.
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"low" | "info" | "note" => Severity::Low,
"high" => Severity::High,
"critical" | "crit" | "blocker" => Severity::Critical,
_ => Severity::Medium,
}
}
}
impl std::str::FromStr for Severity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"low" => Ok(Severity::Low),
"medium" | "med" => Ok(Severity::Medium),
"high" => Ok(Severity::High),
"critical" => Ok(Severity::Critical),
other => Err(format!(
"unknown severity '{other}' (expected one of: low, medium, high, critical)"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ck(name: &str) -> Check {
Check {
name: name.to_string(),
description: Some("desc".into()),
model: None,
turn_limit: None,
tools: None,
severity_default: None,
path: PathBuf::from(format!("/.agents/checks/{name}.md")),
scope_dir: String::new(),
body: "look for bugs".into(),
}
}
#[test]
fn check_prompt_is_strict_and_diff_aware() {
let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT);
assert!(p.contains("automated code review check"));
assert!(p.contains("Check name: perf"));
assert!(p.contains("```diff\ndiff content\n```"));
assert!(p.contains("Return ONLY valid JSON"));
assert!(p.contains("look for bugs"));
assert!(!p.contains("Reviewer instructions"));
}
#[test]
fn check_prompt_restricts_findings_to_added_or_modified_lines() {
// Mirrors Amp's prompt language; without these the model
// happily flags pre-existing code shown for context.
let p = build_check_prompt(&ck("perf"), "diff content", None, DEFAULT_CHECK_TURN_LIMIT);
assert!(p.contains("ONLY in the changed lines"));
assert!(p.contains("lines beginning with `+`"));
assert!(p.contains("ONLY for code that was added or modified"));
assert!(p.contains("Do NOT report issues for unchanged"));
}
#[test]
fn check_prompt_includes_reviewer_instructions_when_provided() {
let p = build_check_prompt(
&ck("perf"),
"diff content",
Some("This is a refactor; flag any behavior change."),
DEFAULT_CHECK_TURN_LIMIT,
);
assert!(p.contains("Reviewer instructions:"));
assert!(p.contains("flag any behavior change"));
}
#[test]
fn check_prompt_skips_blank_reviewer_instructions() {
let p = build_check_prompt(
&ck("perf"),
"diff content",
Some(" \n "),
DEFAULT_CHECK_TURN_LIMIT,
);
assert!(!p.contains("Reviewer instructions"));
}
#[test]
fn check_prompt_includes_turn_budget() {
let p = build_check_prompt(&ck("perf"), "diff content", None, 12);
assert!(p.contains("## Turn budget"));
assert!(p.contains("at most 12 agent turns"));
assert!(p.contains("--max-turns"));
}
#[test]
fn resolve_main_turn_limit_uses_cli_default_or_fallback() {
assert_eq!(resolve_main_turn_limit(Some(40)), 40);
assert_eq!(resolve_main_turn_limit(None), DEFAULT_CHECK_TURN_LIMIT);
}
#[test]
fn parse_findings_accepts_bare_json() {
let raw = r#"{"findings":[{"severity":"high","path":"a.py","line_start":1,"line_end":2,"summary":"bad"}]}"#;
let f = parse_findings(raw).unwrap();
assert_eq!(f.len(), 1);
assert_eq!(f[0].path.as_deref(), Some("a.py"));
}
#[test]
fn parse_findings_strips_code_fences() {
let raw = "```json\n{\"findings\":[]}\n```";
let f = parse_findings(raw).unwrap();
assert!(f.is_empty());
}
#[test]
fn parse_findings_extracts_object_when_model_adds_chatter() {
let raw =
"Sure, here are the findings:\n{\"findings\":[]}\n\nLet me know if you need more.";
let f = parse_findings(raw).unwrap();
assert!(f.is_empty());
}
#[test]
fn extract_json_object_respects_string_braces() {
let raw = r#"{"a": "value with } brace", "b": 1}"#;
let extracted = extract_json_object(raw).unwrap();
assert_eq!(extracted, raw);
}
#[test]
fn resolve_check_model_prefers_override() {
let check = ck("perf");
let mut c = check.clone();
c.model = Some("per-check".into());
let opts = ReviewOptions {
override_model: Some("OVERRIDE".into()),
default_model: Some("default".into()),
..ReviewOptions::default()
};
assert_eq!(resolve_check_model(&c, &opts).as_deref(), Some("OVERRIDE"));
}
#[test]
fn resolve_check_model_falls_through_to_per_check_then_default() {
let mut c = ck("perf");
c.model = Some("per-check".into());
let opts = ReviewOptions {
default_model: Some("default".into()),
..ReviewOptions::default()
};
assert_eq!(resolve_check_model(&c, &opts).as_deref(), Some("per-check"));
let c = ck("perf");
let opts = ReviewOptions {
default_model: Some("default".into()),
..ReviewOptions::default()
};
assert_eq!(resolve_check_model(&c, &opts).as_deref(), Some("default"));
}
#[test]
fn severity_orders_correctly() {
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}
#[test]
fn severity_parse_from_finding_string_is_lenient() {
assert_eq!(Severity::parse("low"), Severity::Low);
assert_eq!(Severity::parse("LOW"), Severity::Low);
assert_eq!(Severity::parse("info"), Severity::Low);
assert_eq!(Severity::parse("note"), Severity::Low);
assert_eq!(Severity::parse("medium"), Severity::Medium);
assert_eq!(Severity::parse("high"), Severity::High);
assert_eq!(Severity::parse("critical"), Severity::Critical);
assert_eq!(Severity::parse("blocker"), Severity::Critical);
// Unknown strings default to Medium so they still surface.
assert_eq!(Severity::parse("weird"), Severity::Medium);
assert_eq!(Severity::parse(""), Severity::Medium);
}
#[test]
fn severity_from_str_strict_for_cli() {
use std::str::FromStr;
assert_eq!(Severity::from_str("low").unwrap(), Severity::Low);
assert_eq!(Severity::from_str("MEDIUM").unwrap(), Severity::Medium);
assert_eq!(Severity::from_str("med").unwrap(), Severity::Medium);
assert_eq!(Severity::from_str("high").unwrap(), Severity::High);
assert_eq!(Severity::from_str("critical").unwrap(), Severity::Critical);
assert!(Severity::from_str("info").is_err());
assert!(Severity::from_str("").is_err());
}
#[test]
fn resolve_check_model_cli_provider_wins_over_per_check_model() {
let mut c = ck("perf");
c.model = Some("goose-claude-4-sonnet".into()); // wrong provider
let opts = ReviewOptions {
provider: Some("google".into()),
default_model: Some("gemini-3.1-pro-preview".into()),
..ReviewOptions::default()
};
assert_eq!(
resolve_check_model(&c, &opts).as_deref(),
Some("gemini-3.1-pro-preview")
);
}
#[test]
fn resolve_check_model_cli_provider_alone_drops_per_check_model() {
// `--provider google` without `--model`: a per-check model pinned
// to a Claude/Databricks model would 404 against Google. Drop it.
let mut c = ck("perf");
c.model = Some("goose-claude-4-sonnet".into());
let opts = ReviewOptions {
provider: Some("google".into()),
default_model: None,
..ReviewOptions::default()
};
assert_eq!(resolve_check_model(&c, &opts), None);
}
#[test]
fn split_diff_by_file_separates_files_in_source_order() {
let diff = "\
diff --git a/foo.rs b/foo.rs
index 1111..2222 100644
--- a/foo.rs
+++ b/foo.rs
@@ -1 +1 @@
-old foo
+new foo
diff --git a/bar/baz.go b/bar/baz.go
index 3333..4444 100644
--- a/bar/baz.go
+++ b/bar/baz.go
@@ -10 +10 @@
-old baz
+new baz
";
let chunks = split_diff_by_file(diff);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].0, "foo.rs");
assert!(chunks[0].1.starts_with("diff --git a/foo.rs b/foo.rs"));
assert!(chunks[0].1.contains("+new foo"));
// Each chunk must end at the next `diff --git` boundary, never
// leak into the following file's body.
assert!(!chunks[0].1.contains("baz.go"));
assert_eq!(chunks[1].0, "bar/baz.go");
assert!(chunks[1].1.contains("+new baz"));
}
#[test]
fn split_diff_by_file_handles_single_file() {
let diff = "\
diff --git a/only.py b/only.py
index aaa..bbb 100644
--- a/only.py
+++ b/only.py
@@ -1 +1 @@
-x = 1
+x = 2
";
let chunks = split_diff_by_file(diff);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].0, "only.py");
}
#[test]
fn split_diff_by_file_returns_empty_for_empty_input() {
assert!(split_diff_by_file("").is_empty());
assert!(split_diff_by_file("\n").is_empty());
}
#[test]
fn split_diff_by_file_handles_quoted_headers_with_octal_escapes() {
// git emits `"a/dir/\303\251.txt" "b/dir/\303\251.txt"` for paths
// containing non-ASCII bytes when core.quotePath is on. Without
// quote handling the chunk gets dropped from the per-file pass.
let diff = "\
diff --git \"a/dir/\\303\\251.txt\" \"b/dir/\\303\\251.txt\"
index 1111..2222 100644
--- \"a/dir/\\303\\251.txt\"
+++ \"b/dir/\\303\\251.txt\"
@@ -1 +1 @@
-old
+new
";
let chunks = split_diff_by_file(diff);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].0, "dir/\u{e9}.txt");
}
#[test]
fn split_diff_by_file_handles_quoted_header_with_space() {
let diff = "\
diff --git \"a/with space.txt\" \"b/with space.txt\"
index aaa..bbb 100644
--- \"a/with space.txt\"
+++ \"b/with space.txt\"
@@ -1 +1 @@
-old
+new
";
let chunks = split_diff_by_file(diff);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].0, "with space.txt");
}
#[test]
fn split_diff_by_file_picks_post_image_path_for_renames() {
let diff = "\
diff --git a/old/name.rs b/new/name.rs
similarity index 100%
rename from old/name.rs
rename to new/name.rs
";
let chunks = split_diff_by_file(diff);
assert_eq!(chunks.len(), 1);
// We use the post-image path (b/) so the model references the
// file under its new name when emitting line numbers.
assert_eq!(chunks[0].0, "new/name.rs");
}
#[test]
fn main_pass_prompt_pins_file_and_demands_strict_json() {
let p = build_main_pass_prompt(
"src/foo.rs",
"diff --git a/src/foo.rs b/src/foo.rs\n@@ -1 +1 @@\n-old\n+new\n",
"BASE PROMPT",
None,
DEFAULT_CHECK_TURN_LIMIT,
);
assert!(p.starts_with("BASE PROMPT"));
assert!(p.contains("Path: `src/foo.rs`"));
assert!(p.contains("Walk every added/modified line"));
assert!(p.contains("\"findings\""));
assert!(p.contains("Return ONLY valid JSON"));
assert!(p.contains("Do NOT include any text before or after the JSON"));
assert!(p.contains("```diff\ndiff --git a/src/foo.rs"));
assert!(!p.contains("Reviewer instructions"));
}
#[test]
fn main_pass_prompt_includes_reviewer_instructions_when_provided() {
let p = build_main_pass_prompt(
"src/foo.rs",
"diff body",
"BASE",
Some("PR is a refactor; flag behavior changes."),
DEFAULT_CHECK_TURN_LIMIT,
);
assert!(p.contains("## Reviewer instructions"));
assert!(p.contains("flag behavior changes"));
}
#[test]
fn main_pass_prompt_skips_blank_reviewer_instructions() {
let p = build_main_pass_prompt(
"src/foo.rs",
"diff body",
"BASE",
Some(" \n \t\n"),
DEFAULT_CHECK_TURN_LIMIT,
);
assert!(!p.contains("Reviewer instructions"));
}
#[test]
fn main_pass_prompt_includes_turn_budget() {
let p = build_main_pass_prompt("src/foo.rs", "diff body", "BASE", None, 18);
assert!(p.contains("## Turn budget"));
assert!(p.contains("at most 18 agent turns"));
}
}