use std::path::Path;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::client::DeepSeekClient;
use crate::dependencies::ExternalTool;
use crate::features::Feature;
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
use crate::tui::app::ReasoningEffort;
use crate::utils::truncate_with_ellipsis;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
};
const DEFAULT_MAX_EVIDENCE_CHARS: usize = 120_000;
const PER_FILE_MAX_CHARS: usize = 40_000;
const FALLBACK_SUMMARY_MAX_CHARS: usize = 4_000;
tokio::task_local! {
static VERIFY_ACTIVE: ();
}
const CRITIC_SYSTEM_PROMPT: &str = "You are an adversarial critic performing a rigorous \
self-review of a code change on behalf of the engineer who wrote it. Your job is to REFUTE the \
claim, not to praise it. Assume the change is WRONG or INCOMPLETE until the evidence proves \
otherwise.\n\
\n\
Hunt specifically for: correctness bugs; requirements that are only partially met or silently \
dropped; unhandled edge cases (empty / huge / malformed input, concurrency and re-entrancy, \
error and failure paths, off-by-one, integer overflow, null/None); regressions in existing \
behaviour; and tests that pass but assert the wrong thing (green-CI-but-wrong). Prefer a small \
number of concrete, evidence-backed findings over vague concerns. Cite `path:line` from the \
evidence whenever you can. If, after a genuine effort to break it, you cannot refute the claim, \
say so honestly rather than inventing problems.\n\
\n\
Return ONLY valid JSON (no prose, no markdown fences) matching this schema:\n\
{\n\
\"verdict\": \"refuted\" | \"upheld\" | \"uncertain\",\n\
\"summary\": \"<= 3 sentence adversarial assessment\",\n\
\"findings\": [\n\
{\n\
\"severity\": \"critical\" | \"high\" | \"medium\" | \"low\",\n\
\"issue\": \"what is wrong or unproven\",\n\
\"evidence\": \"where/why, path:line when possible\",\n\
\"suggested_fix\": \"concrete, actionable fix\"\n\
}\n\
],\n\
\"unresolved_risk\": true | false\n\
}\n\
Set verdict=refuted if you found at least one critical or high finding; upheld only if you \
genuinely could not refute the claim; uncertain if the evidence was insufficient to decide. Set \
unresolved_risk=true whenever any unaddressed correctness risk remains.";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CritiqueFinding {
#[serde(default)]
pub severity: String,
#[serde(default)]
pub issue: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_fix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CritiqueReport {
#[serde(default)]
pub verdict: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub findings: Vec<CritiqueFinding>,
#[serde(default)]
pub unresolved_risk: bool,
}
impl CritiqueReport {
#[must_use]
pub fn from_model_text(raw: &str) -> Self {
if let Some(parsed) = parse_report_json(raw) {
return parsed.normalize();
}
if let Some(block) = extract_json_block(raw)
&& let Some(parsed) = parse_report_json(block)
{
return parsed.normalize();
}
Self::fallback(raw).normalize()
}
fn fallback(raw: &str) -> Self {
let trimmed = raw.trim();
let summary = if trimmed.is_empty() {
"Critic returned no output; treat the change as unverified.".to_string()
} else {
format!(
"Critic returned unstructured output (treated as unresolved risk):\n{}",
truncate_with_ellipsis(trimmed, FALLBACK_SUMMARY_MAX_CHARS, "\n...[truncated]\n")
)
};
Self {
verdict: "uncertain".to_string(),
summary,
findings: Vec::new(),
unresolved_risk: true,
}
}
fn normalize(mut self) -> Self {
self.summary = self.summary.trim().to_string();
for finding in &mut self.findings {
finding.severity = normalize_severity(&finding.severity);
finding.issue = finding.issue.trim().to_string();
finding.evidence = normalize_optional(finding.evidence.take());
finding.suggested_fix = normalize_optional(finding.suggested_fix.take());
}
let has_serious = self
.findings
.iter()
.any(|f| matches!(f.severity.as_str(), "critical" | "high"));
let has_blocking = self
.findings
.iter()
.any(|f| matches!(f.severity.as_str(), "critical" | "high" | "medium"));
self.verdict = match self.verdict.trim().to_ascii_lowercase().as_str() {
"refuted" | "rejected" | "fail" | "failed" => "refuted".to_string(),
"upheld" | "confirmed" | "pass" | "passed" | "ok" => {
if has_serious {
"refuted".to_string()
} else if has_blocking {
"uncertain".to_string()
} else {
"upheld".to_string()
}
}
"" => {
if has_serious {
"refuted".to_string()
} else {
"uncertain".to_string()
}
}
_ => "uncertain".to_string(),
};
self.unresolved_risk = self.unresolved_risk || has_blocking;
self
}
#[must_use]
fn highest_severity(&self) -> &'static str {
for level in ["critical", "high", "medium", "low"] {
if self.findings.iter().any(|f| f.severity == level) {
return level;
}
}
"none"
}
}
struct CritiqueInput {
claim: String,
requirement: Option<String>,
focus: Option<String>,
evidence: Vec<EvidenceBlock>,
no_code_evidence: bool,
}
struct EvidenceBlock {
label: String,
body: String,
}
struct CritiqueRun {
report: CritiqueReport,
usage: Usage,
incomplete_stop_reason: Option<String>,
}
pub struct VerifyTool {
client: Option<DeepSeekClient>,
model: String,
critic_effort: ReasoningEffort,
}
impl VerifyTool {
#[must_use]
pub fn new(client: Option<DeepSeekClient>, model: String) -> Self {
Self {
client,
model,
critic_effort: ReasoningEffort::Max,
}
}
#[allow(dead_code)]
#[must_use]
pub fn with_critic_effort(mut self, effort: ReasoningEffort) -> Self {
self.critic_effort = clamp_to_elevated(effort);
self
}
}
#[async_trait]
impl ToolSpec for VerifyTool {
fn name(&self) -> &'static str {
"verify"
}
fn description(&self) -> &'static str {
"Run an INDEPENDENT adversarial critic over your own recent work before you claim it is \
done. You state a claim (what you believe your change accomplishes) plus optional scope (the \
recent git diff, specific files, the original requirement); an independent critic runs at \
elevated reasoning and tries to REFUTE it, returning structured findings (issue, severity, \
suggested fix). Call this when it is worth spending extra thinking: before claiming a non-trivial \
change complete, after a risky or subtle edit, or when you are unsure the change fully satisfies \
the requirement and handles edge cases. Skip it for trivial or mechanical changes. This is not a \
test runner (use run_verifiers) or a code review of an arbitrary target (use review) — it is a \
self-check of whether what you just did is actually correct and complete."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"claim": {
"type": "string",
"description": "What you believe your recent change accomplishes and why it is correct and complete. State it as an assertion the critic will try to REFUTE."
},
"requirement": {
"type": "string",
"description": "Optional: the original requirement / task / acceptance criteria the change must satisfy. The critic checks the change against THIS, not against your restatement of it."
},
"scope": {
"type": "string",
"enum": ["diff", "staged", "none"],
"default": "diff",
"description": "Code evidence to gather for the critic. 'diff' = uncommitted working-tree changes; 'staged' = git staged changes; 'none' = rely only on `files` and the claim text."
},
"base": {
"type": "string",
"description": "Optional git base ref for the diff (e.g. origin/main). Defaults to the plain working-tree/staged diff."
},
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Optional explicit file paths (relative to the workspace) whose current contents to include as evidence."
},
"focus": {
"type": "string",
"description": "Optional: a specific risk to scrutinize (e.g. 'concurrency', 'the empty-input case', 'error handling on network failure')."
}
},
"required": ["claim"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Network]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
if !context.features.enabled(Feature::Verify) {
return Err(ToolError::not_available(
"verify tool is disabled ([features] verify_tool = false)".to_string(),
));
}
if VERIFY_ACTIVE.try_with(|_| ()).is_ok() {
return Err(ToolError::not_available(
"verify cannot run inside its own critic pass (recursion guard)".to_string(),
));
}
let claim = required_str(&input, "claim")?.trim().to_string();
if claim.is_empty() {
return Err(ToolError::invalid_input("claim cannot be empty"));
}
let requirement = optional_str(&input, "requirement")?
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let focus = optional_str(&input, "focus")?
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let base = optional_str(&input, "base")?
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let scope = optional_str(&input, "scope")?.unwrap_or("diff").trim();
let staged = match scope {
"diff" | "" => false,
"staged" => true,
"none" => {
false
}
other => {
return Err(ToolError::invalid_input(format!(
"unknown scope '{other}' (expected diff | staged | none)"
)));
}
};
let gather_diff_scope = scope != "none";
let files = extract_string_array(&input, "files");
let Some(client) = self.client.clone() else {
return Err(ToolError::not_available(
"verify tool requires an active model client".to_string(),
));
};
let mut evidence: Vec<EvidenceBlock> = Vec::new();
if gather_diff_scope {
evidence.extend(
gather_diff_evidence(context.workspace.as_path(), staged, base.as_deref()).await?,
);
}
evidence.extend(gather_files(&files, context));
let no_code_evidence = evidence.is_empty();
let critique_input = CritiqueInput {
claim,
requirement,
focus,
evidence,
no_code_evidence,
};
let route = client.effective_route_envelope(&self.model, chrono::Utc::now());
let max_tokens = crate::route_budget::effective_max_output_tokens_for_route(
route.provider,
&route.model,
None,
);
let run = VERIFY_ACTIVE
.scope(
(),
run_critique(
&client,
&self.model,
self.critic_effort,
max_tokens,
&critique_input,
),
)
.await?;
if let Some(reason) = &run.incomplete_stop_reason {
let mut metadata = json!({ "tool": "verify" });
crate::cost_status::attach_child_usage_metadata(&mut metadata, &route, &run.usage);
return Ok(ToolResult::error(format!(
"Verify critic response incomplete: provider stop reason `{reason}`; the partial critique was not accepted."
))
.with_metadata(metadata));
}
let mut metadata = json!({
"tool": "verify",
"verdict": run.report.verdict,
"finding_count": run.report.findings.len(),
"highest_severity": run.report.highest_severity(),
"unresolved_risk": run.report.unresolved_risk,
"critic_effort": self.critic_effort.as_setting(),
});
crate::cost_status::attach_child_usage_metadata(&mut metadata, &route, &run.usage);
let result = ToolResult::json(&run.report)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
Ok(result.with_metadata(metadata))
}
}
async fn run_critique<C: LlmClient>(
client: &C,
model: &str,
effort: ReasoningEffort,
max_tokens: u32,
input: &CritiqueInput,
) -> Result<CritiqueRun, ToolError> {
let prompt = build_critic_prompt(input, DEFAULT_MAX_EVIDENCE_CHARS);
let request = build_critic_request(model, effort, max_tokens, prompt);
let response = client
.create_message(request)
.await
.map_err(|e| ToolError::execution_failed(format!("verify critic request failed: {e}")))?;
let incomplete_stop_reason =
crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()).then(|| {
crate::models::stop_reason_detail(response.stop_reason.as_deref()).to_string()
});
let text = extract_text(&response.content);
Ok(CritiqueRun {
report: CritiqueReport::from_model_text(&text),
usage: response.usage,
incomplete_stop_reason,
})
}
fn build_critic_request(
model: &str,
effort: ReasoningEffort,
max_tokens: u32,
prompt: String,
) -> MessageRequest {
MessageRequest {
model: model.to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt,
cache_control: None,
}],
}],
max_tokens,
system: Some(SystemPrompt::Text(CRITIC_SYSTEM_PROMPT.to_string())),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some(clamp_to_elevated(effort).as_setting().to_string()),
stream: Some(false),
temperature: None,
top_p: None,
}
}
fn build_critic_prompt(input: &CritiqueInput, max_chars: usize) -> String {
let mut out = String::new();
out.push_str("CLAIM (to be refuted):\n");
out.push_str(&input.claim);
out.push('\n');
if let Some(req) = &input.requirement {
out.push_str("\nORIGINAL REQUIREMENT (verify the change against THIS):\n");
out.push_str(req);
out.push('\n');
}
if let Some(focus) = &input.focus {
out.push_str("\nFOCUS (scrutinize this in particular):\n");
out.push_str(focus);
out.push('\n');
}
let header_len = out.len();
let evidence_budget = max_chars.saturating_sub(header_len).max(1_000);
out.push_str("\n=== EVIDENCE ===\n");
if input.no_code_evidence {
out.push_str(
"No code diff or file contents were available. Critique the claim on its own terms, \
and explicitly note in your summary that you could not inspect the actual change.\n",
);
} else {
let mut evidence_text = String::new();
for block in &input.evidence {
evidence_text.push_str("--- ");
evidence_text.push_str(&block.label);
evidence_text.push_str(" ---\n");
evidence_text.push_str(&block.body);
if !block.body.ends_with('\n') {
evidence_text.push('\n');
}
evidence_text.push('\n');
}
out.push_str(&truncate_with_ellipsis(
&evidence_text,
evidence_budget,
"\n...[evidence truncated]...\n",
));
}
out.push_str("=== END EVIDENCE ===\n\nRefute the claim. Return ONLY the JSON object.");
out
}
async fn gather_diff_evidence(
workspace: &Path,
staged: bool,
base: Option<&str>,
) -> Result<Vec<EvidenceBlock>, ToolError> {
let base = base.filter(|b| !b.trim().is_empty());
let mut blocks = Vec::new();
if staged {
let mut args: Vec<String> = vec!["--cached".to_string()];
if let Some(base) = base {
args.push(base.to_string());
}
if let Some(diff) = run_git_diff(workspace, &args).await? {
blocks.push(EvidenceBlock {
label: "git diff --cached (staged)".to_string(),
body: diff,
});
}
return Ok(blocks);
}
if let Some(base) = base {
if let Some(diff) = run_git_diff(workspace, &[format!("{base}...HEAD")]).await? {
blocks.push(EvidenceBlock {
label: format!("committed changes since {base} (git diff {base}...HEAD)"),
body: diff,
});
}
}
let worktree = match run_git_diff(workspace, &["HEAD".to_string()]).await {
Ok(diff) => diff,
Err(_) => run_git_diff(workspace, &[]).await?,
};
if let Some(diff) = worktree {
blocks.push(EvidenceBlock {
label: "uncommitted changes (git diff HEAD, working tree)".to_string(),
body: diff,
});
}
Ok(blocks)
}
async fn run_git_diff(workspace: &Path, args: &[String]) -> Result<Option<String>, ToolError> {
let Some(mut cmd) = crate::dependencies::Git::command() else {
return Ok(None);
};
cmd.arg("diff");
for arg in args {
cmd.arg(arg);
}
cmd.current_dir(workspace);
let output = tokio::task::spawn_blocking(move || cmd.output())
.await
.map_err(|e| ToolError::execution_failed(format!("git diff task panicked: {e}")))?
.map_err(|e| ToolError::execution_failed(format!("failed to run git diff: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ToolError::execution_failed(format!(
"git diff failed: {}",
stderr.trim()
)));
}
let diff = String::from_utf8_lossy(&output.stdout).to_string();
if diff.trim().is_empty() {
Ok(None)
} else {
Ok(Some(diff))
}
}
fn gather_files(files: &[String], context: &ToolContext) -> Vec<EvidenceBlock> {
let mut blocks = Vec::new();
for raw in files {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
match context.resolve_path(raw) {
Ok(path) => match std::fs::read_to_string(&path) {
Ok(content) => {
let display = path
.strip_prefix(&context.workspace)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let numbered = number_lines(&content);
blocks.push(EvidenceBlock {
label: format!("file: {display}"),
body: truncate_with_ellipsis(
&numbered,
PER_FILE_MAX_CHARS,
"\n...[file truncated]...\n",
),
});
}
Err(e) => blocks.push(EvidenceBlock {
label: format!("file: {raw} (unreadable)"),
body: format!("<could not read file: {e}>"),
}),
},
Err(e) => blocks.push(EvidenceBlock {
label: format!("file: {raw} (rejected)"),
body: format!("<path rejected: {e}>"),
}),
}
}
blocks
}
fn number_lines(content: &str) -> String {
content
.lines()
.enumerate()
.map(|(idx, line)| format!("{:>4} | {line}", idx + 1))
.collect::<Vec<_>>()
.join("\n")
}
fn clamp_to_elevated(effort: ReasoningEffort) -> ReasoningEffort {
match effort {
ReasoningEffort::High | ReasoningEffort::Max => effort,
_ => ReasoningEffort::High,
}
}
fn extract_string_array(input: &Value, key: &str) -> Vec<String> {
input
.get(key)
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn extract_text(blocks: &[ContentBlock]) -> String {
let mut out = String::new();
for block in blocks {
if let ContentBlock::Text { text, .. } = block {
out.push_str(text);
}
}
out
}
fn parse_report_json(raw: &str) -> Option<CritiqueReport> {
serde_json::from_str::<CritiqueReport>(raw.trim()).ok()
}
fn extract_json_block(raw: &str) -> Option<&str> {
if let Some(start) = raw.find("```json") {
let after = &raw[start + "```json".len()..];
if let Some(end) = after.find("```") {
return Some(after[..end].trim());
}
}
let start = raw.find('{')?;
let end = raw.rfind('}')?;
if end > start {
Some(raw[start..=end].trim())
} else {
None
}
}
fn normalize_severity(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"critical" | "crit" | "blocker" | "severe" => "critical",
"high" | "major" | "important" => "high",
"low" | "minor" | "nit" | "trivial" => "low",
_ => "medium",
}
.to_string()
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::mock::MockLlmClient;
use serde_json::json;
use std::path::Path;
fn ctx() -> ToolContext {
ToolContext::new(Path::new("."))
}
fn text_response(model: &str, body: &str) -> crate::models::MessageResponse {
crate::models::MessageResponse {
id: "msg_test".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: body.to_string(),
cache_control: None,
}],
model: model.to_string(),
stop_reason: Some("stop".to_string()),
stop_sequence: None,
container: None,
usage: Usage::default(),
}
}
fn planted_bug_input() -> CritiqueInput {
CritiqueInput {
claim: "average() now correctly computes the mean for any input slice".to_string(),
requirement: Some("Must not panic on empty input.".to_string()),
focus: None,
evidence: vec![EvidenceBlock {
label: "git diff (working tree)".to_string(),
body: "+fn average(xs: &[f64]) -> f64 {\n+ xs.iter().sum::<f64>() / xs.len() as f64\n+}\n"
.to_string(),
}],
no_code_evidence: false,
}
}
#[test]
fn tool_contract_name_and_schema() {
let tool = VerifyTool::new(None, "test-model".to_string());
assert_eq!(tool.name(), "verify");
let schema = tool.input_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["claim"].is_object());
assert!(schema["properties"]["scope"]["enum"].is_array());
let required = schema["required"].as_array().expect("required array");
assert!(required.iter().any(|v| v == "claim"));
assert!(tool.capabilities().contains(&ToolCapability::ReadOnly));
assert!(tool.is_read_only());
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
assert!(tool.model_visible());
}
#[test]
fn critic_request_is_elevated_and_toolless() {
let req = build_critic_request("m", ReasoningEffort::Low, 65_536, "prompt".to_string());
assert_eq!(
req.reasoning_effort.as_deref(),
Some("high"),
"Low must clamp up to elevated reasoning"
);
assert!(
req.tools.is_none(),
"critic must be given NO tools — this is the structural recursion guard"
);
assert_eq!(
req.max_tokens, 65_536,
"critic must use the route-effective allowance it was handed"
);
assert_eq!(req.temperature, None, "no sampling params on the wire");
assert_eq!(req.top_p, None, "no sampling params on the wire");
let req_max = build_critic_request("m", ReasoningEffort::Max, 65_536, "prompt".to_string());
assert_eq!(req_max.reasoning_effort.as_deref(), Some("max"));
assert!(req_max.tools.is_none());
}
#[test]
fn with_critic_effort_clamps_below_high() {
let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Low);
assert_eq!(tool.critic_effort, ReasoningEffort::High);
let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Max);
assert_eq!(tool.critic_effort, ReasoningEffort::Max);
}
#[tokio::test]
async fn critic_surfaces_planted_bug() {
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(text_response(
"mock-critic",
r#"{
"verdict": "refuted",
"summary": "average() divides by len() with no empty-slice guard.",
"findings": [
{
"severity": "critical",
"issue": "Divide-by-zero / NaN when xs is empty; violates the no-panic requirement.",
"evidence": "average(): xs.len() as f64 is 0 for empty input",
"suggested_fix": "Return 0.0 or Option::None when xs.is_empty()."
}
],
"unresolved_risk": true
}"#,
));
let run = run_critique(
&mock,
"mock-critic",
ReasoningEffort::Max,
65_536,
&planted_bug_input(),
)
.await
.expect("critique runs");
assert_eq!(run.report.verdict, "refuted");
assert!(run.report.unresolved_risk);
assert_eq!(run.report.findings.len(), 1);
assert_eq!(run.report.findings[0].severity, "critical");
assert!(
run.report.findings[0]
.issue
.to_lowercase()
.contains("empty"),
"finding should name the empty-input defect"
);
assert_eq!(run.report.highest_severity(), "critical");
let sent = mock.last_request().expect("request captured");
assert_eq!(sent.reasoning_effort.as_deref(), Some("max"));
assert!(sent.tools.is_none());
let prompt = match &sent.messages[0].content[0] {
ContentBlock::Text { text, .. } => text.clone(),
_ => panic!("expected text content"),
};
assert!(prompt.contains("CLAIM"));
assert!(prompt.contains("Must not panic on empty input"));
assert!(prompt.contains("average("));
}
#[tokio::test]
async fn unstructured_critic_output_is_unresolved_risk() {
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(text_response("m", "I think it looks fine, ship it."));
let run = run_critique(
&mock,
"m",
ReasoningEffort::High,
65_536,
&planted_bug_input(),
)
.await
.expect("runs");
assert_eq!(run.report.verdict, "uncertain");
assert!(run.report.unresolved_risk);
}
#[test]
fn upheld_with_serious_finding_is_downgraded_to_refuted() {
let report = CritiqueReport::from_model_text(
r#"{"verdict":"upheld","summary":"looks ok","findings":[{"severity":"high","issue":"missing null check"}],"unresolved_risk":false}"#,
);
assert_eq!(report.verdict, "refuted");
assert!(report.unresolved_risk);
}
#[test]
fn upheld_with_medium_finding_flags_unresolved_risk() {
let report = CritiqueReport::from_model_text(
r#"{"verdict":"upheld","summary":"seems fine","findings":[{"severity":"medium","issue":"unhandled empty-input case"}],"unresolved_risk":false}"#,
);
assert!(
report.unresolved_risk,
"a medium finding must force unresolved_risk=true"
);
assert_ne!(
report.verdict, "upheld",
"cannot remain 'upheld' with an open medium finding"
);
assert_eq!(
report.verdict, "uncertain",
"medium (not serious) downgrades upheld to uncertain, not refuted"
);
}
#[test]
fn upheld_with_only_low_finding_stays_upheld() {
let report = CritiqueReport::from_model_text(
r#"{"verdict":"upheld","summary":"clean","findings":[{"severity":"low","issue":"nit: rename variable"}],"unresolved_risk":false}"#,
);
assert_eq!(report.verdict, "upheld");
assert!(!report.unresolved_risk);
}
#[tokio::test]
async fn execute_refuses_reentry() {
let tool = VerifyTool::new(None, "m".to_string());
let err = VERIFY_ACTIVE
.scope((), async {
tool.execute(json!({ "claim": "x" }), &ctx()).await
})
.await
.expect_err("re-entry must be refused");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("recursion") || msg.contains("inside its own"),
"expected a recursion-guard error, got: {err}"
);
}
#[tokio::test]
async fn execute_without_client_is_not_available() {
let tool = VerifyTool::new(None, "m".to_string());
let err = tool
.execute(json!({ "claim": "did the thing" }), &ctx())
.await
.expect_err("no client");
assert!(err.to_string().to_lowercase().contains("client"));
}
#[tokio::test]
async fn execute_rejects_empty_and_unknown_scope() {
let tool = VerifyTool::new(None, "m".to_string());
let err = tool
.execute(json!({ "claim": " " }), &ctx())
.await
.expect_err("empty claim");
assert!(err.to_string().to_lowercase().contains("claim"), "{err}");
let err = tool
.execute(json!({ "claim": "ok", "scope": "everything" }), &ctx())
.await
.expect_err("unknown scope");
assert!(err.to_string().to_lowercase().contains("scope"), "{err}");
}
#[test]
fn parses_fenced_json_block() {
let raw = "Here is my critique:\n```json\n{\"verdict\":\"refuted\",\"summary\":\"s\",\"findings\":[],\"unresolved_risk\":true}\n```\nDone.";
let report = CritiqueReport::from_model_text(raw);
assert_eq!(report.verdict, "refuted");
assert!(report.unresolved_risk);
}
#[test]
fn severity_normalization() {
assert_eq!(normalize_severity("BLOCKER"), "critical");
assert_eq!(normalize_severity("Major"), "high");
assert_eq!(normalize_severity("nit"), "low");
assert_eq!(normalize_severity("weird"), "medium");
assert_eq!(normalize_severity(""), "medium");
}
fn run_git(dir: &Path, args: &[&str]) {
let mut cmd = crate::dependencies::Git::command().expect("git available");
cmd.args(args).current_dir(dir);
let out = cmd.output().expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn diff_scope_with_base_includes_uncommitted_worktree_changes() {
if crate::dependencies::Git::command().is_none() {
return; }
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path();
run_git(repo, &["init", "-q"]);
run_git(repo, &["config", "user.email", "t@example.com"]);
run_git(repo, &["config", "user.name", "Test"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
std::fs::write(repo.join("f.txt"), "line1\n").expect("write");
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-q", "-m", "base"]);
std::fs::write(repo.join("f.txt"), "line1\nCOMMITTED_MARKER\n").expect("write");
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-q", "-m", "second"]);
std::fs::write(
repo.join("f.txt"),
"line1\nCOMMITTED_MARKER\nUNCOMMITTED_MARKER\n",
)
.expect("write");
let blocks = gather_diff_evidence(repo, false, Some("HEAD~1"))
.await
.expect("gather diff");
let joined = blocks
.iter()
.map(|b| format!("[{}]\n{}", b.label, b.body))
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("UNCOMMITTED_MARKER"),
"uncommitted working-tree change must appear in evidence with a base set:\n{joined}"
);
assert!(
joined.contains("COMMITTED_MARKER"),
"committed-since-base change must also appear:\n{joined}"
);
assert!(
blocks
.iter()
.any(|b| b.label.contains("committed changes since")),
"expected a committed-since-base block: {:?}",
blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
);
assert!(
blocks
.iter()
.any(|b| b.label.contains("uncommitted changes")),
"expected an uncommitted working-tree block: {:?}",
blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
);
}
}