Skip to main content

omni_dev/jev/
verify.rs

1//! Decision-comment verification for `omni-dev ai jev verify-decision` (#1779).
2//!
3//! Splits a maintainer's decision comment into single, checkable statements
4//! (via the configured AI backend), then checks each one against the
5//! source it cites with one `noul` question per statement, one Jev call per
6//! cited source. This is the wording and grouping validated in #1779's
7//! per-statement experiment (E4): checking one statement at a time against
8//! its source accepted 5/5 accurate claims and 0/20 wrong ones, where
9//! checking a whole comment at once let overstatements through.
10//!
11//! A coverage check (new, unvalidated) additionally asks whether the split
12//! statements together say everything the comment claims, guarding against
13//! a splitter that drops or strengthens a claim.
14
15use std::collections::{BTreeMap, BTreeSet, HashSet};
16use std::path::Path;
17
18use anyhow::{anyhow, bail, Context, Result};
19use serde::Serialize;
20use tracing::warn;
21
22use crate::claude::client::ClaudeClient;
23use crate::github_issues::{fetch_issues, fetch_items};
24use crate::jev::citations::{find_citations, first_citation, Citation};
25use crate::jev::client::JevClient;
26use crate::jev::error::is_auth_failure;
27use crate::jev::input::truncate_middle;
28use crate::jev::protocol::{Answer, NoulCriteria, Question, SystemOneRequest, Usage};
29use crate::provider::{Comment, IssueDoc, ItemKind, ItemRef};
30
31/// Default threshold above which a statement counts as supported.
32pub const DEFAULT_SUPPORTED: f64 = 0.5;
33/// Default threshold below which a statement rejects the whole comment.
34pub const DEFAULT_REJECT_BELOW: f64 = 0.3;
35/// Default threshold below which low coverage is a review reason.
36pub const DEFAULT_COVERAGE: f64 = 0.5;
37/// Most statements one comment may be split into, before the splitter's
38/// output is treated as broken rather than trusted.
39pub const MAX_STATEMENTS: usize = 100;
40
41// ── Citations ──────────────────────────────────────────────────────────
42
43/// Resolves a split statement's raw `cites` text to the index, in `sources`,
44/// of the source it names — by re-running the citation parser on that one
45/// string and matching its `(project, number)` against every source's
46/// [`Source::keys`], not by comparing display strings (which the splitter
47/// might not reproduce with identical spacing or case).
48fn resolve_cites_index(cites: &str, default_project: &str, sources: &[Source]) -> Option<usize> {
49    let (project, _kind, number) = first_citation(cites, default_project)?;
50    sources
51        .iter()
52        .position(|s| s.keys.contains(&(project.clone(), number)))
53}
54
55/// Whether a split statement's `cites` names the judged issue itself.
56///
57/// Rule 6 of the splitter prompt asks for `cites: null` on a statement
58/// about the judged issue, and a live run showed the splitter sometimes
59/// names it anyway. The judged issue is never a [`Source`] (a
60/// self-citation is dropped by [`find_citations`]), so without this the
61/// statement would be reported as an unresolved citation and drag the
62/// verdict to `needs_review` — for obeying the rule in substance.
63fn cites_judged_issue(cites: &str, judged: &IssueDoc) -> bool {
64    first_citation(cites, &judged.project).is_some_and(|(project, _kind, number)| {
65        project == judged.project && number == judged.number
66    })
67}
68
69// ── Comment selection ──────────────────────────────────────────────────
70
71/// Which decision comment `verify-decision` should check.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum CommentSelector {
74    /// The most recent human comment that cites another issue or pull
75    /// request.
76    Latest,
77    /// A specific comment, by its GitHub database id.
78    Id(u64),
79}
80
81/// Parses `--comment`'s value: `latest`, a bare numeric id, or a full
82/// issue-comment URL (`...#issuecomment-<id>`, as GitHub links a comment).
83pub fn parse_comment_selector(raw: &str) -> Result<CommentSelector> {
84    let raw = raw.trim();
85    if raw.eq_ignore_ascii_case("latest") {
86        return Ok(CommentSelector::Latest);
87    }
88    if let Ok(id) = raw.parse::<u64>() {
89        return Ok(CommentSelector::Id(id));
90    }
91    if let Some(digits) = raw.strip_prefix_last("#issuecomment-") {
92        if let Ok(id) = digits.parse::<u64>() {
93            return Ok(CommentSelector::Id(id));
94        }
95    }
96    bail!(
97        "`{raw}` is not a recognised comment selector \
98         (expected `latest`, a numeric comment id, or an issue-comment URL)"
99    );
100}
101
102/// Splits on the *last* occurrence of `sep`, returning what follows it.
103trait StripPrefixLast {
104    fn strip_prefix_last<'a>(&'a self, sep: &str) -> Option<&'a str>;
105}
106impl StripPrefixLast for str {
107    fn strip_prefix_last<'a>(&'a self, sep: &str) -> Option<&'a str> {
108        self.rfind(sep).map(|i| &self[i + sep.len()..])
109    }
110}
111
112/// Selects the comment `verify-decision` should check.
113///
114/// [`CommentSelector::Latest`] picks the most recent human comment that
115/// cites another issue or pull request; a comment that cites nothing
116/// verifiable would go straight to `needs_review` anyway, so those are
117/// skipped rather than selected by default.
118pub fn select_comment<'a>(
119    doc: &'a IssueDoc,
120    selector: &CommentSelector,
121    default_project: &str,
122) -> Result<&'a Comment> {
123    let judged = ItemRef {
124        provider: doc.provider,
125        project: doc.project.clone(),
126        kind: doc.kind,
127        number: doc.number,
128    };
129    match selector {
130        CommentSelector::Id(id) => {
131            doc.comments
132                .iter()
133                .find(|c| c.id == Some(*id))
134                .ok_or_else(|| {
135                    anyhow!(
136                        "issue {}#{} has no comment with id {id}",
137                        doc.project,
138                        doc.number
139                    )
140                })
141        }
142        CommentSelector::Latest => doc
143            .comments
144            .iter()
145            .rev()
146            .find(|c| !find_citations(&c.body, default_project, &judged).is_empty())
147            .ok_or_else(|| {
148                anyhow!(
149                    "issue {}#{} has no comment that cites another issue or pull request; \
150                     pass --comment ID to select one explicitly",
151                    doc.project,
152                    doc.number
153                )
154            }),
155    }
156}
157
158// ── Sources ────────────────────────────────────────────────────────────
159
160/// One cited source: a cited issue plus the pull requests that closed it,
161/// or a standalone cited pull request.
162///
163/// Matches the grouping validated in #1779's per-statement experiment
164/// (E4), whose `SOURCES` names each key as exactly this pairing.
165#[derive(Debug, Clone)]
166pub struct Source {
167    /// How a statement's `cites` string names this source, e.g. `"#1614"`.
168    pub label: String,
169    /// The full display name, e.g. `"#1614 and PR #1629"`.
170    pub name: String,
171    /// Display label for every item making up this source.
172    pub items: Vec<String>,
173    /// Every `(project, number)` that resolves to this source: the issue
174    /// itself (or the standalone PR) plus any merged-in closing PRs.
175    pub keys: Vec<(String, u64)>,
176    /// The source text, in the tested `## SOURCE: ...` format, untruncated.
177    pub text: String,
178}
179
180fn ref_label(project: &str, number: u64, judged_project: &str) -> String {
181    if project == judged_project {
182        format!("#{number}")
183    } else {
184        format!("{project}#{number}")
185    }
186}
187
188fn pr_label(project: &str, number: u64, judged_project: &str) -> String {
189    if project == judged_project {
190        format!("PR #{number}")
191    } else {
192        format!("PR {project}#{number}")
193    }
194}
195
196fn issue_heading(doc: &IssueDoc, judged_project: &str) -> String {
197    if doc.project == judged_project {
198        format!("# Issue #{}: {}", doc.number, doc.title)
199    } else {
200        format!("# Issue {}#{}: {}", doc.project, doc.number, doc.title)
201    }
202}
203
204fn pr_heading(doc: &IssueDoc, judged_project: &str) -> String {
205    if doc.project == judged_project {
206        format!("# Pull request #{}: {}", doc.number, doc.title)
207    } else {
208        format!(
209            "# Pull request {}#{}: {}",
210            doc.project, doc.number, doc.title
211        )
212    }
213}
214
215/// Builds a cited issue's source text, in the format validated by E4's
216/// `source_text`: the issue heading and body, then each human comment
217/// (single leading newline — deliberately *not* `route`'s two-newline
218/// separator, which was never part of this experiment), then each closing
219/// pull request's own heading and body.
220fn build_issue_source_text(
221    doc: &IssueDoc,
222    prs: &[IssueDoc],
223    judged_project: &str,
224    name: &str,
225) -> String {
226    let mut s = format!("{}\n\n{}\n", issue_heading(doc, judged_project), doc.body);
227    for c in &doc.comments {
228        s.push_str(&format!("\n**Comment by {}:**\n\n{}\n", c.author, c.body));
229    }
230    for pr in prs {
231        s.push_str(&format!(
232            "\n\n{}\n\n{}\n",
233            pr_heading(pr, judged_project),
234            pr.body
235        ));
236    }
237    format!("## SOURCE: {name}\n\n{}\n", s.trim())
238}
239
240/// Builds a standalone cited pull request's source text. Unlike
241/// [`build_issue_source_text`], this shape was never exercised by the
242/// experiment — every source it tested was an issue (with or without
243/// closing PRs) — so it is new, not tested.
244fn build_pr_source_text(doc: &IssueDoc, judged_project: &str, name: &str) -> String {
245    let s = format!("{}\n\n{}\n", pr_heading(doc, judged_project), doc.body);
246    format!("## SOURCE: {name}\n\n{}\n", s.trim())
247}
248
249/// Groups a decision comment's citations into [`Source`]s.
250///
251/// One source per cited issue (merged with the pull requests that closed
252/// it), plus one per standalone cited pull request not already merged
253/// into an issue's source. `fetched` holds every successfully resolved
254/// citation and closing PR, keyed by `(project, number)`; a citation
255/// missing from it (not found, or its fetch failed) simply produces no
256/// source, so a statement citing it surfaces later as unresolved rather
257/// than failing the whole run.
258#[must_use]
259pub fn group_sources(
260    citations: &[Citation],
261    judged_project: &str,
262    fetched: &BTreeMap<(String, u64), IssueDoc>,
263) -> Vec<Source> {
264    let mut sources = Vec::new();
265    let mut consumed_prs: BTreeSet<(String, u64)> = BTreeSet::new();
266    let mut seen_issues: BTreeSet<(String, u64)> = BTreeSet::new();
267
268    for citation in citations {
269        let key = (citation.item_ref.project.clone(), citation.item_ref.number);
270        let Some(doc) = fetched.get(&key) else {
271            continue;
272        };
273        if doc.kind != ItemKind::Issue || !seen_issues.insert(key.clone()) {
274            continue;
275        }
276        let mut keys = vec![key.clone()];
277        let mut items = vec![ref_label(&doc.project, doc.number, judged_project)];
278        let mut pr_docs = Vec::new();
279        for pr_ref in &doc.closed_by {
280            let pr_key = (pr_ref.project.clone(), pr_ref.number);
281            if let Some(pr_doc) = fetched.get(&pr_key) {
282                items.push(pr_label(&pr_doc.project, pr_doc.number, judged_project));
283                pr_docs.push(pr_doc.clone());
284                keys.push(pr_key.clone());
285                consumed_prs.insert(pr_key);
286            }
287        }
288        let label = ref_label(&doc.project, doc.number, judged_project);
289        let name = if pr_docs.is_empty() {
290            label.clone()
291        } else {
292            format!("{label} and {}", items[1..].join(" and "))
293        };
294        let text = build_issue_source_text(doc, &pr_docs, judged_project, &name);
295        sources.push(Source {
296            label,
297            name,
298            items,
299            keys,
300            text,
301        });
302    }
303
304    let mut seen_prs: BTreeSet<(String, u64)> = BTreeSet::new();
305    for citation in citations {
306        let key = (citation.item_ref.project.clone(), citation.item_ref.number);
307        let Some(doc) = fetched.get(&key) else {
308            continue;
309        };
310        if doc.kind != ItemKind::ChangeRequest
311            || consumed_prs.contains(&key)
312            || !seen_prs.insert(key.clone())
313        {
314            continue;
315        }
316        let label = pr_label(&doc.project, doc.number, judged_project);
317        let text = build_pr_source_text(doc, judged_project, &label);
318        sources.push(Source {
319            label: label.clone(),
320            name: label.clone(),
321            items: vec![label],
322            keys: vec![key],
323            text,
324        });
325    }
326    sources
327}
328
329// ── Fetching (blocking) ────────────────────────────────────────────────
330
331/// Fetches everything `verify-decision` needs for one issue.
332///
333/// Fetches the judged issue, the selected comment, its citations, and the
334/// [`Source`]s those citations resolve to (a first fetch of the cited
335/// items, then a second for any closing pull requests not already
336/// fetched). **Blocking** — callers must be on a blocking thread.
337pub fn fetch_verify_input(
338    bin: &Path,
339    default_project: &str,
340    judged: &ItemRef,
341    selector: &CommentSelector,
342) -> Result<(IssueDoc, Comment, Vec<Citation>, Vec<Source>)> {
343    let issue = fetch_issues(bin, std::slice::from_ref(judged))?
344        .into_iter()
345        .next()
346        .ok_or_else(|| anyhow!("issue {judged} missing from the parsed gh reply (bug)"))?;
347    let comment = select_comment(&issue, selector, default_project)?.clone();
348    let citations = find_citations(&comment.body, default_project, judged);
349    if citations.is_empty() {
350        return Ok((issue, comment, citations, Vec::new()));
351    }
352
353    let mut seen = HashSet::new();
354    let refs: Vec<ItemRef> = citations
355        .iter()
356        .map(|c| c.item_ref.clone())
357        .filter(|r| seen.insert((r.project.clone(), r.number)))
358        .collect();
359    let mut fetched: BTreeMap<(String, u64), IssueDoc> = BTreeMap::new();
360    for (item_ref, doc) in refs.iter().zip(fetch_items(bin, &refs)?) {
361        if let Some(doc) = doc {
362            fetched.insert((item_ref.project.clone(), item_ref.number), doc);
363        }
364    }
365
366    let mut seen_pr = HashSet::new();
367    let pr_refs: Vec<ItemRef> = fetched
368        .values()
369        .filter(|doc| doc.kind == ItemKind::Issue)
370        .flat_map(|doc| doc.closed_by.iter())
371        .filter(|pr| !fetched.contains_key(&(pr.project.clone(), pr.number)))
372        .filter(|pr| seen_pr.insert((pr.project.clone(), pr.number)))
373        .cloned()
374        .collect();
375    if !pr_refs.is_empty() {
376        for (item_ref, doc) in pr_refs.iter().zip(fetch_items(bin, &pr_refs)?) {
377            if let Some(doc) = doc {
378                fetched.insert((item_ref.project.clone(), item_ref.number), doc);
379            }
380        }
381    }
382
383    let sources = group_sources(&citations, &issue.project, &fetched);
384    Ok((issue, comment, citations, sources))
385}
386
387// ── Statement splitting (AI backend) ───────────────────────────────────
388
389/// One statement produced by the AI splitter, matching the reply shape
390/// [`build_split_user_prompt`] asks for.
391#[derive(Debug, Clone, serde::Deserialize)]
392struct SplitStatement {
393    text: String,
394    cites: Option<String>,
395}
396
397#[derive(Debug, Clone, serde::Deserialize)]
398struct SplitResponse {
399    statements: Vec<SplitStatement>,
400}
401
402/// The splitter's system prompt (new, not validated against Jev — only the
403/// per-statement and coverage *questions* were validated; the split itself
404/// is new work for this command).
405const SPLIT_SYSTEM_PROMPT: &str =
406    "You split a maintainer's decision comment into single checkable \
407     factual statements. Reply with the requested object and nothing else.";
408
409/// Builds the splitter's user prompt.
410///
411/// Rules 1–7 are #1779's wording; the rest was added after a live run of
412/// this command's own pipeline over the issue's 25 claim comments (see
413/// `docs/jev.md`). Two additions are load-bearing, and both fixed a
414/// measured failure rather than a hypothetical one:
415///
416/// - **Attribution of the judged issue.** "This issue was resolved by
417///   #1655" is a claim about the judged issue, not about #1655, so
418///   checking it against #1655 fails it. That rejected 3 of 10 accurate
419///   comments before rule 6 gained its second paragraph. The judged
420///   issue is named as "this issue" rather than by number on purpose:
421///   giving the number made the splitter write it into statements the
422///   comment never numbered, which the coverage check then read as added
423///   content and marked down (9 faithful splits fell below the coverage
424///   threshold).
425/// - **The reply shape.** Without it the splitter answers in whatever
426///   shape it likes whenever the schema is not enforced — every run
427///   failed to parse on the schema-less path, which is what `ollama` and
428///   the #1561 `output_config` fallback both take.
429fn build_split_user_prompt(comment_body: &str, citations: &[Citation]) -> String {
430    let cited = if citations.is_empty() {
431        "(none)".to_string()
432    } else {
433        citations
434            .iter()
435            .map(|c| c.raw.as_str())
436            .collect::<Vec<_>>()
437            .join(", ")
438    };
439    format!(
440        "Split the COMMENT below into single factual statements, so that each statement can be checked\n\
441         on its own against the item it cites.\n\n\
442         Rules:\n\
443         1. One fact per statement. Split \"A and B\" into two statements.\n\
444         2. Make every statement self-contained: name the item it is about (for example \"PR #1629 added\n\
445         ...\"), replace pronouns with what they refer to, and do not depend on other statements.\n\
446         3. Keep certainty exactly as written. \"was decided\", \"was considered\", \"is deferred\", \"is tracked\n\
447         separately\" and \"may happen later\" are different facts. Never make a claim stronger or weaker.\n\
448         4. Do not add, infer, summarise or correct anything. If the comment says something wrong, keep\n\
449         the wrong claim as written.\n\
450         5. Include every factual claim about a cited item, including conclusions such as \"so the open\n\
451         question is settled\"; restate exactly what the comment says was settled.\n\
452         6. Set \"cites\" to the one item the statement is about, written as it appears in the comment\n\
453         (for example \"#1614\" or \"PR #1629\"). If the statement is a decision about the judged issue\n\
454         itself rather than a claim about a cited item, set \"cites\" to null.\n\
455         A statement that a cited item resolved, settled or answered the judged issue's question is\n\
456         about the judged issue, so its \"cites\" is null. A statement about what a cited item did or\n\
457         decided cites that item, even when it also mentions another item (such as a tracking issue).\n\
458         7. Leave out greetings, opinions and instructions that make no factual claim.\n\n\
459         The COMMENT was posted on the judged issue, which the comment calls \"this issue\". Refer to it\n\
460         the same way; do not add its number.\n\n\
461         COMMENT:\n{comment}\n\n\
462         Items the comment cites: {cited}\n\n\
463         Reply with an object whose \"statements\" field is a list; each entry has a \"text\" string and\n\
464         a \"cites\" string or null. For example:\n\
465         {{\"statements\": [{{\"text\": \"PR #1629 added a tool.\", \"cites\": \"PR #1629\"}}]}}",
466        comment = comment_body.trim(),
467    )
468}
469
470/// Strips a Markdown code fence (` ```json `, ` ```yaml ` or bare ` ``` `)
471/// around the splitter's reply, for a backend that wraps it in prose
472/// despite being asked for the object alone.
473fn strip_code_fence(s: &str) -> &str {
474    let s = s
475        .strip_prefix("```json")
476        .or_else(|| s.strip_prefix("```yaml"))
477        .or_else(|| s.strip_prefix("```"))
478        .map_or(s, str::trim_start);
479    s.strip_suffix("```").map_or(s, str::trim_end)
480}
481
482fn parse_split_response(raw: &str) -> Result<SplitResponse> {
483    let trimmed = raw.trim();
484    if let Ok(parsed) = serde_json::from_str(trimmed) {
485        return Ok(parsed);
486    }
487    serde_yaml::from_str(strip_code_fence(trimmed))
488        .with_context(|| format!("Failed to parse the statement splitter's response: {trimmed}"))
489}
490
491/// Splits `comment_body` into statements via the AI backend, validating the
492/// result: non-empty, no more than [`MAX_STATEMENTS`], and no blank
493/// statement text.
494async fn split_statements(
495    ai: &ClaudeClient,
496    comment_body: &str,
497    citations: &[Citation],
498) -> Result<Vec<SplitStatement>> {
499    let user_prompt = build_split_user_prompt(comment_body, citations);
500    let raw = ai
501        .send_message(SPLIT_SYSTEM_PROMPT, &user_prompt)
502        .await
503        .context("Failed to split the decision comment into statements")?;
504    let parsed = parse_split_response(&raw)?;
505    if parsed.statements.is_empty() {
506        bail!("the statement splitter returned no statements");
507    }
508    if parsed.statements.len() > MAX_STATEMENTS {
509        bail!(
510            "the statement splitter returned {} statements, more than the {MAX_STATEMENTS} limit",
511            parsed.statements.len()
512        );
513    }
514    if parsed.statements.iter().any(|s| s.text.trim().is_empty()) {
515        bail!("the statement splitter returned an empty statement");
516    }
517    Ok(parsed.statements)
518}
519
520// ── Jev questions ──────────────────────────────────────────────────────
521
522/// Builds the per-statement `noul` question, reproducing #1779's tested
523/// wording (E4's `STATEMENT_Q`/`STATEMENT_CRIT`) verbatim.
524fn statement_question(text: &str) -> Question {
525    Question::Noul {
526        instructions: format!(
527            "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
528             states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
529             it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: {text}"
530        ),
531        criteria: Some(NoulCriteria {
532            true_means: Some("The source supports the statement".to_string()),
533            false_means: Some("The source contradicts it, leaves it open, or does not mention it".to_string()),
534        }),
535    }
536}
537
538/// Builds the coverage `noul` question, reproducing #1779's tested wording
539/// verbatim (new relative to E4: this check itself was not part of the
540/// validated experiment, only its wording as specified in the issue).
541fn coverage_question() -> Question {
542    Question::Noul {
543        instructions: "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
544             added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
545             statement adds something the comment does not say, or if any statement changes how certain or \
546             final a claim is (for example \"considered\" becoming \"decided\")."
547            .to_string(),
548        criteria: Some(NoulCriteria {
549            true_means: Some("The statements faithfully cover the comment".to_string()),
550            false_means: Some("A claim is missing, added, or changed in certainty".to_string()),
551        }),
552    }
553}
554
555fn build_coverage_state(comment_body: &str, statements: &[SplitStatement]) -> String {
556    let mut s = format!("## COMMENT\n\n{}\n\n## STATEMENTS\n\n", comment_body.trim());
557    for (i, st) in statements.iter().enumerate() {
558        s.push_str(&format!("{}. {}\n", i + 1, st.text));
559    }
560    s
561}
562
563// ── Report types ───────────────────────────────────────────────────────
564
565/// The result of checking one decision comment.
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
567#[serde(rename_all = "snake_case")]
568pub enum Verdict {
569    /// Every statement was checked, scored at least the supported
570    /// threshold, and the coverage check ran and passed.
571    Accepted,
572    /// At least one statement scored below the reject threshold.
573    Rejected,
574    /// Nothing was definitely wrong, but something needs a person to look:
575    /// an uncertain statement, an unresolved citation, low coverage, or a
576    /// comment that cited nothing verifiable.
577    NeedsReview,
578}
579
580/// Identifies the comment that was checked.
581#[derive(Debug, Clone, Serialize)]
582pub struct CommentRef {
583    /// The comment's GitHub database id.
584    pub id: Option<u64>,
585    /// The comment's author.
586    pub author: Option<String>,
587}
588
589/// One statement's verification outcome.
590#[derive(Debug, Clone, Serialize)]
591pub struct VerifiedStatement {
592    /// The statement's text, as split by the AI backend.
593    pub text: String,
594    /// The source this statement was checked against, or `None` for a
595    /// statement the splitter marked as a decision about the judged issue
596    /// itself (`cites: null`) — reported, not verified.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub source: Option<String>,
599    /// The probability Jev assigned that the source supports this
600    /// statement. `None` when the statement was never checked (no source,
601    /// or the source's call failed).
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub supported: Option<f64>,
604    /// Why this statement affected the verdict, if it did.
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub reason: Option<String>,
607}
608
609/// One source that was (or was meant to be) checked against.
610#[derive(Debug, Clone, Serialize)]
611pub struct SourceReport {
612    /// The source's display name, e.g. `"#1614 and PR #1629"`.
613    pub source: String,
614    /// Every item making up this source.
615    pub items: Vec<String>,
616    /// Whether this source's text was cut at the input cap.
617    #[serde(skip_serializing_if = "std::ops::Not::not")]
618    pub truncated: bool,
619    /// Why this source could not be checked, if it could not.
620    #[serde(skip_serializing_if = "Option::is_none")]
621    pub error: Option<String>,
622}
623
624/// The models that answered.
625///
626/// Either field is omitted when that model never answered: a comment
627/// citing nothing verifiable returns before any call is made, and naming
628/// a model there would claim a request that never happened. `jev` is the
629/// concrete version the API reports (`jev-1.13.0`), not the requested
630/// alias.
631#[derive(Debug, Clone, Default, Serialize)]
632pub struct ReportModels {
633    /// The Jev model that answered.
634    #[serde(skip_serializing_if = "String::is_empty")]
635    pub jev: String,
636    /// The AI backend model that split the comment.
637    #[serde(skip_serializing_if = "String::is_empty")]
638    pub ai: String,
639}
640
641/// Token usage summed over every call.
642#[derive(Debug, Clone, Default, Serialize)]
643pub struct ReportUsage {
644    /// Jev usage, summed over every source's call and the coverage call.
645    pub jev: Usage,
646}
647
648/// The result of verifying one decision comment.
649#[derive(Debug, Clone, Serialize)]
650pub struct VerifyReport {
651    /// The judged issue, as `owner/repo#N`.
652    #[serde(rename = "issue")]
653    pub item_ref: String,
654    /// The issue's web URL.
655    pub url: String,
656    /// The comment that was checked.
657    pub comment: CommentRef,
658    /// The overall verdict.
659    pub verdict: Verdict,
660    /// Why the verdict is what it is; empty for a clean `accepted`.
661    #[serde(skip_serializing_if = "Vec::is_empty")]
662    pub reasons: Vec<String>,
663    /// The coverage check's probability, or `None` if it could not be run.
664    #[serde(skip_serializing_if = "Option::is_none")]
665    pub coverage: Option<f64>,
666    /// Every source that was checked against.
667    pub sources: Vec<SourceReport>,
668    /// Every statement the comment was split into.
669    pub statements: Vec<VerifiedStatement>,
670    /// The models that answered.
671    pub models: ReportModels,
672    /// Token usage.
673    pub usage: ReportUsage,
674}
675
676/// Knobs for [`run_verify`].
677#[derive(Debug, Clone)]
678pub struct VerifyOptions {
679    /// The Jev model to request.
680    pub jev_model: String,
681    /// Threshold above which a statement counts as supported.
682    pub supported: f64,
683    /// Threshold below which a statement rejects the comment outright.
684    pub reject_below: f64,
685    /// Threshold below which low coverage becomes a review reason.
686    pub coverage: f64,
687    /// Cap, in characters, on each source's text.
688    pub max_input_chars: usize,
689}
690
691fn validate_verify_options(opts: &VerifyOptions) -> Result<()> {
692    if !(0.0..=1.0).contains(&opts.reject_below) || !(0.0..=1.0).contains(&opts.supported) {
693        bail!("--threshold and --reject-below must each be between 0 and 1");
694    }
695    if opts.reject_below > opts.supported {
696        bail!("--reject-below must not be greater than --threshold");
697    }
698    if !(0.0..=1.0).contains(&opts.coverage) {
699        bail!("--coverage-threshold must be between 0 and 1");
700    }
701    if opts.max_input_chars == 0 {
702        bail!("the input cap must be at least 1 character");
703    }
704    Ok(())
705}
706
707/// Verifies one decision comment against the sources it cites.
708///
709/// A comment with no citations (or none that resolved to a fetched source)
710/// is `needs_review` without any Jev or AI call — there is nothing to check.
711/// Otherwise: the comment is split into statements (one AI call), each
712/// statement is checked against its cited source (one Jev call per source,
713/// covering every statement attributed to it), and a coverage check asks
714/// whether the statements together say everything the comment claims (one
715/// more Jev call). Any statement scoring below `opts.reject_below` rejects
716/// the comment outright; otherwise anything uncertain, unresolved, or with
717/// low coverage marks it `needs_review`; a clean run is `accepted`.
718///
719/// A Jev authentication failure (401/403) aborts immediately, since no
720/// later call in the same run would fare differently.
721pub async fn run_verify(
722    jev: &JevClient,
723    ai: &ClaudeClient,
724    issue: &IssueDoc,
725    comment: &Comment,
726    citations: &[Citation],
727    sources: &[Source],
728    opts: &VerifyOptions,
729) -> Result<VerifyReport> {
730    validate_verify_options(opts)?;
731
732    let item_ref = format!("{}#{}", issue.project, issue.number);
733    let comment_ref = CommentRef {
734        id: comment.id,
735        author: Some(comment.author.clone()),
736    };
737
738    if citations.is_empty() || sources.is_empty() {
739        return Ok(VerifyReport {
740            item_ref,
741            url: issue.url.clone(),
742            comment: comment_ref,
743            verdict: Verdict::NeedsReview,
744            reasons: vec!["the comment cites nothing verifiable".to_string()],
745            coverage: None,
746            sources: Vec::new(),
747            statements: Vec::new(),
748            models: ReportModels::default(),
749            usage: ReportUsage::default(),
750        });
751    }
752
753    let split = split_statements(ai, &comment.body, citations).await?;
754    let ai_model = ai.get_ai_client_metadata().model;
755
756    // Bucket each statement's index by the source it resolves to.
757    let source_of: Vec<Option<usize>> = split
758        .iter()
759        .map(|s| {
760            s.cites
761                .as_deref()
762                .and_then(|c| resolve_cites_index(c, &issue.project, sources))
763        })
764        .collect();
765    let mut by_source: Vec<Vec<usize>> = vec![Vec::new(); sources.len()];
766    for (i, src) in source_of.iter().enumerate() {
767        if let Some(idx) = src {
768            by_source[*idx].push(i);
769        }
770    }
771
772    let mut jev_model = String::new();
773    let mut usage = Usage::default();
774    let mut supported: BTreeMap<usize, f64> = BTreeMap::new();
775    let mut source_reports = Vec::with_capacity(sources.len());
776
777    for (idx, source) in sources.iter().enumerate() {
778        let indices = &by_source[idx];
779        let (state, truncated) = truncate_middle(&source.text, opts.max_input_chars);
780        if indices.is_empty() {
781            // Fetched and grouped, but the splitter attributed nothing to it.
782            // Still reported, so it is distinguishable from a citation that
783            // never resolved at all.
784            source_reports.push(SourceReport {
785                source: source.name.clone(),
786                items: source.items.clone(),
787                truncated,
788                error: None,
789            });
790            continue;
791        }
792        let questions = indices
793            .iter()
794            .map(|&i| (format!("s{}", i + 1), statement_question(&split[i].text)))
795            .collect();
796        let request = SystemOneRequest {
797            state: serde_json::Value::String(state),
798            model: opts.jev_model.clone(),
799            questions,
800        };
801        match jev.system_one(&request).await {
802            Err(err) if is_auth_failure(&err) => {
803                return Err(err)
804                    .with_context(|| format!("Failed to verify against source {}", source.label));
805            }
806            Err(err) => {
807                let error = format!("{err:#}");
808                warn!("Could not verify against source {}: {error}", source.label);
809                source_reports.push(SourceReport {
810                    source: source.name.clone(),
811                    items: source.items.clone(),
812                    truncated,
813                    error: Some(error),
814                });
815            }
816            Ok(response) => {
817                jev_model = response.model;
818                usage.input_tokens += response.usage.input_tokens;
819                usage.output_tokens += response.usage.output_tokens;
820                for &i in indices {
821                    if let Some(Answer::Noul { noul }) =
822                        response.answers.get(&format!("s{}", i + 1))
823                    {
824                        supported.insert(i, *noul);
825                    }
826                }
827                source_reports.push(SourceReport {
828                    source: source.name.clone(),
829                    items: source.items.clone(),
830                    truncated,
831                    error: None,
832                });
833            }
834        }
835    }
836
837    let mut coverage = None;
838    let mut coverage_reason = None;
839    {
840        let state = build_coverage_state(&comment.body, &split);
841        let request = SystemOneRequest {
842            state: serde_json::Value::String(state),
843            model: opts.jev_model.clone(),
844            questions: BTreeMap::from([("coverage".to_string(), coverage_question())]),
845        };
846        match jev.system_one(&request).await {
847            Err(err) if is_auth_failure(&err) => {
848                return Err(err).context("Failed to check statement coverage");
849            }
850            Err(err) => {
851                let msg = format!("{err:#}");
852                warn!("Could not check statement coverage: {msg}");
853                coverage_reason = Some(format!("coverage unavailable: {msg}"));
854            }
855            Ok(response) => {
856                jev_model = response.model;
857                usage.input_tokens += response.usage.input_tokens;
858                usage.output_tokens += response.usage.output_tokens;
859                if let Some(Answer::Noul { noul }) = response.answers.get("coverage") {
860                    coverage = Some(*noul);
861                } else {
862                    // Answered, but not with the `coverage` noul we asked
863                    // for. The guard did not run, and an unrun guard must
864                    // not read as a guard that passed — the same rule the
865                    // per-statement path applies to an unanswered statement.
866                    warn!("The coverage call returned no usable `coverage` answer");
867                    coverage_reason = Some(
868                        "coverage unavailable: the reply carried no `coverage` answer".to_string(),
869                    );
870                }
871            }
872        }
873    }
874
875    let mut statements = Vec::with_capacity(split.len());
876    let mut reasons = Vec::new();
877    let mut any_rejected = false;
878    let mut any_review = false;
879
880    for (i, statement) in split.iter().enumerate() {
881        let (source_label, score, reason) = match source_of[i] {
882            Some(idx) => {
883                let src = &sources[idx];
884                let score = supported.get(&i).copied();
885                let reason = match score {
886                    None => {
887                        // The source's call failed, or its answer skipped this
888                        // key. Either way the statement is unchecked, which
889                        // must not read as verified: without this, one failed
890                        // source among several still produced `accepted`.
891                        any_review = true;
892                        Some(format!(
893                            "statement {} has no answer against {}",
894                            i + 1,
895                            src.label
896                        ))
897                    }
898                    Some(s) if s < opts.reject_below => {
899                        any_rejected = true;
900                        Some(format!(
901                            "statement {} scored {s:.2} (< {:.2}) against {}",
902                            i + 1,
903                            opts.reject_below,
904                            src.label
905                        ))
906                    }
907                    Some(s) if s < opts.supported => {
908                        any_review = true;
909                        Some(format!(
910                            "statement {} scored {s:.2} (uncertain) against {}",
911                            i + 1,
912                            src.label
913                        ))
914                    }
915                    Some(_) => None,
916                };
917                (Some(src.label.clone()), score, reason)
918            }
919            None => match &statement.cites {
920                None => (None, None, None),
921                // A statement about the judged issue, named rather than left
922                // null: rule 6's intent, so treated as rule 6 asks.
923                Some(raw) if cites_judged_issue(raw, issue) => (None, None, None),
924                Some(raw) => {
925                    any_review = true;
926                    (
927                        Some(raw.clone()),
928                        None,
929                        Some(format!(
930                            "statement {} cites {raw:?}, which could not be resolved",
931                            i + 1
932                        )),
933                    )
934                }
935            },
936        };
937        if let Some(msg) = &reason {
938            reasons.push(msg.clone());
939        }
940        statements.push(VerifiedStatement {
941            text: statement.text.clone(),
942            source: source_label,
943            supported: score,
944            reason,
945        });
946    }
947
948    if statements.iter().all(|s| s.supported.is_none()) {
949        any_review = true;
950        reasons.push("no statement could be checked against a resolved source".to_string());
951    }
952
953    match (coverage, &coverage_reason) {
954        (Some(cov), _) if cov < opts.coverage => {
955            any_review = true;
956            reasons.push(format!("coverage scored {cov:.2} (< {:.2})", opts.coverage));
957        }
958        (None, Some(msg)) => {
959            any_review = true;
960            reasons.push(msg.clone());
961        }
962        _ => {}
963    }
964
965    let verdict = if any_rejected {
966        Verdict::Rejected
967    } else if any_review {
968        Verdict::NeedsReview
969    } else {
970        Verdict::Accepted
971    };
972
973    Ok(VerifyReport {
974        item_ref,
975        url: issue.url.clone(),
976        comment: comment_ref,
977        verdict,
978        reasons,
979        coverage,
980        sources: source_reports,
981        statements,
982        models: ReportModels {
983            jev: jev_model,
984            ai: ai_model,
985        },
986        usage: ReportUsage { jev: usage },
987    })
988}
989
990#[cfg(test)]
991#[allow(clippy::unwrap_used, clippy::expect_used)]
992mod tests {
993    use super::*;
994    use crate::claude::test_utils::ConfigurableMockAiClient;
995    use crate::provider::{GitProvider, ItemState};
996    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
997    use std::path::PathBuf;
998
999    fn judged(number: u64) -> ItemRef {
1000        ItemRef {
1001            provider: GitProvider::GitHub,
1002            project: "rust-works/omni-dev".to_string(),
1003            kind: ItemKind::Issue,
1004            number,
1005        }
1006    }
1007
1008    // ── resolve_cites_index / cites_judged_issue ───────────────────────
1009
1010    #[test]
1011    fn resolve_cites_index_matches_a_sources_keys() {
1012        let citations = find_citations("see #1614", "rust-works/omni-dev", &judged(1));
1013        let issue = issue_doc(1614, "Title", "Body.", vec![], vec![]);
1014        let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), issue)]);
1015        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1016        assert_eq!(
1017            resolve_cites_index("#1614", "rust-works/omni-dev", &sources),
1018            Some(0)
1019        );
1020    }
1021
1022    /// A `cites` string that names nothing citable (no boundary-safe number)
1023    /// resolves no index rather than mis-reading it.
1024    #[test]
1025    fn resolve_cites_index_is_none_for_an_unparseable_cites_string() {
1026        let sources = Vec::new();
1027        assert_eq!(
1028            resolve_cites_index("#1-overview", "rust-works/omni-dev", &sources),
1029            None
1030        );
1031    }
1032
1033    #[test]
1034    fn cites_judged_issue_is_true_only_for_the_judged_number() {
1035        let issue = doc_with_comments(vec![]);
1036        assert!(cites_judged_issue("#1779", &issue));
1037        assert!(!cites_judged_issue("#1614", &issue));
1038        assert!(!cites_judged_issue("#1-overview", &issue));
1039    }
1040
1041    // ── parse_comment_selector / select_comment ───────────────────────
1042
1043    #[test]
1044    fn parses_latest_case_insensitively() {
1045        assert_eq!(
1046            parse_comment_selector("Latest").unwrap(),
1047            CommentSelector::Latest
1048        );
1049    }
1050
1051    #[test]
1052    fn parses_a_numeric_id() {
1053        assert_eq!(
1054            parse_comment_selector("123").unwrap(),
1055            CommentSelector::Id(123)
1056        );
1057    }
1058
1059    #[test]
1060    fn parses_an_issue_comment_url() {
1061        let sel = parse_comment_selector(
1062            "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-5742808381",
1063        )
1064        .unwrap();
1065        assert_eq!(sel, CommentSelector::Id(5_742_808_381));
1066    }
1067
1068    #[test]
1069    fn rejects_garbage_selector() {
1070        assert!(parse_comment_selector("whenever").is_err());
1071    }
1072
1073    /// An `#issuecomment-` suffix whose digits do not parse as `u64` is not
1074    /// a recognised selector, even though the prefix matched.
1075    #[test]
1076    fn rejects_an_issue_comment_url_with_a_non_numeric_id() {
1077        assert!(parse_comment_selector(
1078            "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-abc"
1079        )
1080        .is_err());
1081    }
1082
1083    fn doc_with_comments(comments: Vec<Comment>) -> IssueDoc {
1084        IssueDoc {
1085            provider: GitProvider::GitHub,
1086            project: "rust-works/omni-dev".to_string(),
1087            number: 1779,
1088            kind: ItemKind::Issue,
1089            title: "t".to_string(),
1090            state: ItemState::Open,
1091            body: "b".to_string(),
1092            comments,
1093            closed_by: vec![],
1094            url: "https://github.com/rust-works/omni-dev/issues/1779".to_string(),
1095        }
1096    }
1097
1098    fn comment(id: u64, author: &str, body: &str) -> Comment {
1099        Comment {
1100            author: author.to_string(),
1101            body: body.to_string(),
1102            id: Some(id),
1103        }
1104    }
1105
1106    #[test]
1107    fn select_comment_latest_picks_the_last_comment_that_cites_something() {
1108        let doc = doc_with_comments(vec![
1109            comment(1, "alice", "no citation here"),
1110            comment(2, "bob", "settled by #1614"),
1111            comment(3, "carol", "thanks!"),
1112        ]);
1113        let selected =
1114            select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap();
1115        assert_eq!(selected.id, Some(2));
1116    }
1117
1118    #[test]
1119    fn select_comment_latest_errors_when_none_cite_anything() {
1120        let doc = doc_with_comments(vec![comment(1, "alice", "thanks!")]);
1121        let err =
1122            select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap_err();
1123        assert!(err.to_string().contains("--comment ID"), "{err}");
1124    }
1125
1126    #[test]
1127    fn select_comment_by_id_errors_when_missing() {
1128        let doc = doc_with_comments(vec![comment(1, "alice", "#1614")]);
1129        let err =
1130            select_comment(&doc, &CommentSelector::Id(999), "rust-works/omni-dev").unwrap_err();
1131        assert!(err.to_string().contains("999"), "{err}");
1132    }
1133
1134    // ── group_sources / source text ───────────────────────────────────
1135
1136    fn issue_doc(
1137        number: u64,
1138        title: &str,
1139        body: &str,
1140        comments: Vec<Comment>,
1141        closed_by: Vec<ItemRef>,
1142    ) -> IssueDoc {
1143        IssueDoc {
1144            provider: GitProvider::GitHub,
1145            project: "rust-works/omni-dev".to_string(),
1146            number,
1147            kind: ItemKind::Issue,
1148            title: title.to_string(),
1149            state: ItemState::Closed,
1150            body: body.to_string(),
1151            comments,
1152            closed_by,
1153            url: format!("https://github.com/rust-works/omni-dev/issues/{number}"),
1154        }
1155    }
1156
1157    fn pr_doc(number: u64, title: &str, body: &str) -> IssueDoc {
1158        IssueDoc {
1159            provider: GitProvider::GitHub,
1160            project: "rust-works/omni-dev".to_string(),
1161            number,
1162            kind: ItemKind::ChangeRequest,
1163            title: title.to_string(),
1164            state: ItemState::Closed,
1165            body: body.to_string(),
1166            comments: vec![],
1167            closed_by: vec![],
1168            url: format!("https://github.com/rust-works/omni-dev/pull/{number}"),
1169        }
1170    }
1171
1172    fn pr_ref(number: u64) -> ItemRef {
1173        ItemRef {
1174            provider: GitProvider::GitHub,
1175            project: "rust-works/omni-dev".to_string(),
1176            kind: ItemKind::ChangeRequest,
1177            number,
1178        }
1179    }
1180
1181    /// Mirrors E4's source "A": `("#1614 and PR #1629", 1614, [1629])`.
1182    #[test]
1183    fn group_sources_merges_a_cited_issue_with_its_closing_pr() {
1184        let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1185        let issue = issue_doc(
1186            1614,
1187            "Sheets MCP tools",
1188            "Body text.",
1189            vec![comment(1, "newhoggy", "A human comment.")],
1190            vec![pr_ref(1629)],
1191        );
1192        let pr = pr_doc(1629, "Add drive_sheets_info", "PR body text.");
1193        let fetched = BTreeMap::from([
1194            (("rust-works/omni-dev".to_string(), 1614), issue),
1195            (("rust-works/omni-dev".to_string(), 1629), pr),
1196        ]);
1197        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1198        assert_eq!(sources.len(), 1);
1199        assert_eq!(sources[0].label, "#1614");
1200        assert_eq!(sources[0].name, "#1614 and PR #1629");
1201        assert_eq!(
1202            sources[0].keys,
1203            vec![
1204                ("rust-works/omni-dev".to_string(), 1614),
1205                ("rust-works/omni-dev".to_string(), 1629)
1206            ]
1207        );
1208        assert_eq!(
1209            sources[0].text,
1210            "## SOURCE: #1614 and PR #1629\n\n\
1211             # Issue #1614: Sheets MCP tools\n\n\
1212             Body text.\n\
1213             \n**Comment by newhoggy:**\n\nA human comment.\n\
1214             \n\n# Pull request #1629: Add drive_sheets_info\n\n\
1215             PR body text.\n"
1216        );
1217    }
1218
1219    /// A closing PR in a different repository from the judged issue gets the
1220    /// qualified `PR project#N` label and heading, not the bare `PR #N` form.
1221    #[test]
1222    fn group_sources_uses_a_qualified_label_for_a_cross_repo_closing_pr() {
1223        let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1224        let issue = issue_doc(
1225            1614,
1226            "Title",
1227            "Body.",
1228            vec![],
1229            vec![ItemRef {
1230                provider: GitProvider::GitHub,
1231                project: "other/repo".to_string(),
1232                kind: ItemKind::ChangeRequest,
1233                number: 42,
1234            }],
1235        );
1236        let mut pr = pr_doc(42, "Fix", "PR body.");
1237        pr.project = "other/repo".to_string();
1238        let fetched = BTreeMap::from([
1239            (("rust-works/omni-dev".to_string(), 1614), issue),
1240            (("other/repo".to_string(), 42), pr),
1241        ]);
1242        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1243        assert_eq!(sources.len(), 1);
1244        assert_eq!(sources[0].name, "#1614 and PR other/repo#42");
1245        assert!(sources[0]
1246            .text
1247            .contains("# Pull request other/repo#42: Fix"));
1248    }
1249
1250    /// Mirrors E4's source "E": `("#1237", 1237, [])` — an issue with no
1251    /// closing PR keeps a bare label.
1252    #[test]
1253    fn group_sources_keeps_a_bare_label_when_there_is_no_closing_pr() {
1254        let citations = find_citations("see #1237", "rust-works/omni-dev", &judged(1));
1255        let issue = issue_doc(1237, "Title", "Body.", vec![], vec![]);
1256        let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1237), issue)]);
1257        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1258        assert_eq!(sources[0].name, "#1237");
1259    }
1260
1261    #[test]
1262    fn group_sources_makes_a_standalone_source_for_a_pr_not_closing_any_cited_issue() {
1263        let citations = find_citations("see PR #999", "rust-works/omni-dev", &judged(1));
1264        let pr = pr_doc(999, "Some PR", "PR body.");
1265        let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 999), pr)]);
1266        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1267        assert_eq!(sources.len(), 1);
1268        assert_eq!(sources[0].label, "PR #999");
1269        assert_eq!(
1270            sources[0].text,
1271            "## SOURCE: PR #999\n\n# Pull request #999: Some PR\n\nPR body.\n"
1272        );
1273    }
1274
1275    #[test]
1276    fn group_sources_uses_a_qualified_label_across_repos() {
1277        let citations = find_citations("see other/repo#5", "rust-works/omni-dev", &judged(1));
1278        let mut issue = issue_doc(5, "Cross repo", "Body.", vec![], vec![]);
1279        issue.project = "other/repo".to_string();
1280        let fetched = BTreeMap::from([(("other/repo".to_string(), 5), issue)]);
1281        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1282        assert_eq!(sources[0].label, "other/repo#5");
1283    }
1284
1285    #[test]
1286    fn group_sources_skips_a_citation_that_never_resolved() {
1287        let citations = find_citations("see #404", "rust-works/omni-dev", &judged(1));
1288        let sources = group_sources(&citations, "rust-works/omni-dev", &BTreeMap::new());
1289        assert!(sources.is_empty());
1290    }
1291
1292    // ── question and prompt wording ────────────────────────────────────
1293
1294    #[test]
1295    fn statement_question_reproduces_the_tested_wording() {
1296        let Question::Noul {
1297            instructions,
1298            criteria,
1299        } = statement_question("PR #1629 added a tool.")
1300        else {
1301            panic!("expected a noul question");
1302        };
1303        assert_eq!(
1304            instructions,
1305            "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
1306             states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
1307             it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: \
1308             PR #1629 added a tool."
1309        );
1310        let criteria = criteria.unwrap();
1311        assert_eq!(
1312            criteria.true_means.unwrap(),
1313            "The source supports the statement"
1314        );
1315        assert_eq!(
1316            criteria.false_means.unwrap(),
1317            "The source contradicts it, leaves it open, or does not mention it"
1318        );
1319    }
1320
1321    #[test]
1322    fn coverage_question_reproduces_the_tested_wording() {
1323        let Question::Noul {
1324            instructions,
1325            criteria,
1326        } = coverage_question()
1327        else {
1328            panic!("expected a noul question");
1329        };
1330        assert_eq!(
1331            instructions,
1332            "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
1333             added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
1334             statement adds something the comment does not say, or if any statement changes how certain or \
1335             final a claim is (for example \"considered\" becoming \"decided\")."
1336        );
1337        let criteria = criteria.unwrap();
1338        assert_eq!(
1339            criteria.true_means.unwrap(),
1340            "The statements faithfully cover the comment"
1341        );
1342        assert_eq!(
1343            criteria.false_means.unwrap(),
1344            "A claim is missing, added, or changed in certainty"
1345        );
1346    }
1347
1348    #[test]
1349    fn build_coverage_state_numbers_statements_from_one() {
1350        let statements = vec![
1351            SplitStatement {
1352                text: "First fact.".to_string(),
1353                cites: None,
1354            },
1355            SplitStatement {
1356                text: "Second fact.".to_string(),
1357                cites: None,
1358            },
1359        ];
1360        let state = build_coverage_state("The comment body.", &statements);
1361        assert_eq!(
1362            state,
1363            "## COMMENT\n\nThe comment body.\n\n## STATEMENTS\n\n1. First fact.\n2. Second fact.\n"
1364        );
1365    }
1366
1367    #[test]
1368    fn split_prompt_names_every_citation_and_falls_back_to_none() {
1369        let citations = find_citations("see #1614 and PR #1629", "rust-works/omni-dev", &judged(1));
1370        let prompt = build_split_user_prompt("The comment.", &citations);
1371        assert!(
1372            prompt.contains("Items the comment cites: #1614, PR #1629"),
1373            "{prompt}"
1374        );
1375        assert!(prompt.contains("COMMENT:\nThe comment."), "{prompt}");
1376
1377        let prompt = build_split_user_prompt("no citations here", &[]);
1378        assert!(
1379            prompt.contains("Items the comment cites: (none)"),
1380            "{prompt}"
1381        );
1382    }
1383
1384    /// Both additions to #1779's wording earned their place by fixing a
1385    /// measured failure (see [`build_split_user_prompt`]), so dropping
1386    /// either one silently regresses the live behaviour.
1387    #[test]
1388    fn split_prompt_keeps_the_two_additions_that_fixed_live_failures() {
1389        let prompt = build_split_user_prompt("The comment.", &[]);
1390        // Attribution: resolution framing belongs to the judged issue, which
1391        // is named the way the comment names it, never by number.
1392        assert!(
1393            prompt.contains("resolved, settled or answered the judged issue's question"),
1394            "{prompt}"
1395        );
1396        assert!(prompt.contains("do not add its number"), "{prompt}");
1397        // Reply shape: the schema-less path has nothing else to go on.
1398        assert!(prompt.contains(r#""statements""#), "{prompt}");
1399        assert!(prompt.contains(r#""cites""#), "{prompt}");
1400    }
1401
1402    #[test]
1403    fn strip_code_fence_removes_json_and_bare_fences() {
1404        assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}");
1405        assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}");
1406        assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}");
1407    }
1408
1409    /// A non-JSON reply (a schema-less backend may not honour the requested
1410    /// shape exactly) falls back to YAML, fence and all.
1411    #[test]
1412    fn parse_split_response_falls_back_to_yaml() {
1413        let raw =
1414            "```yaml\nstatements:\n  - text: \"PR #1629 added a tool.\"\n    cites: \"#1614\"\n```";
1415        let parsed = parse_split_response(raw).unwrap();
1416        assert_eq!(parsed.statements.len(), 1);
1417        assert_eq!(parsed.statements[0].text, "PR #1629 added a tool.");
1418        assert_eq!(parsed.statements[0].cites.as_deref(), Some("#1614"));
1419    }
1420
1421    #[test]
1422    fn parse_split_response_errors_when_neither_json_nor_yaml_parses() {
1423        let err = parse_split_response("This is not json or yaml.").unwrap_err();
1424        assert!(
1425            format!("{err:#}").contains("Failed to parse the statement splitter's response"),
1426            "{err:#}"
1427        );
1428    }
1429
1430    #[tokio::test]
1431    async fn split_statements_rejects_an_empty_reply() {
1432        let ai = ai_client(vec![Ok(serde_json::json!({"statements": []}).to_string())]);
1433        let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1434        assert!(err.to_string().contains("no statements"), "{err}");
1435    }
1436
1437    #[tokio::test]
1438    async fn split_statements_rejects_more_than_the_statement_limit() {
1439        let stmts: Vec<serde_json::Value> = (0..=MAX_STATEMENTS)
1440            .map(|i| serde_json::json!({"text": format!("statement {i}"), "cites": null}))
1441            .collect();
1442        let ai = ai_client(vec![Ok(
1443            serde_json::json!({"statements": stmts}).to_string()
1444        )]);
1445        let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1446        assert!(err.to_string().contains("limit"), "{err}");
1447    }
1448
1449    #[tokio::test]
1450    async fn split_statements_rejects_a_blank_statement() {
1451        let ai = ai_client(vec![Ok(split_response_json(&[("   ", None)]))]);
1452        let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1453        assert!(err.to_string().contains("empty statement"), "{err}");
1454    }
1455
1456    // ── validate_verify_options ─────────────────────────────────────────
1457
1458    fn opts() -> VerifyOptions {
1459        VerifyOptions {
1460            jev_model: "jev-latest".to_string(),
1461            supported: DEFAULT_SUPPORTED,
1462            reject_below: DEFAULT_REJECT_BELOW,
1463            coverage: DEFAULT_COVERAGE,
1464            max_input_chars: 60_000,
1465        }
1466    }
1467
1468    #[test]
1469    fn rejects_reject_below_greater_than_threshold() {
1470        let mut o = opts();
1471        o.reject_below = 0.9;
1472        o.supported = 0.5;
1473        assert!(validate_verify_options(&o).is_err());
1474    }
1475
1476    #[test]
1477    fn rejects_out_of_range_knobs() {
1478        for f in [
1479            |o: &mut VerifyOptions| o.supported = 1.5,
1480            |o: &mut VerifyOptions| o.reject_below = -0.1,
1481            |o: &mut VerifyOptions| o.coverage = 2.0,
1482            |o: &mut VerifyOptions| o.max_input_chars = 0,
1483        ] {
1484            let mut o = opts();
1485            f(&mut o);
1486            assert!(validate_verify_options(&o).is_err());
1487        }
1488    }
1489
1490    // ── fetch_verify_input (fake-gh shim) ────────────────────────────────
1491
1492    fn fake_gh_no_citations(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1493        let guard = shim_lock();
1494        let issue = serde_json::json!({
1495            "title": "t", "body": "b", "state": "OPEN", "url": "u",
1496            "comments": {"totalCount": 1, "nodes": [
1497                {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "thanks!"}
1498            ]},
1499            "closedByPullRequestsReferences": {"nodes": []}
1500        });
1501        let path = dir.join("fake-gh");
1502        write_exec_script(
1503            &path,
1504            &format!(
1505                "#!/bin/sh\ncat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n"
1506            ),
1507        );
1508        (path, guard)
1509    }
1510
1511    #[test]
1512    fn fetch_verify_input_short_circuits_when_the_comment_cites_nothing() {
1513        let dir = tempfile::tempdir().unwrap();
1514        let (bin, _shim) = fake_gh_no_citations(dir.path());
1515        let (issue, comment, citations, sources) = retry_on_etxtbsy(|| {
1516            fetch_verify_input(
1517                &bin,
1518                "rust-works/omni-dev",
1519                &judged(1),
1520                &CommentSelector::Id(1),
1521            )
1522        })
1523        .unwrap();
1524        assert_eq!(issue.number, 1);
1525        assert_eq!(comment.body, "thanks!");
1526        assert!(citations.is_empty());
1527        assert!(sources.is_empty());
1528    }
1529
1530    /// A cited issue's own closing PR is not among the original citations,
1531    /// so resolving it takes a second `fetch_items` round.
1532    fn fake_gh_second_round_pr(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1533        let guard = shim_lock();
1534        let issue = serde_json::json!({
1535            "title": "t", "body": "b", "state": "OPEN", "url": "u",
1536            "comments": {"totalCount": 1, "nodes": [
1537                {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "settled by #1614"}
1538            ]},
1539            "closedByPullRequestsReferences": {"nodes": []}
1540        });
1541        let cited_issue = serde_json::json!({
1542            "__typename": "Issue",
1543            "title": "cited", "body": "cited body", "state": "CLOSED", "url": "u2",
1544            "comments": {"totalCount": 0, "nodes": []},
1545            "closedByPullRequestsReferences": {"nodes": [{"number": 1629}]}
1546        });
1547        let closing_pr = serde_json::json!({
1548            "__typename": "PullRequest",
1549            "title": "closer", "body": "pr body", "state": "MERGED", "url": "u3"
1550        });
1551        let path = dir.join("fake-gh");
1552        write_exec_script(
1553            &path,
1554            &format!(
1555                "#!/bin/sh\ncase \"$4\" in\n\
1556                 *'number:1614'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {cited_issue}}}}}}}\nJSON\n;;\n\
1557                 *'number:1629'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {closing_pr}}}}}}}\nJSON\n;;\n\
1558                 *) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n;;\n\
1559                 esac\n",
1560            ),
1561        );
1562        (path, guard)
1563    }
1564
1565    #[test]
1566    fn fetch_verify_input_fetches_a_second_round_of_closing_prs() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let (bin, _shim) = fake_gh_second_round_pr(dir.path());
1569        let (_issue, _comment, citations, sources) = retry_on_etxtbsy(|| {
1570            fetch_verify_input(
1571                &bin,
1572                "rust-works/omni-dev",
1573                &judged(1),
1574                &CommentSelector::Latest,
1575            )
1576        })
1577        .unwrap();
1578        assert_eq!(citations.len(), 1);
1579        assert_eq!(sources.len(), 1);
1580        assert_eq!(sources[0].name, "#1614 and PR #1629");
1581        assert_eq!(
1582            sources[0].keys,
1583            vec![
1584                ("rust-works/omni-dev".to_string(), 1614),
1585                ("rust-works/omni-dev".to_string(), 1629)
1586            ]
1587        );
1588    }
1589
1590    // ── run_verify (wiremock + mock AI client) ───────────────────────────
1591
1592    fn split_response_json(statements: &[(&str, Option<&str>)]) -> String {
1593        let stmts: Vec<serde_json::Value> = statements
1594            .iter()
1595            .map(|(text, cites)| serde_json::json!({"text": text, "cites": cites}))
1596            .collect();
1597        serde_json::json!({"statements": stmts}).to_string()
1598    }
1599
1600    fn ai_client(responses: Vec<Result<String>>) -> ClaudeClient {
1601        ClaudeClient::new(Box::new(ConfigurableMockAiClient::new(responses)))
1602    }
1603
1604    fn noul_json(value: f64) -> serde_json::Value {
1605        serde_json::json!({"type": "noul", "noul": value})
1606    }
1607
1608    async fn source_and_coverage_server(
1609        source_answers: serde_json::Value,
1610        coverage_noul: f64,
1611    ) -> wiremock::MockServer {
1612        let server = wiremock::MockServer::start().await;
1613        // Coverage call: state starts with "## COMMENT"; source calls: state
1614        // starts with "## SOURCE:". Distinguish via body_string_contains.
1615        wiremock::Mock::given(wiremock::matchers::method("POST"))
1616            .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1617            .respond_with(
1618                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1619                    "model": "jev-1.13.0",
1620                    "answers": source_answers,
1621                    "usage": {"input_tokens": 10, "output_tokens": 5}
1622                })),
1623            )
1624            .mount(&server)
1625            .await;
1626        wiremock::Mock::given(wiremock::matchers::method("POST"))
1627            .and(wiremock::matchers::body_string_contains("## COMMENT"))
1628            .respond_with(
1629                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1630                    "model": "jev-1.13.0",
1631                    "answers": {"coverage": noul_json(coverage_noul)},
1632                    "usage": {"input_tokens": 3, "output_tokens": 1}
1633                })),
1634            )
1635            .mount(&server)
1636            .await;
1637        server
1638    }
1639
1640    fn issue_and_source() -> (IssueDoc, Comment, Vec<Citation>, Vec<Source>) {
1641        let issue = doc_with_comments(vec![comment(1, "newhoggy", "settled by #1614")]);
1642        let citations = find_citations(
1643            &issue.comments[0].body,
1644            "rust-works/omni-dev",
1645            &judged(1779),
1646        );
1647        let cited = issue_doc(1614, "Cited issue", "Cited body.", vec![], vec![]);
1648        let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), cited)]);
1649        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1650        (
1651            issue,
1652            comment(1, "newhoggy", "settled by #1614"),
1653            citations,
1654            sources,
1655        )
1656    }
1657
1658    #[tokio::test]
1659    async fn run_verify_accepts_a_clean_comment() {
1660        let server =
1661            source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1662        let jev = JevClient::new(&server.uri(), "key").unwrap();
1663        let ai = ai_client(vec![Ok(split_response_json(&[(
1664            "PR #1629 exists.",
1665            Some("#1614"),
1666        )]))]);
1667        let (issue, comment, citations, sources) = issue_and_source();
1668
1669        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1670            .await
1671            .unwrap();
1672
1673        assert_eq!(report.verdict, Verdict::Accepted);
1674        assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1675        assert_eq!(report.statements[0].supported, Some(0.95));
1676        assert_eq!(report.models.ai, "mock-model");
1677        assert_eq!(report.usage.jev.input_tokens, 13);
1678    }
1679
1680    #[tokio::test]
1681    async fn run_verify_rejects_when_a_statement_scores_below_reject_below() {
1682        let server =
1683            source_and_coverage_server(serde_json::json!({"s1": noul_json(0.05)}), 0.9).await;
1684        let jev = JevClient::new(&server.uri(), "key").unwrap();
1685        let ai = ai_client(vec![Ok(split_response_json(&[(
1686            "A false claim.",
1687            Some("#1614"),
1688        )]))]);
1689        let (issue, comment, citations, sources) = issue_and_source();
1690
1691        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1692            .await
1693            .unwrap();
1694
1695        assert_eq!(report.verdict, Verdict::Rejected);
1696        assert!(
1697            report.reasons[0].contains("scored 0.05"),
1698            "{:?}",
1699            report.reasons
1700        );
1701    }
1702
1703    #[tokio::test]
1704    async fn run_verify_needs_review_when_a_statement_is_uncertain() {
1705        let server =
1706            source_and_coverage_server(serde_json::json!({"s1": noul_json(0.4)}), 0.9).await;
1707        let jev = JevClient::new(&server.uri(), "key").unwrap();
1708        let ai = ai_client(vec![Ok(split_response_json(&[(
1709            "An uncertain claim.",
1710            Some("#1614"),
1711        )]))]);
1712        let (issue, comment, citations, sources) = issue_and_source();
1713
1714        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1715            .await
1716            .unwrap();
1717
1718        assert_eq!(report.verdict, Verdict::NeedsReview);
1719    }
1720
1721    #[tokio::test]
1722    async fn run_verify_needs_review_when_coverage_is_low() {
1723        let server =
1724            source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.1).await;
1725        let jev = JevClient::new(&server.uri(), "key").unwrap();
1726        let ai = ai_client(vec![Ok(split_response_json(&[(
1727            "A true claim.",
1728            Some("#1614"),
1729        )]))]);
1730        let (issue, comment, citations, sources) = issue_and_source();
1731
1732        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1733            .await
1734            .unwrap();
1735
1736        assert_eq!(report.verdict, Verdict::NeedsReview);
1737        assert!(
1738            report
1739                .reasons
1740                .iter()
1741                .any(|r| r.contains("coverage scored 0.10")),
1742            "{:?}",
1743            report.reasons
1744        );
1745    }
1746
1747    #[tokio::test]
1748    async fn run_verify_needs_review_when_no_citations_resolved() {
1749        let jev = JevClient::new("http://127.0.0.1:1", "key").unwrap();
1750        let ai = ai_client(vec![]);
1751        let issue = doc_with_comments(vec![comment(1, "newhoggy", "no citation here")]);
1752        let comment = comment(1, "newhoggy", "no citation here");
1753
1754        let report = run_verify(&jev, &ai, &issue, &comment, &[], &[], &opts())
1755            .await
1756            .unwrap();
1757
1758        assert_eq!(report.verdict, Verdict::NeedsReview);
1759        assert_eq!(report.reasons, ["the comment cites nothing verifiable"]);
1760    }
1761
1762    /// A statement whose source never answered is unchecked, and an
1763    /// unchecked statement must not ride along inside an `accepted`
1764    /// verdict. Before this, one failed source among several still
1765    /// produced `accepted` — with the unanswered statement listed in
1766    /// `reasons`, which `accepted` is documented never to carry.
1767    #[tokio::test]
1768    async fn run_verify_needs_review_when_only_one_of_two_sources_answers() {
1769        let server = wiremock::MockServer::start().await;
1770        // The #1614 source answers; the #1237 source fails outright.
1771        wiremock::Mock::given(wiremock::matchers::method("POST"))
1772            .and(wiremock::matchers::body_string_contains("SOURCE: #1614"))
1773            .respond_with(
1774                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1775                    "model": "jev-1.13.0",
1776                    "answers": {"s1": noul_json(0.95)},
1777                    "usage": {"input_tokens": 1, "output_tokens": 1}
1778                })),
1779            )
1780            .mount(&server)
1781            .await;
1782        wiremock::Mock::given(wiremock::matchers::method("POST"))
1783            .and(wiremock::matchers::body_string_contains("SOURCE: #1237"))
1784            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1785            .mount(&server)
1786            .await;
1787        wiremock::Mock::given(wiremock::matchers::method("POST"))
1788            .and(wiremock::matchers::body_string_contains("## COMMENT"))
1789            .respond_with(
1790                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1791                    "model": "jev-1.13.0",
1792                    "answers": {"coverage": noul_json(0.9)},
1793                    "usage": {"input_tokens": 1, "output_tokens": 1}
1794                })),
1795            )
1796            .mount(&server)
1797            .await;
1798        let jev = JevClient::new(&server.uri(), "key").unwrap();
1799        let ai = ai_client(vec![Ok(split_response_json(&[
1800            ("A claim about #1614.", Some("#1614")),
1801            ("A claim about #1237.", Some("#1237")),
1802        ]))]);
1803
1804        let issue = doc_with_comments(vec![comment(1, "newhoggy", "see #1614 and #1237")]);
1805        let comment = comment(1, "newhoggy", "see #1614 and #1237");
1806        let citations = find_citations(&comment.body, "rust-works/omni-dev", &judged(1779));
1807        let fetched = BTreeMap::from([
1808            (
1809                ("rust-works/omni-dev".to_string(), 1614),
1810                issue_doc(1614, "One", "Body one.", vec![], vec![]),
1811            ),
1812            (
1813                ("rust-works/omni-dev".to_string(), 1237),
1814                issue_doc(1237, "Two", "Body two.", vec![], vec![]),
1815            ),
1816        ]);
1817        let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1818        assert_eq!(sources.len(), 2);
1819
1820        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1821            .await
1822            .unwrap();
1823
1824        assert_eq!(report.verdict, Verdict::NeedsReview);
1825        assert_eq!(report.statements[0].supported, Some(0.95));
1826        assert!(report.statements[1].supported.is_none());
1827        assert!(
1828            report.reasons.iter().any(|r| r.contains("has no answer")),
1829            "{:?}",
1830            report.reasons
1831        );
1832        // The failed source is still reported, carrying its error.
1833        assert!(report.sources.iter().any(|s| s.error.is_some()));
1834    }
1835
1836    /// A source that resolved but that the splitter attributed nothing to
1837    /// is still listed, so it is distinguishable from a citation that
1838    /// never resolved.
1839    #[tokio::test]
1840    async fn run_verify_reports_a_source_with_no_attributed_statements() {
1841        let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1842        let jev = JevClient::new(&server.uri(), "key").unwrap();
1843        let ai = ai_client(vec![Ok(split_response_json(&[(
1844            "A decision about this issue.",
1845            None,
1846        )]))]);
1847        let (issue, comment, citations, sources) = issue_and_source();
1848
1849        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1850            .await
1851            .unwrap();
1852
1853        assert_eq!(report.sources.len(), 1);
1854        assert_eq!(report.sources[0].source, "#1614");
1855        assert!(report.sources[0].error.is_none());
1856    }
1857
1858    /// Rule 6 asks the splitter to leave `cites` null for a statement about
1859    /// the judged issue. When it names the judged issue instead, that is
1860    /// the rule's intent, not an unresolved citation.
1861    #[tokio::test]
1862    async fn run_verify_treats_a_self_citation_as_a_judged_issue_statement() {
1863        let server =
1864            source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1865        let jev = JevClient::new(&server.uri(), "key").unwrap();
1866        let ai = ai_client(vec![Ok(split_response_json(&[
1867            ("A claim about #1614.", Some("#1614")),
1868            ("This issue's question is settled.", Some("#1779")),
1869        ]))]);
1870        let (issue, comment, citations, sources) = issue_and_source();
1871
1872        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1873            .await
1874            .unwrap();
1875
1876        assert_eq!(report.verdict, Verdict::Accepted);
1877        assert!(report.statements[1].source.is_none());
1878        assert!(report.statements[1].reason.is_none());
1879        assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1880    }
1881
1882    /// With no call made, no model answered, so neither is named.
1883    #[test]
1884    fn report_models_omit_the_models_that_never_answered() {
1885        let value = serde_json::to_value(ReportModels::default()).unwrap();
1886        assert_eq!(value, serde_json::json!({}));
1887    }
1888
1889    #[tokio::test]
1890    async fn run_verify_flags_an_unresolved_citation() {
1891        let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1892        let jev = JevClient::new(&server.uri(), "key").unwrap();
1893        // The splitter names a citation that group_sources never produced a
1894        // source for (e.g. drift between the splitter and the fetch).
1895        let ai = ai_client(vec![Ok(split_response_json(&[(
1896            "Some claim.",
1897            Some("#9999"),
1898        )]))]);
1899        let (issue, comment, citations, sources) = issue_and_source();
1900
1901        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1902            .await
1903            .unwrap();
1904
1905        assert_eq!(report.verdict, Verdict::NeedsReview);
1906        assert!(report.statements[0]
1907            .reason
1908            .as_ref()
1909            .unwrap()
1910            .contains("could not be resolved"));
1911    }
1912
1913    #[tokio::test]
1914    async fn run_verify_stops_on_an_auth_failure() {
1915        let server = wiremock::MockServer::start().await;
1916        wiremock::Mock::given(wiremock::matchers::method("POST"))
1917            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1918            .mount(&server)
1919            .await;
1920        let jev = JevClient::new(&server.uri(), "key").unwrap();
1921        let ai = ai_client(vec![Ok(split_response_json(&[(
1922            "A claim.",
1923            Some("#1614"),
1924        )]))]);
1925        let (issue, comment, citations, sources) = issue_and_source();
1926
1927        let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1928            .await
1929            .unwrap_err();
1930        assert!(format!("{err:#}").contains("Failed to verify against source #1614"));
1931    }
1932
1933    /// An auth failure on the coverage call itself (distinct from a
1934    /// per-source call) still aborts the whole run.
1935    #[tokio::test]
1936    async fn run_verify_stops_on_a_coverage_auth_failure() {
1937        let server = wiremock::MockServer::start().await;
1938        wiremock::Mock::given(wiremock::matchers::method("POST"))
1939            .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1940            .respond_with(
1941                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1942                    "model": "jev-1.13.0",
1943                    "answers": {"s1": noul_json(0.95)},
1944                    "usage": {"input_tokens": 1, "output_tokens": 1}
1945                })),
1946            )
1947            .mount(&server)
1948            .await;
1949        wiremock::Mock::given(wiremock::matchers::method("POST"))
1950            .and(wiremock::matchers::body_string_contains("## COMMENT"))
1951            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1952            .mount(&server)
1953            .await;
1954        let jev = JevClient::new(&server.uri(), "key").unwrap();
1955        let ai = ai_client(vec![Ok(split_response_json(&[(
1956            "A claim.",
1957            Some("#1614"),
1958        )]))]);
1959        let (issue, comment, citations, sources) = issue_and_source();
1960
1961        let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1962            .await
1963            .unwrap_err();
1964        assert!(
1965            format!("{err:#}").contains("Failed to check statement coverage"),
1966            "{err:#}"
1967        );
1968    }
1969
1970    /// A non-auth failure on the coverage call is a review reason, not a
1971    /// fatal error — the per-statement checks already ran.
1972    #[tokio::test]
1973    async fn run_verify_needs_review_when_the_coverage_call_fails() {
1974        let server = wiremock::MockServer::start().await;
1975        wiremock::Mock::given(wiremock::matchers::method("POST"))
1976            .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1977            .respond_with(
1978                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1979                    "model": "jev-1.13.0",
1980                    "answers": {"s1": noul_json(0.95)},
1981                    "usage": {"input_tokens": 1, "output_tokens": 1}
1982                })),
1983            )
1984            .mount(&server)
1985            .await;
1986        wiremock::Mock::given(wiremock::matchers::method("POST"))
1987            .and(wiremock::matchers::body_string_contains("## COMMENT"))
1988            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1989            .mount(&server)
1990            .await;
1991        let jev = JevClient::new(&server.uri(), "key").unwrap();
1992        let ai = ai_client(vec![Ok(split_response_json(&[(
1993            "A claim.",
1994            Some("#1614"),
1995        )]))]);
1996        let (issue, comment, citations, sources) = issue_and_source();
1997
1998        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1999            .await
2000            .unwrap();
2001        assert_eq!(report.verdict, Verdict::NeedsReview);
2002        assert!(report.coverage.is_none());
2003        assert!(
2004            report
2005                .reasons
2006                .iter()
2007                .any(|r| r.contains("coverage unavailable")),
2008            "{:?}",
2009            report.reasons
2010        );
2011    }
2012
2013    /// A coverage reply that carries no `coverage` answer must not read as a
2014    /// guard that ran and passed.
2015    #[tokio::test]
2016    async fn run_verify_needs_review_when_the_coverage_answer_is_missing() {
2017        let server = wiremock::MockServer::start().await;
2018        wiremock::Mock::given(wiremock::matchers::method("POST"))
2019            .and(wiremock::matchers::body_string_contains("## SOURCE:"))
2020            .respond_with(
2021                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2022                    "model": "jev-1.13.0",
2023                    "answers": {"s1": noul_json(0.95)},
2024                    "usage": {"input_tokens": 1, "output_tokens": 1}
2025                })),
2026            )
2027            .mount(&server)
2028            .await;
2029        wiremock::Mock::given(wiremock::matchers::method("POST"))
2030            .and(wiremock::matchers::body_string_contains("## COMMENT"))
2031            .respond_with(
2032                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2033                    "model": "jev-1.13.0",
2034                    "answers": {},
2035                    "usage": {"input_tokens": 1, "output_tokens": 1}
2036                })),
2037            )
2038            .mount(&server)
2039            .await;
2040        let jev = JevClient::new(&server.uri(), "key").unwrap();
2041        let ai = ai_client(vec![Ok(split_response_json(&[(
2042            "A claim.",
2043            Some("#1614"),
2044        )]))]);
2045        let (issue, comment, citations, sources) = issue_and_source();
2046
2047        let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
2048            .await
2049            .unwrap();
2050        assert_eq!(report.verdict, Verdict::NeedsReview);
2051        assert!(report.coverage.is_none());
2052        assert!(
2053            report
2054                .reasons
2055                .iter()
2056                .any(|r| r.contains("no `coverage` answer")),
2057            "{:?}",
2058            report.reasons
2059        );
2060    }
2061}