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