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;
37const RISK_MEDIUM_FILES: usize = 5;
40const RISK_MEDIUM_LINES: i64 = 100;
43
44const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
46
47#[must_use]
49#[allow(
50 clippy::implicit_hasher,
51 reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
52)]
53pub fn build_review_deltas(
54 head_boundary: &FxHashSet<String>,
55 base_boundary: &FxHashSet<String>,
56 head_cycles: &FxHashSet<String>,
57 base_cycles: &FxHashSet<String>,
58 head_public_api: &FxHashSet<String>,
59 base_public_api: &FxHashSet<String>,
60) -> ReviewDeltas {
61 use crate::audit::review_deltas::introduced_keys;
62 ReviewDeltas {
63 boundary_introduced: introduced_keys(head_boundary, base_boundary),
64 cycle_introduced: introduced_keys(head_cycles, base_cycles),
65 public_api_added: introduced_keys(head_public_api, base_public_api),
66 }
67}
68
69#[must_use]
72pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
73 let lines = net_lines.unwrap_or(0).abs();
74 if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
75 RiskClass::High
76 } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
77 RiskClass::Medium
78 } else {
79 RiskClass::Low
80 }
81}
82
83#[must_use]
85pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
86 match risk {
87 RiskClass::Low => ReviewEffort::Glance,
88 RiskClass::Medium => ReviewEffort::Review,
89 RiskClass::High => ReviewEffort::DeepDive,
90 }
91}
92
93#[must_use]
95pub fn build_triage(result: &AuditResult) -> DiffTriage {
96 let files = result.changed_files_count;
97 let hunks = None;
100 let net_lines = None;
101 let risk_class = classify_risk(files, net_lines);
102 DiffTriage {
103 files,
104 hunks,
105 net_lines,
106 risk_class,
107 review_effort: review_effort_for(risk_class),
108 }
109}
110
111#[must_use]
119pub fn derive_graph_facts(
120 results: &AnalysisResults,
121 closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
122) -> GraphFacts {
123 let mut zones: FxHashSet<String> = FxHashSet::default();
124 for finding in &results.boundary_violations {
125 zones.insert(finding.violation.from_zone.clone());
126 zones.insert(finding.violation.to_zone.clone());
127 }
128 let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
129 boundaries_touched.sort();
130
131 let reachable_from = closure
132 .map(|c| c.affected_not_shown.clone())
133 .unwrap_or_default();
134
135 GraphFacts {
136 exports_added: 0,
137 api_width_delta: 0,
138 reachable_from,
139 boundaries_touched,
140 }
141}
142
143#[must_use]
147fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
148 let Some(closure) = result
149 .check
150 .as_ref()
151 .and_then(|c| c.impact_closure.as_ref())
152 else {
153 return ImpactClosureFacts::default();
154 };
155 let coordination_gap = closure
156 .coordination_gap
157 .iter()
158 .map(|gap| CoordinationGapFact {
159 changed_file: gap.changed_file.clone(),
160 consumer_file: gap.consumer_file.clone(),
161 consumed_symbols: gap.consumed_symbols.clone(),
162 note: COORDINATION_GAP_NOTE.to_string(),
163 })
164 .collect();
165 ImpactClosureFacts {
166 affected_not_shown: closure.affected_not_shown.clone(),
167 coordination_gap,
168 }
169}
170
171#[must_use]
175fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
176 let Some(partition) = result
177 .check
178 .as_ref()
179 .and_then(|c| c.partition_order.as_ref())
180 else {
181 return PartitionFacts::default();
182 };
183 let units = partition
184 .units
185 .iter()
186 .map(|unit| ReviewUnitFact {
187 module_dir: unit.module_dir.clone(),
188 files: unit.files.clone(),
189 })
190 .collect();
191 PartitionFacts {
192 units,
193 order: partition.order.clone(),
194 }
195}
196
197#[must_use]
210fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
211 use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
212
213 let Some(check) = result.check.as_ref() else {
214 return crate::audit_focus::FocusMap::default();
215 };
216 let Some(graph_facts) = check.focus_facts.as_ref() else {
217 return crate::audit_focus::FocusMap::default();
218 };
219 let root = &check.config.root;
220
221 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
224 let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
225 for finding in &check.results.boundary_violations {
226 let key = crate::audit::review_deltas::boundary_edge_key(finding);
227 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
228 continue;
229 }
230 boundary_files.push(BoundaryZoneFile {
231 from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
232 });
233 }
234
235 let coordination_changed_files: Vec<String> = check
238 .impact_closure
239 .as_ref()
240 .map(|c| {
241 let mut files: Vec<String> = c
242 .coordination_gap
243 .iter()
244 .map(|gap| gap.changed_file.clone())
245 .collect();
246 files.sort_unstable();
247 files.dedup();
248 files
249 })
250 .unwrap_or_default();
251
252 let taint_touched_files = taint_touched_files(result.check.as_ref());
257
258 let runtime_focus = build_runtime_focus(result, root);
262
263 build_focus_map(&FocusInputs {
264 graph_facts,
265 boundary_files: &boundary_files,
266 public_api_added: &deltas.public_api_added,
267 coordination_changed_files: &coordination_changed_files,
268 taint_touched_files: &taint_touched_files,
269 runtime: runtime_focus.as_ref(),
270 })
271}
272
273fn build_runtime_focus(
295 result: &AuditResult,
296 root: &std::path::Path,
297) -> Option<crate::audit_focus::RuntimeFocus> {
298 let report = result.health.as_ref()?.report.runtime_coverage.as_ref()?;
299
300 let hot_pairs: Vec<(String, u64)> = report
301 .hot_paths
302 .iter()
303 .map(|hot| {
304 (
305 crate::audit::keys::relative_key_path(&hot.path, root),
306 hot.invocations,
307 )
308 })
309 .collect();
310
311 let mut safe_to_delete: FxHashSet<String> = FxHashSet::default();
314 let mut other_verdict: FxHashSet<String> = FxHashSet::default();
315 for finding in &report.findings {
316 let file = crate::audit::keys::relative_key_path(&finding.path, root);
317 if matches!(
318 finding.verdict,
319 fallow_output::RuntimeCoverageVerdict::SafeToDelete
320 ) {
321 safe_to_delete.insert(file);
322 } else {
323 other_verdict.insert(file);
324 }
325 }
326
327 reconcile_runtime_focus(hot_pairs, &safe_to_delete, &other_verdict)
328}
329
330fn reconcile_runtime_focus(
338 hot_pairs: Vec<(String, u64)>,
339 safe_to_delete: &FxHashSet<String>,
340 other_verdict: &FxHashSet<String>,
341) -> Option<crate::audit_focus::RuntimeFocus> {
342 use crate::audit_focus::{RuntimeFocus, RuntimeHotFile};
343
344 let mut hot_by_file: rustc_hash::FxHashMap<String, u64> = rustc_hash::FxHashMap::default();
346 for (file, invocations) in hot_pairs {
347 let entry = hot_by_file.entry(file).or_insert(0);
348 *entry = (*entry).max(invocations);
349 }
350
351 let mut hot_files: Vec<RuntimeHotFile> = hot_by_file
352 .into_iter()
353 .map(|(file, invocations)| RuntimeHotFile { file, invocations })
354 .collect();
355 hot_files.sort_by(|a, b| a.file.cmp(&b.file));
356 let hot_set: FxHashSet<&str> = hot_files.iter().map(|hot| hot.file.as_str()).collect();
357
358 let mut cold_files: Vec<String> = safe_to_delete
359 .iter()
360 .filter(|file| !other_verdict.contains(*file) && !hot_set.contains(file.as_str()))
361 .cloned()
362 .collect();
363 cold_files.sort();
364
365 if hot_files.is_empty() && cold_files.is_empty() {
366 return None;
367 }
368 Some(RuntimeFocus {
369 hot_files,
370 cold_files,
371 })
372}
373
374fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
383 let Some(check) = check else {
384 return Vec::new();
385 };
386 let root = &check.config.root;
387 let mut touched: FxHashSet<String> = FxHashSet::default();
388 for finding in &check.results.security_findings {
389 touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
390 for hop in &finding.trace {
391 touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
392 }
393 }
394 let mut files: Vec<String> = touched.into_iter().collect();
395 files.sort();
396 files
397}
398
399#[must_use]
403pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
404 let triage = build_triage(result);
405 let closure = result
406 .check
407 .as_ref()
408 .and_then(|c| c.impact_closure.as_ref());
409 let deltas = result.review_deltas.clone().unwrap_or_default();
410 let mut graph_facts = result.check.as_ref().map_or_else(
411 || GraphFacts {
412 exports_added: 0,
413 api_width_delta: 0,
414 reachable_from: Vec::new(),
415 boundaries_touched: Vec::new(),
416 },
417 |check| derive_graph_facts(&check.results, closure),
418 );
419 let added = deltas.public_api_added.len();
423 graph_facts.exports_added = added;
424 graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
425 let partition = build_partition_facts(result);
426 let impact_closure = build_impact_closure_facts(result);
427 let focus = build_focus_map(result, &deltas);
428 ReviewBriefOutput {
429 schema_version: ReviewBriefSchemaVersion::default(),
430 version: env!("CARGO_PKG_VERSION").to_string(),
431 command: "audit-brief".to_string(),
432 triage,
433 graph_facts,
434 partition,
435 impact_closure,
436 focus,
437 deltas,
438 weakening: result.weakening_signals.clone(),
439 routing: result.routing.clone().unwrap_or_default(),
440 decisions: result.decision_surface.clone().unwrap_or_default(),
441 }
442}
443
444fn build_brief_subtract_sections(
447 result: &AuditResult,
448) -> Result<ReviewBriefSubtractSections, ExitCode> {
449 let mut obj = serde_json::Map::new();
450 if let Some(ref check) = result.check {
451 crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
452 }
453 if let Some(ref dupes) = result.dupes {
454 crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
455 }
456 if let Some(ref health) = result.health {
457 crate::audit::insert_audit_health_json(&mut obj, result, health)?;
458 }
459 Ok(ReviewBriefSubtractSections {
460 dead_code: obj.remove("dead_code"),
461 duplication: obj.remove("duplication"),
462 complexity: obj.remove("complexity"),
463 })
464}
465
466fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
470 let brief = build_brief_output(result);
471 let audit_header = fallow_api::build_audit_header_map(crate::audit::audit_json_header_input(
472 result,
473 ))
474 .map_err(|err| {
475 crate::error::emit_error(
476 &format!("JSON serialization error: {err}"),
477 2,
478 fallow_config::OutputFormat::Json,
479 )
480 })?;
481 let subtract = build_brief_subtract_sections(result)?;
482 fallow_output::build_review_brief_json_output(&brief, audit_header, subtract).map_err(|err| {
483 crate::error::emit_error(
484 &format!("JSON serialization error: {err}"),
485 2,
486 fallow_config::OutputFormat::Json,
487 )
488 })
489}
490
491fn print_brief_json(result: &AuditResult) -> ExitCode {
494 match build_brief_json(result) {
495 Ok(output) => {
496 let Ok(output) = fallow_output::serialize_review_brief_json_output(
497 output,
498 crate::output_runtime::current_root_envelope_mode(),
499 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
500 ) else {
501 return ExitCode::SUCCESS;
502 };
503 let _ = crate::report::emit_json(&output, "audit-brief");
504 ExitCode::SUCCESS
505 }
506 Err(_) => ExitCode::SUCCESS,
507 }
508}
509
510fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
514 let brief = build_brief_output(result);
515
516 if !quiet {
517 eprintln!();
518 print_decision_surface_human(&brief.decisions);
520 eprintln!(
522 "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
523 result.changed_files_count,
524 crate::report::plural(result.changed_files_count),
525 result.base_ref,
526 risk_label(brief.triage.risk_class),
527 effort_label(brief.triage.review_effort),
528 );
529 if !brief.graph_facts.boundaries_touched.is_empty() {
530 eprintln!(
531 " boundaries touched: {}",
532 brief.graph_facts.boundaries_touched.join(", ")
533 );
534 }
535 print_partition_human(&brief.partition);
536 print_impact_closure_human(&brief.impact_closure);
537 print_focus_human(&brief.focus, show_deprioritized);
538 print_deltas_human(&brief.deltas);
539 print_weakening_human(&brief.weakening);
540 print_routing_human(&brief.routing);
541 }
542
543 crate::audit::print_audit_findings(result, quiet, explain, false);
547}
548
549fn print_partition_human(partition: &PartitionFacts) {
554 if partition.units.is_empty() {
555 return;
556 }
557 eprintln!(
558 " partition: {} unit{} (by module)",
559 partition.units.len(),
560 crate::report::plural(partition.units.len()),
561 );
562 if !partition.order.is_empty() {
563 let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
564 eprintln!(" review order: {}", labeled.join(" \u{2192} "));
565 }
566}
567
568fn unit_label(module_dir: &str) -> String {
571 if module_dir.is_empty() {
572 "<root>".to_string()
573 } else {
574 module_dir.to_string()
575 }
576}
577
578fn print_impact_closure_human(closure: &ImpactClosureFacts) {
582 if !closure.affected_not_shown.is_empty() {
583 eprintln!(
584 " impact closure: {} file{} affected beyond the diff",
585 closure.affected_not_shown.len(),
586 crate::report::plural(closure.affected_not_shown.len()),
587 );
588 }
589 for gap in &closure.coordination_gap {
590 eprintln!(
591 " coordination gap: {} consumes {} from {} (not in this diff)",
592 gap.consumer_file,
593 gap.consumed_symbols.join(", "),
594 gap.changed_file,
595 );
596 }
597}
598
599fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
605 if focus.total_units() == 0 {
606 return;
607 }
608 if !focus.review_here.is_empty() {
609 eprintln!(
610 " focus: {} unit{} to review here (of {} changed)",
611 focus.review_here.len(),
612 crate::report::plural(focus.review_here.len()),
613 focus.total_units(),
614 );
615 for unit in &focus.review_here {
616 eprintln!(
617 " [{}] {}: {}",
618 unit.label.token(),
619 unit.file,
620 unit.reason
621 );
622 for flag in &unit.confidence {
623 eprintln!(" confidence {}", flag.message());
624 }
625 }
626 }
627 if focus.deprioritized.is_empty() {
628 return;
629 }
630 if show_deprioritized {
631 eprintln!(" de-prioritized ({}):", focus.deprioritized.len());
632 for unit in &focus.deprioritized {
633 eprintln!(
634 " [{}] {}: {}",
635 unit.label.token(),
636 unit.file,
637 unit.reason
638 );
639 for flag in &unit.confidence {
640 eprintln!(" confidence {}", flag.message());
641 }
642 }
643 } else {
644 eprintln!(
645 " de-prioritized: {} unit{} (run with --show-deprioritized to list)",
646 focus.deprioritized.len(),
647 crate::report::plural(focus.deprioritized.len()),
648 );
649 }
650}
651
652fn print_deltas_human(deltas: &ReviewDeltas) {
656 for edge in &deltas.boundary_introduced {
657 eprintln!(" new boundary edge: {edge} (not present at base)");
658 }
659 for cycle in &deltas.cycle_introduced {
660 eprintln!(" new circular dependency: {cycle} (not present at base)");
661 }
662 if !deltas.public_api_added.is_empty() {
663 eprintln!(
664 " public API surface widened by {} export{} (exports-aware)",
665 deltas.public_api_added.len(),
666 crate::report::plural(deltas.public_api_added.len()),
667 );
668 }
669}
670
671fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
673 if signals.is_empty() {
674 return;
675 }
676 eprintln!(
677 " weakening signals ({}, reviewer-private, advisory):",
678 signals.len()
679 );
680 for signal in signals {
681 eprintln!(
682 " {}: {} in {}",
683 weakening_label(signal.kind),
684 signal.evidence,
685 signal.file,
686 );
687 }
688}
689
690fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
692 for unit in &routing.units {
693 if unit.expert.is_empty() {
694 continue;
695 }
696 let bus = if unit.bus_factor_one {
697 " (bus-factor 1)"
698 } else {
699 ""
700 };
701 eprintln!(
702 " review {}: ask {}{bus}",
703 unit.file,
704 unit.expert.join(", "),
705 );
706 }
707}
708
709fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
713 if surface.decisions.is_empty() {
714 eprintln!("Decisions: none (no consequential structural decision in this change)");
715 eprintln!();
716 return;
717 }
718 eprintln!("Decisions to make ({}):", surface.decisions.len());
719 for (i, decision) in surface.decisions.iter().enumerate() {
720 eprintln!(
724 " {}. [{}] {}",
725 i + 1,
726 decision.category.tag(),
727 decision.question
728 );
729 if !decision.tradeoff.is_empty() {
730 eprintln!(" trade-off: {}", decision.tradeoff);
731 }
732 if !decision.expert.is_empty() {
733 let bus = if decision.bus_factor_one {
734 " (bus-factor 1)"
735 } else {
736 ""
737 };
738 eprintln!(" ask: {}{bus}", decision.expert.join(", "));
739 }
740 }
741 if let Some(note) = &surface.truncated {
742 eprintln!(" ... {}", note.reason);
743 }
744 eprintln!();
745}
746
747fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
748 use crate::audit::weakening::WeakeningKind;
749 match kind {
750 WeakeningKind::TestWeakened => "test weakened",
751 WeakeningKind::ThresholdLowered => "threshold lowered",
752 WeakeningKind::SuppressionAdded => "suppression added",
753 WeakeningKind::SecurityCheckRemoved => "security check removed",
754 }
755}
756
757fn risk_label(risk: RiskClass) -> &'static str {
758 match risk {
759 RiskClass::Low => "low",
760 RiskClass::Medium => "medium",
761 RiskClass::High => "high",
762 }
763}
764
765fn effort_label(effort: ReviewEffort) -> &'static str {
766 match effort {
767 ReviewEffort::Glance => "glance",
768 ReviewEffort::Review => "review",
769 ReviewEffort::DeepDive => "deep-dive",
770 }
771}
772
773#[must_use]
784pub fn print_brief_result(
785 result: &AuditResult,
786 quiet: bool,
787 explain: bool,
788 show_deprioritized: bool,
789) -> ExitCode {
790 use fallow_config::OutputFormat;
791
792 match result.output {
793 OutputFormat::Json => print_brief_json(result),
794 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
795 print_brief_human(result, quiet, explain, show_deprioritized);
796 ExitCode::SUCCESS
797 }
798 _ => {
799 let _ = crate::audit::print_audit_result(result, quiet, explain);
803 ExitCode::SUCCESS
804 }
805 }
806}
807
808#[must_use]
815pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
816 use fallow_config::OutputFormat;
817
818 let surface = result.decision_surface.clone().unwrap_or_default();
819 match result.output {
820 OutputFormat::Json => {
821 let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
822 match fallow_output::serialize_decision_surface_json_output(
823 output,
824 crate::output_runtime::current_root_envelope_mode(),
825 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
826 ) {
827 Ok(value) => {
828 let _ = crate::report::emit_json(&value, "decision-surface");
829 ExitCode::SUCCESS
830 }
831 Err(_) => ExitCode::SUCCESS,
832 }
833 }
834 _ => {
835 if !quiet {
836 print_decision_surface_human(&surface);
837 }
838 ExitCode::SUCCESS
839 }
840 }
841}
842
843#[must_use]
850pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
851 let guide = crate::audit_walkthrough::build_guide_from_result(result);
852 if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
853 guide,
854 crate::output_runtime::current_root_envelope_mode(),
855 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
856 ) {
857 let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
858 }
859 ExitCode::SUCCESS
860}
861
862#[must_use]
872pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
873 let contents = std::fs::read_to_string(path).unwrap_or_default();
874 let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
875 let surface = result.decision_surface.clone().unwrap_or_default();
876 let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
877 let change_anchor_ids =
878 crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
879 let validation = crate::audit_walkthrough::validate_walkthrough(
880 &agent,
881 &surface,
882 &change_anchor_ids,
883 ¤t_hash,
884 );
885 if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
886 validation,
887 crate::output_runtime::current_root_envelope_mode(),
888 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
889 ) {
890 let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
891 }
892 ExitCode::SUCCESS
893}
894
895#[must_use]
915pub fn print_walkthrough_human_result(
916 result: &AuditResult,
917 root: &std::path::Path,
918 cache_dir: &std::path::Path,
919 mark_viewed: &[std::path::PathBuf],
920 show_cleared: bool,
921 quiet: bool,
922) -> ExitCode {
923 use fallow_config::OutputFormat;
924
925 if matches!(result.output, OutputFormat::Json) {
927 return print_walkthrough_guide_result(result);
928 }
929
930 let guide = crate::audit_walkthrough::build_guide_from_result(result);
931 record_walkthrough_marks(&guide, root, cache_dir, mark_viewed);
932
933 let viewed = crate::walkthrough_state::load_viewed_state(cache_dir);
938
939 if matches!(result.output, OutputFormat::Markdown) {
940 let viewed_files = crate::report::walkthrough_viewed_files(&guide, &viewed);
941 let markdown = fallow_api::build_walkthrough_markdown(&guide, root, &viewed_files);
942 outln!("{markdown}");
943 return ExitCode::SUCCESS;
944 }
945
946 let render = crate::report::build_walkthrough_human(&guide, &viewed, show_cleared);
947 if !quiet {
948 for line in &render.header {
949 eprintln!("{line}");
950 }
951 }
952 for line in &render.body {
953 outln!("{line}");
954 }
955 if !quiet {
956 eprintln!("{}", render.status);
957 }
958 ExitCode::SUCCESS
959}
960
961fn record_walkthrough_marks(
967 guide: &crate::audit_walkthrough::WalkthroughGuide,
968 root: &std::path::Path,
969 cache_dir: &std::path::Path,
970 mark_viewed: &[std::path::PathBuf],
971) {
972 if mark_viewed.is_empty() {
973 return;
974 }
975 let keys: Vec<String> = mark_viewed
976 .iter()
977 .map(|path| walkthrough_view_key(path, root))
978 .collect();
979 let _ = crate::walkthrough_state::mark_viewed(cache_dir, &keys, &guide.graph_snapshot_hash);
980}
981
982fn walkthrough_view_key(path: &std::path::Path, root: &std::path::Path) -> String {
985 let rel = path.strip_prefix(root).unwrap_or(path);
986 rel.to_string_lossy().replace('\\', "/")
987}
988
989#[cfg(test)]
990mod tests {
991 use std::time::Duration;
992
993 use fallow_config::{AuditGate, OutputFormat};
994 use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
995 use rustc_hash::FxHashSet;
996
997 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
998
999 fn str_set(files: &[&str]) -> FxHashSet<String> {
1000 files.iter().map(|file| (*file).to_string()).collect()
1001 }
1002
1003 #[test]
1008 fn reconcile_runtime_focus_classifies_hot_cold_and_excludes_mixed() {
1009 let hot_pairs = vec![
1010 ("src/hot.ts".to_string(), 120),
1011 ("src/hot.ts".to_string(), 900), ("src/both.ts".to_string(), 300), ];
1014 let safe = str_set(&["src/cold.ts", "src/mixed.ts", "src/both.ts"]);
1015 let other = str_set(&["src/mixed.ts"]); let focus =
1017 super::reconcile_runtime_focus(hot_pairs, &safe, &other).expect("non-empty focus");
1018
1019 let hot: Vec<(&str, u64)> = focus
1021 .hot_files
1022 .iter()
1023 .map(|hot| (hot.file.as_str(), hot.invocations))
1024 .collect();
1025 assert_eq!(hot, vec![("src/both.ts", 300), ("src/hot.ts", 900)]);
1026
1027 assert_eq!(focus.cold_files, vec!["src/cold.ts".to_string()]);
1029 }
1030
1031 #[test]
1033 fn reconcile_runtime_focus_is_none_when_empty() {
1034 assert!(super::reconcile_runtime_focus(Vec::new(), &str_set(&[]), &str_set(&[])).is_none());
1035 }
1036
1037 use super::*;
1038
1039 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1040 AuditResult {
1041 verdict,
1042 summary: AuditSummary {
1043 dead_code_issues: 0,
1044 dead_code_has_errors: false,
1045 complexity_findings: 0,
1046 max_cyclomatic: None,
1047 duplication_clone_groups: 0,
1048 },
1049 attribution: AuditAttribution {
1050 gate: AuditGate::NewOnly,
1051 ..AuditAttribution::default()
1052 },
1053 base_snapshot: None,
1054 base_snapshot_skipped: false,
1055 changed_files_count: 0,
1056 changed_files: Vec::new(),
1057 base_ref: "origin/main".to_string(),
1058 base_description: None,
1059 head_sha: None,
1060 output,
1061 performance: false,
1062 check: None,
1063 dupes: None,
1064 health: None,
1065 elapsed: Duration::ZERO,
1066 review_deltas: None,
1067 weakening_signals: Vec::new(),
1068 routing: None,
1069 decision_surface: None,
1070 graph_snapshot_hash: None,
1071 change_anchors: Vec::new(),
1072 }
1073 }
1074
1075 #[test]
1076 fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1077 let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1079 assert_eq!(
1080 print_brief_result(&human, true, false, false),
1081 ExitCode::SUCCESS
1082 );
1083
1084 let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1086 assert_eq!(
1087 print_brief_result(&json, true, false, false),
1088 ExitCode::SUCCESS
1089 );
1090 }
1091
1092 #[test]
1093 fn brief_json_validates_against_audit_brief_schema_variant() {
1094 let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1095 let value = fallow_output::serialize_review_brief_json_output(
1096 build_brief_json(&result).expect("brief json must build"),
1097 crate::output_runtime::current_root_envelope_mode(),
1098 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
1099 )
1100 .expect("brief json must serialize");
1101
1102 assert_eq!(value["kind"], "audit-brief");
1103 assert_eq!(value["command"], "audit-brief");
1104 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1105 }
1106
1107 #[test]
1108 fn brief_json_is_byte_identical_on_repeated_serialization() {
1109 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1112 let first = build_brief_json(&result).expect("first build");
1113 let second = build_brief_json(&result).expect("second build");
1114 let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1115 let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1116 assert_eq!(first_str, second_str);
1117 }
1118
1119 #[test]
1120 fn risk_class_thresholds_are_pure_functions_of_size() {
1121 assert_eq!(classify_risk(0, None), RiskClass::Low);
1122 assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1123 assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1124 assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1125 assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1126 }
1127
1128 #[test]
1129 fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1130 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1133 let value = build_brief_json(&result).expect("brief json must build");
1134 assert!(value.get("impact_closure").is_some(), "{value}");
1135 assert_eq!(
1136 value["impact_closure"]["affected_not_shown"],
1137 serde_json::json!([])
1138 );
1139 assert_eq!(
1140 value["impact_closure"]["coordination_gap"],
1141 serde_json::json!([])
1142 );
1143 }
1144
1145 #[test]
1146 fn derive_graph_facts_populates_reachable_from_from_closure() {
1147 use fallow_engine::module_graph::{CoordinationGapPaths, ImpactClosurePaths};
1148 let results = AnalysisResults::default();
1149 let closure = ImpactClosurePaths {
1150 in_diff: vec!["src/core.ts".to_string()],
1151 affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1152 coordination_gap: vec![CoordinationGapPaths {
1153 changed_file: "src/core.ts".to_string(),
1154 consumer_file: "src/mid.ts".to_string(),
1155 consumed_symbols: vec!["compute".to_string()],
1156 }],
1157 };
1158 let facts = derive_graph_facts(&results, Some(&closure));
1159 assert_eq!(
1160 facts.reachable_from,
1161 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1162 );
1163 }
1164
1165 #[test]
1166 fn coordination_gap_fact_carries_honest_scope_note() {
1167 let gap = CoordinationGapFact {
1168 changed_file: "src/core.ts".to_string(),
1169 consumer_file: "src/mid.ts".to_string(),
1170 consumed_symbols: vec!["compute".to_string()],
1171 note: COORDINATION_GAP_NOTE.to_string(),
1172 };
1173 assert!(gap.note.contains("attention pointer"));
1174 assert!(gap.note.contains("not a correctness proof"));
1175 }
1176}