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