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;
26
27pub type ReviewBriefOutput = fallow_output::StandardReviewBriefOutput;
28
29const RISK_HIGH_FILES: usize = 20;
31const RISK_HIGH_LINES: i64 = 500;
36const RISK_MEDIUM_FILES: usize = 5;
39const RISK_MEDIUM_LINES: i64 = 100;
42
43const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
45
46#[must_use]
48#[allow(
49 clippy::implicit_hasher,
50 reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
51)]
52pub fn build_review_deltas(
53 head_boundary: &FxHashSet<String>,
54 base_boundary: &FxHashSet<String>,
55 head_cycles: &FxHashSet<String>,
56 base_cycles: &FxHashSet<String>,
57 head_public_api: &FxHashSet<String>,
58 base_public_api: &FxHashSet<String>,
59) -> ReviewDeltas {
60 use crate::audit::review_deltas::introduced_keys;
61 ReviewDeltas {
62 boundary_introduced: introduced_keys(head_boundary, base_boundary),
63 cycle_introduced: introduced_keys(head_cycles, base_cycles),
64 public_api_added: introduced_keys(head_public_api, base_public_api),
65 }
66}
67
68#[must_use]
71pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
72 let lines = net_lines.unwrap_or(0).abs();
73 if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
74 RiskClass::High
75 } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
76 RiskClass::Medium
77 } else {
78 RiskClass::Low
79 }
80}
81
82#[must_use]
84pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
85 match risk {
86 RiskClass::Low => ReviewEffort::Glance,
87 RiskClass::Medium => ReviewEffort::Review,
88 RiskClass::High => ReviewEffort::DeepDive,
89 }
90}
91
92#[must_use]
94pub fn build_triage(result: &AuditResult) -> DiffTriage {
95 let files = result.changed_files_count;
96 let hunks = None;
99 let net_lines = None;
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::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 build_focus_map(&FocusInputs {
258 graph_facts,
259 boundary_files: &boundary_files,
260 public_api_added: &deltas.public_api_added,
261 coordination_changed_files: &coordination_changed_files,
262 taint_touched_files: &taint_touched_files,
263 })
264}
265
266fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
275 let Some(check) = check else {
276 return Vec::new();
277 };
278 let root = &check.config.root;
279 let mut touched: FxHashSet<String> = FxHashSet::default();
280 for finding in &check.results.security_findings {
281 touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
282 for hop in &finding.trace {
283 touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
284 }
285 }
286 let mut files: Vec<String> = touched.into_iter().collect();
287 files.sort();
288 files
289}
290
291#[must_use]
295pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
296 let triage = build_triage(result);
297 let closure = result
298 .check
299 .as_ref()
300 .and_then(|c| c.impact_closure.as_ref());
301 let deltas = result.review_deltas.clone().unwrap_or_default();
302 let mut graph_facts = result.check.as_ref().map_or_else(
303 || GraphFacts {
304 exports_added: 0,
305 api_width_delta: 0,
306 reachable_from: Vec::new(),
307 boundaries_touched: Vec::new(),
308 },
309 |check| derive_graph_facts(&check.results, closure),
310 );
311 let added = deltas.public_api_added.len();
315 graph_facts.exports_added = added;
316 graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
317 let partition = build_partition_facts(result);
318 let impact_closure = build_impact_closure_facts(result);
319 let focus = build_focus_map(result, &deltas);
320 ReviewBriefOutput {
321 schema_version: ReviewBriefSchemaVersion::default(),
322 version: env!("CARGO_PKG_VERSION").to_string(),
323 command: "audit-brief".to_string(),
324 triage,
325 graph_facts,
326 partition,
327 impact_closure,
328 focus,
329 deltas,
330 weakening: result.weakening_signals.clone(),
331 routing: result.routing.clone().unwrap_or_default(),
332 decisions: result.decision_surface.clone().unwrap_or_default(),
333 }
334}
335
336fn build_brief_subtract_sections(
339 result: &AuditResult,
340) -> Result<ReviewBriefSubtractSections, ExitCode> {
341 let mut obj = serde_json::Map::new();
342 if let Some(ref check) = result.check {
343 crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
344 }
345 if let Some(ref dupes) = result.dupes {
346 crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
347 }
348 if let Some(ref health) = result.health {
349 crate::audit::insert_audit_health_json(&mut obj, result, health)?;
350 }
351 Ok(ReviewBriefSubtractSections {
352 dead_code: obj.remove("dead_code"),
353 duplication: obj.remove("duplication"),
354 complexity: obj.remove("complexity"),
355 })
356}
357
358fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
362 let brief = build_brief_output(result);
363 let audit_header = fallow_api::build_audit_header_map(crate::audit::audit_json_header_input(
364 result,
365 ))
366 .map_err(|err| {
367 crate::error::emit_error(
368 &format!("JSON serialization error: {err}"),
369 2,
370 fallow_config::OutputFormat::Json,
371 )
372 })?;
373 let subtract = build_brief_subtract_sections(result)?;
374 fallow_output::build_review_brief_json_output(&brief, audit_header, subtract).map_err(|err| {
375 crate::error::emit_error(
376 &format!("JSON serialization error: {err}"),
377 2,
378 fallow_config::OutputFormat::Json,
379 )
380 })
381}
382
383fn print_brief_json(result: &AuditResult) -> ExitCode {
386 match build_brief_json(result) {
387 Ok(output) => {
388 let Ok(output) = fallow_output::serialize_review_brief_json_output(
389 output,
390 crate::output_runtime::current_root_envelope_mode(),
391 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
392 ) else {
393 return ExitCode::SUCCESS;
394 };
395 let _ = crate::report::emit_json(&output, "audit-brief");
396 ExitCode::SUCCESS
397 }
398 Err(_) => ExitCode::SUCCESS,
399 }
400}
401
402fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
406 let brief = build_brief_output(result);
407
408 if !quiet {
409 eprintln!();
410 print_decision_surface_human(&brief.decisions);
412 eprintln!(
414 "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
415 result.changed_files_count,
416 crate::report::plural(result.changed_files_count),
417 result.base_ref,
418 risk_label(brief.triage.risk_class),
419 effort_label(brief.triage.review_effort),
420 );
421 if !brief.graph_facts.boundaries_touched.is_empty() {
422 eprintln!(
423 " boundaries touched: {}",
424 brief.graph_facts.boundaries_touched.join(", ")
425 );
426 }
427 print_partition_human(&brief.partition);
428 print_impact_closure_human(&brief.impact_closure);
429 print_focus_human(&brief.focus, show_deprioritized);
430 print_deltas_human(&brief.deltas);
431 print_weakening_human(&brief.weakening);
432 print_routing_human(&brief.routing);
433 }
434
435 crate::audit::print_audit_findings(result, quiet, explain, false);
439}
440
441fn print_partition_human(partition: &PartitionFacts) {
446 if partition.units.is_empty() {
447 return;
448 }
449 eprintln!(
450 " partition: {} unit{} (by module)",
451 partition.units.len(),
452 crate::report::plural(partition.units.len()),
453 );
454 if !partition.order.is_empty() {
455 let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
456 eprintln!(" review order: {}", labeled.join(" \u{2192} "));
457 }
458}
459
460fn unit_label(module_dir: &str) -> String {
463 if module_dir.is_empty() {
464 "<root>".to_string()
465 } else {
466 module_dir.to_string()
467 }
468}
469
470fn print_impact_closure_human(closure: &ImpactClosureFacts) {
474 if !closure.affected_not_shown.is_empty() {
475 eprintln!(
476 " impact closure: {} file{} affected beyond the diff",
477 closure.affected_not_shown.len(),
478 crate::report::plural(closure.affected_not_shown.len()),
479 );
480 }
481 for gap in &closure.coordination_gap {
482 eprintln!(
483 " coordination gap: {} consumes {} from {} (not in this diff)",
484 gap.consumer_file,
485 gap.consumed_symbols.join(", "),
486 gap.changed_file,
487 );
488 }
489}
490
491fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
497 if focus.total_units() == 0 {
498 return;
499 }
500 if !focus.review_here.is_empty() {
501 eprintln!(
502 " focus: {} unit{} to review here (of {} changed)",
503 focus.review_here.len(),
504 crate::report::plural(focus.review_here.len()),
505 focus.total_units(),
506 );
507 for unit in &focus.review_here {
508 eprintln!(
509 " [{}] {}: {}",
510 unit.label.token(),
511 unit.file,
512 unit.reason
513 );
514 for flag in &unit.confidence {
515 eprintln!(" confidence {}", flag.message());
516 }
517 }
518 }
519 if focus.deprioritized.is_empty() {
520 return;
521 }
522 if show_deprioritized {
523 eprintln!(" de-prioritized ({}):", focus.deprioritized.len());
524 for unit in &focus.deprioritized {
525 eprintln!(
526 " [{}] {}: {}",
527 unit.label.token(),
528 unit.file,
529 unit.reason
530 );
531 for flag in &unit.confidence {
532 eprintln!(" confidence {}", flag.message());
533 }
534 }
535 } else {
536 eprintln!(
537 " de-prioritized: {} unit{} (run with --show-deprioritized to list)",
538 focus.deprioritized.len(),
539 crate::report::plural(focus.deprioritized.len()),
540 );
541 }
542}
543
544fn print_deltas_human(deltas: &ReviewDeltas) {
548 for edge in &deltas.boundary_introduced {
549 eprintln!(" new boundary edge: {edge} (not present at base)");
550 }
551 for cycle in &deltas.cycle_introduced {
552 eprintln!(" new circular dependency: {cycle} (not present at base)");
553 }
554 if !deltas.public_api_added.is_empty() {
555 eprintln!(
556 " public API surface widened by {} export{} (exports-aware)",
557 deltas.public_api_added.len(),
558 crate::report::plural(deltas.public_api_added.len()),
559 );
560 }
561}
562
563fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
565 if signals.is_empty() {
566 return;
567 }
568 eprintln!(
569 " weakening signals ({}, reviewer-private, advisory):",
570 signals.len()
571 );
572 for signal in signals {
573 eprintln!(
574 " {}: {} in {}",
575 weakening_label(signal.kind),
576 signal.evidence,
577 signal.file,
578 );
579 }
580}
581
582fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
584 for unit in &routing.units {
585 if unit.expert.is_empty() {
586 continue;
587 }
588 let bus = if unit.bus_factor_one {
589 " (bus-factor 1)"
590 } else {
591 ""
592 };
593 eprintln!(
594 " review {}: ask {}{bus}",
595 unit.file,
596 unit.expert.join(", "),
597 );
598 }
599}
600
601fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
605 if surface.decisions.is_empty() {
606 eprintln!("Decisions: none (no consequential structural decision in this change)");
607 eprintln!();
608 return;
609 }
610 eprintln!("Decisions to make ({}):", surface.decisions.len());
611 for (i, decision) in surface.decisions.iter().enumerate() {
612 eprintln!(
616 " {}. [{}] {}",
617 i + 1,
618 decision.category.tag(),
619 decision.question
620 );
621 if !decision.tradeoff.is_empty() {
622 eprintln!(" trade-off: {}", decision.tradeoff);
623 }
624 if !decision.expert.is_empty() {
625 let bus = if decision.bus_factor_one {
626 " (bus-factor 1)"
627 } else {
628 ""
629 };
630 eprintln!(" ask: {}{bus}", decision.expert.join(", "));
631 }
632 }
633 if let Some(note) = &surface.truncated {
634 eprintln!(" ... {}", note.reason);
635 }
636 eprintln!();
637}
638
639fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
640 use crate::audit::weakening::WeakeningKind;
641 match kind {
642 WeakeningKind::TestWeakened => "test weakened",
643 WeakeningKind::ThresholdLowered => "threshold lowered",
644 WeakeningKind::SuppressionAdded => "suppression added",
645 WeakeningKind::SecurityCheckRemoved => "security check removed",
646 }
647}
648
649fn risk_label(risk: RiskClass) -> &'static str {
650 match risk {
651 RiskClass::Low => "low",
652 RiskClass::Medium => "medium",
653 RiskClass::High => "high",
654 }
655}
656
657fn effort_label(effort: ReviewEffort) -> &'static str {
658 match effort {
659 ReviewEffort::Glance => "glance",
660 ReviewEffort::Review => "review",
661 ReviewEffort::DeepDive => "deep-dive",
662 }
663}
664
665#[must_use]
676pub fn print_brief_result(
677 result: &AuditResult,
678 quiet: bool,
679 explain: bool,
680 show_deprioritized: bool,
681) -> ExitCode {
682 use fallow_config::OutputFormat;
683
684 match result.output {
685 OutputFormat::Json => print_brief_json(result),
686 OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
687 print_brief_human(result, quiet, explain, show_deprioritized);
688 ExitCode::SUCCESS
689 }
690 _ => {
691 let _ = crate::audit::print_audit_result(result, quiet, explain);
695 ExitCode::SUCCESS
696 }
697 }
698}
699
700#[must_use]
707pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
708 use fallow_config::OutputFormat;
709
710 let surface = result.decision_surface.clone().unwrap_or_default();
711 match result.output {
712 OutputFormat::Json => {
713 let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
714 match fallow_output::serialize_decision_surface_json_output(
715 output,
716 crate::output_runtime::current_root_envelope_mode(),
717 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
718 ) {
719 Ok(value) => {
720 let _ = crate::report::emit_json(&value, "decision-surface");
721 ExitCode::SUCCESS
722 }
723 Err(_) => ExitCode::SUCCESS,
724 }
725 }
726 _ => {
727 if !quiet {
728 print_decision_surface_human(&surface);
729 }
730 ExitCode::SUCCESS
731 }
732 }
733}
734
735#[must_use]
742pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
743 let guide = crate::audit_walkthrough::build_guide_from_result(result);
744 if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
745 guide,
746 crate::output_runtime::current_root_envelope_mode(),
747 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
748 ) {
749 let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
750 }
751 ExitCode::SUCCESS
752}
753
754#[must_use]
764pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
765 let contents = std::fs::read_to_string(path).unwrap_or_default();
766 let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
767 let surface = result.decision_surface.clone().unwrap_or_default();
768 let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
769 let change_anchor_ids =
770 crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
771 let validation = crate::audit_walkthrough::validate_walkthrough(
772 &agent,
773 &surface,
774 &change_anchor_ids,
775 ¤t_hash,
776 );
777 if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
778 validation,
779 crate::output_runtime::current_root_envelope_mode(),
780 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
781 ) {
782 let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
783 }
784 ExitCode::SUCCESS
785}
786
787#[cfg(test)]
788mod tests {
789 use std::time::Duration;
790
791 use fallow_config::{AuditGate, OutputFormat};
792 use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
793
794 use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
795
796 use super::*;
797
798 fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
799 AuditResult {
800 verdict,
801 summary: AuditSummary {
802 dead_code_issues: 0,
803 dead_code_has_errors: false,
804 complexity_findings: 0,
805 max_cyclomatic: None,
806 duplication_clone_groups: 0,
807 },
808 attribution: AuditAttribution {
809 gate: AuditGate::NewOnly,
810 ..AuditAttribution::default()
811 },
812 base_snapshot: None,
813 base_snapshot_skipped: false,
814 changed_files_count: 0,
815 changed_files: Vec::new(),
816 base_ref: "origin/main".to_string(),
817 base_description: None,
818 head_sha: None,
819 output,
820 performance: false,
821 check: None,
822 dupes: None,
823 health: None,
824 elapsed: Duration::ZERO,
825 review_deltas: None,
826 weakening_signals: Vec::new(),
827 routing: None,
828 decision_surface: None,
829 graph_snapshot_hash: None,
830 change_anchors: Vec::new(),
831 }
832 }
833
834 #[test]
835 fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
836 let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
838 assert_eq!(
839 print_brief_result(&human, true, false, false),
840 ExitCode::SUCCESS
841 );
842
843 let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
845 assert_eq!(
846 print_brief_result(&json, true, false, false),
847 ExitCode::SUCCESS
848 );
849 }
850
851 #[test]
852 fn brief_json_validates_against_audit_brief_schema_variant() {
853 let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
854 let value = fallow_output::serialize_review_brief_json_output(
855 build_brief_json(&result).expect("brief json must build"),
856 crate::output_runtime::current_root_envelope_mode(),
857 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
858 )
859 .expect("brief json must serialize");
860
861 assert_eq!(value["kind"], "audit-brief");
862 assert_eq!(value["command"], "audit-brief");
863 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
864 }
865
866 #[test]
867 fn brief_json_is_byte_identical_on_repeated_serialization() {
868 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
871 let first = build_brief_json(&result).expect("first build");
872 let second = build_brief_json(&result).expect("second build");
873 let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
874 let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
875 assert_eq!(first_str, second_str);
876 }
877
878 #[test]
879 fn risk_class_thresholds_are_pure_functions_of_size() {
880 assert_eq!(classify_risk(0, None), RiskClass::Low);
881 assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
882 assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
883 assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
884 assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
885 }
886
887 #[test]
888 fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
889 let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
892 let value = build_brief_json(&result).expect("brief json must build");
893 assert!(value.get("impact_closure").is_some(), "{value}");
894 assert_eq!(
895 value["impact_closure"]["affected_not_shown"],
896 serde_json::json!([])
897 );
898 assert_eq!(
899 value["impact_closure"]["coordination_gap"],
900 serde_json::json!([])
901 );
902 }
903
904 #[test]
905 fn derive_graph_facts_populates_reachable_from_from_closure() {
906 use fallow_engine::graph::{CoordinationGapPaths, ImpactClosurePaths};
907 let results = AnalysisResults::default();
908 let closure = ImpactClosurePaths {
909 in_diff: vec!["src/core.ts".to_string()],
910 affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
911 coordination_gap: vec![CoordinationGapPaths {
912 changed_file: "src/core.ts".to_string(),
913 consumer_file: "src/mid.ts".to_string(),
914 consumed_symbols: vec!["compute".to_string()],
915 }],
916 };
917 let facts = derive_graph_facts(&results, Some(&closure));
918 assert_eq!(
919 facts.reachable_from,
920 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
921 );
922 }
923
924 #[test]
925 fn coordination_gap_fact_carries_honest_scope_note() {
926 let gap = CoordinationGapFact {
927 changed_file: "src/core.ts".to_string(),
928 consumer_file: "src/mid.ts".to_string(),
929 consumed_symbols: vec!["compute".to_string()],
930 note: COORDINATION_GAP_NOTE.to_string(),
931 };
932 assert!(gap.note.contains("attention pointer"));
933 assert!(gap.note.contains("not a correctness proof"));
934 }
935}