1use std::process::ExitCode;
17
18use fallow_types::results::AnalysisResults;
19use rustc_hash::FxHashSet;
20use serde::Serialize;
21
22use crate::audit::AuditResult;
23
24pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 5;
36
37const RISK_HIGH_FILES: usize = 20;
39const RISK_HIGH_LINES: i64 = 500;
44const RISK_MEDIUM_FILES: usize = 5;
47const RISK_MEDIUM_LINES: i64 = 100;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub struct ReviewBriefSchemaVersion(pub u32);
56
57impl Default for ReviewBriefSchemaVersion {
58 fn default() -> Self {
59 Self(REVIEW_BRIEF_SCHEMA_VERSION)
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(rename_all = "snake_case")]
68pub enum RiskClass {
69 Low,
71 Medium,
73 High,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[serde(rename_all = "snake_case")]
81pub enum ReviewEffort {
82 Glance,
84 Review,
86 DeepDive,
88}
89
90#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct DiffTriage {
98 pub files: usize,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub hunks: Option<usize>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub net_lines: Option<i64>,
106 pub risk_class: RiskClass,
108 pub review_effort: ReviewEffort,
110}
111
112#[derive(Debug, Clone, Serialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122pub struct GraphFacts {
123 pub exports_added: usize,
125 pub api_width_delta: i64,
128 pub reachable_from: Vec<String>,
132 pub boundaries_touched: Vec<String>,
135}
136
137#[derive(Debug, Clone, Default, Serialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145pub struct ImpactClosureFacts {
146 pub affected_not_shown: Vec<String>,
149 pub coordination_gap: Vec<CoordinationGapFact>,
152}
153
154#[derive(Debug, Clone, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct CoordinationGapFact {
160 pub changed_file: String,
162 pub consumer_file: String,
164 pub consumed_symbols: Vec<String>,
166 pub note: String,
168}
169
170const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
172
173#[derive(Debug, Clone, Default, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct PartitionFacts {
185 pub units: Vec<ReviewUnitFact>,
188 pub order: Vec<String>,
192}
193
194#[derive(Debug, Clone, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct ReviewUnitFact {
198 pub module_dir: String,
201 pub files: Vec<String>,
203}
204
205#[derive(Debug, Clone, Default, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct ReviewDeltas {
215 pub boundary_introduced: Vec<String>,
218 pub cycle_introduced: Vec<String>,
220 pub public_api_added: Vec<String>,
226}
227
228#[must_use]
230#[allow(
231 clippy::implicit_hasher,
232 reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
233)]
234pub fn build_review_deltas(
235 head_boundary: &FxHashSet<String>,
236 base_boundary: &FxHashSet<String>,
237 head_cycles: &FxHashSet<String>,
238 base_cycles: &FxHashSet<String>,
239 head_public_api: &FxHashSet<String>,
240 base_public_api: &FxHashSet<String>,
241) -> ReviewDeltas {
242 use crate::audit::review_deltas::introduced_keys;
243 ReviewDeltas {
244 boundary_introduced: introduced_keys(head_boundary, base_boundary),
245 cycle_introduced: introduced_keys(head_cycles, base_cycles),
246 public_api_added: introduced_keys(head_public_api, base_public_api),
247 }
248}
249
250#[derive(Debug, Clone, Serialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256#[cfg_attr(
257 feature = "schema",
258 schemars(title = "fallow audit --brief --format json")
259)]
260pub struct ReviewBriefOutput {
261 pub schema_version: ReviewBriefSchemaVersion,
263 pub version: String,
265 pub command: String,
267 pub triage: DiffTriage,
269 pub graph_facts: GraphFacts,
271 pub partition: PartitionFacts,
274 pub impact_closure: ImpactClosureFacts,
276 pub focus: crate::audit_focus::FocusMap,
283 pub deltas: ReviewDeltas,
286 pub weakening: Vec<crate::audit::weakening::WeakeningSignal>,
290 pub routing: crate::audit::routing::RoutingFacts,
292 pub decisions: crate::audit_decision_surface::DecisionSurface,
297}
298
299#[must_use]
302pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
303 let lines = net_lines.unwrap_or(0).abs();
304 if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
305 RiskClass::High
306 } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
307 RiskClass::Medium
308 } else {
309 RiskClass::Low
310 }
311}
312
313#[must_use]
315pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
316 match risk {
317 RiskClass::Low => ReviewEffort::Glance,
318 RiskClass::Medium => ReviewEffort::Review,
319 RiskClass::High => ReviewEffort::DeepDive,
320 }
321}
322
323#[must_use]
325pub fn build_triage(result: &AuditResult) -> DiffTriage {
326 let files = result.changed_files_count;
327 let hunks = None;
330 let net_lines = None;
331 let risk_class = classify_risk(files, net_lines);
332 DiffTriage {
333 files,
334 hunks,
335 net_lines,
336 risk_class,
337 review_effort: review_effort_for(risk_class),
338 }
339}
340
341#[must_use]
349pub fn derive_graph_facts(
350 results: &AnalysisResults,
351 closure: Option<&fallow_core::graph::ImpactClosurePaths>,
352) -> GraphFacts {
353 let mut zones: FxHashSet<String> = FxHashSet::default();
354 for finding in &results.boundary_violations {
355 zones.insert(finding.violation.from_zone.clone());
356 zones.insert(finding.violation.to_zone.clone());
357 }
358 let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
359 boundaries_touched.sort();
360
361 let reachable_from = closure
362 .map(|c| c.affected_not_shown.clone())
363 .unwrap_or_default();
364
365 GraphFacts {
366 exports_added: 0,
367 api_width_delta: 0,
368 reachable_from,
369 boundaries_touched,
370 }
371}
372
373#[must_use]
377fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
378 let Some(closure) = result
379 .check
380 .as_ref()
381 .and_then(|c| c.impact_closure.as_ref())
382 else {
383 return ImpactClosureFacts::default();
384 };
385 let coordination_gap = closure
386 .coordination_gap
387 .iter()
388 .map(|gap| CoordinationGapFact {
389 changed_file: gap.changed_file.clone(),
390 consumer_file: gap.consumer_file.clone(),
391 consumed_symbols: gap.consumed_symbols.clone(),
392 note: COORDINATION_GAP_NOTE.to_string(),
393 })
394 .collect();
395 ImpactClosureFacts {
396 affected_not_shown: closure.affected_not_shown.clone(),
397 coordination_gap,
398 }
399}
400
401#[must_use]
405fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
406 let Some(partition) = result
407 .check
408 .as_ref()
409 .and_then(|c| c.partition_order.as_ref())
410 else {
411 return PartitionFacts::default();
412 };
413 let units = partition
414 .units
415 .iter()
416 .map(|unit| ReviewUnitFact {
417 module_dir: unit.module_dir.clone(),
418 files: unit.files.clone(),
419 })
420 .collect();
421 PartitionFacts {
422 units,
423 order: partition.order.clone(),
424 }
425}
426
427#[must_use]
440fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
441 use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
442
443 let Some(check) = result.check.as_ref() else {
444 return crate::audit_focus::FocusMap::default();
445 };
446 let Some(graph_facts) = check.focus_facts.as_ref() else {
447 return crate::audit_focus::FocusMap::default();
448 };
449 let root = &check.config.root;
450
451 let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
454 let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
455 for finding in &check.results.boundary_violations {
456 let key = crate::audit::review_deltas::boundary_edge_key(finding);
457 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
458 continue;
459 }
460 boundary_files.push(BoundaryZoneFile {
461 from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
462 });
463 }
464
465 let coordination_changed_files: Vec<String> = check
468 .impact_closure
469 .as_ref()
470 .map(|c| {
471 let mut files: Vec<String> = c
472 .coordination_gap
473 .iter()
474 .map(|gap| gap.changed_file.clone())
475 .collect();
476 files.sort_unstable();
477 files.dedup();
478 files
479 })
480 .unwrap_or_default();
481
482 let taint_touched_files = taint_touched_files(result.check.as_ref());
487
488 build_focus_map(&FocusInputs {
489 graph_facts,
490 boundary_files: &boundary_files,
491 public_api_added: &deltas.public_api_added,
492 coordination_changed_files: &coordination_changed_files,
493 taint_touched_files: &taint_touched_files,
494 })
495}
496
497fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
506 let Some(check) = check else {
507 return Vec::new();
508 };
509 let root = &check.config.root;
510 let mut touched: FxHashSet<String> = FxHashSet::default();
511 for finding in &check.results.security_findings {
512 touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
513 for hop in &finding.trace {
514 touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
515 }
516 }
517 let mut files: Vec<String> = touched.into_iter().collect();
518 files.sort();
519 files
520}
521
522#[must_use]
526pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
527 let triage = build_triage(result);
528 let closure = result
529 .check
530 .as_ref()
531 .and_then(|c| c.impact_closure.as_ref());
532 let deltas = result.review_deltas.clone().unwrap_or_default();
533 let mut graph_facts = result.check.as_ref().map_or_else(
534 || GraphFacts {
535 exports_added: 0,
536 api_width_delta: 0,
537 reachable_from: Vec::new(),
538 boundaries_touched: Vec::new(),
539 },
540 |check| derive_graph_facts(&check.results, closure),
541 );
542 let added = deltas.public_api_added.len();
546 graph_facts.exports_added = added;
547 graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
548 let partition = build_partition_facts(result);
549 let impact_closure = build_impact_closure_facts(result);
550 let focus = build_focus_map(result, &deltas);
551 ReviewBriefOutput {
552 schema_version: ReviewBriefSchemaVersion::default(),
553 version: env!("CARGO_PKG_VERSION").to_string(),
554 command: "audit-brief".to_string(),
555 triage,
556 graph_facts,
557 partition,
558 impact_closure,
559 focus,
560 deltas,
561 weakening: result.weakening_signals.clone(),
562 routing: result.routing.clone().unwrap_or_default(),
563 decisions: result.decision_surface.clone().unwrap_or_default(),
564 }
565}
566
567fn insert_brief_triage_json(
569 obj: &mut serde_json::Map<String, serde_json::Value>,
570 brief: &ReviewBriefOutput,
571) {
572 if let Ok(value) = serde_json::to_value(&brief.triage) {
573 obj.insert("triage".into(), value);
574 }
575}
576
577fn insert_brief_graph_facts_json(
579 obj: &mut serde_json::Map<String, serde_json::Value>,
580 brief: &ReviewBriefOutput,
581) {
582 if let Ok(value) = serde_json::to_value(&brief.graph_facts) {
583 obj.insert("graph_facts".into(), value);
584 }
585}
586
587fn insert_brief_partition_json(
589 obj: &mut serde_json::Map<String, serde_json::Value>,
590 brief: &ReviewBriefOutput,
591) {
592 if let Ok(value) = serde_json::to_value(&brief.partition) {
593 obj.insert("partition".into(), value);
594 }
595}
596
597fn insert_brief_impact_closure_json(
599 obj: &mut serde_json::Map<String, serde_json::Value>,
600 brief: &ReviewBriefOutput,
601) {
602 if let Ok(value) = serde_json::to_value(&brief.impact_closure) {
603 obj.insert("impact_closure".into(), value);
604 }
605}
606
607fn insert_brief_focus_json(
612 obj: &mut serde_json::Map<String, serde_json::Value>,
613 brief: &ReviewBriefOutput,
614) {
615 if let Ok(value) = serde_json::to_value(&brief.focus) {
616 obj.insert("focus".into(), value);
617 }
618}
619
620fn insert_brief_e3_json(
622 obj: &mut serde_json::Map<String, serde_json::Value>,
623 brief: &ReviewBriefOutput,
624) {
625 if let Ok(value) = serde_json::to_value(&brief.deltas) {
626 obj.insert("deltas".into(), value);
627 }
628 if let Ok(value) = serde_json::to_value(&brief.weakening) {
629 obj.insert("weakening".into(), value);
630 }
631 if let Ok(value) = serde_json::to_value(&brief.routing) {
632 obj.insert("routing".into(), value);
633 }
634}
635
636fn insert_brief_decisions_json(
638 obj: &mut serde_json::Map<String, serde_json::Value>,
639 brief: &ReviewBriefOutput,
640) {
641 if let Ok(value) = serde_json::to_value(&brief.decisions) {
642 obj.insert("decisions".into(), value);
643 }
644}
645
646fn insert_brief_subtract_json(
651 obj: &mut serde_json::Map<String, serde_json::Value>,
652 result: &AuditResult,
653) -> Result<(), ExitCode> {
654 if let Some(ref check) = result.check {
655 crate::audit::insert_audit_dead_code_json(obj, result, check)?;
656 }
657 if let Some(ref dupes) = result.dupes {
658 crate::audit::insert_audit_duplication_json(obj, result, dupes)?;
659 }
660 if let Some(ref health) = result.health {
661 crate::audit::insert_audit_health_json(obj, result, health)?;
662 }
663 Ok(())
664}
665
666fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
670 let brief = build_brief_output(result);
671 let mut obj = serde_json::Map::new();
672
673 obj.insert(
674 "schema_version".into(),
675 serde_json::to_value(brief.schema_version).unwrap_or(serde_json::Value::Null),
676 );
677 obj.insert(
678 "version".into(),
679 serde_json::Value::String(brief.version.clone()),
680 );
681 obj.insert(
682 "command".into(),
683 serde_json::Value::String(brief.command.clone()),
684 );
685
686 crate::audit::insert_audit_json_header(&mut obj, result);
689 obj.insert(
692 "schema_version".into(),
693 serde_json::to_value(brief.schema_version).unwrap_or(serde_json::Value::Null),
694 );
695 obj.insert(
696 "command".into(),
697 serde_json::Value::String(brief.command.clone()),
698 );
699
700 insert_brief_decisions_json(&mut obj, &brief);
703 insert_brief_triage_json(&mut obj, &brief);
704 insert_brief_graph_facts_json(&mut obj, &brief);
705 insert_brief_partition_json(&mut obj, &brief);
706 insert_brief_impact_closure_json(&mut obj, &brief);
707 insert_brief_focus_json(&mut obj, &brief);
708 insert_brief_e3_json(&mut obj, &brief);
709 insert_brief_subtract_json(&mut obj, result)?;
710
711 Ok(serde_json::Value::Object(obj))
712}
713
714fn print_brief_json(result: &AuditResult) -> ExitCode {
717 match build_brief_json(result) {
718 Ok(mut output) => {
719 crate::output_envelope::apply_root_kind(&mut output, "audit-brief");
720 crate::output_envelope::attach_telemetry_meta(&mut output);
721 let _ = crate::report::emit_json(&output, "audit-brief");
722 ExitCode::SUCCESS
723 }
724 Err(_) => ExitCode::SUCCESS,
725 }
726}
727
728fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
732 let brief = build_brief_output(result);
733
734 if !quiet {
735 eprintln!();
736 print_decision_surface_human(&brief.decisions);
738 eprintln!(
740 "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
741 result.changed_files_count,
742 crate::report::plural(result.changed_files_count),
743 result.base_ref,
744 risk_label(brief.triage.risk_class),
745 effort_label(brief.triage.review_effort),
746 );
747 if !brief.graph_facts.boundaries_touched.is_empty() {
748 eprintln!(
749 " boundaries touched: {}",
750 brief.graph_facts.boundaries_touched.join(", ")
751 );
752 }
753 print_partition_human(&brief.partition);
754 print_impact_closure_human(&brief.impact_closure);
755 print_focus_human(&brief.focus, show_deprioritized);
756 print_deltas_human(&brief.deltas);
757 print_weakening_human(&brief.weakening);
758 print_routing_human(&brief.routing);
759 }
760
761 crate::audit::print_audit_findings(result, quiet, explain, false);
765}
766
767fn print_partition_human(partition: &PartitionFacts) {
772 if partition.units.is_empty() {
773 return;
774 }
775 eprintln!(
776 " partition: {} unit{} (by module)",
777 partition.units.len(),
778 crate::report::plural(partition.units.len()),
779 );
780 if !partition.order.is_empty() {
781 let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
782 eprintln!(" review order: {}", labeled.join(" \u{2192} "));
783 }
784}
785
786fn unit_label(module_dir: &str) -> String {
789 if module_dir.is_empty() {
790 "<root>".to_string()
791 } else {
792 module_dir.to_string()
793 }
794}
795
796fn print_impact_closure_human(closure: &ImpactClosureFacts) {
800 if !closure.affected_not_shown.is_empty() {
801 eprintln!(
802 " impact closure: {} file{} affected beyond the diff",
803 closure.affected_not_shown.len(),
804 crate::report::plural(closure.affected_not_shown.len()),
805 );
806 }
807 for gap in &closure.coordination_gap {
808 eprintln!(
809 " coordination gap: {} consumes {} from {} (not in this diff)",
810 gap.consumer_file,
811 gap.consumed_symbols.join(", "),
812 gap.changed_file,
813 );
814 }
815}
816
817fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
823 if focus.total_units() == 0 {
824 return;
825 }
826 if !focus.review_here.is_empty() {
827 eprintln!(
828 " focus: {} unit{} to review here (of {} changed)",
829 focus.review_here.len(),
830 crate::report::plural(focus.review_here.len()),
831 focus.total_units(),
832 );
833 for unit in &focus.review_here {
834 eprintln!(
835 " [{}] {}: {}",
836 unit.label.token(),
837 unit.file,
838 unit.reason
839 );
840 for flag in &unit.confidence {
841 eprintln!(" confidence {}", flag.message());
842 }
843 }
844 }
845 if focus.deprioritized.is_empty() {
846 return;
847 }
848 if show_deprioritized {
849 eprintln!(" de-prioritized ({}):", focus.deprioritized.len());
850 for unit in &focus.deprioritized {
851 eprintln!(
852 " [{}] {}: {}",
853 unit.label.token(),
854 unit.file,
855 unit.reason
856 );
857 for flag in &unit.confidence {
858 eprintln!(" confidence {}", flag.message());
859 }
860 }
861 } else {
862 eprintln!(
863 " de-prioritized: {} unit{} (run with --show-deprioritized to list)",
864 focus.deprioritized.len(),
865 crate::report::plural(focus.deprioritized.len()),
866 );
867 }
868}
869
870fn print_deltas_human(deltas: &ReviewDeltas) {
874 for edge in &deltas.boundary_introduced {
875 eprintln!(" new boundary edge: {edge} (not present at base)");
876 }
877 for cycle in &deltas.cycle_introduced {
878 eprintln!(" new circular dependency: {cycle} (not present at base)");
879 }
880 if !deltas.public_api_added.is_empty() {
881 eprintln!(
882 " public API surface widened by {} export{} (exports-aware)",
883 deltas.public_api_added.len(),
884 crate::report::plural(deltas.public_api_added.len()),
885 );
886 }
887}
888
889fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
891 if signals.is_empty() {
892 return;
893 }
894 eprintln!(
895 " weakening signals ({}, reviewer-private, advisory):",
896 signals.len()
897 );
898 for signal in signals {
899 eprintln!(
900 " {}: {} in {}",
901 weakening_label(signal.kind),
902 signal.evidence,
903 signal.file,
904 );
905 }
906}
907
908fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
910 for unit in &routing.units {
911 if unit.expert.is_empty() {
912 continue;
913 }
914 let bus = if unit.bus_factor_one {
915 " (bus-factor 1)"
916 } else {
917 ""
918 };
919 eprintln!(
920 " review {}: ask {}{bus}",
921 unit.file,
922 unit.expert.join(", "),
923 );
924 }
925}
926
927fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
931 if surface.decisions.is_empty() {
932 eprintln!("Decisions: none (no consequential structural decision in this change)");
933 eprintln!();
934 return;
935 }
936 eprintln!("Decisions to make ({}):", surface.decisions.len());
937 for (i, decision) in surface.decisions.iter().enumerate() {
938 eprintln!(
942 " {}. [{}] {}",
943 i + 1,
944 decision.category.tag(),
945 decision.question
946 );
947 if !decision.tradeoff.is_empty() {
948 eprintln!(" trade-off: {}", decision.tradeoff);
949 }
950 if !decision.expert.is_empty() {
951 let bus = if decision.bus_factor_one {
952 " (bus-factor 1)"
953 } else {
954 ""
955 };
956 eprintln!(" ask: {}{bus}", decision.expert.join(", "));
957 }
958 }
959 if let Some(note) = &surface.truncated {
960 eprintln!(" ... {}", note.reason);
961 }
962 eprintln!();
963}
964
965fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
966 use crate::audit::weakening::WeakeningKind;
967 match kind {
968 WeakeningKind::TestWeakened => "test weakened",
969 WeakeningKind::ThresholdLowered => "threshold lowered",
970 WeakeningKind::SuppressionAdded => "suppression added",
971 WeakeningKind::SecurityCheckRemoved => "security check removed",
972 }
973}
974
975fn risk_label(risk: RiskClass) -> &'static str {
976 match risk {
977 RiskClass::Low => "low",
978 RiskClass::Medium => "medium",
979 RiskClass::High => "high",
980 }
981}
982
983fn effort_label(effort: ReviewEffort) -> &'static str {
984 match effort {
985 ReviewEffort::Glance => "glance",
986 ReviewEffort::Review => "review",
987 ReviewEffort::DeepDive => "deep-dive",
988 }
989}
990
991#[must_use]
1002pub fn print_brief_result(
1003 result: &AuditResult,
1004 quiet: bool,
1005 explain: bool,
1006 show_deprioritized: bool,
1007) -> ExitCode {
1008 use fallow_config::OutputFormat;
1009
1010 match result.output {
1011 OutputFormat::Json => print_brief_json(result),
1012 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
1013 print_brief_human(result, quiet, explain, show_deprioritized);
1014 ExitCode::SUCCESS
1015 }
1016 _ => {
1017 let _ = crate::audit::print_audit_result(result, quiet, explain);
1021 ExitCode::SUCCESS
1022 }
1023 }
1024}
1025
1026#[must_use]
1033pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
1034 use fallow_config::OutputFormat;
1035
1036 let surface = result.decision_surface.clone().unwrap_or_default();
1037 match result.output {
1038 OutputFormat::Json => {
1039 let envelope = crate::output_envelope::FallowOutput::DecisionSurface(
1040 crate::audit_decision_surface::build_decision_surface_output(&surface),
1041 );
1042 match serde_json::to_value(&envelope) {
1043 Ok(mut value) => {
1044 crate::output_envelope::apply_root_kind(&mut value, "decision-surface");
1045 crate::output_envelope::attach_telemetry_meta(&mut value);
1046 let _ = crate::report::emit_json(&value, "decision-surface");
1047 ExitCode::SUCCESS
1048 }
1049 Err(_) => ExitCode::SUCCESS,
1050 }
1051 }
1052 _ => {
1053 if !quiet {
1054 print_decision_surface_human(&surface);
1055 }
1056 ExitCode::SUCCESS
1057 }
1058 }
1059}
1060
1061#[must_use]
1068pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
1069 let guide = crate::audit_walkthrough::build_guide_from_result(result);
1070 let envelope = crate::output_envelope::FallowOutput::WalkthroughGuide(guide);
1071 if let Ok(mut value) = serde_json::to_value(&envelope) {
1072 crate::output_envelope::apply_root_kind(&mut value, "review-walkthrough-guide");
1073 crate::output_envelope::attach_telemetry_meta(&mut value);
1074 let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
1075 }
1076 ExitCode::SUCCESS
1077}
1078
1079#[must_use]
1089pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
1090 let contents = std::fs::read_to_string(path).unwrap_or_default();
1091 let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
1092 let surface = result.decision_surface.clone().unwrap_or_default();
1093 let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
1094 let change_anchor_ids =
1095 crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
1096 let validation = crate::audit_walkthrough::validate_walkthrough(
1097 &agent,
1098 &surface,
1099 &change_anchor_ids,
1100 ¤t_hash,
1101 );
1102 let envelope = crate::output_envelope::FallowOutput::WalkthroughValidation(validation);
1103 if let Ok(mut value) = serde_json::to_value(&envelope) {
1104 crate::output_envelope::apply_root_kind(&mut value, "review-walkthrough-validation");
1105 crate::output_envelope::attach_telemetry_meta(&mut value);
1106 let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
1107 }
1108 ExitCode::SUCCESS
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use std::time::Duration;
1114
1115 use fallow_config::{AuditGate, OutputFormat};
1116
1117 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
1118
1119 use super::*;
1120
1121 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1122 AuditResult {
1123 verdict,
1124 summary: AuditSummary {
1125 dead_code_issues: 0,
1126 dead_code_has_errors: false,
1127 complexity_findings: 0,
1128 max_cyclomatic: None,
1129 duplication_clone_groups: 0,
1130 },
1131 attribution: AuditAttribution {
1132 gate: AuditGate::NewOnly,
1133 ..AuditAttribution::default()
1134 },
1135 base_snapshot: None,
1136 base_snapshot_skipped: false,
1137 changed_files_count: 0,
1138 changed_files: Vec::new(),
1139 base_ref: "origin/main".to_string(),
1140 base_description: None,
1141 head_sha: None,
1142 output,
1143 performance: false,
1144 check: None,
1145 dupes: None,
1146 health: None,
1147 elapsed: Duration::ZERO,
1148 review_deltas: None,
1149 weakening_signals: Vec::new(),
1150 routing: None,
1151 decision_surface: None,
1152 graph_snapshot_hash: None,
1153 change_anchors: Vec::new(),
1154 }
1155 }
1156
1157 #[test]
1158 fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1159 let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1161 assert_eq!(
1162 print_brief_result(&human, true, false, false),
1163 ExitCode::SUCCESS
1164 );
1165
1166 let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1168 assert_eq!(
1169 print_brief_result(&json, true, false, false),
1170 ExitCode::SUCCESS
1171 );
1172 }
1173
1174 #[test]
1175 fn brief_json_validates_against_audit_brief_schema_variant() {
1176 let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1177 let mut value = build_brief_json(&result).expect("brief json must build");
1178 crate::output_envelope::apply_root_kind(&mut value, "audit-brief");
1179
1180 assert_eq!(value["kind"], "audit-brief");
1181 assert_eq!(value["command"], "audit-brief");
1182 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1183 }
1184
1185 #[test]
1186 fn brief_json_is_byte_identical_on_repeated_serialization() {
1187 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1190 let first = build_brief_json(&result).expect("first build");
1191 let second = build_brief_json(&result).expect("second build");
1192 let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1193 let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1194 assert_eq!(first_str, second_str);
1195 }
1196
1197 #[test]
1198 fn risk_class_thresholds_are_pure_functions_of_size() {
1199 assert_eq!(classify_risk(0, None), RiskClass::Low);
1200 assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1201 assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1202 assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1203 assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1204 }
1205
1206 #[test]
1207 fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1208 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1211 let value = build_brief_json(&result).expect("brief json must build");
1212 assert!(value.get("impact_closure").is_some(), "{value}");
1213 assert_eq!(
1214 value["impact_closure"]["affected_not_shown"],
1215 serde_json::json!([])
1216 );
1217 assert_eq!(
1218 value["impact_closure"]["coordination_gap"],
1219 serde_json::json!([])
1220 );
1221 }
1222
1223 #[test]
1224 fn derive_graph_facts_populates_reachable_from_from_closure() {
1225 use fallow_core::graph::{CoordinationGapPaths, ImpactClosurePaths};
1226 let results = AnalysisResults::default();
1227 let closure = ImpactClosurePaths {
1228 in_diff: vec!["src/core.ts".to_string()],
1229 affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1230 coordination_gap: vec![CoordinationGapPaths {
1231 changed_file: "src/core.ts".to_string(),
1232 consumer_file: "src/mid.ts".to_string(),
1233 consumed_symbols: vec!["compute".to_string()],
1234 }],
1235 };
1236 let facts = derive_graph_facts(&results, Some(&closure));
1237 assert_eq!(
1238 facts.reachable_from,
1239 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1240 );
1241 }
1242
1243 #[test]
1244 fn coordination_gap_fact_carries_honest_scope_note() {
1245 let gap = CoordinationGapFact {
1246 changed_file: "src/core.ts".to_string(),
1247 consumer_file: "src/mid.ts".to_string(),
1248 consumed_symbols: vec!["compute".to_string()],
1249 note: COORDINATION_GAP_NOTE.to_string(),
1250 };
1251 assert!(gap.note.contains("attention pointer"));
1252 assert!(gap.note.contains("not a correctness proof"));
1253 }
1254}