1use super::parse::{parse_issues, severity_rank};
2use super::prompts::{build_segmented_prompt, build_user_prompt};
3use super::{
4 HttpReviewLlm, ReviewCheckInput, ReviewCheckResult, ReviewIssueRecord, ReviewLlm,
5 ReviewPerspective, ReviewStats,
6};
7use crate::review_trajectory::{RuleSource, TrajectoryBuilder, TrajectoryStep};
8use gate4agent::CliTool;
9
10mod chat;
11mod judge;
12mod resolver;
13mod rules;
14mod validate;
15
16pub(super) use chat::resolve_review_engine;
17#[cfg(test)]
18pub(super) use validate::{run_review_summary, verify_pass};
19
20use chat::{
21 PerspectiveRun, call_review_engine, get_active_provider, make_review_llm, run_one_perspective,
22};
23use rules::{build_recalled_verdicts, recall_past_verdicts_for_review};
24use validate::{
25 run_review_summary as run_review_summary_internal, verify_pass as verify_pass_internal,
26};
27
28pub(super) fn repo_scopes_for_input(input: &ReviewCheckInput) -> Vec<String> {
29 let mut scopes = Vec::new();
30 if let Some(repo) = input.repo_full_name.as_deref() {
31 let repo = repo.trim();
32 if !repo.is_empty() {
33 scopes.push(repo.to_owned());
34 }
35 }
36 for repo in &input.repo_full_name_aliases {
37 let repo = repo.trim();
38 if repo.is_empty() {
39 continue;
40 }
41 if !scopes
42 .iter()
43 .any(|existing| existing.eq_ignore_ascii_case(repo))
44 {
45 scopes.push(repo.to_owned());
46 }
47 }
48 scopes
49}
50
51const JUDGE_CANDIDATE_POOL_TOP_K: usize = 18;
59
60struct PreparedReviewRules {
64 rules_text: Option<String>,
65 count: i32,
66 ids: Vec<String>,
67 titles: Vec<String>,
68}
69
70fn rules_text_from_items(
75 items: &[crate::context::types::ContextSourceItemRecord],
76) -> Option<String> {
77 if items.is_empty() {
78 return None;
79 }
80 Some(
81 items
82 .iter()
83 .map(|item| item.content.clone())
84 .collect::<Vec<_>>()
85 .join("\n\n"),
86 )
87}
88
89async fn prepare_review_rules(
104 db: &sqlx::SqlitePool,
105 input: &ReviewCheckInput,
106 retrieval_query: &str,
107 repo_scopes: &[String],
108 judge_llm: &dyn ReviewLlm,
109 review_engine: &crate::models::ReviewEngineRecord,
110 log_tag: &str,
111) -> PreparedReviewRules {
112 if input.project_id.is_empty() {
113 return PreparedReviewRules {
114 rules_text: None,
115 count: 0,
116 ids: Vec::new(),
117 titles: Vec::new(),
118 };
119 }
120
121 let judge_enabled = review_engine.rule_applicability_judge;
122 let top_k_override = judge_enabled.then_some(JUDGE_CANDIDATE_POOL_TOP_K);
125
126 let pack = match crate::context::orchestrator::prepare_with_hint_and_repo_scopes_with_top_k(
127 db,
128 &input.project_id,
129 input.engine.as_deref().unwrap_or("claude"),
130 retrieval_query,
131 Some("review"),
132 input.file_path.as_deref(),
133 repo_scopes,
134 top_k_override,
135 )
136 .await
137 {
138 Ok(pack) => pack,
139 Err(e) => {
140 eprintln!("[{log_tag}] context prepare failed: {e:?}, proceeding without rules");
141 return PreparedReviewRules {
142 rules_text: None,
143 count: 0,
144 ids: Vec::new(),
145 titles: Vec::new(),
146 };
147 }
148 };
149
150 let reranked =
151 crate::context::intent_filter::maybe_rerank_for_review(&pack.rule_context, retrieval_query);
152
153 if !judge_enabled {
158 let (rules_text, count, ids, titles) = if let Some((reranked, rules_text)) = reranked {
159 let count = i32::try_from(reranked.len()).unwrap_or(i32::MAX);
160 let (ids, titles) = matched_rule_ids_and_titles(&reranked);
161 (rules_text, count, ids, titles)
162 } else {
163 let count = i32::try_from(pack.metadata.rule_count).unwrap_or(i32::MAX);
164 let (ids, titles) = matched_rule_ids_and_titles(&pack.rule_context);
165 (pack.sections.rules, count, ids, titles)
166 };
167 return PreparedReviewRules {
168 rules_text,
169 count,
170 ids,
171 titles,
172 };
173 }
174
175 let pool: Vec<_> = match reranked {
182 Some((reranked, _reranked_text)) => reranked,
183 None => pack.rule_context.clone(),
184 };
185
186 let pool = judge::run_applicability_judge(judge_llm, true, &input.diff_content, pool).await;
187
188 let rules_text = rules_text_from_items(&pool);
189 let count = i32::try_from(pool.len()).unwrap_or(i32::MAX);
190 let (ids, titles) = matched_rule_ids_and_titles(&pool);
191 PreparedReviewRules {
192 rules_text,
193 count,
194 ids,
195 titles,
196 }
197}
198
199pub(in super::super) fn count_blocking(issues: &[ReviewIssueRecord]) -> (u32, u32) {
200 let mut blocking = 0u32;
201 let mut non_blocking = 0u32;
202 for i in issues {
203 match i.severity.as_str() {
204 "error" | "critical" => blocking += 1,
205 _ => non_blocking += 1,
206 }
207 }
208 (blocking, non_blocking)
209}
210
211pub(in super::super) fn collect_diff_files(diff: &str) -> Vec<String> {
212 let mut out: Vec<String> = Vec::new();
213 for line in diff.lines() {
214 if let Some(rest) = line.strip_prefix("+++ ") {
215 let file = rest.strip_prefix("b/").unwrap_or(rest).trim().to_owned();
216 if file.is_empty() || file == "/dev/null" {
217 continue;
218 }
219 if !out.iter().any(|f| f == &file) {
220 out.push(file);
221 }
222 }
223 }
224 out
225}
226
227#[derive(Debug, Clone)]
232pub enum ReviewEngine {
233 HttpProvider {
234 provider_name: String,
235 base_url: String,
236 api_key: String,
237 model: String,
238 },
239 AgentCli {
240 tool: CliTool,
241 model: String,
244 },
245}
246
247pub fn merge_perspective_issues(
253 per_perspective: Vec<(ReviewPerspective, Vec<ReviewIssueRecord>)>,
254) -> Vec<ReviewIssueRecord> {
255 use std::collections::BTreeMap;
256
257 let mut order: Vec<String> = Vec::new();
259 let mut merged: BTreeMap<String, ReviewIssueRecord> = BTreeMap::new();
260
261 for (persp, issues) in per_perspective {
262 let persp_name = persp.name();
263 for mut issue in issues {
264 let key = format!(
265 "{}|{}|{}",
266 issue.file.as_deref().unwrap_or_default(),
267 issue.line.map(|n| n.to_string()).unwrap_or_default(),
268 issue.rule_id.as_deref().unwrap_or(issue.rule.as_str()),
269 );
270
271 if let Some(existing) = merged.get_mut(&key) {
272 if severity_rank(&issue.severity) > severity_rank(&existing.severity) {
273 let mut perspectives = existing.perspectives.clone();
274 if !perspectives.iter().any(|p| p == persp_name) {
275 perspectives.push(persp_name.to_owned());
276 }
277 issue.perspectives = perspectives;
278 *existing = issue;
279 } else if !existing.perspectives.iter().any(|p| p == persp_name) {
280 existing.perspectives.push(persp_name.to_owned());
281 }
282 } else {
283 if !issue.perspectives.iter().any(|p| p == persp_name) {
284 issue.perspectives.push(persp_name.to_owned());
285 }
286 order.push(key.clone());
287 merged.insert(key, issue);
288 }
289 }
290 }
291
292 let canonical = [
294 ReviewPerspective::Safety.name(),
295 ReviewPerspective::Performance.name(),
296 ReviewPerspective::Style.name(),
297 ReviewPerspective::Docs.name(),
298 ReviewPerspective::ApiDesign.name(),
299 ];
300
301 order
302 .into_iter()
303 .filter_map(|k| merged.remove(&k))
304 .map(|mut issue| {
305 let mut sorted: Vec<String> = canonical
306 .iter()
307 .filter(|c| issue.perspectives.iter().any(|p| p == *c))
308 .map(ToString::to_string)
309 .collect();
310 for p in &issue.perspectives {
312 if !sorted.iter().any(|s| s == p) {
313 sorted.push(p.clone());
314 }
315 }
316 issue.perspectives = sorted;
317 issue
318 })
319 .collect()
320}
321
322fn matched_rule_ids_and_titles(
323 rule_context: &[crate::context::types::ContextSourceItemRecord],
324) -> (Vec<String>, Vec<String>) {
325 let ids = rule_context
326 .iter()
327 .map(|item| item.source_id.clone())
328 .collect();
329 let titles = rule_context
330 .iter()
331 .map(|item| {
332 item.title
333 .clone()
334 .filter(|title| !title.trim().is_empty())
335 .unwrap_or_else(|| item.source_id.clone())
336 })
337 .collect();
338 (ids, titles)
339}
340
341fn issue_text_for_attribution(issue: &ReviewIssueRecord) -> String {
342 format!(
343 "{} {} {} {}",
344 issue.rule,
345 issue.message,
346 issue.suggestion.as_deref().unwrap_or_default(),
347 issue.file.as_deref().unwrap_or_default(),
348 )
349 .to_ascii_lowercase()
350}
351
352fn contains_any(text: &str, needles: &[&str]) -> bool {
353 needles.iter().any(|needle| text.contains(needle))
354}
355
356fn is_workflow_pin_issue(issue: &ReviewIssueRecord) -> bool {
357 let text = issue_text_for_attribution(issue);
358 let workflow_context = issue
359 .file
360 .as_deref()
361 .is_some_and(|file| file.contains(".github/workflows/"))
362 || contains_any(
363 &text,
364 &[
365 "github action",
366 "actions/",
367 "uses:",
368 "workflow",
369 "checkout@",
370 ],
371 );
372 let pin_context = contains_any(
373 &text,
374 &[
375 "pin",
376 "sha",
377 "immutable",
378 "mutable",
379 "floating",
380 "@main",
381 "@master",
382 ],
383 );
384 workflow_context && pin_context
385}
386
387fn is_workflow_pin_rule_title(title: &str) -> bool {
388 let text = title.to_ascii_lowercase();
389 contains_any(&text, &["github action", "actions", "workflow"])
390 && contains_any(&text, &["pin", "sha", "immutable"])
391}
392
393fn attribution_tokens(text: &str) -> std::collections::BTreeSet<String> {
394 const STOPWORDS: &[&str] = &[
395 "the", "and", "for", "from", "into", "with", "this", "that", "must", "should", "would",
396 "could", "rule", "rules", "file", "line", "review", "code", "when", "where", "than",
397 "then", "they", "them", "your", "their",
398 ];
399 text.split(|c: char| !c.is_ascii_alphanumeric())
400 .filter_map(|raw| {
401 let token = raw.trim().to_ascii_lowercase();
402 if token.is_empty() || token.len() < 3 {
403 return None;
404 }
405 let token = match token.as_str() {
406 "shas" => "sha".to_owned(),
407 "references" => "reference".to_owned(),
408 other => other.to_owned(),
409 };
410 (!STOPWORDS.contains(&token.as_str())).then_some(token)
411 })
412 .collect()
413}
414
415fn infer_rule_id_for_issue(
416 issue: &ReviewIssueRecord,
417 matched_rule_ids: &[String],
418 matched_rule_titles: &[String],
419) -> Option<String> {
420 if matched_rule_ids.is_empty() {
421 return None;
422 }
423
424 if is_workflow_pin_issue(issue)
425 && let Some((idx, _)) = matched_rule_titles
426 .iter()
427 .enumerate()
428 .find(|(_, title)| is_workflow_pin_rule_title(title))
429 {
430 return matched_rule_ids.get(idx).cloned();
431 }
432
433 let issue_tokens = attribution_tokens(&issue_text_for_attribution(issue));
434 if issue_tokens.is_empty() {
435 return None;
436 }
437
438 let mut best: Option<(usize, f32, usize)> = None;
439 let mut second_best = 0.0_f32;
440 for (idx, title) in matched_rule_titles.iter().enumerate() {
441 let title_tokens = attribution_tokens(title);
442 if title_tokens.is_empty() {
443 continue;
444 }
445 let overlap = title_tokens
446 .iter()
447 .filter(|token| issue_tokens.contains(*token))
448 .count();
449 if overlap < 2 {
450 continue;
451 }
452 let score = overlap as f32 / title_tokens.len() as f32;
453 match best {
454 Some((_, best_score, _)) if score > best_score => {
455 second_best = best_score;
456 best = Some((idx, score, overlap));
457 }
458 Some(_) => {
459 second_best = second_best.max(score);
460 }
461 None => best = Some((idx, score, overlap)),
462 }
463 }
464
465 let (idx, score, overlap) = best?;
466 if overlap >= 2 && score >= 0.60 && score >= second_best + 0.15 {
467 matched_rule_ids.get(idx).cloned()
468 } else {
469 None
470 }
471}
472
473fn apply_missing_rule_attributions(
474 issues: &mut [ReviewIssueRecord],
475 matched_rule_ids: &[String],
476 matched_rule_titles: &[String],
477) {
478 for issue in issues {
479 if issue
480 .rule_id
481 .as_deref()
482 .is_some_and(|rule_id| !rule_id.trim().is_empty())
483 {
484 continue;
485 }
486 if let Some(rule_id) = infer_rule_id_for_issue(issue, matched_rule_ids, matched_rule_titles)
487 {
488 issue.rule_id = Some(rule_id);
489 }
490 }
491}
492
493fn apply_hunk_line_resolution(
505 issues: &mut [ReviewIssueRecord],
506 snippets: &[Option<String>],
507 diff: &str,
508) {
509 use std::collections::HashMap;
510
511 let sections = split_diff_by_file(diff);
513 let mut cache: HashMap<String, Vec<resolver::DiffHunk>> = HashMap::new();
514
515 for (idx, issue) in issues.iter_mut().enumerate() {
516 let Some(file) = issue.file.as_deref() else {
517 continue;
518 };
519 let hunks = cache.entry(file.to_owned()).or_insert_with(|| {
520 sections
521 .get(file)
522 .map(|section| resolver::parse_hunks(section))
523 .unwrap_or_default()
524 });
525 if hunks.is_empty() {
526 continue;
527 }
528 let target = resolver::ResolveTarget {
529 snippet: snippets.get(idx).and_then(Clone::clone),
530 claimed_line: issue.line,
531 };
532 if let Some((start, _end)) = resolver::resolve_issue_lines(&target, hunks) {
533 issue.line = Some(start);
534 }
535 }
536}
537
538fn split_diff_by_file(diff: &str) -> std::collections::HashMap<String, String> {
543 let mut out = std::collections::HashMap::new();
544 let mut current_path: Option<String> = None;
545 let mut current_body = String::new();
546
547 let flush = |path: &mut Option<String>,
548 body: &mut String,
549 out: &mut std::collections::HashMap<String, String>| {
550 if let Some(p) = path.take() {
551 if body.trim().is_empty() {
552 body.clear();
553 } else {
554 out.insert(p, std::mem::take(body));
555 }
556 }
557 };
558
559 for line in diff.lines() {
560 if line.starts_with("diff --git ") {
561 flush(&mut current_path, &mut current_body, &mut out);
562 current_path = None;
563 current_body.clear();
564 } else if let Some(rest) = line.strip_prefix("+++ ") {
565 let path = rest.strip_prefix("b/").unwrap_or(rest).trim();
566 if !path.is_empty() && path != "/dev/null" {
567 current_path = Some(path.to_owned());
568 }
569 }
570 if current_path.is_some() {
571 current_body.push_str(line);
572 current_body.push('\n');
573 }
574 }
575 flush(&mut current_path, &mut current_body, &mut out);
576 out
577}
578
579pub async fn run_review_multi(
581 db: &sqlx::SqlitePool,
582 input: ReviewCheckInput,
583) -> crate::Result<ReviewCheckResult> {
584 run_review_multi_with_trajectory(db, input, None).await
585}
586
587pub async fn run_review_multi_with_trajectory(
589 db: &sqlx::SqlitePool,
590 input: ReviewCheckInput,
591 mut trajectory: Option<&mut TrajectoryBuilder>,
592) -> crate::Result<ReviewCheckResult> {
593 let trace_id = uuid::Uuid::new_v4().to_string();
594
595 let (provider_name, base_url, api_key, model) = get_active_provider(db).await?;
597
598 let retrieval_intent = crate::context::intent_filter::build_review_intent_text(
599 input.file_path.as_deref(),
600 &input.diff_content,
601 );
602 let retrieval_query = if retrieval_intent.trim().is_empty() {
603 input.diff_content.as_str()
604 } else {
605 retrieval_intent.as_str()
606 };
607 let repo_scopes = repo_scopes_for_input(&input);
608
609 let settings_for_recall = crate::settings::get().await.unwrap_or_default();
612
613 let judge_llm = HttpReviewLlm {
617 provider_name: provider_name.clone(),
618 base_url: base_url.clone(),
619 api_key: api_key.clone(),
620 model: model.clone(),
621 };
622 let prepared = prepare_review_rules(
623 db,
624 &input,
625 retrieval_query,
626 &repo_scopes,
627 &judge_llm,
628 &settings_for_recall.review_engine,
629 "review_check_multi",
630 )
631 .await;
632 let PreparedReviewRules {
633 rules_text,
634 count: matched_rules,
635 ids: matched_rule_ids,
636 titles: matched_rule_titles,
637 } = prepared;
638
639 if let Some(tb) = trajectory.as_deref_mut() {
640 tb.push(TrajectoryStep::ChunksRetrieved {
641 count: matched_rules.try_into().unwrap_or(usize::MAX),
642 symbols: matched_rule_titles.clone(),
643 similarity_scores: Vec::new(),
644 });
645 tb.push(TrajectoryStep::RulesApplied {
646 rule_ids: matched_rule_ids.clone(),
647 source: RuleSource::Team,
648 });
649 }
650
651 let user_prompt = build_user_prompt(
653 &input.diff_content,
654 rules_text.as_deref(),
655 input.file_path.as_deref(),
656 );
657 let prompt_tokens_estimate = (i32::try_from(user_prompt.len())
658 .unwrap_or(i32::MAX)
659 .saturating_add(3))
660 / 4;
661
662 let past_verdicts = if input.fast_preview {
665 Vec::new()
666 } else {
667 recall_past_verdicts_for_review(
668 &settings_for_recall,
669 &input.diff_content,
670 if input.project_id.is_empty() {
671 None
672 } else {
673 Some(&input.project_id)
674 },
675 &repo_scopes,
676 )
677 .await
678 };
679
680 if let Some(tb) = trajectory.as_deref_mut() {
681 let recalled_items = build_recalled_verdicts(&past_verdicts);
682 let top_similarities: Vec<f32> =
683 recalled_items.iter().map(|item| item.similarity).collect();
684 tb.push(TrajectoryStep::PastVerdictsRecalled {
685 count: past_verdicts.len(),
686 top_similarities,
687 recalled_items,
688 });
689 }
690
691 let (safety_issues, perf_issues, style_issues, docs_issues, api_design_issues) = tokio::join!(
692 run_one_perspective(PerspectiveRun {
693 provider_name: &provider_name,
694 base_url: &base_url,
695 api_key: &api_key,
696 model: &model,
697 user_prompt: &user_prompt,
698 perspective: ReviewPerspective::Safety,
699 diff_content: &input.diff_content,
700 past_verdicts: &past_verdicts,
701 }),
702 run_one_perspective(PerspectiveRun {
703 provider_name: &provider_name,
704 base_url: &base_url,
705 api_key: &api_key,
706 model: &model,
707 user_prompt: &user_prompt,
708 perspective: ReviewPerspective::Performance,
709 diff_content: &input.diff_content,
710 past_verdicts: &past_verdicts,
711 }),
712 run_one_perspective(PerspectiveRun {
713 provider_name: &provider_name,
714 base_url: &base_url,
715 api_key: &api_key,
716 model: &model,
717 user_prompt: &user_prompt,
718 perspective: ReviewPerspective::Style,
719 diff_content: &input.diff_content,
720 past_verdicts: &past_verdicts,
721 }),
722 run_one_perspective(PerspectiveRun {
723 provider_name: &provider_name,
724 base_url: &base_url,
725 api_key: &api_key,
726 model: &model,
727 user_prompt: &user_prompt,
728 perspective: ReviewPerspective::Docs,
729 diff_content: &input.diff_content,
730 past_verdicts: &past_verdicts,
731 }),
732 run_one_perspective(PerspectiveRun {
733 provider_name: &provider_name,
734 base_url: &base_url,
735 api_key: &api_key,
736 model: &model,
737 user_prompt: &user_prompt,
738 perspective: ReviewPerspective::ApiDesign,
739 diff_content: &input.diff_content,
740 past_verdicts: &past_verdicts,
741 }),
742 );
743
744 if let Some(tb) = trajectory.as_deref_mut() {
745 let per_call_input = u32::try_from(prompt_tokens_estimate).unwrap_or(u32::MAX);
746 for perspective in ReviewPerspective::all() {
747 tb.push(TrajectoryStep::LlmCall {
748 perspective: perspective.name().to_owned(),
749 input_tokens: per_call_input,
750 output_tokens: 0,
751 raw_output: None,
752 });
753 }
754 }
755
756 let issues = merge_perspective_issues(vec![
757 (ReviewPerspective::Safety, safety_issues),
758 (ReviewPerspective::Performance, perf_issues),
759 (ReviewPerspective::Style, style_issues),
760 (ReviewPerspective::Docs, docs_issues),
761 (ReviewPerspective::ApiDesign, api_design_issues),
762 ]);
763
764 let llm: Box<dyn ReviewLlm> = Box::new(HttpReviewLlm {
767 provider_name,
768 base_url,
769 api_key,
770 model,
771 });
772 let pre_verify_count = issues.len();
773 let issues = verify_pass_internal(
774 llm.as_ref(),
775 settings_for_recall.review_engine.self_check_enabled && !input.fast_preview,
776 &input.diff_content,
777 issues,
778 )
779 .await;
780
781 if let Some(tb) = trajectory.as_deref_mut() {
782 let keep_count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
783 let drop_count =
784 u32::try_from(pre_verify_count.saturating_sub(issues.len())).unwrap_or(u32::MAX);
785 let avg_confidence = if issues.is_empty() {
786 0.0
787 } else {
788 issues.iter().map(|i| i.confidence).sum::<f32>() / (issues.len() as f32)
789 };
790 tb.push(TrajectoryStep::SelfCheck {
791 keep_count,
792 drop_count,
793 avg_confidence,
794 });
795 }
796
797 let mut issues = issues;
798 apply_missing_rule_attributions(&mut issues, &matched_rule_ids, &matched_rule_titles);
799 if settings_for_recall.review_engine.hunk_line_resolution {
802 apply_hunk_line_resolution(&mut issues, &[], &input.diff_content);
803 }
804 issues.sort_by(|a, b| {
805 b.confidence
806 .partial_cmp(&a.confidence)
807 .unwrap_or(std::cmp::Ordering::Equal)
808 });
809
810 let summary = run_review_summary_internal(
811 llm.as_ref(),
812 settings_for_recall.review_engine.review_summary_enabled && !input.fast_preview,
813 &input.diff_content,
814 &issues,
815 )
816 .await;
817
818 if let Some(tb) = trajectory.as_deref_mut() {
819 let ids = issues
820 .iter()
821 .map(|i| i.rule_id.clone().unwrap_or_else(|| i.rule.clone()))
822 .collect();
823 tb.push(TrajectoryStep::FinalDecision {
824 issue_ids_emitted: ids,
825 });
826 }
827
828 let stats = ReviewStats {
829 input_tokens: u32::try_from(prompt_tokens_estimate.max(0)).unwrap_or(u32::MAX),
830 duration_ms: None,
831 perspective_count: 5,
832 past_verdicts_used: u32::try_from(past_verdicts.len()).unwrap_or(u32::MAX),
833 trajectory_step_count: trajectory
834 .as_deref()
835 .map(|tb| u32::try_from(tb.len()).unwrap_or(u32::MAX)),
836 };
837
838 Ok(ReviewCheckResult {
839 issues,
840 matched_rules,
841 matched_rule_ids,
842 matched_rule_titles,
843 prompt_tokens_estimate,
844 trace_id,
845 summary,
846 stats: Some(stats),
847 })
848}
849
850pub const fn select_review_mode(multi_perspective: bool) -> &'static str {
852 if multi_perspective { "multi" } else { "single" }
853}
854
855#[allow(clippy::items_after_test_module)]
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 fn review_input(repo: Option<&str>, aliases: Vec<&str>) -> ReviewCheckInput {
861 ReviewCheckInput {
862 project_id: "project-1".to_owned(),
863 diff_content: String::new(),
864 file_path: None,
865 engine: None,
866 review_id: None,
867 repo_full_name: repo.map(str::to_owned),
868 repo_full_name_aliases: aliases.into_iter().map(str::to_owned).collect(),
869 fast_preview: false,
870 }
871 }
872
873 #[test]
874 fn repo_scopes_include_origin_and_upstream_aliases() {
875 let input = review_input(
876 Some("difflore-fixtures/router"),
877 vec!["difflore-fixtures/router", "tanstack/router"],
878 );
879
880 assert_eq!(
881 repo_scopes_for_input(&input),
882 vec![
883 "difflore-fixtures/router".to_owned(),
884 "tanstack/router".to_owned(),
885 ],
886 );
887 }
888
889 #[test]
890 fn repo_scopes_dedupe_aliases_case_insensitively() {
891 let input = review_input(
892 Some("TanStack/router"),
893 vec!["tanstack/router", " ", "difflore-fixtures/router"],
894 );
895
896 assert_eq!(
897 repo_scopes_for_input(&input),
898 vec![
899 "TanStack/router".to_owned(),
900 "difflore-fixtures/router".to_owned(),
901 ],
902 );
903 }
904
905 #[test]
906 fn fast_preview_input_marks_secondary_review_passes_skippable() {
907 let mut input = review_input(Some("owner/repo"), vec![]);
908 assert!(!input.fast_preview);
909
910 input.fast_preview = true;
911
912 assert!(input.fast_preview);
913 }
914
915 #[test]
916 fn workflow_pin_issue_gets_recalled_rule_id_when_model_omits_it() {
917 let issue = ReviewIssueRecord {
918 severity: "warning".to_owned(),
919 rule: "Pin GitHub Actions to immutable references".to_owned(),
920 rule_id: None,
921 message: "actions/checkout@main is a floating ref".to_owned(),
922 file: Some(".github/workflows/pr.yml".to_owned()),
923 line: Some(26),
924 suggestion: Some("Use a commit SHA instead of main.".to_owned()),
925 source_badge: None,
926 perspectives: Vec::new(),
927 confidence: 0.98,
928 };
929
930 let rule_id = infer_rule_id_for_issue(
931 &issue,
932 &[
933 "pin-actions-rule".to_owned(),
934 "version-update-rule".to_owned(),
935 ],
936 &[
937 "Pin Actions to commit SHAs".to_owned(),
938 "Update GitHub Actions versions atomically".to_owned(),
939 ],
940 );
941
942 assert_eq!(rule_id.as_deref(), Some("pin-actions-rule"));
943 }
944
945 #[test]
946 fn missing_rule_attribution_stays_empty_for_ambiguous_text() {
947 let mut issues = vec![ReviewIssueRecord {
948 severity: "warning".to_owned(),
949 rule: "Improve code".to_owned(),
950 rule_id: None,
951 message: "This should be cleaner.".to_owned(),
952 file: Some("src/lib.rs".to_owned()),
953 line: Some(1),
954 suggestion: Some("Refactor it.".to_owned()),
955 source_badge: None,
956 perspectives: Vec::new(),
957 confidence: 0.8,
958 }];
959
960 apply_missing_rule_attributions(
961 &mut issues,
962 &["pin-actions-rule".to_owned()],
963 &["Pin Actions to commit SHAs".to_owned()],
964 );
965
966 assert!(issues[0].rule_id.is_none());
967 }
968
969 const MULTI_FILE_DIFF: &str = "\
970diff --git a/src/a.rs b/src/a.rs
971index 1111111..2222222 100644
972--- a/src/a.rs
973+++ b/src/a.rs
974@@ -5,4 +5,5 @@ fn a() {
975 let x = 1;
976 let y = 2;
977+ let z = dangerous(x, y);
978 done();
979 }
980diff --git a/src/b.rs b/src/b.rs
981index 3333333..4444444 100644
982--- a/src/b.rs
983+++ b/src/b.rs
984@@ -20,3 +20,4 @@ fn b() {
985 setup();
986+ let secret = read_env();
987 teardown();
988";
989
990 fn issue_at(file: &str, line: i32) -> ReviewIssueRecord {
991 ReviewIssueRecord {
992 severity: "warning".to_owned(),
993 rule: "r".to_owned(),
994 rule_id: None,
995 message: "m".to_owned(),
996 file: Some(file.to_owned()),
997 line: Some(line),
998 suggestion: None,
999 source_badge: None,
1000 perspectives: Vec::new(),
1001 confidence: 0.9,
1002 }
1003 }
1004
1005 #[test]
1006 fn split_diff_by_file_keys_on_new_side_path() {
1007 let map = split_diff_by_file(MULTI_FILE_DIFF);
1008 assert_eq!(map.len(), 2);
1009 assert!(map.contains_key("src/a.rs"));
1010 assert!(map.contains_key("src/b.rs"));
1011 assert!(map["src/a.rs"].contains("dangerous(x, y)"));
1012 assert!(map["src/b.rs"].contains("read_env()"));
1013 }
1014
1015 #[test]
1016 fn hunk_resolution_snaps_issue_to_exact_line_via_snippet() {
1017 let mut issues = vec![issue_at("src/a.rs", 999), issue_at("src/b.rs", 1)];
1018 let snippets = vec![
1019 Some("let z = dangerous(x, y);".to_owned()),
1020 Some("let secret = read_env();".to_owned()),
1021 ];
1022 apply_hunk_line_resolution(&mut issues, &snippets, MULTI_FILE_DIFF);
1023 assert_eq!(issues[0].line, Some(7));
1025 assert_eq!(issues[1].line, Some(21));
1027 }
1028
1029 #[test]
1030 fn hunk_resolution_leaves_line_when_file_not_in_diff() {
1031 let mut issues = vec![issue_at("src/unknown.rs", 42)];
1032 let snippets = vec![Some("whatever".to_owned())];
1033 apply_hunk_line_resolution(&mut issues, &snippets, MULTI_FILE_DIFF);
1034 assert_eq!(issues[0].line, Some(42), "untouched when no diff section");
1035 }
1036
1037 #[test]
1038 fn hunk_resolution_snaps_via_claimed_line_without_snippet() {
1039 let mut issues = vec![issue_at("src/a.rs", 6)];
1042 apply_hunk_line_resolution(&mut issues, &[], MULTI_FILE_DIFF);
1043 assert_eq!(issues[0].line, Some(6));
1044 }
1045
1046 #[test]
1047 fn hunk_resolution_tolerates_shorter_snippet_slice() {
1048 let mut issues = vec![issue_at("src/a.rs", 7), issue_at("src/b.rs", 21)];
1051 let snippets = vec![Some("let z = dangerous(x, y);".to_owned())];
1052 apply_hunk_line_resolution(&mut issues, &snippets, MULTI_FILE_DIFF);
1053 assert_eq!(issues[0].line, Some(7));
1054 assert_eq!(issues[1].line, Some(21));
1055 }
1056
1057 #[test]
1058 fn hunk_resolution_falls_back_when_nothing_matches() {
1059 let mut issues = vec![issue_at("src/a.rs", 900)];
1065 let snippets = vec![Some("text that appears nowhere in the diff".to_owned())];
1066 apply_hunk_line_resolution(&mut issues, &snippets, MULTI_FILE_DIFF);
1067 assert_eq!(
1068 issues[0].line,
1069 Some(900),
1070 "no confident hunk match → claimed line preserved (no regression)"
1071 );
1072 }
1073
1074 #[test]
1075 fn hunk_resolution_maps_multiline_finding_to_range_start() {
1076 let mut issues = vec![issue_at("src/a.rs", 1)];
1083 let snippets = vec![Some("let z = dangerous(x, y);\ndone();".to_owned())];
1084 apply_hunk_line_resolution(&mut issues, &snippets, MULTI_FILE_DIFF);
1085 assert_eq!(
1086 issues[0].line,
1087 Some(7),
1088 "multi-line finding anchors on the first line of the changed range"
1089 );
1090 }
1091
1092 const HONO_DIFF: &str = "\
1103--- a/src/compose.ts
1104+++ b/src/compose.ts
1105@@ -39,6 +39,9 @@ export const compose = <E extends Env = Env>(
1106 let isError = false
1107 let handler
1108
1109+ const apiKey = \"sk-live-1234567890abcdef\"
1110+ console.log(\"dispatching middleware at index \" + i + \" key=\" + apiKey)
1111+
1112 if (middleware[i]) {
1113 handler = middleware[i][0][0]
1114 context.req.routeIndex = i
1115@@ -46,6 +49,10 @@ export const compose = <E extends Env = Env>(
1116 handler = (i === middleware.length && next) || undefined
1117 }
1118
1119+ if (handler == null) {
1120+ handler = middleware[i][0][0]
1121+ }
1122+
1123 if (handler) {
1124 try {
1125 res = await handler(context, () => dispatch(i + 1))
1126";
1127
1128 fn hono_cases() -> Vec<(String, i32)> {
1131 vec![
1132 (
1133 " const apiKey = \"sk-live-1234567890abcdef\"".to_owned(),
1134 42,
1135 ),
1136 (
1137 " console.log(\"dispatching middleware at index \" + i + \" key=\" + apiKey)"
1138 .to_owned(),
1139 43,
1140 ),
1141 (
1142 " if (handler == null) {\n handler = middleware[i][0][0]\n }"
1143 .to_owned(),
1144 52,
1145 ),
1146 ]
1147 }
1148
1149 fn build(claimed: &[i32], with_snippet: bool) -> (Vec<ReviewIssueRecord>, Vec<Option<String>>) {
1154 let cases = hono_cases();
1155 let issues = claimed
1156 .iter()
1157 .map(|&l| issue_at("src/compose.ts", l))
1158 .collect();
1159 let snippets = cases
1160 .iter()
1161 .map(|(s, _)| if with_snippet { Some(s.clone()) } else { None })
1162 .collect();
1163 (issues, snippets)
1164 }
1165
1166 fn ground_truth() -> Vec<i32> {
1167 hono_cases().into_iter().map(|(_, gt)| gt).collect()
1168 }
1169
1170 fn precise_count(issues: &[ReviewIssueRecord], gt: &[i32]) -> usize {
1172 issues
1173 .iter()
1174 .zip(gt.iter())
1175 .filter(|(iss, g)| iss.line == Some(**g))
1176 .count()
1177 }
1178
1179 #[test]
1180 fn measure_real_response_off_equals_on_no_change() {
1181 let gt = ground_truth();
1184 let claimed_real = gt.clone(); let (off_issues, snippets) = build(&claimed_real, true);
1186 let off_precise = precise_count(&off_issues, >);
1187
1188 let (mut on_issues, _) = build(&claimed_real, true);
1189 apply_hunk_line_resolution(&mut on_issues, &snippets, HONO_DIFF);
1190 let on_precise = precise_count(&on_issues, >);
1191
1192 let on_lines: Vec<_> = on_issues.iter().map(|i| i.line).collect();
1193 eprintln!(
1194 "[MEASURE A real-response] OFF precise={off_precise}/3 ON precise={on_precise}/3 ON_lines={on_lines:?}"
1195 );
1196 assert_eq!(off_precise, 3, "model already correct on this diff");
1197 assert_eq!(on_precise, 3, "ON keeps all correct (no regression)");
1198 }
1199
1200 #[test]
1201 fn measure_corrupted_lines_with_real_snippets() {
1202 let gt = ground_truth();
1211 let corrupted = vec![4, 45, 49];
1212
1213 let (off_issues, _) = build(&corrupted, true);
1214 let off_precise = precise_count(&off_issues, >);
1215
1216 let (mut on_issues, snippets) = build(&corrupted, true);
1217 apply_hunk_line_resolution(&mut on_issues, &snippets, HONO_DIFF);
1218 let on_precise = precise_count(&on_issues, >);
1219
1220 let off_lines: Vec<_> = off_issues.iter().map(|i| i.line).collect();
1221 let on_lines: Vec<_> = on_issues.iter().map(|i| i.line).collect();
1222 eprintln!(
1223 "[MEASURE B corrupted+snippet] GT={gt:?} corrupted={corrupted:?} \
1224 OFF_lines={off_lines:?} (precise {off_precise}/3) \
1225 ON_lines={on_lines:?} (precise {on_precise}/3)"
1226 );
1227 assert_eq!(off_precise, 0, "all corrupted lines are wrong");
1229 assert_eq!(on_precise, 3, "snippet match recovers exact line for all");
1230 }
1231
1232 #[test]
1233 fn measure_corrupted_lines_without_snippets_claimed_only() {
1234 let gt = ground_truth();
1239 let corrupted = vec![4, 45, 49];
1240
1241 let (off_issues, _) = build(&corrupted, false);
1242 let off_precise = precise_count(&off_issues, >);
1243
1244 let (mut on_issues, _) = build(&corrupted, false);
1245 apply_hunk_line_resolution(&mut on_issues, &[], HONO_DIFF);
1246 let on_precise = precise_count(&on_issues, >);
1247
1248 let mut regressions = 0;
1251 for ((off, on), &g) in off_issues.iter().zip(on_issues.iter()).zip(gt.iter()) {
1252 let off_d = (off.line.unwrap_or(g) - g).abs();
1253 let on_d = (on.line.unwrap_or(g) - g).abs();
1254 if on_d > off_d {
1255 regressions += 1;
1256 }
1257 }
1258
1259 let off_lines: Vec<_> = off_issues.iter().map(|i| i.line).collect();
1260 let on_lines: Vec<_> = on_issues.iter().map(|i| i.line).collect();
1261 eprintln!(
1262 "[MEASURE C corrupted no-snippet] GT={gt:?} corrupted={corrupted:?} \
1263 OFF_lines={off_lines:?} (precise {off_precise}/3) \
1264 ON_lines={on_lines:?} (precise {on_precise}/3) regressions={regressions}"
1265 );
1266 assert_eq!(
1269 regressions, 0,
1270 "claimed-line snap must not move AWAY from GT"
1271 );
1272 }
1273
1274 #[test]
1275 fn measure_claimed_only_boundary_offbyone() {
1276 let gt = vec![43, 52];
1282 let corrupted = vec![48, 59];
1283 let issues_off: Vec<_> = corrupted
1284 .iter()
1285 .map(|&l| issue_at("src/compose.ts", l))
1286 .collect();
1287 let mut issues_on = issues_off.clone();
1288 apply_hunk_line_resolution(&mut issues_on, &[], HONO_DIFF);
1289 let on_lines: Vec<_> = issues_on.iter().map(|i| i.line).collect();
1290 let mut improved = 0;
1292 let mut regressed = 0;
1293 for ((off, on), &g) in issues_off.iter().zip(issues_on.iter()).zip(gt.iter()) {
1294 let off_d = (off.line.unwrap_or(g) - g).abs();
1295 let on_d = (on.line.unwrap_or(g) - g).abs();
1296 if on_d < off_d {
1297 improved += 1;
1298 }
1299 if on_d > off_d {
1300 regressed += 1;
1301 }
1302 }
1303 eprintln!(
1304 "[MEASURE C' claimed-only boundary] GT={gt:?} corrupted={corrupted:?} \
1305 ON_lines={on_lines:?} improved(closer)={improved} regressed={regressed}"
1306 );
1307 assert_eq!(regressed, 0);
1310 }
1311
1312 #[test]
1313 fn ambiguous_duplicate_snippet_prefers_claimed_occurrence() {
1314 let snippet = " handler = middleware[i][0][0]".to_owned();
1321 let mut issues = vec![issue_at("src/compose.ts", 53)];
1322 let snippets = vec![Some(snippet)];
1323 apply_hunk_line_resolution(&mut issues, &snippets, HONO_DIFF);
1324 assert_eq!(
1325 issues[0].line,
1326 Some(53),
1327 "must keep the claimed duplicate (53), not snap to the far one (46)"
1328 );
1329 }
1330}
1331
1332pub async fn run_review_smart(
1333 db: &sqlx::SqlitePool,
1334 input: ReviewCheckInput,
1335) -> crate::Result<ReviewCheckResult> {
1336 let settings = crate::settings::get().await.unwrap_or_default();
1337 let review_id = input.review_id.clone();
1338 let multi_perspective = settings.review_engine.multi_perspective;
1339
1340 if review_id.is_none() {
1341 let started = std::time::Instant::now();
1342 let mut result = match select_review_mode(multi_perspective) {
1343 "multi" => run_review_multi(db, input).await?,
1344 _ => run_review(db, input).await?,
1345 };
1346 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1347 if let Some(stats) = result.stats.as_mut() {
1348 stats.duration_ms = Some(duration_ms);
1349 }
1350 return Ok(result);
1351 }
1352
1353 let started = std::time::Instant::now();
1354 let mut trajectory = TrajectoryBuilder::new();
1355 let mut result = match select_review_mode(multi_perspective) {
1356 "multi" => run_review_multi_with_trajectory(db, input, Some(&mut trajectory)).await?,
1357 _ => run_review_with_trajectory(db, input, Some(&mut trajectory)).await?,
1358 };
1359 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1360 if let Some(stats) = result.stats.as_mut() {
1361 stats.duration_ms = Some(duration_ms);
1362 }
1363
1364 if let Some(id) = review_id {
1365 upload_review_telemetry(id, duration_ms, multi_perspective, &result, trajectory).await;
1366 }
1367
1368 Ok(result)
1369}
1370
1371async fn upload_review_telemetry(
1373 review_id: String,
1374 duration_ms: u64,
1375 multi_perspective: bool,
1376 result: &ReviewCheckResult,
1377 trajectory: TrajectoryBuilder,
1378) {
1379 let cloud = crate::cloud::client::CloudClient::create().await;
1380 if !cloud.is_logged_in() {
1381 return;
1382 }
1383
1384 let past_verdicts_used = trajectory.steps().iter().find_map(|step| match step {
1385 TrajectoryStep::PastVerdictsRecalled { count, .. } => {
1386 Some(u32::try_from(*count).unwrap_or(u32::MAX))
1387 }
1388 _ => None,
1389 });
1390
1391 let metrics_req = crate::cloud::api_types::RecordReviewMetricsRequest {
1392 input_tokens: Some(u32::try_from(result.prompt_tokens_estimate.max(0)).unwrap_or(u32::MAX)),
1393 output_tokens: None,
1394 estimated_cost_usd: None,
1395 duration_ms: Some(duration_ms),
1396 perspective_count: Some(if multi_perspective { 5 } else { 1 }),
1397 past_verdicts_used,
1398 };
1399
1400 let pool = crate::db::init_db().await.ok();
1401 if let Some(pool) = pool {
1402 let q = crate::cloud::outbox::OutboxQueue::new(pool);
1403 let metrics_payload = serde_json::json!({
1404 "review_id": review_id,
1405 "req": metrics_req,
1406 });
1407 if let Ok(s) = serde_json::to_string(&metrics_payload) {
1408 let _ = q
1409 .enqueue(crate::cloud::outbox::kind::REVIEW_METRICS, &s)
1410 .await;
1411 }
1412 if !trajectory.is_empty() {
1413 let trajectory_payload = serde_json::json!({
1414 "pr_review_id": review_id,
1415 "steps": trajectory.into_json(),
1416 });
1417 if let Ok(s) = serde_json::to_string(&trajectory_payload) {
1418 let _ = q.enqueue(crate::cloud::outbox::kind::TRAJECTORY, &s).await;
1419 }
1420 }
1421 let _ = crate::cloud::outbox::drain_outbox(&q, &cloud, 8).await;
1422 } else {
1423 let _ = cloud.record_review_metrics(&review_id, metrics_req).await;
1424 if !trajectory.is_empty() {
1425 let _ = cloud
1426 .save_trajectory(&review_id, trajectory.into_json())
1427 .await;
1428 }
1429 }
1430}
1431
1432pub async fn run_review(
1433 db: &sqlx::SqlitePool,
1434 input: ReviewCheckInput,
1435) -> crate::Result<ReviewCheckResult> {
1436 run_review_with_trajectory(db, input, None).await
1437}
1438
1439pub async fn run_review_with_trajectory(
1441 db: &sqlx::SqlitePool,
1442 input: ReviewCheckInput,
1443 mut trajectory: Option<&mut TrajectoryBuilder>,
1444) -> crate::Result<ReviewCheckResult> {
1445 let trace_id = uuid::Uuid::new_v4().to_string();
1446
1447 let engine = resolve_review_engine(db).await?;
1448
1449 let retrieval_intent = crate::context::intent_filter::build_review_intent_text(
1450 input.file_path.as_deref(),
1451 &input.diff_content,
1452 );
1453 let retrieval_query = if retrieval_intent.trim().is_empty() {
1454 input.diff_content.as_str()
1455 } else {
1456 retrieval_intent.as_str()
1457 };
1458 let repo_scopes = repo_scopes_for_input(&input);
1459
1460 let settings = crate::settings::get().await.unwrap_or_default();
1463
1464 let judge_llm = make_review_llm(engine.clone());
1468 let prepared = prepare_review_rules(
1469 db,
1470 &input,
1471 retrieval_query,
1472 &repo_scopes,
1473 judge_llm.as_ref(),
1474 &settings.review_engine,
1475 "review_check",
1476 )
1477 .await;
1478 let PreparedReviewRules {
1479 rules_text,
1480 count: matched_rules,
1481 ids: matched_rule_ids,
1482 titles: matched_rule_titles,
1483 } = prepared;
1484
1485 if let Some(tb) = trajectory.as_deref_mut() {
1486 tb.push(TrajectoryStep::ChunksRetrieved {
1487 count: matched_rules.try_into().unwrap_or(usize::MAX),
1488 symbols: matched_rule_titles.clone(),
1489 similarity_scores: Vec::new(),
1490 });
1491 tb.push(TrajectoryStep::RulesApplied {
1492 rule_ids: matched_rule_ids.clone(),
1493 source: RuleSource::Team,
1494 });
1495 }
1496
1497 let past_verdicts = if input.fast_preview {
1498 Vec::new()
1499 } else {
1500 recall_past_verdicts_for_review(
1501 &settings,
1502 &input.diff_content,
1503 if input.project_id.is_empty() {
1504 None
1505 } else {
1506 Some(&input.project_id)
1507 },
1508 &repo_scopes,
1509 )
1510 .await
1511 };
1512
1513 if let Some(tb) = trajectory.as_deref_mut() {
1514 let recalled_items = build_recalled_verdicts(&past_verdicts);
1515 let top_similarities: Vec<f32> =
1516 recalled_items.iter().map(|item| item.similarity).collect();
1517 tb.push(TrajectoryStep::PastVerdictsRecalled {
1518 count: past_verdicts.len(),
1519 top_similarities,
1520 recalled_items,
1521 });
1522 }
1523
1524 let seg = build_segmented_prompt(
1525 None,
1526 &[],
1527 &input.diff_content,
1528 "",
1529 None,
1530 if past_verdicts.is_empty() {
1531 None
1532 } else {
1533 Some(&past_verdicts)
1534 },
1535 );
1536 let user_prompt = build_user_prompt(
1537 &input.diff_content,
1538 rules_text.as_deref(),
1539 input.file_path.as_deref(),
1540 );
1541
1542 let prompt_tokens_estimate = (i32::try_from(user_prompt.len())
1543 .unwrap_or(i32::MAX)
1544 .saturating_add(3))
1545 / 4;
1546
1547 if let Some(path) = crate::env::fix_dump_dir() {
1548 let _ = std::fs::create_dir_all(&path);
1549 let _ = std::fs::write(format!("{path}/last_user.txt"), &user_prompt);
1550 let _ = std::fs::write(
1551 format!("{path}/last_system.txt"),
1552 format!("{}{}", seg.stable_prefix, seg.dynamic_suffix),
1553 );
1554 }
1555
1556 let ai_response = call_review_engine(&engine, &seg, &user_prompt).await?;
1557 if let Some(path) = crate::env::fix_dump_dir() {
1558 let _ = std::fs::write(format!("{path}/last_response.txt"), &ai_response);
1559 }
1560
1561 if let Some(tb) = trajectory.as_deref_mut() {
1562 tb.push(TrajectoryStep::LlmCall {
1563 perspective: "single".to_owned(),
1564 input_tokens: u32::try_from(prompt_tokens_estimate.max(0)).unwrap_or(u32::MAX),
1565 output_tokens: 0,
1566 raw_output: None,
1567 });
1568 }
1569
1570 let mut issues = parse_issues(&ai_response);
1571 if settings.review_engine.hunk_line_resolution {
1575 let snippets = super::parse::extract_issue_snippets(&ai_response);
1576 apply_hunk_line_resolution(&mut issues, &snippets, &input.diff_content);
1577 }
1578 let issues = issues;
1579 if crate::env::fix_debug() {
1580 eprintln!(
1581 "[fix-debug] single-pass raw_response_len={} parsed_issues={}",
1582 ai_response.len(),
1583 issues.len(),
1584 );
1585 if issues.is_empty() && ai_response.len() < 4000 {
1586 eprintln!("[fix-debug] response body: {ai_response}");
1587 }
1588 }
1589
1590 let llm: Box<dyn ReviewLlm> = make_review_llm(engine);
1591 let pre_verify_count = issues.len();
1592 let issues = verify_pass_internal(
1593 llm.as_ref(),
1594 settings.review_engine.self_check_enabled && !input.fast_preview,
1595 &input.diff_content,
1596 issues,
1597 )
1598 .await;
1599 if crate::env::fix_debug() {
1600 eprintln!(
1601 "[fix-debug] verify: pre={} post={} self_check_enabled={}",
1602 pre_verify_count,
1603 issues.len(),
1604 settings.review_engine.self_check_enabled && !input.fast_preview,
1605 );
1606 }
1607
1608 if let Some(tb) = trajectory.as_deref_mut() {
1609 let keep_count = u32::try_from(issues.len()).unwrap_or(u32::MAX);
1610 let drop_count =
1611 u32::try_from(pre_verify_count.saturating_sub(issues.len())).unwrap_or(u32::MAX);
1612 let avg_confidence = if issues.is_empty() {
1613 0.0
1614 } else {
1615 issues.iter().map(|i| i.confidence).sum::<f32>() / (issues.len() as f32)
1616 };
1617 tb.push(TrajectoryStep::SelfCheck {
1618 keep_count,
1619 drop_count,
1620 avg_confidence,
1621 });
1622 }
1623
1624 let mut issues = issues;
1625 apply_missing_rule_attributions(&mut issues, &matched_rule_ids, &matched_rule_titles);
1626 issues.sort_by(|a, b| {
1627 b.confidence
1628 .partial_cmp(&a.confidence)
1629 .unwrap_or(std::cmp::Ordering::Equal)
1630 });
1631
1632 let summary = run_review_summary_internal(
1633 llm.as_ref(),
1634 settings.review_engine.review_summary_enabled && !input.fast_preview,
1635 &input.diff_content,
1636 &issues,
1637 )
1638 .await;
1639
1640 if let Some(tb) = trajectory.as_deref_mut() {
1641 let ids = issues
1642 .iter()
1643 .map(|i| i.rule_id.clone().unwrap_or_else(|| i.rule.clone()))
1644 .collect();
1645 tb.push(TrajectoryStep::FinalDecision {
1646 issue_ids_emitted: ids,
1647 });
1648 }
1649
1650 let stats = ReviewStats {
1651 input_tokens: u32::try_from(prompt_tokens_estimate.max(0)).unwrap_or(u32::MAX),
1652 duration_ms: None,
1653 perspective_count: 1,
1654 past_verdicts_used: u32::try_from(past_verdicts.len()).unwrap_or(u32::MAX),
1655 trajectory_step_count: trajectory
1656 .as_deref()
1657 .map(|tb| u32::try_from(tb.len()).unwrap_or(u32::MAX)),
1658 };
1659
1660 Ok(ReviewCheckResult {
1661 issues,
1662 matched_rules,
1663 matched_rule_ids,
1664 matched_rule_titles,
1665 prompt_tokens_estimate,
1666 trace_id,
1667 summary,
1668 stats: Some(stats),
1669 })
1670}