Skip to main content

spar/
review_only.rs

1//! Reviewing a pull request without touching it.
2//!
3//! The custody loop in [`crate::review`] converges because the diff changes
4//! between rounds: a reviewer objects, an author fixes, the next review sees
5//! something new. Here nothing changes. The pull request belongs to somebody
6//! else, spar has no write access to it, and the product is the finding list
7//! rather than a commit.
8//!
9//! So the loop is not "review until it converges", which would just re-litigate
10//! the same unchanged code until the budget ran out. It is three phases:
11//!
12//! 1. **Independent review.** Both agents review at the same time, neither
13//!    seeing the other. A finding both reach on their own is the strongest
14//!    signal available, and it costs nothing extra to look for.
15//! 2. **Cross-adjudication.** Each reads the other's remaining findings, goes
16//!    to the code, and rules on them. A finding one model raised and the other
17//!    examined and rejected is usually pattern matching, and saying so is more
18//!    useful to a maintainer than forwarding both.
19//! 3. **Rebuttal.** Anything rejected goes back to whoever raised it, to
20//!    withdraw or to substantiate with the line, the input, the failing case.
21//!
22//! What survives is sorted by how well it is attested, and anything the two
23//! still disagree about is handed to a person rather than resolved by fiat.
24
25use std::path::Path;
26
27use crate::agent::Agent;
28use crate::config::Config;
29use crate::error::Result;
30use crate::jsonx::finding_key;
31use crate::model::{
32    AdjudicationDoc, Finding, IssueRun, Judged, PrView, Review, Severity, Standing, Status,
33};
34use crate::repo::Repo;
35use crate::style::{self, Style};
36use crate::{log, logdim, schema, spar_err};
37
38const REVIEW_ONLY_PROMPT: &str = "\
39Review pull request #{number} against `{base}`: {title}
40
41You are reviewing somebody else's work. Your checkout is detached and read only.
42Do not modify, commit, or push anything. The only thing you produce is findings.
43
44Review thoroughly: correctness, edge cases, error handling, security, and
45whether the change actually does what it claims. Read the surrounding code, do
46not only read the diff.
47
48Label every finding by severity, and be honest about which is which:
49- blocking: this should not merge as is. Real defects only.
50- non-blocking: a genuine improvement that need not gate the merge.
51- nit: style or taste.
52
53Confirm anything you label blocking before you label it. Run the code, reproduce
54the failure, or point at the exact line that breaks, and say in the detail what
55you did to confirm it. Someone else's contribution is on the other end of this.
56An unverified blocking finding costs them a round trip and costs the maintainer
57their credibility, so if you suspect a problem but could not confirm it, say so
58and label it non-blocking.
59
60Set in_scope=false for a real problem that exists but is not caused by this pull
61request. next_action is not used in this mode; set it to hand_back.";
62
63const ADJUDICATE_PROMPT: &str = "\
64Another reviewer examined this same pull request and raised the findings below.
65You have already reviewed it yourself.
66
67For each one, go to the code at the location given and rule on it.
68
69Agree only if you read the code and confirmed the defect is real. Do not defer
70to the other reviewer, and do not agree in order to be agreeable. A finding you
71cannot confirm wastes the contributor's time and the maintainer's, which is the
72thing this whole exercise exists to protect. Disagreeing with a reason is the
73most useful thing you can do here.
74
75Give your own severity even where you agree the defect is real: the other
76reviewer calling something blocking does not make it so.
77
78Findings:
79{findings}";
80
81const REBUT_PROMPT: &str = "\
82You raised the findings below. The other reviewer went to the code and rejected
83each one, for the reason given under it.
84
85For each, set agrees=true only if you stand by the finding, and then give the
86specific evidence that settles it: the line, the input, the failing case. Set
87agrees=false to withdraw it, which is the right answer when they are correct.
88
89Withdrawing costs nothing. Defending a point you cannot substantiate puts it in
90front of a maintainer with two reviewers' names on it, which is worse than never
91having raised it.
92
93Findings, with the objection to each:
94{findings}";
95
96/// Review a pull request without changing it.
97pub fn review_pr(
98    agents: &[Agent],
99    cfg: &Config,
100    repo: &Repo,
101    pr_number: i64,
102    dry_run: bool,
103) -> IssueRun {
104    match review_inner(agents, cfg, repo, pr_number, dry_run) {
105        Ok(state) => state,
106        Err(e) => {
107            log!("PR #{pr_number} review failed: {e}");
108            let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
109            state.status = Status::Error;
110            state.notes.push(e.to_string());
111            state
112        }
113    }
114}
115
116fn review_inner(
117    agents: &[Agent],
118    cfg: &Config,
119    repo: &Repo,
120    pr_number: i64,
121    dry_run: bool,
122) -> Result<IssueRun> {
123    let pr: PrView = repo.pr_view(pr_number)?;
124    if !pr.is_open() {
125        return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
126    }
127    let base = if pr.base_ref_name.trim().is_empty() {
128        cfg.base_branch().to_string()
129    } else {
130        pr.base_ref_name.clone()
131    };
132
133    let mut state = IssueRun::new(pr_number, pr.title.clone());
134    state.pr = Some(pr.url.clone());
135
136    let work_dir = repo.worktree_for_pr_head(pr_number)?;
137    let outcome = run_phases(
138        agents, cfg, repo, &pr, &base, &work_dir, &mut state, dry_run,
139    );
140    repo.release_review_worktree(pr_number);
141    outcome?;
142    Ok(state)
143}
144
145#[allow(clippy::too_many_arguments)]
146fn run_phases(
147    agents: &[Agent],
148    cfg: &Config,
149    repo: &Repo,
150    pr: &PrView,
151    base: &str,
152    work_dir: &Path,
153    state: &mut IssueRun,
154    dry_run: bool,
155) -> Result<()> {
156    let budget = cfg.loop_cfg.max_rounds;
157
158    // -- phase 1: two independent reviews, at the same time ---------------
159    log!(
160        "PR #{}: {} reviewing independently",
161        pr.number,
162        agents
163            .iter()
164            .map(Agent::name)
165            .collect::<Vec<_>>()
166            .join(" and ")
167    );
168    let prompt = REVIEW_ONLY_PROMPT
169        .replace("{number}", &pr.number.to_string())
170        .replace("{base}", base)
171        .replace("{title}", &pr.title);
172
173    let reviews = concurrently(agents, |a| {
174        let effort = cfg.effort_for_round(&a.spec, 1);
175        a.review::<Review>(
176            base,
177            &prompt,
178            &schema::review(),
179            work_dir,
180            effort.as_deref(),
181        )
182    });
183
184    let mut by_agent: Vec<(String, Vec<Finding>)> = Vec::new();
185    for (name, result) in reviews {
186        match result {
187            Ok(review) => by_agent.push((name, review.findings)),
188            Err(e) => {
189                // One reviewer failing is a degraded review, not a dead one,
190                // but the report has to say so rather than quietly halving the
191                // coverage the whole design rests on.
192                logdim!("{name} could not review PR #{}: {e}", pr.number);
193                state
194                    .notes
195                    .push(format!("{name} did not return a review: {e}"));
196            }
197        }
198    }
199    if by_agent.is_empty() {
200        return Err(spar_err!("neither reviewer returned a usable review"));
201    }
202    if by_agent.len() == 1 {
203        state
204            .notes
205            .push("only one reviewer answered, so nothing was cross-checked".into());
206    }
207
208    let mut judged = corroborate(&by_agent);
209
210    // -- phase 2: each rules on what only the other raised ----------------
211    if budget >= 2 && by_agent.len() == 2 {
212        adjudicate(agents, cfg, repo, work_dir, &mut judged, 2)?;
213    } else if budget < 2 {
214        for j in judged.iter_mut() {
215            if j.standing == Standing::Unverified {
216                j.counterpoint = Some("not cross-checked, max_rounds was 1".into());
217            }
218        }
219    }
220
221    // -- phase 3: whoever raised a rejected point defends it or drops it --
222    if budget >= 3 && judged.iter().any(|j| j.standing == Standing::Disputed) {
223        rebut(agents, cfg, repo, work_dir, &mut judged, 3)?;
224    }
225
226    state.rounds = budget.min(3);
227    finish(repo, pr, state, &judged, dry_run)
228}
229
230/// Findings both reviewers reached on their own, matched by finding key.
231fn corroborate(by_agent: &[(String, Vec<Finding>)]) -> Vec<Judged> {
232    let mut judged: Vec<Judged> = Vec::new();
233
234    for (name, findings) in by_agent {
235        for finding in findings {
236            let key = finding_key(&finding.title, &finding.file);
237            match judged
238                .iter_mut()
239                .find(|j| finding_key(&j.finding.title, &j.finding.file) == key)
240            {
241                Some(existing) => {
242                    // Both reached it independently. Keep the graver severity:
243                    // one reviewer calling it blocking is a reason to look.
244                    existing.finding.severity = existing.finding.severity.graver(finding.severity);
245                    existing.standing = Standing::Corroborated;
246                    existing.raised_by = format!("{} and {name}", existing.raised_by);
247                }
248                None => judged.push(Judged {
249                    finding: finding.clone(),
250                    raised_by: name.clone(),
251                    standing: Standing::Unverified,
252                    counterpoint: None,
253                }),
254            }
255        }
256    }
257    judged
258}
259
260fn adjudicate(
261    agents: &[Agent],
262    cfg: &Config,
263    repo: &Repo,
264    work_dir: &Path,
265    judged: &mut [Judged],
266    round: u32,
267) -> Result<()> {
268    let pending: Vec<usize> = judged
269        .iter()
270        .enumerate()
271        .filter(|(_, j)| j.standing == Standing::Unverified)
272        .map(|(i, _)| i)
273        .collect();
274    if pending.is_empty() {
275        return Ok(());
276    }
277    log!(
278        "cross-checking {} finding{} raised by one reviewer",
279        pending.len(),
280        plural(pending.len())
281    );
282
283    let answers = concurrently(agents, |adjudicator| {
284        // Each agent rules on what the *other* raised, never on its own.
285        let theirs: Vec<&Judged> = pending
286            .iter()
287            .map(|i| &judged[*i])
288            .filter(|j| j.raised_by != adjudicator.name())
289            .collect();
290        if theirs.is_empty() {
291            return Ok(AdjudicationDoc { verdicts: vec![] });
292        }
293        let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
294        let prompt =
295            ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
296        adjudicator.ask_json::<AdjudicationDoc>(
297            &prompt,
298            &schema::adjudication(),
299            work_dir,
300            cfg.effort_for_round(&adjudicator.spec, round).as_deref(),
301        )
302    });
303
304    for (name, result) in answers {
305        let doc = match result {
306            Ok(doc) => doc,
307            Err(e) => {
308                logdim!("{name} could not adjudicate: {e}");
309                continue;
310            }
311        };
312        for verdict in doc.verdicts {
313            let key = finding_key(&verdict.title, &verdict.file);
314            let Some(target) = judged.iter_mut().find(|j| {
315                j.raised_by != name
316                    && (finding_key(&j.finding.title, &j.finding.file) == key
317                        || crate::review::same_point(&j.finding.title, &verdict.title))
318            }) else {
319                continue;
320            };
321            if target.standing != Standing::Unverified {
322                continue;
323            }
324            target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
325            if verdict.agrees {
326                target.standing = Standing::Confirmed;
327                // A second reader who agrees it is real but calls it a nit is
328                // exactly the signal that stops a nitpick reaching a
329                // maintainer as a blocker.
330                target.finding.severity = target.finding.severity.graver(verdict.severity);
331            } else {
332                target.standing = Standing::Disputed;
333            }
334        }
335    }
336    Ok(())
337}
338
339fn rebut(
340    agents: &[Agent],
341    cfg: &Config,
342    repo: &Repo,
343    work_dir: &Path,
344    judged: &mut [Judged],
345    round: u32,
346) -> Result<()> {
347    let disputed: Vec<usize> = judged
348        .iter()
349        .enumerate()
350        .filter(|(_, j)| j.standing == Standing::Disputed)
351        .map(|(i, _)| i)
352        .collect();
353    log!(
354        "{} disputed finding{} going back to whoever raised them",
355        disputed.len(),
356        plural(disputed.len())
357    );
358
359    let answers = concurrently(agents, |author| {
360        let mine: Vec<&Judged> = disputed
361            .iter()
362            .map(|i| &judged[*i])
363            .filter(|j| j.raised_by == author.name())
364            .collect();
365        if mine.is_empty() {
366            return Ok(AdjudicationDoc { verdicts: vec![] });
367        }
368        let listed = mine
369            .iter()
370            .map(|j| {
371                format!(
372                    "- [{}] {} ({})\n  {}\n  OBJECTION: {}",
373                    j.finding.severity,
374                    j.finding.title,
375                    j.finding.where_at(),
376                    j.finding.detail,
377                    j.counterpoint.as_deref().unwrap_or("(none given)")
378                )
379            })
380            .collect::<Vec<_>>()
381            .join("\n");
382        let prompt = REBUT_PROMPT.replace("{findings}", &listed);
383        author.ask_json::<AdjudicationDoc>(
384            &prompt,
385            &schema::adjudication(),
386            work_dir,
387            cfg.effort_for_round(&author.spec, round).as_deref(),
388        )
389    });
390
391    for (name, result) in answers {
392        let doc = match result {
393            Ok(doc) => doc,
394            Err(e) => {
395                logdim!("{name} could not answer the objections: {e}");
396                continue;
397            }
398        };
399        for verdict in doc.verdicts {
400            let key = finding_key(&verdict.title, &verdict.file);
401            let Some(target) = judged.iter_mut().find(|j| {
402                j.raised_by == name
403                    && j.standing == Standing::Disputed
404                    && (finding_key(&j.finding.title, &j.finding.file) == key
405                        || crate::review::same_point(&j.finding.title, &verdict.title))
406            }) else {
407                continue;
408            };
409            if verdict.agrees {
410                // Stands by it. Attach the evidence so a person can weigh both.
411                let defence = style::summary(&verdict.reasoning, &repo.style);
412                target.counterpoint = Some(match target.counterpoint.take() {
413                    Some(objection) => format!("{objection} | answered: {defence}"),
414                    None => defence,
415                });
416            } else {
417                target.standing = Standing::Withdrawn;
418            }
419        }
420    }
421    Ok(())
422}
423
424fn finish(
425    repo: &Repo,
426    pr: &PrView,
427    state: &mut IssueRun,
428    judged: &[Judged],
429    dry_run: bool,
430) -> Result<()> {
431    let blocking = judged
432        .iter()
433        .filter(|j| j.finding.blocks() && j.standing.counts())
434        .count();
435
436    state.status = if blocking == 0 {
437        Status::Clean
438    } else {
439        Status::Reviewed
440    };
441    for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
442        state.disputes.push(crate::model::Dispute {
443            title: style::title(&j.finding.title, &repo.style),
444            reasoning: j.counterpoint.clone().unwrap_or_default(),
445        });
446    }
447
448    let comment = verdict_comment(judged, &repo.style);
449    if dry_run {
450        println!("\n{comment}\n");
451        log!("dry run, nothing posted to PR #{}", pr.number);
452        return Ok(());
453    }
454    match repo.comment_pr(pr.number, &comment) {
455        Ok(()) => log!(
456            "PR #{}: {}",
457            pr.number,
458            if blocking == 0 {
459                "no blocking findings, review posted".to_string()
460            } else {
461                format!(
462                    "{blocking} blocking finding{}, review posted",
463                    plural(blocking)
464                )
465            }
466        ),
467        Err(e) => {
468            state.notes.push(format!("could not post the review: {e}"));
469            println!("\n{comment}\n");
470        }
471    }
472    Ok(())
473}
474
475impl Standing {
476    /// Whether a finding should be put in front of a maintainer as real.
477    pub fn counts(self) -> bool {
478        matches!(
479            self,
480            Standing::Corroborated | Standing::Confirmed | Standing::Unverified
481        )
482    }
483
484    pub fn label(self) -> &'static str {
485        match self {
486            Standing::Corroborated => "both reviewers raised this independently",
487            Standing::Confirmed => "raised by one reviewer, confirmed by the other",
488            Standing::Disputed => "the reviewers disagree",
489            Standing::Withdrawn => "withdrawn",
490            Standing::Unverified => "raised by one reviewer, not cross-checked",
491        }
492    }
493}
494
495/// The one thing a maintainer reads.
496pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
497    let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
498    let pick = |severity: Severity| -> Vec<&Judged> {
499        live.iter()
500            .copied()
501            .filter(|j| j.finding.severity == severity && j.finding.in_scope)
502            .collect()
503    };
504    let blocking = pick(Severity::Blocking);
505    let non_blocking = pick(Severity::NonBlocking);
506    let nits = pick(Severity::Nit);
507    let disputed: Vec<&Judged> = judged
508        .iter()
509        .filter(|j| j.standing == Standing::Disputed)
510        .collect();
511    let withdrawn = judged
512        .iter()
513        .filter(|j| j.standing == Standing::Withdrawn)
514        .count();
515
516    let headline = if blocking.is_empty() {
517        "nothing blocking a merge".to_string()
518    } else {
519        format!("{} blocking", blocking.len())
520    };
521    let mut counts = vec![headline];
522    if !non_blocking.is_empty() {
523        counts.push(format!("{} non-blocking", non_blocking.len()));
524    }
525    if !nits.is_empty() {
526        counts.push(format!("{} nit", nits.len()));
527    }
528    if !disputed.is_empty() {
529        counts.push(format!("{} disputed", disputed.len()));
530    }
531
532    let mut out = vec![format!("Two independent reviews: {}.", counts.join(", "))];
533    if withdrawn == 1 {
534        out.push(
535            "A further point was raised and withdrawn on a second look, and is not listed."
536                .to_string(),
537        );
538    } else if withdrawn > 1 {
539        out.push(format!(
540            "{withdrawn} further points were raised and withdrawn on a second look, and are \
541             not listed."
542        ));
543    }
544
545    let line = |j: &Judged| -> String {
546        let where_at = match j.finding.where_at() {
547            "general" => String::new(),
548            file => format!(" ({file})"),
549        };
550        let detail = style::detail(&j.finding.detail, style);
551        let attested = if j.standing == Standing::Corroborated {
552            " [both]"
553        } else if j.standing == Standing::Unverified {
554            " [one reviewer only]"
555        } else {
556            ""
557        };
558        if detail.is_empty() {
559            format!(
560                "- {}{where_at}{attested}",
561                style::title(&j.finding.title, style)
562            )
563        } else {
564            format!(
565                "- {}{where_at}{attested}. {detail}",
566                style::title(&j.finding.title, style)
567            )
568        }
569    };
570
571    if !blocking.is_empty() {
572        out.push(format!(
573            "needs changing before merge\n{}",
574            blocking
575                .iter()
576                .copied()
577                .map(line)
578                .collect::<Vec<_>>()
579                .join("\n")
580        ));
581    }
582    if !non_blocking.is_empty() {
583        out.push(format!(
584            "worth doing, does not block\n{}",
585            non_blocking
586                .iter()
587                .copied()
588                .map(line)
589                .collect::<Vec<_>>()
590                .join("\n")
591        ));
592    }
593    if !nits.is_empty() {
594        out.push(format!(
595            "nits\n{}",
596            nits.iter()
597                .copied()
598                .map(line)
599                .collect::<Vec<_>>()
600                .join("\n")
601        ));
602    }
603    if !disputed.is_empty() {
604        let lines: Vec<String> = disputed
605            .iter()
606            .map(|j| {
607                format!(
608                    "- {} ({}). {} says yes; the other says {}",
609                    style::title(&j.finding.title, style),
610                    j.finding.where_at(),
611                    j.raised_by,
612                    style::summary(j.counterpoint.as_deref().unwrap_or("no"), style)
613                )
614            })
615            .collect();
616        out.push(format!(
617            "the reviewers disagree, your call\n{}",
618            lines.join("\n")
619        ));
620    }
621    if blocking.is_empty() && disputed.is_empty() {
622        out.push("Both reviewers are happy for this to merge.".to_string());
623    }
624    out.join("\n\n")
625}
626
627/// "s" unless there is exactly one of the thing.
628fn plural(n: usize) -> &'static str {
629    if n == 1 {
630        ""
631    } else {
632        "s"
633    }
634}
635
636/// Run the same closure on every agent at once.
637fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
638where
639    T: Send,
640    F: Fn(&Agent) -> Result<T> + Sync,
641{
642    std::thread::scope(|scope| {
643        let handles: Vec<_> = agents
644            .iter()
645            .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
646            .collect();
647        handles
648            .into_iter()
649            .zip(agents)
650            .map(|(handle, agent)| {
651                handle.join().unwrap_or_else(|_| {
652                    (
653                        agent.name().to_string(),
654                        Err(spar_err!("thread for '{}' panicked", agent.name())),
655                    )
656                })
657            })
658            .collect()
659    })
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn finding(severity: &str, title: &str, file: &str) -> Finding {
667        Finding {
668            severity: Severity::parse_lenient(severity).unwrap(),
669            title: title.into(),
670            detail: "why it matters".into(),
671            file: file.into(),
672            in_scope: true,
673        }
674    }
675
676    fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
677        (name.to_string(), findings)
678    }
679
680    // -- corroboration ---------------------------------------------------
681
682    /// The whole thesis of the tool: a defect two models reach independently,
683    /// with different training and different blind spots, is worth more than
684    /// either one saying it twice.
685    #[test]
686    fn a_finding_both_reviewers_reached_alone_is_corroborated() {
687        let judged = corroborate(&[
688            from(
689                "claude",
690                vec![finding("blocking", "Retry loop spins", "src/net.rs")],
691            ),
692            from(
693                "codex",
694                vec![finding("blocking", "retry loop spins!", "src/net.rs")],
695            ),
696        ]);
697        assert_eq!(1, judged.len(), "the same point must not be listed twice");
698        assert_eq!(Standing::Corroborated, judged[0].standing);
699        assert!(judged[0].raised_by.contains("claude"));
700        assert!(judged[0].raised_by.contains("codex"));
701    }
702
703    #[test]
704    fn a_finding_only_one_reviewer_raised_starts_unverified() {
705        let judged = corroborate(&[
706            from(
707                "claude",
708                vec![finding("blocking", "Only claude saw this", "a.rs")],
709            ),
710            from("codex", vec![]),
711        ]);
712        assert_eq!(Standing::Unverified, judged[0].standing);
713        assert_eq!("claude", judged[0].raised_by);
714    }
715
716    #[test]
717    fn the_same_title_in_a_different_file_is_two_findings() {
718        let judged = corroborate(&[
719            from("claude", vec![finding("nit", "Naming", "a.rs")]),
720            from("codex", vec![finding("nit", "Naming", "b.rs")]),
721        ]);
722        assert_eq!(2, judged.len());
723    }
724
725    /// Resolved upward on purpose. Nothing here gates a merge, so advice that
726    /// under-reports a real defect is worse than advice that over-reports.
727    #[test]
728    fn disagreement_about_severity_keeps_the_graver_one() {
729        let judged = corroborate(&[
730            from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
731            from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
732        ]);
733        assert_eq!(Severity::Blocking, judged[0].finding.severity);
734
735        // And the other way round, so it is not an artefact of ordering.
736        let judged = corroborate(&[
737            from(
738                "claude",
739                vec![finding("blocking", "Unbounded loop", "a.rs")],
740            ),
741            from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
742        ]);
743        assert_eq!(Severity::Blocking, judged[0].finding.severity);
744    }
745
746    #[test]
747    fn severity_ordering_does_not_depend_on_declaration_order() {
748        assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
749        assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
750        assert_eq!(
751            Severity::NonBlocking,
752            Severity::Nit.graver(Severity::NonBlocking)
753        );
754        assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
755        assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
756    }
757
758    #[test]
759    fn a_single_reviewer_still_produces_a_list() {
760        let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
761        assert_eq!(1, judged.len());
762        assert_eq!(Standing::Unverified, judged[0].standing);
763    }
764
765    // -- what reaches a maintainer ---------------------------------------
766
767    #[test]
768    fn only_surviving_standings_count() {
769        assert!(Standing::Corroborated.counts());
770        assert!(Standing::Confirmed.counts());
771        assert!(Standing::Unverified.counts());
772        assert!(
773            !Standing::Disputed.counts(),
774            "a disputed point is listed separately"
775        );
776        assert!(
777            !Standing::Withdrawn.counts(),
778            "a withdrawn point is not a finding"
779        );
780    }
781
782    fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
783        Judged {
784            finding: finding(severity, title, "src/net.rs"),
785            raised_by: "claude".into(),
786            standing,
787            counterpoint: None,
788        }
789    }
790
791    #[test]
792    fn a_clean_pr_says_so_in_one_breath() {
793        let text = verdict_comment(&[], &Style::default());
794        assert!(
795            text.starts_with("Two independent reviews: nothing blocking a merge."),
796            "{text}"
797        );
798        assert!(text.contains("happy for this to merge"), "{text}");
799    }
800
801    #[test]
802    fn a_corroborated_blocker_is_marked_as_such() {
803        let text = verdict_comment(
804            &[judged(
805                Standing::Corroborated,
806                "blocking",
807                "Retry loop spins",
808            )],
809            &Style::default(),
810        );
811        assert!(text.contains("needs changing before merge"), "{text}");
812        assert!(text.contains("[both]"), "{text}");
813        assert!(!text.contains("happy for this to merge"), "{text}");
814    }
815
816    #[test]
817    fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
818        let text = verdict_comment(
819            &[judged(
820                Standing::Unverified,
821                "blocking",
822                "Only one saw this",
823            )],
824            &Style::default(),
825        );
826        assert!(text.contains("[one reviewer only]"), "{text}");
827    }
828
829    #[test]
830    fn a_confirmed_finding_carries_no_qualifier() {
831        let text = verdict_comment(
832            &[judged(Standing::Confirmed, "blocking", "Checked and real")],
833            &Style::default(),
834        );
835        assert!(
836            !text.contains("[both]") && !text.contains("[one reviewer only]"),
837            "{text}"
838        );
839    }
840
841    /// The point of the whole exercise: a claim one model made and the other
842    /// read the code and rejected does not go to a maintainer as fact.
843    #[test]
844    fn a_withdrawn_finding_never_reaches_the_maintainer() {
845        let text = verdict_comment(
846            &[judged(
847                Standing::Withdrawn,
848                "blocking",
849                "Wrong on a second look",
850            )],
851            &Style::default(),
852        );
853        assert!(!text.contains("Wrong on a second look"), "{text}");
854        assert!(
855            text.contains("raised and withdrawn"),
856            "the count is still owned up to: {text}"
857        );
858        assert!(text.contains("nothing blocking a merge"), "{text}");
859    }
860
861    #[test]
862    fn one_withdrawn_point_reads_as_english() {
863        let text = verdict_comment(
864            &[judged(Standing::Withdrawn, "blocking", "Wrong")],
865            &Style::default(),
866        );
867        assert!(
868            text.contains("A further point was raised and withdrawn"),
869            "{text}"
870        );
871        assert!(!text.contains("point(s)"), "{text}");
872    }
873
874    #[test]
875    fn several_withdrawn_points_read_as_english_too() {
876        let text = verdict_comment(
877            &[
878                judged(Standing::Withdrawn, "blocking", "Wrong one"),
879                judged(Standing::Withdrawn, "nit", "Wrong two"),
880            ],
881            &Style::default(),
882        );
883        assert!(text.contains("2 further points were raised"), "{text}");
884    }
885
886    #[test]
887    fn a_disputed_finding_goes_to_a_person_with_both_sides() {
888        let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
889        j.counterpoint = Some("the caller already validates the file".into());
890        let text = verdict_comment(&[j], &Style::default());
891        assert!(text.contains("the reviewers disagree, your call"), "{text}");
892        assert!(text.contains("claude says yes"), "{text}");
893        assert!(text.contains("the caller already validates"), "{text}");
894        assert!(
895            !text.contains("needs changing before merge"),
896            "disputed does not block: {text}"
897        );
898    }
899
900    #[test]
901    fn the_three_severities_are_kept_apart() {
902        let text = verdict_comment(
903            &[
904                judged(Standing::Corroborated, "blocking", "Must fix"),
905                judged(Standing::Confirmed, "non-blocking", "Could improve"),
906                judged(Standing::Confirmed, "nit", "Taste"),
907            ],
908            &Style::default(),
909        );
910        assert!(text.contains("1 blocking, 1 non-blocking, 1 nit"), "{text}");
911        assert!(text.contains("needs changing before merge"), "{text}");
912        assert!(text.contains("worth doing, does not block"), "{text}");
913        assert!(text.contains("nits"), "{text}");
914    }
915
916    #[test]
917    fn a_verbose_reviewer_is_still_clipped() {
918        let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
919        j.finding.detail = "Extremely long explanation. ".repeat(60);
920        let text = verdict_comment(&[j], &Style::default());
921        assert!(text.len() < 700, "{} chars:\n{text}", text.len());
922    }
923
924    #[test]
925    fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
926        let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
927        j.finding.in_scope = false;
928        let text = verdict_comment(&[j], &Style::default());
929        assert!(!text.contains("needs changing before merge"), "{text}");
930        assert!(text.contains("nothing blocking a merge"), "{text}");
931    }
932}