返回 CodeWhale
verify.rs
根目录 / crates / tui / src / tools / verify.rs
1 //! `verify` — agent-callable adversarial self-critique (#4196).
2 //!
3 //! This tool lets the agent DECIDE to spend extra test-time compute on a
4 //! self-review of its own recent work before claiming a change done. It runs
5 //! an INDEPENDENT adversarial critic pass at elevated reasoning (High/Max,
6 //! regardless of the session tier) whose job is to REFUTE the agent's claim —
7 //! surfacing correctness gaps, missed requirements, and edge cases as
8 //! structured findings the agent must then address.
9 //!
10 //! # Why elevated reasoning is the mechanism
11 //!
12 //! The critic request explicitly sets `reasoning_effort` to a high tier
13 //! ([`VerifyTool::critic_effort`], default [`ReasoningEffort::Max`]). Elevated
14 //! reasoning IS the test-time-compute lever, so the critic never inherits a low
15 //! session tier — [`build_critic_request`] threads the effort onto the outgoing
16 //! [`MessageRequest`], which the client forwards to the provider.
17 //!
18 //! # Bounded / no runaway (hard requirement)
19 //!
20 //! A verify call must not be able to trigger another verify. Two independent
21 //! guards enforce this:
22 //!
23 //! 1. **Structural (primary):** the critic is a single model call with
24 //! `tools: None` (see [`build_critic_request`]). With no tools of any kind,
25 //! the critic literally cannot invoke `verify` — recursion is impossible by
26 //! construction, not by a denylist that could be forgotten.
27 //! 2. **Re-entry guard (defense in depth):** [`VerifyTool::execute`] refuses if
28 //! it is entered while a critique is already in progress on the same task
29 //! (tracked via the [`static@VERIFY_ACTIVE`] task-local). This protects any
30 //! future path that might run the critic inside a tool loop.
31 //!
32 //! # Relationship to neighbouring tools
33 //!
34 //! - `review` critiques a specific target (file/diff/PR) as a code review.
35 //! - `run_verifiers` executes external test/build gates (pytest, cargo, …).
36 //! - `verify` (this tool) is an adversarial reasoning pass over a *claim* and
37 //! its supporting evidence — "is what I just did actually correct and
38 //! complete?" — not a linter and not a test runner.
39
40 use std::path::Path;
41
42 use async_trait::async_trait;
43 use serde::{Deserialize, Serialize};
44 use serde_json::{Value, json};
45
46 use crate::client::CodewhaleClient;
47 use crate::dependencies::ExternalTool;
48 use crate::features::Feature;
49 use crate::llm_client::LlmClient;
50 use crate::reasoning_preference::ReasoningEffort;
51 use crate::utils::truncate_with_ellipsis;
52 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
53
54 use super::spec::{
55 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
56 optional_str, required_str,
57 };
58 use codewhale_models::Role;
59
60 /// Total evidence budget handed to the critic. Kept well under a turn so the
61 /// critic has room to reason. Large diffs/files are truncated with a marker.
62 const DEFAULT_MAX_EVIDENCE_CHARS: usize = 120_000;
63 /// Per-file evidence cap so a single huge file can't crowd out the rest.
64 const PER_FILE_MAX_CHARS: usize = 40_000;
65 /// Cap on the raw-text fallback summary when the critic returns non-JSON.
66 const FALLBACK_SUMMARY_MAX_CHARS: usize = 4_000;
67
68 // Task-local marker set for the duration of a critic pass. Presence means "a
69 // verify critique is already running on this task", which `VerifyTool::execute`
70 // treats as illegal re-entry. This is defense-in-depth on top of the structural
71 // `tools: None` guard in `build_critic_request`.
72 tokio::task_local! {
73 static VERIFY_ACTIVE: ();
74 }
75
76 const CRITIC_SYSTEM_PROMPT: &str = "You are an adversarial critic performing a rigorous \
77 self-review of a code change on behalf of the engineer who wrote it. Your job is to REFUTE the \
78 claim, not to praise it. Assume the change is WRONG or INCOMPLETE until the evidence proves \
79 otherwise.\n\
80 \n\
81 Hunt specifically for: correctness bugs; requirements that are only partially met or silently \
82 dropped; unhandled edge cases (empty / huge / malformed input, concurrency and re-entrancy, \
83 error and failure paths, off-by-one, integer overflow, null/None); regressions in existing \
84 behaviour; and tests that pass but assert the wrong thing (green-CI-but-wrong). Prefer a small \
85 number of concrete, evidence-backed findings over vague concerns. Cite `path:line` from the \
86 evidence whenever you can. If, after a genuine effort to break it, you cannot refute the claim, \
87 say so honestly rather than inventing problems.\n\
88 \n\
89 Return ONLY valid JSON (no prose, no markdown fences) matching this schema:\n\
90 {\n\
91 \"verdict\": \"refuted\" | \"upheld\" | \"uncertain\",\n\
92 \"summary\": \"<= 3 sentence adversarial assessment\",\n\
93 \"findings\": [\n\
94 {\n\
95 \"severity\": \"critical\" | \"high\" | \"medium\" | \"low\",\n\
96 \"issue\": \"what is wrong or unproven\",\n\
97 \"evidence\": \"where/why, path:line when possible\",\n\
98 \"suggested_fix\": \"concrete, actionable fix\"\n\
99 }\n\
100 ],\n\
101 \"unresolved_risk\": true | false\n\
102 }\n\
103 Set verdict=refuted if you found at least one critical or high finding; upheld only if you \
104 genuinely could not refute the claim; uncertain if the evidence was insufficient to decide. Set \
105 unresolved_risk=true whenever any unaddressed correctness risk remains.";
106
107 /// A single adversarial finding the agent should address.
108 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109 pub struct CritiqueFinding {
110 /// Normalized to one of `critical` / `high` / `medium` / `low`.
111 #[serde(default)]
112 pub severity: String,
113 /// What is wrong or unproven.
114 #[serde(default)]
115 pub issue: String,
116 /// Where/why, ideally `path:line`.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub evidence: Option<String>,
119 /// Concrete, actionable fix.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub suggested_fix: Option<String>,
122 }
123
124 /// Structured result of a verify/critique pass.
125 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126 pub struct CritiqueReport {
127 /// `refuted` (found a real problem), `upheld` (could not refute), or
128 /// `uncertain` (insufficient evidence / unstructured critic output).
129 #[serde(default)]
130 pub verdict: String,
131 /// Short adversarial assessment.
132 #[serde(default)]
133 pub summary: String,
134 /// Concrete findings the agent must address before claiming done.
135 #[serde(default)]
136 pub findings: Vec<CritiqueFinding>,
137 /// True whenever an unaddressed correctness risk remains. Forced `true` by
138 /// any finding at `medium` severity or above (only `low` nits are exempt),
139 /// regardless of what the critic self-reported, so "green-but-wrong"
140 /// changes are not waved through.
141 #[serde(default)]
142 pub unresolved_risk: bool,
143 }
144
145 impl CritiqueReport {
146 /// Parse the critic's raw response text into a structured report, tolerating
147 /// bare JSON, a fenced ```json block, or free-form prose (fallback).
148 #[must_use]
149 pub fn from_model_text(raw: &str) -> Self {
150 if let Some(parsed) = parse_report_json(raw) {
151 return parsed.normalize();
152 }
153 if let Some(block) = extract_json_block(raw)
154 && let Some(parsed) = parse_report_json(block)
155 {
156 return parsed.normalize();
157 }
158 Self::fallback(raw).normalize()
159 }
160
161 /// The critic returned something we could not parse as JSON. Fail safe:
162 /// treat it as unresolved risk rather than a clean bill of health.
163 fn fallback(raw: &str) -> Self {
164 let trimmed = raw.trim();
165 let summary = if trimmed.is_empty() {
166 "Critic returned no output; treat the change as unverified.".to_string()
167 } else {
168 format!(
169 "Critic returned unstructured output (treated as unresolved risk):\n{}",
170 truncate_with_ellipsis(trimmed, FALLBACK_SUMMARY_MAX_CHARS, "\n...[truncated]\n")
171 )
172 };
173 Self {
174 verdict: "uncertain".to_string(),
175 summary,
176 findings: Vec::new(),
177 unresolved_risk: true,
178 }
179 }
180
181 /// Canonicalize severities/verdict and derive a fail-safe `unresolved_risk`.
182 fn normalize(mut self) -> Self {
183 self.summary = self.summary.trim().to_string();
184 for finding in &mut self.findings {
185 finding.severity = normalize_severity(&finding.severity);
186 finding.issue = finding.issue.trim().to_string();
187 finding.evidence = normalize_optional(finding.evidence.take());
188 finding.suggested_fix = normalize_optional(finding.suggested_fix.take());
189 }
190
191 // `has_serious` (critical/high) refutes the claim outright.
192 let has_serious = self
193 .findings
194 .iter()
195 .any(|f| matches!(f.severity.as_str(), "critical" | "high"));
196 // `has_blocking` (medium-or-above, i.e. anything that is not a `low`
197 // nit) is a real unresolved correctness concern: it must force
198 // `unresolved_risk` and forbid an `upheld` verdict, even if the critic
199 // self-reported `unresolved_risk = false`. This closes the green-but-
200 // wrong gap where verdict=upheld + a MEDIUM finding + critic-set
201 // `unresolved_risk=false` reported "no unresolved risk". `low`-only
202 // findings are intentionally exempt so nits don't block a claim.
203 let has_blocking = self
204 .findings
205 .iter()
206 .any(|f| matches!(f.severity.as_str(), "critical" | "high" | "medium"));
207
208 // Verdict: honour an explicit, recognized value; otherwise infer.
209 // Precedence: any serious finding => refuted; else any blocking
210 // (medium) finding => uncertain (never upheld); else honour the critic.
211 self.verdict = match self.verdict.trim().to_ascii_lowercase().as_str() {
212 "refuted" | "rejected" | "fail" | "failed" => "refuted".to_string(),
213 "upheld" | "confirmed" | "pass" | "passed" | "ok" => {
214 if has_serious {
215 "refuted".to_string()
216 } else if has_blocking {
217 // A medium finding is a genuine open concern — the claim
218 // cannot be "upheld" while it stands.
219 "uncertain".to_string()
220 } else {
221 "upheld".to_string()
222 }
223 }
224 "" => {
225 if has_serious {
226 "refuted".to_string()
227 } else {
228 "uncertain".to_string()
229 }
230 }
231 _ => "uncertain".to_string(),
232 };
233
234 // Fail safe: any medium-or-above finding => unresolved risk, regardless
235 // of what the model set.
236 self.unresolved_risk = self.unresolved_risk || has_blocking;
237 self
238 }
239
240 /// Highest severity present, or "none".
241 #[must_use]
242 fn highest_severity(&self) -> &'static str {
243 for level in ["critical", "high", "medium", "low"] {
244 if self.findings.iter().any(|f| f.severity == level) {
245 return level;
246 }
247 }
248 "none"
249 }
250 }
251
252 /// Evidence gathered by the tool and handed to the critic.
253 struct CritiqueInput {
254 claim: String,
255 requirement: Option<String>,
256 focus: Option<String>,
257 evidence: Vec<EvidenceBlock>,
258 /// True when no diff or file contents could be gathered.
259 no_code_evidence: bool,
260 }
261
262 struct EvidenceBlock {
263 label: String,
264 body: String,
265 }
266
267 /// Outcome of a single critic invocation, plus accounting for metadata.
268 struct CritiqueRun {
269 report: CritiqueReport,
270 usage: Usage,
271 /// Provider stop reason when the critic response was incomplete; billed
272 /// usage above still counts, but the partial report must not.
273 incomplete_stop_reason: Option<String>,
274 }
275
276 /// Agent-callable adversarial self-critique tool.
277 pub struct VerifyTool {
278 client: Option<CodewhaleClient>,
279 model: String,
280 /// Reasoning tier the critic runs at, independent of the session tier.
281 critic_effort: ReasoningEffort,
282 }
283
284 impl VerifyTool {
285 /// Construct with the default critic effort ([`ReasoningEffort::Max`]).
286 #[must_use]
287 pub fn new(client: Option<CodewhaleClient>, model: String) -> Self {
288 Self {
289 client,
290 model,
291 critic_effort: ReasoningEffort::Max,
292 }
293 }
294
295 /// Override the critic reasoning tier. Values below `High` are clamped up to
296 /// `High` — elevated reasoning is the whole point of this tool. This is the
297 /// seam for a future `[verify] critic_effort` config knob; production
298 /// registration currently uses the `Max` default from [`Self::new`].
299 #[must_use]
300 #[cfg_attr(not(test), expect(dead_code))]
301 pub fn with_critic_effort(mut self, effort: ReasoningEffort) -> Self {
302 self.critic_effort = clamp_to_elevated(effort);
303 self
304 }
305 }
306
307 #[async_trait]
308 impl ToolSpec for VerifyTool {
309 fn name(&self) -> &'static str {
310 "verify"
311 }
312
313 fn description(&self) -> &'static str {
314 "Run an INDEPENDENT adversarial critic over your own recent work before you claim it is \
315 done. You state a claim (what you believe your change accomplishes) plus optional scope (the \
316 recent git diff, specific files, the original requirement); an independent critic runs at \
317 elevated reasoning and tries to REFUTE it, returning structured findings (issue, severity, \
318 suggested fix). Call this when it is worth spending extra thinking: before claiming a non-trivial \
319 change complete, after a risky or subtle edit, or when you are unsure the change fully satisfies \
320 the requirement and handles edge cases. Skip it for trivial or mechanical changes. This is not a \
321 test runner (use run_verifiers) or a code review of an arbitrary target (use review) — it is a \
322 self-check of whether what you just did is actually correct and complete."
323 }
324
325 fn input_schema(&self) -> Value {
326 json!({
327 "type": "object",
328 "properties": {
329 "claim": {
330 "type": "string",
331 "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."
332 },
333 "requirement": {
334 "type": "string",
335 "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."
336 },
337 "scope": {
338 "type": "string",
339 "enum": ["diff", "staged", "none"],
340 "default": "diff",
341 "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."
342 },
343 "base": {
344 "type": "string",
345 "description": "Optional git base ref for the diff (e.g. origin/main). Defaults to the plain working-tree/staged diff."
346 },
347 "files": {
348 "type": "array",
349 "items": { "type": "string" },
350 "description": "Optional explicit file paths (relative to the workspace) whose current contents to include as evidence."
351 },
352 "focus": {
353 "type": "string",
354 "description": "Optional: a specific risk to scrutinize (e.g. 'concurrency', 'the empty-input case', 'error handling on network failure')."
355 }
356 },
357 "required": ["claim"]
358 })
359 }
360
361 fn capabilities(&self) -> Vec<ToolCapability> {
362 // Read-only: it inspects the workspace (git diff, file reads) and calls
363 // the model. It never mutates the workspace.
364 vec![ToolCapability::ReadOnly, ToolCapability::Network]
365 }
366
367 fn approval_requirement(&self) -> ApprovalRequirement {
368 ApprovalRequirement::Auto
369 }
370
371 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
372 // Opt-out (defense in depth; the primary gate is registration-time in
373 // `with_agent_runtime_surface`). Honours `[features] verify_tool = false`
374 // and lets saved-transcript replays respect a disabled toggle.
375 if !context.features.enabled(Feature::Verify) {
376 return Err(ToolError::not_available(
377 "verify tool is disabled ([features] verify_tool = false)".to_string(),
378 ));
379 }
380
381 // Re-entry guard: refuse if a critique is already running on this task.
382 // Checked BEFORE anything else so it cannot be bypassed via a missing
383 // client or bad input.
384 if VERIFY_ACTIVE.try_with(|_| ()).is_ok() {
385 return Err(ToolError::not_available(
386 "verify cannot run inside its own critic pass (recursion guard)".to_string(),
387 ));
388 }
389
390 // Validate the request shape before checking client availability, so a
391 // malformed call gets a precise input error rather than a generic
392 // "no client" one.
393 let claim = required_str(&input, "claim")?.trim().to_string();
394 if claim.is_empty() {
395 return Err(ToolError::invalid_input("claim cannot be empty"));
396 }
397 let requirement = optional_str(&input, "requirement")?
398 .map(str::trim)
399 .filter(|s| !s.is_empty())
400 .map(str::to_string);
401 let focus = optional_str(&input, "focus")?
402 .map(str::trim)
403 .filter(|s| !s.is_empty())
404 .map(str::to_string);
405 let base = optional_str(&input, "base")?
406 .map(str::trim)
407 .filter(|s| !s.is_empty())
408 .map(str::to_string);
409
410 let scope = optional_str(&input, "scope")?.unwrap_or("diff").trim();
411 let staged = match scope {
412 "diff" | "" => false,
413 "staged" => true,
414 "none" => {
415 // handled below by skipping diff gathering
416 false
417 }
418 other => {
419 return Err(ToolError::invalid_input(format!(
420 "unknown scope '{other}' (expected diff | staged | none)"
421 )));
422 }
423 };
424 let gather_diff_scope = scope != "none";
425
426 let files = extract_string_array(&input, "files");
427
428 let Some(client) = self.client.clone() else {
429 return Err(ToolError::not_available(
430 "verify tool requires an active model client".to_string(),
431 ));
432 };
433
434 // --- Deterministic evidence gathering ---
435 let mut evidence: Vec<EvidenceBlock> = Vec::new();
436 if gather_diff_scope {
437 evidence.extend(
438 gather_diff_evidence(context.workspace.as_path(), staged, base.as_deref()).await?,
439 );
440 }
441 evidence.extend(gather_files(&files, context));
442
443 let no_code_evidence = evidence.is_empty();
444
445 let critique_input = CritiqueInput {
446 claim,
447 requirement,
448 focus,
449 evidence,
450 no_code_evidence,
451 };
452
453 // Run the critic under the re-entry marker so any (future) nested tool
454 // call to `verify` is refused.
455 let route = client.effective_route_envelope(&self.model, chrono::Utc::now());
456 // The critic runs at elevated reasoning, so its output allowance must
457 // be the route's normal one: a small fixed cap is consumed by the
458 // reasoning stream on thinking routes and truncates every critique.
459 let max_tokens = client.effective_max_output_tokens(&route.model);
460 let run = VERIFY_ACTIVE
461 .scope(
462 (),
463 run_critique(
464 &client,
465 &self.model,
466 self.critic_effort,
467 max_tokens,
468 &critique_input,
469 ),
470 )
471 .await?;
472
473 if let Some(reason) = &run.incomplete_stop_reason {
474 let mut metadata = json!({ "tool": "verify" });
475 crate::cost_status::attach_child_usage_metadata(&mut metadata, &route, &run.usage);
476 return Ok(ToolResult::error(format!(
477 "Verify critic response incomplete: provider stop reason `{reason}`; the partial critique was not accepted."
478 ))
479 .with_metadata(metadata));
480 }
481
482 let mut metadata = json!({
483 "tool": "verify",
484 "verdict": run.report.verdict,
485 "finding_count": run.report.findings.len(),
486 "highest_severity": run.report.highest_severity(),
487 "unresolved_risk": run.report.unresolved_risk,
488 "critic_effort": self.critic_effort.as_setting(),
489 });
490 // Previously this reported only input/output, so a verify child's cache
491 // reads *and* cache writes were invisible to the cost audit (#4318).
492 crate::cost_status::attach_child_usage_metadata(&mut metadata, &route, &run.usage);
493
494 let result = ToolResult::json(&run.report)
495 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
496 Ok(result.with_metadata(metadata))
497 }
498 }
499
500 /// Run one adversarial critic pass. Generic over [`LlmClient`] so tests can
501 /// drive it with `MockLlmClient` without a network call.
502 async fn run_critique<C: LlmClient>(
503 client: &C,
504 model: &str,
505 effort: ReasoningEffort,
506 max_tokens: u32,
507 input: &CritiqueInput,
508 ) -> Result<CritiqueRun, ToolError> {
509 let prompt = build_critic_prompt(input, DEFAULT_MAX_EVIDENCE_CHARS);
510 let request = build_critic_request(model, effort, max_tokens, prompt);
511 let response = client
512 .create_message(request)
513 .await
514 .map_err(|e| ToolError::execution_failed(format!("verify critic request failed: {e}")))?;
515 let incomplete_stop_reason =
516 codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()).then(|| {
517 codewhale_models::stop_reason_detail(response.stop_reason.as_deref()).to_string()
518 });
519 let text = extract_text(&response.content);
520 Ok(CritiqueRun {
521 report: CritiqueReport::from_model_text(&text),
522 usage: response.usage,
523 incomplete_stop_reason,
524 })
525 }
526
527 /// Build the critic's outgoing request. **The recursion guarantee lives here:**
528 /// `tools` is always `None`, so the critic cannot invoke `verify` (or any other
529 /// tool). `reasoning_effort` is set explicitly so the critic runs elevated
530 /// regardless of the session tier.
531 fn build_critic_request(
532 model: &str,
533 effort: ReasoningEffort,
534 max_tokens: u32,
535 prompt: String,
536 ) -> MessageRequest {
537 MessageRequest {
538 model: model.to_string(),
539 messages: vec![Message {
540 role: Role::User,
541 content: vec![ContentBlock::Text {
542 text: prompt,
543 cache_control: None,
544 }],
545 }],
546 // Route-effective allowance: elevated reasoning shares the output
547 // budget on thinking routes, so a small fixed cap here truncates the
548 // critique before the JSON starts.
549 max_tokens,
550 system: Some(SystemPrompt::Text(CRITIC_SYSTEM_PROMPT.to_string())),
551 // Hard bound: the critic gets NO tools, so it cannot recurse into verify.
552 tools: None,
553 tool_choice: None,
554 metadata: None,
555 thinking: None,
556 // Test-time compute: elevated reasoning, independent of session tier.
557 reasoning_effort: Some(clamp_to_elevated(effort).as_setting().to_string()),
558 stream: Some(false),
559 // Route parity with ordinary turns: no sampling params, so every
560 // provider's own defaults apply and fixed-sampling routes don't reject.
561 temperature: None,
562 top_p: None,
563 }
564 }
565
566 fn build_critic_prompt(input: &CritiqueInput, max_chars: usize) -> String {
567 let mut out = String::new();
568 out.push_str("CLAIM (to be refuted):\n");
569 out.push_str(&input.claim);
570 out.push('\n');
571
572 if let Some(req) = &input.requirement {
573 out.push_str("\nORIGINAL REQUIREMENT (verify the change against THIS):\n");
574 out.push_str(req);
575 out.push('\n');
576 }
577 if let Some(focus) = &input.focus {
578 out.push_str("\nFOCUS (scrutinize this in particular):\n");
579 out.push_str(focus);
580 out.push('\n');
581 }
582
583 // Evidence gets its own budget so the claim/requirement always survive.
584 let header_len = out.len();
585 let evidence_budget = max_chars.saturating_sub(header_len).max(1_000);
586
587 out.push_str("\n=== EVIDENCE ===\n");
588 if input.no_code_evidence {
589 out.push_str(
590 "No code diff or file contents were available. Critique the claim on its own terms, \
591 and explicitly note in your summary that you could not inspect the actual change.\n",
592 );
593 } else {
594 let mut evidence_text = String::new();
595 for block in &input.evidence {
596 evidence_text.push_str("--- ");
597 evidence_text.push_str(&block.label);
598 evidence_text.push_str(" ---\n");
599 evidence_text.push_str(&block.body);
600 if !block.body.ends_with('\n') {
601 evidence_text.push('\n');
602 }
603 evidence_text.push('\n');
604 }
605 out.push_str(&truncate_with_ellipsis(
606 &evidence_text,
607 evidence_budget,
608 "\n...[evidence truncated]...\n",
609 ));
610 }
611 out.push_str("=== END EVIDENCE ===\n\nRefute the claim. Return ONLY the JSON object.");
612 out
613 }
614
615 /// Gather git-diff evidence for the requested scope. Returns zero or more
616 /// labelled blocks; an empty result is not an error (unlike `review`) — a claim
617 /// can be about reasoning, and `files` may carry the evidence instead.
618 ///
619 /// The key correctness point (fix for the base-omits-worktree gap): when a
620 /// `base` ref is supplied for the working-tree scope, we emit BOTH the committed
621 /// changes since the merge-base (`git diff base...HEAD`) AND the uncommitted
622 /// working-tree changes (`git diff HEAD`). `git diff base...HEAD` alone captures
623 /// branch commits but drops the uncommitted edits the agent usually wants to
624 /// verify before claiming done.
625 async fn gather_diff_evidence(
626 workspace: &Path,
627 staged: bool,
628 base: Option<&str>,
629 ) -> Result<Vec<EvidenceBlock>, ToolError> {
630 let base = match base.filter(|b| !b.trim().is_empty()) {
631 Some(base) => Some(super::git::resolve_commit_ref(workspace, base).await?),
632 None => None,
633 };
634 let mut blocks = Vec::new();
635
636 if staged {
637 // Staged scope: the index (optionally vs an explicit base).
638 let mut args: Vec<String> = vec!["--cached".to_string()];
639 if let Some(base) = &base {
640 args.push(base.to_string());
641 }
642 if let Some(diff) = run_git_diff(workspace, &args).await? {
643 blocks.push(EvidenceBlock {
644 label: "git diff --cached (staged)".to_string(),
645 body: diff,
646 });
647 }
648 return Ok(blocks);
649 }
650
651 // Working-tree scope.
652 if let Some(base) = base {
653 // Committed changes on this branch since the merge-base with `base`.
654 if let Some(diff) = run_git_diff(workspace, &[format!("{base}...HEAD")]).await? {
655 blocks.push(EvidenceBlock {
656 label: format!("committed changes since {base} (git diff {base}...HEAD)"),
657 body: diff,
658 });
659 }
660 }
661 // Uncommitted changes (staged + unstaged) — what "before claiming done"
662 // usually means. `git diff HEAD` captures both; fall back to a plain
663 // working-tree diff on an unborn HEAD (empty repo / no commits yet).
664 let worktree = match run_git_diff(workspace, &["HEAD".to_string()]).await {
665 Ok(diff) => diff,
666 Err(_) => run_git_diff(workspace, &[]).await?,
667 };
668 if let Some(diff) = worktree {
669 blocks.push(EvidenceBlock {
670 label: "uncommitted changes (git diff HEAD, working tree)".to_string(),
671 body: diff,
672 });
673 }
674 Ok(blocks)
675 }
676
677 /// Run `git diff <args>` in `workspace`. Returns `Ok(None)` for an empty diff or
678 /// when git is unavailable, and `Err` when git runs but reports failure.
679 async fn run_git_diff(workspace: &Path, args: &[String]) -> Result<Option<String>, ToolError> {
680 let Some(mut cmd) = crate::dependencies::Git::command() else {
681 // git not installed: degrade gracefully rather than failing the tool.
682 return Ok(None);
683 };
684 cmd.args(["diff", "--no-ext-diff", "--no-textconv"]);
685 for arg in args {
686 cmd.arg(arg);
687 }
688 cmd.arg("--").current_dir(workspace);
689
690 let output = tokio::task::spawn_blocking(move || cmd.output())
691 .await
692 .map_err(|e| ToolError::execution_failed(format!("git diff task panicked: {e}")))?
693 .map_err(|e| ToolError::execution_failed(format!("failed to run git diff: {e}")))?;
694 if !output.status.success() {
695 let stderr = String::from_utf8_lossy(&output.stderr);
696 return Err(ToolError::execution_failed(format!(
697 "git diff failed: {}",
698 stderr.trim()
699 )));
700 }
701 let diff = String::from_utf8_lossy(&output.stdout).to_string();
702 if diff.trim().is_empty() {
703 Ok(None)
704 } else {
705 Ok(Some(diff))
706 }
707 }
708
709 /// Read the requested files as evidence, recording read/path failures as inline
710 /// notes so the critic knows evidence was requested but unavailable.
711 fn gather_files(files: &[String], context: &ToolContext) -> Vec<EvidenceBlock> {
712 let mut blocks = Vec::new();
713 for raw in files {
714 let raw = raw.trim();
715 if raw.is_empty() {
716 continue;
717 }
718 match context.resolve_path(raw) {
719 Ok(path) => match std::fs::read_to_string(&path) {
720 Ok(content) => {
721 let display = path
722 .strip_prefix(&context.workspace)
723 .unwrap_or(&path)
724 .to_string_lossy()
725 .to_string();
726 let numbered = number_lines(&content);
727 blocks.push(EvidenceBlock {
728 label: format!("file: {display}"),
729 body: truncate_with_ellipsis(
730 &numbered,
731 PER_FILE_MAX_CHARS,
732 "\n...[file truncated]...\n",
733 ),
734 });
735 }
736 Err(e) => blocks.push(EvidenceBlock {
737 label: format!("file: {raw} (unreadable)"),
738 body: format!("<could not read file: {e}>"),
739 }),
740 },
741 Err(e) => blocks.push(EvidenceBlock {
742 label: format!("file: {raw} (rejected)"),
743 body: format!("<path rejected: {e}>"),
744 }),
745 }
746 }
747 blocks
748 }
749
750 fn number_lines(content: &str) -> String {
751 content
752 .lines()
753 .enumerate()
754 .map(|(idx, line)| format!("{:>4} | {line}", idx + 1))
755 .collect::<Vec<_>>()
756 .join("\n")
757 }
758
759 /// Reasoning tiers below `High` defeat the purpose; clamp them up.
760 fn clamp_to_elevated(effort: ReasoningEffort) -> ReasoningEffort {
761 match effort {
762 ReasoningEffort::High | ReasoningEffort::Max => effort,
763 // Off / Low / Medium / Auto → High (still elevated, provider-normalized
764 // at the client boundary).
765 _ => ReasoningEffort::High,
766 }
767 }
768
769 fn extract_string_array(input: &Value, key: &str) -> Vec<String> {
770 input
771 .get(key)
772 .and_then(Value::as_array)
773 .map(|arr| {
774 arr.iter()
775 .filter_map(|v| v.as_str())
776 .map(str::to_string)
777 .collect()
778 })
779 .unwrap_or_default()
780 }
781
782 fn extract_text(blocks: &[ContentBlock]) -> String {
783 let mut out = String::new();
784 for block in blocks {
785 if let ContentBlock::Text { text, .. } = block {
786 out.push_str(text);
787 }
788 }
789 out
790 }
791
792 fn parse_report_json(raw: &str) -> Option<CritiqueReport> {
793 serde_json::from_str::<CritiqueReport>(raw.trim()).ok()
794 }
795
796 /// Extract a JSON object from prose: prefer a fenced ```json block, else the
797 /// span from the first `{` to the last `}`.
798 fn extract_json_block(raw: &str) -> Option<&str> {
799 if let Some(start) = raw.find("```json") {
800 let after = &raw[start + "```json".len()..];
801 if let Some(end) = after.find("```") {
802 return Some(after[..end].trim());
803 }
804 }
805 let start = raw.find('{')?;
806 let end = raw.rfind('}')?;
807 if end > start {
808 Some(raw[start..=end].trim())
809 } else {
810 None
811 }
812 }
813
814 fn normalize_severity(value: &str) -> String {
815 match value.trim().to_ascii_lowercase().as_str() {
816 "critical" | "crit" | "blocker" | "severe" => "critical",
817 "high" | "major" | "important" => "high",
818 "low" | "minor" | "nit" | "trivial" => "low",
819 // Default unknown/empty to medium so a finding is never dropped, but is
820 // also not over-escalated to serious.
821 _ => "medium",
822 }
823 .to_string()
824 }
825
826 fn normalize_optional(value: Option<String>) -> Option<String> {
827 value
828 .map(|s| s.trim().to_string())
829 .filter(|s| !s.is_empty())
830 }
831
832 #[cfg(test)]
833 mod tests {
834 use super::*;
835 use crate::llm_client::mock::MockLlmClient;
836 use serde_json::json;
837 use std::path::Path;
838
839 fn ctx() -> ToolContext {
840 ToolContext::new(Path::new("."))
841 }
842
843 fn text_response(model: &str, body: &str) -> codewhale_models::MessageResponse {
844 codewhale_models::MessageResponse {
845 id: "msg_test".to_string(),
846 r#type: "message".to_string(),
847 role: "assistant".to_string(),
848 content: vec![ContentBlock::Text {
849 text: body.to_string(),
850 cache_control: None,
851 }],
852 model: model.to_string(),
853 stop_reason: Some("stop".to_string()),
854 stop_sequence: None,
855 container: None,
856 usage: Usage::default(),
857 }
858 }
859
860 fn planted_bug_input() -> CritiqueInput {
861 // A change that claims to handle all inputs but has an obvious
862 // divide-by-zero / empty-slice defect in the diff.
863 CritiqueInput {
864 claim: "average() now correctly computes the mean for any input slice".to_string(),
865 requirement: Some("Must not panic on empty input.".to_string()),
866 focus: None,
867 evidence: vec![EvidenceBlock {
868 label: "git diff (working tree)".to_string(),
869 body: "+fn average(xs: &[f64]) -> f64 {\n+ xs.iter().sum::<f64>() / xs.len() as f64\n+}\n"
870 .to_string(),
871 }],
872 no_code_evidence: false,
873 }
874 }
875
876 // === Contract tests ===
877
878 #[test]
879 fn tool_contract_name_and_schema() {
880 let tool = VerifyTool::new(None, "test-model".to_string());
881 assert_eq!(tool.name(), "verify");
882 let schema = tool.input_schema();
883 assert_eq!(schema["type"], "object");
884 assert!(schema["properties"]["claim"].is_object());
885 assert!(schema["properties"]["scope"]["enum"].is_array());
886 let required = schema["required"].as_array().expect("required array");
887 assert!(required.iter().any(|v| v == "claim"));
888 // Read-only + network; approval auto.
889 assert!(tool.capabilities().contains(&ToolCapability::ReadOnly));
890 assert!(tool.is_read_only());
891 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
892 assert!(tool.model_visible());
893 }
894
895 // === Elevated-reasoning + no-recursion structural guard ===
896
897 #[test]
898 fn critic_request_is_elevated_and_toolless() {
899 // Even if someone constructs the tool at Low, the critic must run
900 // elevated and carry NO tools (so it cannot recurse into verify).
901 let req = build_critic_request("m", ReasoningEffort::Low, 65_536, "prompt".to_string());
902 assert_eq!(
903 req.reasoning_effort.as_deref(),
904 Some("high"),
905 "Low must clamp up to elevated reasoning"
906 );
907 assert!(
908 req.tools.is_none(),
909 "critic must be given NO tools — this is the structural recursion guard"
910 );
911 assert_eq!(
912 req.max_tokens, 65_536,
913 "critic must use the route-effective allowance it was handed"
914 );
915 assert_eq!(req.temperature, None, "no sampling params on the wire");
916 assert_eq!(req.top_p, None, "no sampling params on the wire");
917
918 let req_max = build_critic_request("m", ReasoningEffort::Max, 65_536, "prompt".to_string());
919 assert_eq!(req_max.reasoning_effort.as_deref(), Some("max"));
920 assert!(req_max.tools.is_none());
921 }
922
923 #[test]
924 fn with_critic_effort_clamps_below_high() {
925 let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Low);
926 assert_eq!(tool.critic_effort, ReasoningEffort::High);
927 let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Max);
928 assert_eq!(tool.critic_effort, ReasoningEffort::Max);
929 }
930
931 // === Critic-finds-a-planted-bug (mocked model) ===
932
933 #[tokio::test]
934 async fn critic_surfaces_planted_bug() {
935 let mock = MockLlmClient::new(vec![]);
936 // Canonical adversarial JSON the critic would return for the defect.
937 mock.push_message_response(text_response(
938 "mock-critic",
939 r#"{
940 "verdict": "refuted",
941 "summary": "average() divides by len() with no empty-slice guard.",
942 "findings": [
943 {
944 "severity": "critical",
945 "issue": "Divide-by-zero / NaN when xs is empty; violates the no-panic requirement.",
946 "evidence": "average(): xs.len() as f64 is 0 for empty input",
947 "suggested_fix": "Return 0.0 or Option::None when xs.is_empty()."
948 }
949 ],
950 "unresolved_risk": true
951 }"#,
952 ));
953
954 let run = run_critique(
955 &mock,
956 "mock-critic",
957 ReasoningEffort::Max,
958 65_536,
959 &planted_bug_input(),
960 )
961 .await
962 .expect("critique runs");
963
964 assert_eq!(run.report.verdict, "refuted");
965 assert!(run.report.unresolved_risk);
966 assert_eq!(run.report.findings.len(), 1);
967 assert_eq!(run.report.findings[0].severity, "critical");
968 assert!(
969 run.report.findings[0]
970 .issue
971 .to_lowercase()
972 .contains("empty"),
973 "finding should name the empty-input defect"
974 );
975 assert_eq!(run.report.highest_severity(), "critical");
976
977 // The outgoing critic request carried elevated reasoning and NO tools.
978 let sent = mock.last_request().expect("request captured");
979 assert_eq!(sent.reasoning_effort.as_deref(), Some("max"));
980 assert!(sent.tools.is_none());
981 // Evidence and requirement were threaded into the prompt.
982 let prompt = match &sent.messages[0].content[0] {
983 ContentBlock::Text { text, .. } => text.clone(),
984 _ => panic!("expected text content"),
985 };
986 assert!(prompt.contains("CLAIM"));
987 assert!(prompt.contains("Must not panic on empty input"));
988 assert!(prompt.contains("average("));
989 }
990
991 #[tokio::test]
992 async fn unstructured_critic_output_is_unresolved_risk() {
993 let mock = MockLlmClient::new(vec![]);
994 mock.push_message_response(text_response("m", "I think it looks fine, ship it."));
995 let run = run_critique(
996 &mock,
997 "m",
998 ReasoningEffort::High,
999 65_536,
1000 &planted_bug_input(),
1001 )
1002 .await
1003 .expect("runs");
1004 // Fail safe: non-JSON critic output must not read as a clean pass.
1005 assert_eq!(run.report.verdict, "uncertain");
1006 assert!(run.report.unresolved_risk);
1007 }
1008
1009 #[test]
1010 fn upheld_with_serious_finding_is_downgraded_to_refuted() {
1011 // Guards the green-but-wrong trap: a critic can't declare "upheld" while
1012 // simultaneously reporting a high-severity finding.
1013 let report = CritiqueReport::from_model_text(
1014 r#"{"verdict":"upheld","summary":"looks ok","findings":[{"severity":"high","issue":"missing null check"}],"unresolved_risk":false}"#,
1015 );
1016 assert_eq!(report.verdict, "refuted");
1017 assert!(report.unresolved_risk);
1018 }
1019
1020 #[test]
1021 fn upheld_with_medium_finding_flags_unresolved_risk() {
1022 // The exact green-but-wrong gap: verdict=upheld + a MEDIUM finding +
1023 // critic-set unresolved_risk=false must still surface as unresolved
1024 // risk, and the verdict must not stay "upheld".
1025 let report = CritiqueReport::from_model_text(
1026 r#"{"verdict":"upheld","summary":"seems fine","findings":[{"severity":"medium","issue":"unhandled empty-input case"}],"unresolved_risk":false}"#,
1027 );
1028 assert!(
1029 report.unresolved_risk,
1030 "a medium finding must force unresolved_risk=true"
1031 );
1032 assert_ne!(
1033 report.verdict, "upheld",
1034 "cannot remain 'upheld' with an open medium finding"
1035 );
1036 assert_eq!(
1037 report.verdict, "uncertain",
1038 "medium (not serious) downgrades upheld to uncertain, not refuted"
1039 );
1040 }
1041
1042 #[test]
1043 fn upheld_with_only_low_finding_stays_upheld() {
1044 // Intentional carve-out: low-severity nits do not block a claim, so an
1045 // otherwise-clean "upheld" with only a low finding stays upheld and
1046 // does not raise unresolved_risk.
1047 let report = CritiqueReport::from_model_text(
1048 r#"{"verdict":"upheld","summary":"clean","findings":[{"severity":"low","issue":"nit: rename variable"}],"unresolved_risk":false}"#,
1049 );
1050 assert_eq!(report.verdict, "upheld");
1051 assert!(!report.unresolved_risk);
1052 }
1053
1054 // === Recursion / re-entry guard ===
1055
1056 #[tokio::test]
1057 async fn execute_refuses_reentry() {
1058 // Simulate being inside a critic pass; execute must refuse before it
1059 // even looks at the (absent) client or input.
1060 let tool = VerifyTool::new(None, "m".to_string());
1061 let err = VERIFY_ACTIVE
1062 .scope((), async {
1063 tool.execute(json!({ "claim": "x" }), &ctx()).await
1064 })
1065 .await
1066 .expect_err("re-entry must be refused");
1067 let msg = err.to_string().to_lowercase();
1068 assert!(
1069 msg.contains("recursion") || msg.contains("inside its own"),
1070 "expected a recursion-guard error, got: {err}"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn execute_without_client_is_not_available() {
1076 let tool = VerifyTool::new(None, "m".to_string());
1077 let err = tool
1078 .execute(json!({ "claim": "did the thing" }), &ctx())
1079 .await
1080 .expect_err("no client");
1081 assert!(err.to_string().to_lowercase().contains("client"));
1082 }
1083
1084 #[tokio::test]
1085 async fn execute_rejects_empty_and_unknown_scope() {
1086 let tool = VerifyTool::new(None, "m".to_string());
1087 // Empty claim is rejected before the (absent) client is consulted.
1088 let err = tool
1089 .execute(json!({ "claim": " " }), &ctx())
1090 .await
1091 .expect_err("empty claim");
1092 assert!(err.to_string().to_lowercase().contains("claim"), "{err}");
1093
1094 // Unknown scope is a precise input error, not a generic client error.
1095 let err = tool
1096 .execute(json!({ "claim": "ok", "scope": "everything" }), &ctx())
1097 .await
1098 .expect_err("unknown scope");
1099 assert!(err.to_string().to_lowercase().contains("scope"), "{err}");
1100 }
1101
1102 #[test]
1103 fn parses_fenced_json_block() {
1104 let raw = "Here is my critique:\n```json\n{\"verdict\":\"refuted\",\"summary\":\"s\",\"findings\":[],\"unresolved_risk\":true}\n```\nDone.";
1105 let report = CritiqueReport::from_model_text(raw);
1106 assert_eq!(report.verdict, "refuted");
1107 assert!(report.unresolved_risk);
1108 }
1109
1110 #[test]
1111 fn severity_normalization() {
1112 assert_eq!(normalize_severity("BLOCKER"), "critical");
1113 assert_eq!(normalize_severity("Major"), "high");
1114 assert_eq!(normalize_severity("nit"), "low");
1115 assert_eq!(normalize_severity("weird"), "medium");
1116 assert_eq!(normalize_severity(""), "medium");
1117 }
1118
1119 // === git diff evidence gathering ===
1120
1121 fn run_git(dir: &Path, args: &[&str]) {
1122 let mut cmd = crate::dependencies::Git::command().expect("git available");
1123 cmd.args(args).current_dir(dir);
1124 let out = cmd.output().expect("run git");
1125 assert!(
1126 out.status.success(),
1127 "git {args:?} failed: {}",
1128 String::from_utf8_lossy(&out.stderr)
1129 );
1130 }
1131
1132 #[tokio::test]
1133 async fn diff_base_cannot_inject_options_or_run_diff_helpers() {
1134 let tmp = tempfile::tempdir().unwrap();
1135 let outside = tempfile::tempdir().unwrap();
1136 let marker = outside.path().join("sentinel");
1137 std::fs::write(&marker, "untouched").unwrap();
1138 let repo = tmp.path();
1139 run_git(repo, &["init", "-q"]);
1140 run_git(repo, &["config", "user.email", "t@example.com"]);
1141 run_git(repo, &["config", "user.name", "Test"]);
1142 run_git(repo, &["config", "commit.gpgsign", "false"]);
1143 std::fs::write(repo.join("f.txt"), "original\n").unwrap();
1144 std::fs::write(repo.join(".gitattributes"), "*.txt diff=hostile\n").unwrap();
1145 run_git(repo, &["add", "."]);
1146 run_git(repo, &["commit", "-q", "-m", "base"]);
1147 run_git(
1148 repo,
1149 &[
1150 "config",
1151 "diff.hostile.textconv",
1152 "definitely-missing-helper",
1153 ],
1154 );
1155 run_git(
1156 repo,
1157 &["config", "diff.external", "definitely-missing-helper"],
1158 );
1159 std::fs::write(repo.join("f.txt"), "changed-marker\n").unwrap();
1160 run_git(repo, &["add", "f.txt"]);
1161 for staged in [false, true] {
1162 for base in [
1163 format!("--output={}", marker.display()),
1164 format!("--out={}", marker.display()),
1165 "--ext-diff".into(),
1166 "HEAD --output=bad".into(),
1167 "HEAD\n--output=bad".into(),
1168 ] {
1169 assert!(
1170 gather_diff_evidence(repo, staged, Some(&base))
1171 .await
1172 .is_err(),
1173 "{staged}: {base}"
1174 );
1175 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "untouched");
1176 }
1177 let blocks = gather_diff_evidence(repo, staged, Some("HEAD"))
1178 .await
1179 .unwrap();
1180 assert!(
1181 blocks
1182 .iter()
1183 .any(|block| block.body.contains("changed-marker"))
1184 );
1185 }
1186 }
1187
1188 #[tokio::test]
1189 async fn diff_without_base_remains_usable_on_unborn_head() {
1190 let tmp = tempfile::tempdir().unwrap();
1191 run_git(tmp.path(), &["init", "-q"]);
1192 assert!(
1193 gather_diff_evidence(tmp.path(), false, None)
1194 .await
1195 .unwrap()
1196 .is_empty()
1197 );
1198 assert!(
1199 gather_diff_evidence(tmp.path(), true, None)
1200 .await
1201 .unwrap()
1202 .is_empty()
1203 );
1204 }
1205
1206 #[tokio::test]
1207 async fn diff_scope_with_base_includes_uncommitted_worktree_changes() {
1208 // Regression for the base-omits-worktree gap: `git diff base...HEAD`
1209 // captures branch commits but drops the uncommitted edits the agent
1210 // usually wants to verify before claiming done. With a base set, the
1211 // gatherer must include BOTH the committed-since-base changes AND the
1212 // uncommitted working-tree changes.
1213 if crate::dependencies::Git::command().is_none() {
1214 return; // no git in this environment — nothing to exercise.
1215 }
1216
1217 let tmp = tempfile::tempdir().expect("tempdir");
1218 let repo = tmp.path();
1219 run_git(repo, &["init", "-q"]);
1220 run_git(repo, &["config", "user.email", "t@example.com"]);
1221 run_git(repo, &["config", "user.name", "Test"]);
1222 run_git(repo, &["config", "commit.gpgsign", "false"]);
1223
1224 // Base commit.
1225 std::fs::write(repo.join("f.txt"), "line1\n").expect("write");
1226 run_git(repo, &["add", "."]);
1227 run_git(repo, &["commit", "-q", "-m", "base"]);
1228
1229 // A committed change on top of the base.
1230 std::fs::write(repo.join("f.txt"), "line1\nCOMMITTED_MARKER\n").expect("write");
1231 run_git(repo, &["add", "."]);
1232 run_git(repo, &["commit", "-q", "-m", "second"]);
1233
1234 // An UNCOMMITTED working-tree change.
1235 std::fs::write(
1236 repo.join("f.txt"),
1237 "line1\nCOMMITTED_MARKER\nUNCOMMITTED_MARKER\n",
1238 )
1239 .expect("write");
1240
1241 let blocks = gather_diff_evidence(repo, false, Some("HEAD~1"))
1242 .await
1243 .expect("gather diff");
1244 let joined = blocks
1245 .iter()
1246 .map(|b| format!("[{}]\n{}", b.label, b.body))
1247 .collect::<Vec<_>>()
1248 .join("\n");
1249
1250 assert!(
1251 joined.contains("UNCOMMITTED_MARKER"),
1252 "uncommitted working-tree change must appear in evidence with a base set:\n{joined}"
1253 );
1254 assert!(
1255 joined.contains("COMMITTED_MARKER"),
1256 "committed-since-base change must also appear:\n{joined}"
1257 );
1258 // Two distinct labelled blocks: committed-since-base and uncommitted.
1259 assert!(
1260 blocks
1261 .iter()
1262 .any(|b| b.label.contains("committed changes since")),
1263 "expected a committed-since-base block: {:?}",
1264 blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
1265 );
1266 assert!(
1267 blocks
1268 .iter()
1269 .any(|b| b.label.contains("uncommitted changes")),
1270 "expected an uncommitted working-tree block: {:?}",
1271 blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
1272 );
1273 }
1274 }
1275
1275 lines RUST