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