1use std::process::ExitCode;
16
17pub use fallow_output::{
18 CoordinationGapFact, DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts,
19 ReviewBriefSchemaVersion, ReviewBriefSubtractSections, ReviewDeltas, ReviewEffort,
20 ReviewUnitFact, RiskClass,
21};
22use fallow_types::results::AnalysisResults;
23use rustc_hash::FxHashSet;
24
25use crate::audit::AuditResult;
26use crate::report::sink::outln;
27
28pub type ReviewBriefOutput = fallow_output::StandardReviewBriefOutput;
29
30const RISK_HIGH_FILES: usize = 20;
32const RISK_HIGH_LINES: i64 = 500;
35const RISK_MEDIUM_FILES: usize = 5;
38const RISK_MEDIUM_LINES: i64 = 100;
41
42const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
44
45#[must_use]
47#[allow(
48 clippy::implicit_hasher,
49 reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
50)]
51pub fn build_review_deltas(
52 head_boundary: &FxHashSet<String>,
53 base_boundary: &FxHashSet<String>,
54 head_cycles: &FxHashSet<String>,
55 base_cycles: &FxHashSet<String>,
56 head_public_api: &FxHashSet<String>,
57 base_public_api: &FxHashSet<String>,
58) -> ReviewDeltas {
59 use crate::audit::review_deltas::introduced_keys;
60 ReviewDeltas {
61 boundary_introduced: introduced_keys(head_boundary, base_boundary),
62 cycle_introduced: introduced_keys(head_cycles, base_cycles),
63 public_api_added: introduced_keys(head_public_api, base_public_api),
64 }
65}
66
67#[must_use]
70pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
71 let lines = net_lines.unwrap_or(0).abs();
72 if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
73 RiskClass::High
74 } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
75 RiskClass::Medium
76 } else {
77 RiskClass::Low
78 }
79}
80
81#[must_use]
83pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
84 match risk {
85 RiskClass::Low => ReviewEffort::Glance,
86 RiskClass::Medium => ReviewEffort::Review,
87 RiskClass::High => ReviewEffort::DeepDive,
88 }
89}
90
91#[must_use]
93pub fn build_triage(
94 result: &AuditResult,
95 diff_index: Option<&fallow_output::DiffIndex>,
96) -> DiffTriage {
97 let files = result.changed_files_count;
98 let hunks = diff_index.map(fallow_output::DiffIndex::hunk_count);
99 let net_lines = diff_index.map(fallow_output::DiffIndex::net_lines);
100 let risk_class = classify_risk(files, net_lines);
101 DiffTriage {
102 files,
103 hunks,
104 net_lines,
105 risk_class,
106 review_effort: review_effort_for(risk_class),
107 }
108}
109
110#[must_use]
118pub fn derive_graph_facts(
119 results: &AnalysisResults,
120 closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
121) -> GraphFacts {
122 let mut zones: FxHashSet<String> = FxHashSet::default();
123 for finding in &results.boundary_violations {
124 zones.insert(finding.violation.from_zone.clone());
125 zones.insert(finding.violation.to_zone.clone());
126 }
127 let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
128 boundaries_touched.sort();
129
130 let reachable_from = closure
131 .map(|c| c.affected_not_shown.clone())
132 .unwrap_or_default();
133
134 GraphFacts {
135 exports_added: 0,
136 api_width_delta: 0,
137 reachable_from,
138 boundaries_touched,
139 }
140}
141
142#[must_use]
146fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
147 let Some(closure) = result
148 .check
149 .as_ref()
150 .and_then(|c| c.impact_closure.as_ref())
151 else {
152 return ImpactClosureFacts::default();
153 };
154 let coordination_gap = closure
155 .coordination_gap
156 .iter()
157 .map(|gap| CoordinationGapFact {
158 changed_file: gap.changed_file.clone(),
159 consumer_file: gap.consumer_file.clone(),
160 consumed_symbols: gap.consumed_symbols.clone(),
161 note: COORDINATION_GAP_NOTE.to_string(),
162 })
163 .collect();
164 ImpactClosureFacts {
165 affected_not_shown: closure.affected_not_shown.clone(),
166 coordination_gap,
167 }
168}
169
170#[must_use]
174fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
175 let Some(partition) = result
176 .check
177 .as_ref()
178 .and_then(|c| c.partition_order.as_ref())
179 else {
180 return PartitionFacts::default();
181 };
182 let units = partition
183 .units
184 .iter()
185 .map(|unit| ReviewUnitFact {
186 module_dir: unit.module_dir.clone(),
187 files: unit.files.clone(),
188 })
189 .collect();
190 PartitionFacts {
191 units,
192 order: partition.order.clone(),
193 }
194}
195
196#[must_use]
209fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
210 use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
211
212 let Some(check) = result.check.as_ref() else {
213 return crate::audit_focus::FocusMap::default();
214 };
215 let Some(graph_facts) = check.focus_facts.as_ref() else {
216 return crate::audit_focus::FocusMap::default();
217 };
218 let root = &check.config.root;
219
220 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
223 let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
224 for finding in &check.results.boundary_violations {
225 let key = crate::audit::review_deltas::boundary_edge_key(finding);
226 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
227 continue;
228 }
229 boundary_files.push(BoundaryZoneFile {
230 from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
231 });
232 }
233
234 let coordination_changed_files: Vec<String> = check
237 .impact_closure
238 .as_ref()
239 .map(|c| {
240 let mut files: Vec<String> = c
241 .coordination_gap
242 .iter()
243 .map(|gap| gap.changed_file.clone())
244 .collect();
245 files.sort_unstable();
246 files.dedup();
247 files
248 })
249 .unwrap_or_default();
250
251 let taint_touched_files = taint_touched_files(result.check.as_ref());
256
257 let runtime_focus = build_runtime_focus(result, root);
261
262 build_focus_map(&FocusInputs {
263 graph_facts,
264 boundary_files: &boundary_files,
265 public_api_added: &deltas.public_api_added,
266 coordination_changed_files: &coordination_changed_files,
267 taint_touched_files: &taint_touched_files,
268 runtime: runtime_focus.as_ref(),
269 })
270}
271
272fn build_runtime_focus(
294 result: &AuditResult,
295 root: &std::path::Path,
296) -> Option<crate::audit_focus::RuntimeFocus> {
297 let report = result.health.as_ref()?.report.runtime_coverage.as_ref()?;
298
299 let hot_pairs: Vec<(String, u64)> = report
300 .hot_paths
301 .iter()
302 .map(|hot| {
303 (
304 crate::audit::keys::relative_key_path(&hot.path, root),
305 hot.invocations,
306 )
307 })
308 .collect();
309
310 let mut safe_to_delete: FxHashSet<String> = FxHashSet::default();
313 let mut other_verdict: FxHashSet<String> = FxHashSet::default();
314 for finding in &report.findings {
315 let file = crate::audit::keys::relative_key_path(&finding.path, root);
316 if matches!(
317 finding.verdict,
318 fallow_output::RuntimeCoverageVerdict::SafeToDelete
319 ) {
320 safe_to_delete.insert(file);
321 } else {
322 other_verdict.insert(file);
323 }
324 }
325
326 reconcile_runtime_focus(hot_pairs, &safe_to_delete, &other_verdict)
327}
328
329fn reconcile_runtime_focus(
337 hot_pairs: Vec<(String, u64)>,
338 safe_to_delete: &FxHashSet<String>,
339 other_verdict: &FxHashSet<String>,
340) -> Option<crate::audit_focus::RuntimeFocus> {
341 use crate::audit_focus::{RuntimeFocus, RuntimeHotFile};
342
343 let mut hot_by_file: rustc_hash::FxHashMap<String, u64> = rustc_hash::FxHashMap::default();
345 for (file, invocations) in hot_pairs {
346 let entry = hot_by_file.entry(file).or_insert(0);
347 *entry = (*entry).max(invocations);
348 }
349
350 let mut hot_files: Vec<RuntimeHotFile> = hot_by_file
351 .into_iter()
352 .map(|(file, invocations)| RuntimeHotFile { file, invocations })
353 .collect();
354 hot_files.sort_by(|a, b| a.file.cmp(&b.file));
355 let hot_set: FxHashSet<&str> = hot_files.iter().map(|hot| hot.file.as_str()).collect();
356
357 let mut cold_files: Vec<String> = safe_to_delete
358 .iter()
359 .filter(|file| !other_verdict.contains(*file) && !hot_set.contains(file.as_str()))
360 .cloned()
361 .collect();
362 cold_files.sort();
363
364 if hot_files.is_empty() && cold_files.is_empty() {
365 return None;
366 }
367 Some(RuntimeFocus {
368 hot_files,
369 cold_files,
370 })
371}
372
373fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
382 let Some(check) = check else {
383 return Vec::new();
384 };
385 let root = &check.config.root;
386 let mut touched: FxHashSet<String> = FxHashSet::default();
387 for finding in &check.results.security_findings {
388 touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
389 for hop in &finding.trace {
390 touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
391 }
392 }
393 let mut files: Vec<String> = touched.into_iter().collect();
394 files.sort();
395 files
396}
397
398#[must_use]
402pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
403 build_brief_output_with_diff(result, result.diff_index.as_ref())
404}
405
406#[must_use]
409pub fn build_brief_output_with_diff(
410 result: &AuditResult,
411 diff_index: Option<&fallow_output::DiffIndex>,
412) -> ReviewBriefOutput {
413 let triage = build_triage(result, diff_index);
414 let closure = result
415 .check
416 .as_ref()
417 .and_then(|c| c.impact_closure.as_ref());
418 let deltas = result.review_deltas.clone().unwrap_or_default();
419 let mut graph_facts = result.check.as_ref().map_or_else(
420 || GraphFacts {
421 exports_added: 0,
422 api_width_delta: 0,
423 reachable_from: Vec::new(),
424 boundaries_touched: Vec::new(),
425 },
426 |check| derive_graph_facts(&check.results, closure),
427 );
428 let added = deltas.public_api_added.len();
432 graph_facts.exports_added = added;
433 graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
434 let partition = build_partition_facts(result);
435 let impact_closure = build_impact_closure_facts(result);
436 let focus = build_focus_map(result, &deltas);
437 ReviewBriefOutput {
438 schema_version: ReviewBriefSchemaVersion::default(),
439 version: env!("CARGO_PKG_VERSION").to_string(),
440 command: "audit-brief".to_string(),
441 triage,
442 graph_facts,
443 partition,
444 impact_closure,
445 focus,
446 deltas,
447 weakening: result.weakening_signals.clone(),
448 routing: result.routing.clone().unwrap_or_default(),
449 decisions: result.decision_surface.clone().unwrap_or_default(),
450 }
451}
452
453fn build_brief_subtract_sections(
456 result: &AuditResult,
457) -> Result<ReviewBriefSubtractSections, ExitCode> {
458 let mut obj = serde_json::Map::new();
459 if let Some(ref check) = result.check {
460 crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
461 }
462 if let Some(ref dupes) = result.dupes {
463 crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
464 }
465 if let Some(ref health) = result.health {
466 crate::audit::insert_audit_health_json(&mut obj, result, health)?;
467 }
468 Ok(ReviewBriefSubtractSections {
469 dead_code: obj.remove("dead_code"),
470 duplication: obj.remove("duplication"),
471 complexity: obj.remove("complexity"),
472 })
473}
474
475fn build_brief_json(
479 result: &AuditResult,
480 diff_index: Option<&fallow_output::DiffIndex>,
481) -> Result<serde_json::Value, ExitCode> {
482 let brief = build_brief_output_with_diff(result, diff_index);
483 let audit_header =
484 fallow_api::build_review_brief_header(crate::audit::audit_json_header_input(result));
485 let subtract = build_brief_subtract_sections(result)?;
486 fallow_output::build_review_brief_json_output(brief, audit_header, subtract).map_err(|err| {
487 crate::error::emit_error(
488 &format!("JSON serialization error: {err}"),
489 2,
490 fallow_config::OutputFormat::Json,
491 )
492 })
493}
494
495fn print_brief_json(
498 result: &AuditResult,
499 diff_index: Option<&fallow_output::DiffIndex>,
500) -> ExitCode {
501 match build_brief_json(result, diff_index) {
502 Ok(output) => {
503 let Ok(output) = fallow_output::serialize_review_brief_json_output(
504 output,
505 crate::output_runtime::current_root_envelope_mode(),
506 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
507 ) else {
508 return ExitCode::SUCCESS;
509 };
510 let _ = crate::report::emit_json(&output, "audit-brief");
511 ExitCode::SUCCESS
512 }
513 Err(_) => ExitCode::SUCCESS,
514 }
515}
516
517fn print_brief_human(
521 result: &AuditResult,
522 diff_index: Option<&fallow_output::DiffIndex>,
523 quiet: bool,
524 explain: bool,
525 show_deprioritized: bool,
526) {
527 let brief = build_brief_output_with_diff(result, diff_index);
528
529 if !quiet {
530 eprintln!();
531 print_decision_surface_human(&brief.decisions);
533 eprintln!(
535 "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
536 result.changed_files_count,
537 crate::report::plural(result.changed_files_count),
538 result.base_ref,
539 risk_label(brief.triage.risk_class),
540 effort_label(brief.triage.review_effort),
541 );
542 if !brief.graph_facts.boundaries_touched.is_empty() {
543 eprintln!(
544 " boundaries touched: {}",
545 brief.graph_facts.boundaries_touched.join(", ")
546 );
547 }
548 print_partition_human(&brief.partition);
549 print_impact_closure_human(&brief.impact_closure);
550 print_focus_human(&brief.focus, show_deprioritized);
551 print_deltas_human(&brief.deltas);
552 print_weakening_human(&brief.weakening);
553 print_routing_human(&brief.routing);
554 }
555
556 crate::audit::print_audit_findings(result, quiet, explain, false);
560}
561
562fn print_partition_human(partition: &PartitionFacts) {
567 if partition.units.is_empty() {
568 return;
569 }
570 eprintln!(
571 " partition: {} unit{} (by module)",
572 partition.units.len(),
573 crate::report::plural(partition.units.len()),
574 );
575 if !partition.order.is_empty() {
576 let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
577 eprintln!(" review order: {}", labeled.join(" \u{2192} "));
578 }
579}
580
581fn unit_label(module_dir: &str) -> String {
584 if module_dir.is_empty() {
585 "<root>".to_string()
586 } else {
587 module_dir.to_string()
588 }
589}
590
591fn print_impact_closure_human(closure: &ImpactClosureFacts) {
595 if !closure.affected_not_shown.is_empty() {
596 eprintln!(
597 " impact closure: {} file{} affected beyond the diff",
598 closure.affected_not_shown.len(),
599 crate::report::plural(closure.affected_not_shown.len()),
600 );
601 }
602 for gap in &closure.coordination_gap {
603 eprintln!(
604 " coordination gap: {} consumes {} from {} (not in this diff)",
605 gap.consumer_file,
606 gap.consumed_symbols.join(", "),
607 gap.changed_file,
608 );
609 }
610}
611
612fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
618 if focus.total_units() == 0 {
619 return;
620 }
621 if !focus.review_here.is_empty() {
622 eprintln!(
623 " focus: {} unit{} to review here (of {} changed)",
624 focus.review_here.len(),
625 crate::report::plural(focus.review_here.len()),
626 focus.total_units(),
627 );
628 for unit in &focus.review_here {
629 eprintln!(
630 " [{}] {}: {}",
631 unit.label.token(),
632 unit.file,
633 unit.reason
634 );
635 for flag in &unit.confidence {
636 eprintln!(" confidence {}", flag.message());
637 }
638 }
639 }
640 if focus.deprioritized.is_empty() {
641 return;
642 }
643 if show_deprioritized {
644 eprintln!(" de-prioritized ({}):", focus.deprioritized.len());
645 for unit in &focus.deprioritized {
646 eprintln!(
647 " [{}] {}: {}",
648 unit.label.token(),
649 unit.file,
650 unit.reason
651 );
652 for flag in &unit.confidence {
653 eprintln!(" confidence {}", flag.message());
654 }
655 }
656 } else {
657 eprintln!(
658 " de-prioritized: {} unit{} (run with --show-deprioritized to list)",
659 focus.deprioritized.len(),
660 crate::report::plural(focus.deprioritized.len()),
661 );
662 }
663}
664
665fn print_deltas_human(deltas: &ReviewDeltas) {
669 for edge in &deltas.boundary_introduced {
670 eprintln!(" new boundary edge: {edge} (not present at base)");
671 }
672 for cycle in &deltas.cycle_introduced {
673 eprintln!(" new circular dependency: {cycle} (not present at base)");
674 }
675 if !deltas.public_api_added.is_empty() {
676 eprintln!(
677 " public API surface widened by {} export{} (exports-aware)",
678 deltas.public_api_added.len(),
679 crate::report::plural(deltas.public_api_added.len()),
680 );
681 }
682}
683
684fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
686 if signals.is_empty() {
687 return;
688 }
689 eprintln!(
690 " weakening signals ({}, reviewer-private, advisory):",
691 signals.len()
692 );
693 for signal in signals {
694 eprintln!(
695 " {}: {} in {}",
696 weakening_label(signal.kind),
697 signal.evidence,
698 signal.file,
699 );
700 }
701}
702
703fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
705 for unit in &routing.units {
706 if unit.expert.is_empty() {
707 continue;
708 }
709 let bus = if unit.bus_factor_one {
710 " (bus-factor 1)"
711 } else {
712 ""
713 };
714 eprintln!(
715 " review {}: ask {}{bus}",
716 unit.file,
717 unit.expert.join(", "),
718 );
719 }
720}
721
722fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
726 if surface.decisions.is_empty() {
727 eprintln!("Decisions: none (no consequential structural decision in this change)");
728 eprintln!();
729 return;
730 }
731 eprintln!("Decisions to make ({}):", surface.decisions.len());
732 for (i, decision) in surface.decisions.iter().enumerate() {
733 eprintln!(
737 " {}. [{}] {}",
738 i + 1,
739 decision.category.tag(),
740 decision.question
741 );
742 if !decision.tradeoff.is_empty() {
743 eprintln!(" trade-off: {}", decision.tradeoff);
744 }
745 if !decision.expert.is_empty() {
746 let bus = if decision.bus_factor_one {
747 " (bus-factor 1)"
748 } else {
749 ""
750 };
751 eprintln!(" ask: {}{bus}", decision.expert.join(", "));
752 }
753 }
754 if let Some(note) = &surface.truncated {
755 eprintln!(" ... {}", note.reason);
756 }
757 eprintln!();
758}
759
760fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
761 use crate::audit::weakening::WeakeningKind;
762 match kind {
763 WeakeningKind::TestWeakened => "test weakened",
764 WeakeningKind::ThresholdLowered => "threshold lowered",
765 WeakeningKind::SuppressionAdded => "suppression added",
766 WeakeningKind::SecurityCheckRemoved => "security check removed",
767 }
768}
769
770fn risk_label(risk: RiskClass) -> &'static str {
771 match risk {
772 RiskClass::Low => "low",
773 RiskClass::Medium => "medium",
774 RiskClass::High => "high",
775 }
776}
777
778fn effort_label(effort: ReviewEffort) -> &'static str {
779 match effort {
780 ReviewEffort::Glance => "glance",
781 ReviewEffort::Review => "review",
782 ReviewEffort::DeepDive => "deep-dive",
783 }
784}
785
786#[must_use]
797pub fn print_brief_result(
798 result: &AuditResult,
799 diff_index: Option<&fallow_output::DiffIndex>,
800 quiet: bool,
801 explain: bool,
802 show_deprioritized: bool,
803) -> ExitCode {
804 use fallow_config::OutputFormat;
805
806 match result.output {
807 OutputFormat::Json => print_brief_json(result, diff_index),
808 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
809 print_brief_human(result, diff_index, quiet, explain, show_deprioritized);
810 ExitCode::SUCCESS
811 }
812 _ => {
813 let _ = crate::audit::print_audit_result(result, quiet, explain);
817 ExitCode::SUCCESS
818 }
819 }
820}
821
822#[must_use]
829pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
830 use fallow_config::OutputFormat;
831
832 let surface = result.decision_surface.clone().unwrap_or_default();
833 match result.output {
834 OutputFormat::Json => {
835 let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
836 match fallow_output::serialize_decision_surface_json_output(
837 output,
838 crate::output_runtime::current_root_envelope_mode(),
839 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
840 ) {
841 Ok(value) => {
842 let _ = crate::report::emit_json(&value, "decision-surface");
843 ExitCode::SUCCESS
844 }
845 Err(_) => ExitCode::SUCCESS,
846 }
847 }
848 _ => {
849 if !quiet {
850 print_decision_surface_human(&surface);
851 }
852 ExitCode::SUCCESS
853 }
854 }
855}
856
857#[must_use]
864pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
865 let guide = crate::audit_walkthrough::build_guide_from_result(result);
866 if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
867 guide,
868 crate::output_runtime::current_root_envelope_mode(),
869 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
870 ) {
871 let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
872 }
873 ExitCode::SUCCESS
874}
875
876#[must_use]
886pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
887 let contents = std::fs::read_to_string(path).unwrap_or_default();
888 let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
889 let surface = result.decision_surface.clone().unwrap_or_default();
890 let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
891 let change_anchor_ids =
892 crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
893 let validation = crate::audit_walkthrough::validate_walkthrough(
894 &agent,
895 &surface,
896 &change_anchor_ids,
897 ¤t_hash,
898 );
899 if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
900 validation,
901 crate::output_runtime::current_root_envelope_mode(),
902 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
903 ) {
904 let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
905 }
906 ExitCode::SUCCESS
907}
908
909#[must_use]
929pub fn print_walkthrough_human_result(
930 result: &AuditResult,
931 root: &std::path::Path,
932 cache_dir: &std::path::Path,
933 mark_viewed: &[std::path::PathBuf],
934 show_cleared: bool,
935 quiet: bool,
936) -> ExitCode {
937 use fallow_config::OutputFormat;
938
939 if matches!(result.output, OutputFormat::Json) {
941 return print_walkthrough_guide_result(result);
942 }
943
944 let guide = crate::audit_walkthrough::build_guide_from_result(result);
945 record_walkthrough_marks(&guide, root, cache_dir, mark_viewed);
946
947 let viewed = crate::walkthrough_state::load_viewed_state(cache_dir);
952
953 if matches!(result.output, OutputFormat::Markdown) {
954 let viewed_files = crate::report::walkthrough_viewed_files(&guide, &viewed);
955 let markdown = fallow_api::build_walkthrough_markdown(&guide, root, &viewed_files);
956 outln!("{markdown}");
957 return ExitCode::SUCCESS;
958 }
959
960 let render = crate::report::build_walkthrough_human(&guide, &viewed, show_cleared);
961 if !quiet {
962 for line in &render.header {
963 eprintln!("{line}");
964 }
965 }
966 for line in &render.body {
967 outln!("{line}");
968 }
969 if !quiet {
970 eprintln!("{}", render.status);
971 }
972 ExitCode::SUCCESS
973}
974
975fn record_walkthrough_marks(
981 guide: &crate::audit_walkthrough::WalkthroughGuide,
982 root: &std::path::Path,
983 cache_dir: &std::path::Path,
984 mark_viewed: &[std::path::PathBuf],
985) {
986 if mark_viewed.is_empty() {
987 return;
988 }
989 let keys: Vec<String> = mark_viewed
990 .iter()
991 .map(|path| walkthrough_view_key(path, root))
992 .collect();
993 let _ = crate::walkthrough_state::mark_viewed(cache_dir, &keys, &guide.graph_snapshot_hash);
994}
995
996fn walkthrough_view_key(path: &std::path::Path, root: &std::path::Path) -> String {
999 let rel = path.strip_prefix(root).unwrap_or(path);
1000 rel.to_string_lossy().replace('\\', "/")
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use std::time::Duration;
1006
1007 use fallow_config::{AuditGate, OutputFormat};
1008 use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
1009 use rustc_hash::FxHashSet;
1010
1011 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
1012
1013 fn str_set(files: &[&str]) -> FxHashSet<String> {
1014 files.iter().map(|file| (*file).to_string()).collect()
1015 }
1016
1017 #[test]
1022 fn reconcile_runtime_focus_classifies_hot_cold_and_excludes_mixed() {
1023 let hot_pairs = vec![
1024 ("src/hot.ts".to_string(), 120),
1025 ("src/hot.ts".to_string(), 900), ("src/both.ts".to_string(), 300), ];
1028 let safe = str_set(&["src/cold.ts", "src/mixed.ts", "src/both.ts"]);
1029 let other = str_set(&["src/mixed.ts"]); let focus =
1031 super::reconcile_runtime_focus(hot_pairs, &safe, &other).expect("non-empty focus");
1032
1033 let hot: Vec<(&str, u64)> = focus
1035 .hot_files
1036 .iter()
1037 .map(|hot| (hot.file.as_str(), hot.invocations))
1038 .collect();
1039 assert_eq!(hot, vec![("src/both.ts", 300), ("src/hot.ts", 900)]);
1040
1041 assert_eq!(focus.cold_files, vec!["src/cold.ts".to_string()]);
1043 }
1044
1045 #[test]
1047 fn reconcile_runtime_focus_is_none_when_empty() {
1048 assert!(super::reconcile_runtime_focus(Vec::new(), &str_set(&[]), &str_set(&[])).is_none());
1049 }
1050
1051 use super::*;
1052
1053 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1054 AuditResult {
1055 verdict,
1056 summary: AuditSummary {
1057 dead_code_issues: 0,
1058 dead_code_has_errors: false,
1059 complexity_findings: 0,
1060 max_cyclomatic: None,
1061 duplication_clone_groups: 0,
1062 },
1063 attribution: AuditAttribution {
1064 gate: AuditGate::NewOnly,
1065 ..AuditAttribution::default()
1066 },
1067 base_snapshot: None,
1068 base_snapshot_skipped: false,
1069 changed_files_count: 0,
1070 changed_files: Vec::new(),
1071 base_ref: "origin/main".to_string(),
1072 base_description: None,
1073 head_sha: None,
1074 output,
1075 performance: false,
1076 check: None,
1077 dupes: None,
1078 health: None,
1079 elapsed: Duration::ZERO,
1080 review_deltas: None,
1081 weakening_signals: Vec::new(),
1082 routing: None,
1083 decision_surface: None,
1084 graph_snapshot_hash: None,
1085 change_anchors: Vec::new(),
1086 diff_index: None,
1087 }
1088 }
1089
1090 #[test]
1091 fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1092 let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1094 assert_eq!(
1095 print_brief_result(&human, None, true, false, false),
1096 ExitCode::SUCCESS
1097 );
1098
1099 let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1101 assert_eq!(
1102 print_brief_result(&json, None, true, false, false),
1103 ExitCode::SUCCESS
1104 );
1105 }
1106
1107 #[test]
1108 fn brief_json_validates_against_audit_brief_schema_variant() {
1109 let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1110 let value = fallow_output::serialize_review_brief_json_output(
1111 build_brief_json(&result, None).expect("brief json must build"),
1112 crate::output_runtime::current_root_envelope_mode(),
1113 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
1114 )
1115 .expect("brief json must serialize");
1116
1117 assert_eq!(value["kind"], "audit-brief");
1118 assert_eq!(value["command"], "audit-brief");
1119 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1120 }
1121
1122 #[test]
1123 fn brief_json_is_byte_identical_on_repeated_serialization() {
1124 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1127 let first = build_brief_json(&result, None).expect("first build");
1128 let second = build_brief_json(&result, None).expect("second build");
1129 let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1130 let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1131 assert_eq!(first_str, second_str);
1132 }
1133
1134 #[test]
1135 fn risk_class_thresholds_are_pure_functions_of_size() {
1136 assert_eq!(classify_risk(0, None), RiskClass::Low);
1137 assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1138 assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1139 assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1140 assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1141 }
1142
1143 #[test]
1144 fn triage_uses_supplied_diff_metrics_and_existing_risk_thresholds() {
1145 let mut result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1146 result.changed_files_count = 1;
1147 let mut diff = String::from(
1148 "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,100 @@\n",
1149 );
1150 for _ in 0..RISK_MEDIUM_LINES {
1151 diff.push_str("+added\n");
1152 }
1153 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1154 result.diff_index = Some(index);
1155
1156 let triage = build_brief_output(&result).triage;
1157
1158 assert_eq!(triage.hunks, Some(1));
1159 assert_eq!(triage.net_lines, Some(RISK_MEDIUM_LINES));
1160 assert_eq!(triage.risk_class, RiskClass::Medium);
1161 assert_eq!(triage.review_effort, ReviewEffort::Review);
1162 }
1163
1164 #[test]
1165 fn no_diff_brief_keeps_optional_triage_fields_absent() {
1166 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1167 let value = serde_json::to_value(build_brief_output_with_diff(&result, None))
1168 .expect("brief serializes");
1169
1170 assert!(value["triage"].get("hunks").is_none());
1171 assert!(value["triage"].get("net_lines").is_none());
1172 }
1173
1174 #[test]
1175 fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1176 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1179 let value = build_brief_json(&result, None).expect("brief json must build");
1180 assert!(value.get("impact_closure").is_some(), "{value}");
1181 assert_eq!(
1182 value["impact_closure"]["affected_not_shown"],
1183 serde_json::json!([])
1184 );
1185 assert_eq!(
1186 value["impact_closure"]["coordination_gap"],
1187 serde_json::json!([])
1188 );
1189 }
1190
1191 #[test]
1192 fn derive_graph_facts_populates_reachable_from_from_closure() {
1193 use fallow_engine::module_graph::{CoordinationGapPaths, ImpactClosurePaths};
1194 let results = AnalysisResults::default();
1195 let closure = ImpactClosurePaths {
1196 in_diff: vec!["src/core.ts".to_string()],
1197 affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1198 coordination_gap: vec![CoordinationGapPaths {
1199 changed_file: "src/core.ts".to_string(),
1200 consumer_file: "src/mid.ts".to_string(),
1201 consumed_symbols: vec!["compute".to_string()],
1202 }],
1203 };
1204 let facts = derive_graph_facts(&results, Some(&closure));
1205 assert_eq!(
1206 facts.reachable_from,
1207 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1208 );
1209 }
1210
1211 #[test]
1212 fn coordination_gap_fact_carries_honest_scope_note() {
1213 let gap = CoordinationGapFact {
1214 changed_file: "src/core.ts".to_string(),
1215 consumer_file: "src/mid.ts".to_string(),
1216 consumed_symbols: vec!["compute".to_string()],
1217 note: COORDINATION_GAP_NOTE.to_string(),
1218 };
1219 assert!(gap.note.contains("attention pointer"));
1220 assert!(gap.note.contains("not a correctness proof"));
1221 }
1222}