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                    defence: None,
254                }),
255            }
256        }
257    }
258    judged
259}
260
261fn adjudicate(
262    agents: &[Agent],
263    cfg: &Config,
264    repo: &Repo,
265    work_dir: &Path,
266    judged: &mut [Judged],
267    round: u32,
268) -> Result<()> {
269    let pending: Vec<usize> = judged
270        .iter()
271        .enumerate()
272        .filter(|(_, j)| j.standing == Standing::Unverified)
273        .map(|(i, _)| i)
274        .collect();
275    if pending.is_empty() {
276        return Ok(());
277    }
278    log!(
279        "cross-checking {} finding{} raised by one reviewer",
280        pending.len(),
281        plural(pending.len())
282    );
283
284    let answers = concurrently(agents, |adjudicator| {
285        // Each agent rules on what the *other* raised, never on its own.
286        let theirs: Vec<&Judged> = pending
287            .iter()
288            .map(|i| &judged[*i])
289            .filter(|j| j.raised_by != adjudicator.name())
290            .collect();
291        if theirs.is_empty() {
292            return Ok(AdjudicationDoc { verdicts: vec![] });
293        }
294        let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
295        let prompt =
296            ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
297        adjudicator.ask_json::<AdjudicationDoc>(
298            &prompt,
299            &schema::adjudication(),
300            work_dir,
301            cfg.effort_for_round(&adjudicator.spec, round).as_deref(),
302        )
303    });
304
305    for (name, result) in answers {
306        let doc = match result {
307            Ok(doc) => doc,
308            Err(e) => {
309                logdim!("{name} could not adjudicate: {e}");
310                continue;
311            }
312        };
313        for verdict in doc.verdicts {
314            let key = finding_key(&verdict.title, &verdict.file);
315            let Some(target) = judged.iter_mut().find(|j| {
316                j.raised_by != name
317                    && (finding_key(&j.finding.title, &j.finding.file) == key
318                        || crate::review::same_point(&j.finding.title, &verdict.title))
319            }) else {
320                continue;
321            };
322            if target.standing != Standing::Unverified {
323                continue;
324            }
325            target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
326            if verdict.agrees {
327                target.standing = Standing::Confirmed;
328                // A second reader who agrees it is real but calls it a nit is
329                // exactly the signal that stops a nitpick reaching a
330                // maintainer as a blocker.
331                target.finding.severity = target.finding.severity.graver(verdict.severity);
332            } else {
333                target.standing = Standing::Disputed;
334            }
335        }
336    }
337    Ok(())
338}
339
340fn rebut(
341    agents: &[Agent],
342    cfg: &Config,
343    repo: &Repo,
344    work_dir: &Path,
345    judged: &mut [Judged],
346    round: u32,
347) -> Result<()> {
348    let disputed: Vec<usize> = judged
349        .iter()
350        .enumerate()
351        .filter(|(_, j)| j.standing == Standing::Disputed)
352        .map(|(i, _)| i)
353        .collect();
354    log!(
355        "{} disputed finding{} going back to whoever raised them",
356        disputed.len(),
357        plural(disputed.len())
358    );
359
360    let answers = concurrently(agents, |author| {
361        let mine: Vec<&Judged> = disputed
362            .iter()
363            .map(|i| &judged[*i])
364            .filter(|j| j.raised_by == author.name())
365            .collect();
366        if mine.is_empty() {
367            return Ok(AdjudicationDoc { verdicts: vec![] });
368        }
369        let listed = mine
370            .iter()
371            .map(|j| {
372                format!(
373                    "- [{}] {} ({})\n  {}\n  OBJECTION: {}",
374                    j.finding.severity,
375                    j.finding.title,
376                    j.finding.where_at(),
377                    j.finding.detail,
378                    j.counterpoint.as_deref().unwrap_or("(none given)")
379                )
380            })
381            .collect::<Vec<_>>()
382            .join("\n");
383        let prompt = REBUT_PROMPT.replace("{findings}", &listed);
384        author.ask_json::<AdjudicationDoc>(
385            &prompt,
386            &schema::adjudication(),
387            work_dir,
388            cfg.effort_for_round(&author.spec, round).as_deref(),
389        )
390    });
391
392    for (name, result) in answers {
393        let doc = match result {
394            Ok(doc) => doc,
395            Err(e) => {
396                logdim!("{name} could not answer the objections: {e}");
397                continue;
398            }
399        };
400        for verdict in doc.verdicts {
401            let key = finding_key(&verdict.title, &verdict.file);
402            let Some(target) = judged.iter_mut().find(|j| {
403                j.raised_by == name
404                    && j.standing == Standing::Disputed
405                    && (finding_key(&j.finding.title, &j.finding.file) == key
406                        || crate::review::same_point(&j.finding.title, &verdict.title))
407            }) else {
408                continue;
409            };
410            if verdict.agrees {
411                // Stands by it. Kept separate from the objection so a person
412                // can weigh the two arguments rather than read them spliced.
413                target.defence = Some(style::sentence(&verdict.reasoning, &repo.style));
414            } else {
415                target.standing = Standing::Withdrawn;
416            }
417        }
418    }
419    Ok(())
420}
421
422fn finish(
423    repo: &Repo,
424    pr: &PrView,
425    state: &mut IssueRun,
426    judged: &[Judged],
427    dry_run: bool,
428) -> Result<()> {
429    let blocking = judged
430        .iter()
431        .filter(|j| j.finding.blocks() && j.standing.counts())
432        .count();
433
434    state.status = if blocking == 0 {
435        Status::Clean
436    } else {
437        Status::Reviewed
438    };
439    for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
440        state.disputes.push(crate::model::Dispute {
441            title: style::title(&j.finding.title, &repo.style),
442            reasoning: j.counterpoint.clone().unwrap_or_default(),
443        });
444    }
445
446    let comment = verdict_comment(judged, &repo.style);
447    if dry_run {
448        println!("\n{comment}\n");
449        log!("dry run, nothing posted to PR #{}", pr.number);
450        return Ok(());
451    }
452    match repo.comment_pr(pr.number, &comment) {
453        Ok(()) => log!(
454            "PR #{}: {}",
455            pr.number,
456            if blocking == 0 {
457                "no blocking findings, review posted".to_string()
458            } else {
459                format!(
460                    "{blocking} blocking finding{}, review posted",
461                    plural(blocking)
462                )
463            }
464        ),
465        Err(e) => {
466            state.notes.push(format!("could not post the review: {e}"));
467            println!("\n{comment}\n");
468        }
469    }
470    Ok(())
471}
472
473impl Standing {
474    /// Whether a finding should be put in front of a maintainer as real.
475    pub fn counts(self) -> bool {
476        matches!(
477            self,
478            Standing::Corroborated | Standing::Confirmed | Standing::Unverified
479        )
480    }
481
482    pub fn label(self) -> &'static str {
483        match self {
484            Standing::Corroborated => "both reviewers raised this independently",
485            Standing::Confirmed => "raised by one reviewer, confirmed by the other",
486            Standing::Disputed => "the reviewers disagree",
487            Standing::Withdrawn => "withdrawn",
488            Standing::Unverified => "raised by one reviewer, not cross-checked",
489        }
490    }
491}
492
493/// The one thing a maintainer reads.
494pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
495    let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
496    let pick = |severity: Severity| -> Vec<&Judged> {
497        live.iter()
498            .copied()
499            .filter(|j| j.finding.severity == severity && j.finding.in_scope)
500            .collect()
501    };
502    let blocking = pick(Severity::Blocking);
503    let non_blocking = pick(Severity::NonBlocking);
504    let nits = pick(Severity::Nit);
505    let disputed: Vec<&Judged> = judged
506        .iter()
507        .filter(|j| j.standing == Standing::Disputed)
508        .collect();
509    let withdrawn = judged
510        .iter()
511        .filter(|j| j.standing == Standing::Withdrawn)
512        .count();
513
514    // "Two independent reviews" stays: it is the only thing that makes [both],
515    // [one reviewer only] and the disagreement heading below mean anything. The
516    // counts go, because everything they count is listed immediately after.
517    let mut out = vec![if blocking.is_empty() && disputed.is_empty() {
518        "Two independent reviews, nothing blocking a merge.".to_string()
519    } else {
520        "Two independent reviews.".to_string()
521    }];
522    let _ = withdrawn;
523
524    let line = |j: &Judged| -> String {
525        let where_at = match j.finding.where_at() {
526            "general" => String::new(),
527            file => format!(" ({file})"),
528        };
529        let detail = style::detail(&j.finding.detail, style);
530        let attested = if j.standing == Standing::Corroborated {
531            " [both]"
532        } else if j.standing == Standing::Unverified {
533            " [one reviewer only]"
534        } else {
535            ""
536        };
537        if detail.is_empty() {
538            format!(
539                "- {}{where_at}{attested}",
540                style::title(&j.finding.title, style)
541            )
542        } else {
543            format!(
544                "- {}{where_at}{attested}. {detail}",
545                style::title(&j.finding.title, style)
546            )
547        }
548    };
549
550    if !blocking.is_empty() {
551        out.push(format!(
552            "needs changing before merge\n{}",
553            blocking
554                .iter()
555                .copied()
556                .map(line)
557                .collect::<Vec<_>>()
558                .join("\n")
559        ));
560    }
561    if !non_blocking.is_empty() {
562        out.push(format!(
563            "worth doing, does not block\n{}",
564            non_blocking
565                .iter()
566                .copied()
567                .map(line)
568                .collect::<Vec<_>>()
569                .join("\n")
570        ));
571    }
572    if !nits.is_empty() {
573        out.push(format!(
574            "nits\n{}",
575            nits.iter()
576                .copied()
577                .map(line)
578                .collect::<Vec<_>>()
579                .join("\n")
580        ));
581    }
582    if !disputed.is_empty() {
583        let lines: Vec<String> = disputed
584            .iter()
585            .map(|j| {
586                let mut line = format!(
587                    "- {} ({})",
588                    style::title(&j.finding.title, style),
589                    j.finding.where_at()
590                );
591                if let Some(objection) = &j.counterpoint {
592                    line.push_str(&format!(
593                        ". Objection: {}",
594                        style::sentence(objection, style)
595                    ));
596                }
597                if let Some(defence) = &j.defence {
598                    line.push_str(&format!(" Answer: {}", style::sentence(defence, style)));
599                }
600                line
601            })
602            .collect();
603        out.push(format!(
604            "the reviewers disagree, your call\n{}",
605            lines.join("\n")
606        ));
607    }
608
609    out.join("\n\n")
610}
611
612/// "s" unless there is exactly one of the thing.
613fn plural(n: usize) -> &'static str {
614    if n == 1 {
615        ""
616    } else {
617        "s"
618    }
619}
620
621/// Run the same closure on every agent at once.
622fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
623where
624    T: Send,
625    F: Fn(&Agent) -> Result<T> + Sync,
626{
627    std::thread::scope(|scope| {
628        let handles: Vec<_> = agents
629            .iter()
630            .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
631            .collect();
632        handles
633            .into_iter()
634            .zip(agents)
635            .map(|(handle, agent)| {
636                handle.join().unwrap_or_else(|_| {
637                    (
638                        agent.name().to_string(),
639                        Err(spar_err!("thread for '{}' panicked", agent.name())),
640                    )
641                })
642            })
643            .collect()
644    })
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    fn finding(severity: &str, title: &str, file: &str) -> Finding {
652        Finding {
653            severity: Severity::parse_lenient(severity).unwrap(),
654            title: title.into(),
655            detail: "why it matters".into(),
656            file: file.into(),
657            in_scope: true,
658        }
659    }
660
661    fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
662        (name.to_string(), findings)
663    }
664
665    // -- corroboration ---------------------------------------------------
666
667    /// The whole thesis of the tool: a defect two models reach independently,
668    /// with different training and different blind spots, is worth more than
669    /// either one saying it twice.
670    #[test]
671    fn a_finding_both_reviewers_reached_alone_is_corroborated() {
672        let judged = corroborate(&[
673            from(
674                "claude",
675                vec![finding("blocking", "Retry loop spins", "src/net.rs")],
676            ),
677            from(
678                "codex",
679                vec![finding("blocking", "retry loop spins!", "src/net.rs")],
680            ),
681        ]);
682        assert_eq!(1, judged.len(), "the same point must not be listed twice");
683        assert_eq!(Standing::Corroborated, judged[0].standing);
684        assert!(judged[0].raised_by.contains("claude"));
685        assert!(judged[0].raised_by.contains("codex"));
686    }
687
688    #[test]
689    fn a_finding_only_one_reviewer_raised_starts_unverified() {
690        let judged = corroborate(&[
691            from(
692                "claude",
693                vec![finding("blocking", "Only claude saw this", "a.rs")],
694            ),
695            from("codex", vec![]),
696        ]);
697        assert_eq!(Standing::Unverified, judged[0].standing);
698        assert_eq!("claude", judged[0].raised_by);
699    }
700
701    #[test]
702    fn the_same_title_in_a_different_file_is_two_findings() {
703        let judged = corroborate(&[
704            from("claude", vec![finding("nit", "Naming", "a.rs")]),
705            from("codex", vec![finding("nit", "Naming", "b.rs")]),
706        ]);
707        assert_eq!(2, judged.len());
708    }
709
710    /// Resolved upward on purpose. Nothing here gates a merge, so advice that
711    /// under-reports a real defect is worse than advice that over-reports.
712    #[test]
713    fn disagreement_about_severity_keeps_the_graver_one() {
714        let judged = corroborate(&[
715            from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
716            from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
717        ]);
718        assert_eq!(Severity::Blocking, judged[0].finding.severity);
719
720        // And the other way round, so it is not an artefact of ordering.
721        let judged = corroborate(&[
722            from(
723                "claude",
724                vec![finding("blocking", "Unbounded loop", "a.rs")],
725            ),
726            from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
727        ]);
728        assert_eq!(Severity::Blocking, judged[0].finding.severity);
729    }
730
731    #[test]
732    fn severity_ordering_does_not_depend_on_declaration_order() {
733        assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
734        assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
735        assert_eq!(
736            Severity::NonBlocking,
737            Severity::Nit.graver(Severity::NonBlocking)
738        );
739        assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
740        assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
741    }
742
743    #[test]
744    fn a_single_reviewer_still_produces_a_list() {
745        let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
746        assert_eq!(1, judged.len());
747        assert_eq!(Standing::Unverified, judged[0].standing);
748    }
749
750    // -- what reaches a maintainer ---------------------------------------
751
752    #[test]
753    fn only_surviving_standings_count() {
754        assert!(Standing::Corroborated.counts());
755        assert!(Standing::Confirmed.counts());
756        assert!(Standing::Unverified.counts());
757        assert!(
758            !Standing::Disputed.counts(),
759            "a disputed point is listed separately"
760        );
761        assert!(
762            !Standing::Withdrawn.counts(),
763            "a withdrawn point is not a finding"
764        );
765    }
766
767    fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
768        Judged {
769            finding: finding(severity, title, "src/net.rs"),
770            raised_by: "claude".into(),
771            standing,
772            counterpoint: None,
773            defence: None,
774        }
775    }
776
777    #[test]
778    fn a_clean_pr_says_so_in_one_breath() {
779        let text = verdict_comment(&[], &Style::default());
780        assert!(
781            text.starts_with("Two independent reviews, nothing blocking a merge."),
782            "{text}"
783        );
784    }
785
786    #[test]
787    fn a_corroborated_blocker_is_marked_as_such() {
788        let text = verdict_comment(
789            &[judged(
790                Standing::Corroborated,
791                "blocking",
792                "Retry loop spins",
793            )],
794            &Style::default(),
795        );
796        assert!(text.contains("needs changing before merge"), "{text}");
797        assert!(text.contains("[both]"), "{text}");
798    }
799
800    #[test]
801    fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
802        let text = verdict_comment(
803            &[judged(
804                Standing::Unverified,
805                "blocking",
806                "Only one saw this",
807            )],
808            &Style::default(),
809        );
810        assert!(text.contains("[one reviewer only]"), "{text}");
811    }
812
813    #[test]
814    fn a_confirmed_finding_carries_no_qualifier() {
815        let text = verdict_comment(
816            &[judged(Standing::Confirmed, "blocking", "Checked and real")],
817            &Style::default(),
818        );
819        assert!(
820            !text.contains("[both]") && !text.contains("[one reviewer only]"),
821            "{text}"
822        );
823    }
824
825    /// The point of the whole exercise: a claim one model made and the other
826    /// read the code and rejected does not go to a maintainer as fact.
827    #[test]
828    fn a_withdrawn_finding_never_reaches_the_maintainer() {
829        let text = verdict_comment(
830            &[judged(
831                Standing::Withdrawn,
832                "blocking",
833                "Wrong on a second look",
834            )],
835            &Style::default(),
836        );
837        assert!(!text.contains("Wrong on a second look"), "{text}");
838        assert!(
839            !text.to_lowercase().contains("withdrawn"),
840            "a point nobody can see or act on is not worth a sentence: {text}"
841        );
842        assert!(text.contains("nothing blocking a merge"), "{text}");
843    }
844
845    #[test]
846    fn a_disputed_finding_goes_to_a_person_with_both_sides() {
847        let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
848        j.counterpoint = Some("the caller already validates the file".into());
849        let text = verdict_comment(&[j], &Style::default());
850        assert!(text.contains("the reviewers disagree, your call"), "{text}");
851        assert!(
852            text.contains("Objection: The caller already validates"),
853            "{text}"
854        );
855        assert!(
856            !text.contains("needs changing before merge"),
857            "disputed does not block: {text}"
858        );
859    }
860
861    #[test]
862    fn the_three_severities_are_kept_apart() {
863        let text = verdict_comment(
864            &[
865                judged(Standing::Corroborated, "blocking", "Must fix"),
866                judged(Standing::Confirmed, "non-blocking", "Could improve"),
867                judged(Standing::Confirmed, "nit", "Taste"),
868            ],
869            &Style::default(),
870        );
871        assert!(
872            !text.contains("1 blocking"),
873            "counts are listed below, not above: {text}"
874        );
875        assert!(text.contains("needs changing before merge"), "{text}");
876        assert!(text.contains("worth doing, does not block"), "{text}");
877        assert!(text.contains("nits"), "{text}");
878    }
879
880    /// A reviewer with a lot to say is not the problem, and clipping the
881    /// explanation of a defect helps nobody. Only a runaway is bounded.
882    #[test]
883    fn a_thorough_reviewer_is_not_cut_short() {
884        let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
885        j.finding.detail = "Here is a step of the reproduction. ".repeat(20);
886        let text = verdict_comment(&[j], &Style::default());
887        assert!(
888            text.contains(
889                &"Here is a step of the reproduction. "
890                    .repeat(20)
891                    .trim()
892                    .to_string()
893            ) || text.len() > 600,
894            "the explanation survived: {} chars",
895            text.len()
896        );
897    }
898
899    #[test]
900    fn a_runaway_reviewer_is_still_bounded() {
901        let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
902        j.finding.detail = "filler ".repeat(20_000);
903        let text = verdict_comment(&[j], &Style::default());
904        assert!(text.len() < 6000, "{} chars", text.len());
905    }
906
907    #[test]
908    fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
909        let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
910        j.finding.in_scope = false;
911        let text = verdict_comment(&[j], &Style::default());
912        assert!(!text.contains("needs changing before merge"), "{text}");
913        assert!(text.contains("nothing blocking a merge"), "{text}");
914    }
915}