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