1mod base_files;
22mod base_ref;
23mod outcome;
24mod scope;
25mod snapshot;
26#[cfg(test)]
27mod tests;
28
29use std::path::{Path, PathBuf};
30
31use fallow_config::{AuditGate, AuditWorsened, ResolvedConfig, RulesConfig};
32use fallow_engine::changed_files::RenamedFile;
33use fallow_engine::repo_refs::{BaseAnalysisRoot, resolve_base_analysis_root};
34use fallow_output::HealthReport;
35use fallow_types::duplicates::CloneGroup;
36use fallow_types::results::AnalysisResults;
37use rustc_hash::FxHashSet;
38
39pub use base_files::{BaseFileReader, BaseRead, can_reuse_current_as_base, export_names_at_base};
40pub use base_ref::{
41 AuditBaseError, AuditBaseOrigin, parse_audit_base_override, resolve_audit_base,
42};
43pub use outcome::{
44 DupeDemotionDiffSource, SharedDiff, compare, demote_preexisting_dupe_introductions, outcome,
45 styling_finding_gates, styling_rule_severity,
46};
47pub use scope::{
48 BaseCoverageInputs, base_coverage_inputs, base_focus_files, remap_focus_files, renamed_files,
49 scope_dependency_findings,
50};
51pub use snapshot::{
52 AuditKeySnapshot, branching_keys, type_aware_attribution_degrade_reason,
53 type_aware_degrade_warning, type_aware_gap_signature,
54};
55
56use crate::audit_keys::AuditComparison;
57use crate::{AuditAttribution, AuditSummary, AuditVerdict};
58
59pub struct DeadCodeView<'a> {
61 pub results: &'a AnalysisResults,
63 pub config: &'a ResolvedConfig,
65 pub root: &'a Path,
67 pub type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
69 pub syntactic_keys: Option<&'a FxHashSet<String>>,
72 pub public_api: Option<&'a FxHashSet<String>>,
74}
75
76pub struct DuplicationView<'a> {
78 pub clone_groups: Vec<&'a CloneGroup>,
80 pub root: &'a Path,
82 pub duplication_percentage: f64,
84 pub threshold: f64,
87}
88
89pub struct HealthView<'a> {
91 pub report: &'a HealthReport,
93 pub root: &'a Path,
95 pub rules: &'a RulesConfig,
97 pub branching: Option<&'a fallow_engine::health::BranchingByFile>,
99}
100
101#[derive(Default)]
104pub struct AuditAnalysesView<'a> {
105 pub dead_code: Option<DeadCodeView<'a>>,
107 pub duplication: Option<DuplicationView<'a>>,
109 pub health: Option<HealthView<'a>>,
111}
112
113pub trait AuditAnalyses {
115 fn view(&self) -> AuditAnalysesView<'_>;
117 fn dead_code_results_mut(&mut self) -> Option<&mut AnalysisResults>;
120 fn health_report_mut(&mut self) -> Option<&mut HealthReport>;
122 fn record_type_aware_warning(&mut self, warning: &str);
125}
126
127pub trait BaseCheckout {
129 fn path(&self) -> &Path;
131}
132
133impl BaseCheckout for fallow_engine::repo_refs::TemporaryBaseWorktree {
134 fn path(&self) -> &Path {
135 Self::path(self)
136 }
137}
138
139pub trait AuditBackend: Sync {
141 type Analyses: AuditAnalyses + Send;
143 type Checkout: BaseCheckout;
146 type CacheKey: Sync;
148 type Error: Send;
150
151 fn prepare(&self) {}
153
154 fn run_head(&self, changed_files: &FxHashSet<PathBuf>) -> Result<Self::Analyses, Self::Error>;
160
161 fn create_base_checkout(
168 &self,
169 base_ref: &str,
170 base_sha: Option<&str>,
171 ) -> Result<Self::Checkout, Self::Error>;
172
173 fn run_base(
180 &self,
181 base_root: &Path,
182 focus: Option<&FxHashSet<PathBuf>>,
183 ) -> Result<Self::Analyses, Self::Error>;
184
185 fn base_cache_key(
192 &self,
193 _base_ref: &str,
194 _focus: &FxHashSet<PathBuf>,
195 ) -> Result<Option<Self::CacheKey>, Self::Error> {
196 Ok(None)
197 }
198
199 fn cached_base_sha<'k>(&self, _key: &'k Self::CacheKey) -> Option<&'k str> {
201 None
202 }
203
204 fn load_cached_base(&self, _key: &Self::CacheKey) -> Option<AuditKeySnapshot> {
206 None
207 }
208
209 fn save_cached_base(&self, _key: &Self::CacheKey, _snapshot: &AuditKeySnapshot) {}
211
212 fn shared_diff(&self) -> Option<SharedDiff<'_>> {
214 None
215 }
216}
217
218pub struct AuditRunInput<'a> {
220 pub root: &'a Path,
222 pub gate: AuditGate,
224 pub worsened: AuditWorsened,
226 pub base_ref: &'a str,
228 pub cache_dir: Option<&'a Path>,
231 pub changed_files: FxHashSet<PathBuf>,
233 pub inherited_base_refs: &'a [String],
236}
237
238#[derive(Debug, Default)]
240pub struct AuditBase {
241 pub snapshot: Option<AuditKeySnapshot>,
243 pub skipped: bool,
246 pub inherited: Vec<AuditKeySnapshot>,
249}
250
251pub struct AuditAttributionInput<'a> {
253 pub root: &'a Path,
255 pub gate: AuditGate,
257 pub worsened: AuditWorsened,
259 pub base_ref: &'a str,
261 pub base: AuditBase,
263 pub renames: &'a [RenamedFile],
265 pub shared_diff: Option<SharedDiff<'a>>,
267}
268
269#[derive(Debug)]
271pub struct AuditOutcome {
272 pub verdict: AuditVerdict,
274 pub summary: AuditSummary,
276 pub attribution: AuditAttribution,
278 pub comparison: AuditComparison,
280 pub base_snapshot: Option<AuditKeySnapshot>,
282 pub base_snapshot_skipped: bool,
284 pub dupe_demotion_diff_source: Option<DupeDemotionDiffSource>,
287 pub type_aware_degrade_warning: Option<String>,
290 pub diff_risk: fallow_output::DiffRiskReport,
293}
294
295impl AuditOutcome {
296 pub fn apply_diff_risk(&mut self, report: fallow_output::DiffRiskReport) {
300 if report.has_errors() {
301 self.verdict = AuditVerdict::Fail;
302 } else if !report.is_empty() && self.verdict == AuditVerdict::Pass {
303 self.verdict = AuditVerdict::Warn;
304 }
305 self.diff_risk = report;
306 }
307}
308
309pub(crate) fn programmatic_base_snapshot(
312 outcome: &AuditOutcome,
313) -> Option<crate::AuditProgrammaticKeySnapshot> {
314 outcome
315 .base_snapshot
316 .as_ref()
317 .map(AuditKeySnapshot::to_programmatic)
318}
319
320pub struct AuditRun<A> {
322 pub analyses: A,
325 pub changed_files: FxHashSet<PathBuf>,
327 pub outcome: AuditOutcome,
329}
330
331pub fn run<B: AuditBackend>(
338 backend: &B,
339 input: AuditRunInput<'_>,
340) -> Result<Option<AuditRun<B::Analyses>>, B::Error> {
341 let AuditRunInput {
342 root,
343 gate,
344 worsened,
345 base_ref,
346 cache_dir,
347 changed_files,
348 inherited_base_refs,
349 } = input;
350 if changed_files.is_empty() {
351 return Ok(None);
352 }
353 backend.prepare();
354
355 let needs_real_base = matches!(gate, AuditGate::NewOnly)
356 && !can_reuse_current_as_base(root, cache_dir, base_ref, &changed_files);
357 let renames = if needs_real_base || !matches!(gate, AuditGate::NewOnly) {
360 renamed_files(root, base_ref)
361 } else {
362 Vec::new()
363 };
364 let focus = base_focus_files(&changed_files, &renames);
365 let cache_key = if needs_real_base {
366 backend.base_cache_key(base_ref, &focus)?
367 } else {
368 None
369 };
370 let cached = cache_key
371 .as_ref()
372 .and_then(|key| backend.load_cached_base(key));
373
374 let (head, fresh_base) = if needs_real_base && cached.is_none() {
375 let base_sha = cache_key
376 .as_ref()
377 .and_then(|key| backend.cached_base_sha(key));
378 let (head, base) = rayon::join(
379 || backend.run_head(&changed_files),
380 || base_snapshot(backend, root, base_ref, &focus, base_sha),
381 );
382 (head, Some(base))
383 } else {
384 (backend.run_head(&changed_files), None)
385 };
386 let mut analyses = head?;
387 scope_dependency_findings_of(&mut analyses, &changed_files);
388
389 let base = if !matches!(gate, AuditGate::NewOnly) {
390 AuditBase::default()
391 } else if let Some(snapshot) = cached {
392 AuditBase {
393 snapshot: Some(snapshot),
394 skipped: false,
395 inherited: inherited_snapshots(backend, root, inherited_base_refs, &focus),
396 }
397 } else if let Some(fresh) = fresh_base {
398 let snapshot = fresh?;
399 if let Some(key) = cache_key.as_ref() {
400 backend.save_cached_base(key, &snapshot);
401 }
402 AuditBase {
403 snapshot: Some(snapshot),
404 skipped: false,
405 inherited: inherited_snapshots(backend, root, inherited_base_refs, &focus),
406 }
407 } else {
408 AuditBase {
409 snapshot: Some(AuditKeySnapshot::from_view(&analyses.view())),
410 skipped: true,
411 inherited: Vec::new(),
412 }
413 };
414
415 let mut outcome = attribute(
416 &mut analyses,
417 AuditAttributionInput {
418 root,
419 gate,
420 worsened,
421 base_ref,
422 base,
423 renames: &renames,
424 shared_diff: backend.shared_diff(),
425 },
426 );
427 let diff_risk = diff_risk_of(
428 &analyses,
429 &outcome,
430 root,
431 base_ref,
432 &changed_files,
433 &renames,
434 );
435 outcome.apply_diff_risk(diff_risk);
436 Ok(Some(AuditRun {
437 analyses,
438 changed_files,
439 outcome,
440 }))
441}
442
443pub fn attribute<A: AuditAnalyses>(
451 analyses: &mut A,
452 input: AuditAttributionInput<'_>,
453) -> AuditOutcome {
454 let AuditAttributionInput {
455 root,
456 gate,
457 worsened,
458 base_ref,
459 base,
460 renames,
461 shared_diff,
462 } = input;
463 let AuditBase {
464 snapshot: mut base_snapshot,
465 skipped,
466 inherited,
467 } = base;
468 if matches!(gate, AuditGate::NewOnly)
469 && let Some(report) = analyses.health_report_mut()
470 {
471 crate::audit_complexity::demote_estimated_crap_gates(&mut report.findings);
472 }
473 if let Some(report) = analyses.health_report_mut() {
474 crate::audit_complexity::exempt_test_file_gates(&mut report.findings, root);
475 }
476 if !skipped && let Some(snapshot) = base_snapshot.as_mut() {
478 snapshot.remap_for_renames(renames, root);
479 for inherited in inherited {
480 snapshot.absorb_inherited(inherited);
481 }
482 if let Some(dead_code) = analyses.view().dead_code.as_ref() {
483 snapshot.inherit_unprovable_dead_code(dead_code);
484 }
485 }
486 let degrade_reason = {
487 let view = analyses.view();
488 type_aware_attribution_degrade_reason(
489 base_snapshot.as_ref(),
490 view.dead_code
491 .as_ref()
492 .and_then(|dead_code| dead_code.type_aware),
493 )
494 };
495 let type_aware_degrade_warning = degrade_reason.map(type_aware_degrade_warning);
496 if let Some(warning) = type_aware_degrade_warning.as_deref() {
497 analyses.record_type_aware_warning(warning);
498 }
499
500 let (comparison, dupe_demotion_diff_source, (attribution, verdict, summary)) = {
501 let view = analyses.view();
502 let mut comparison = compare(&view, base_snapshot.as_ref(), degrade_reason.is_some());
503 if worsened == AuditWorsened::Off {
504 comparison.clear_worsened();
505 }
506 let source = demote_preexisting_dupe_introductions(
507 &mut comparison,
508 &view,
509 root,
510 base_ref,
511 shared_diff,
512 );
513 let result = outcome(gate, worsened, &view, &comparison, base_snapshot.is_some());
514 (comparison, source, result)
515 };
516
517 if base_snapshot.is_some() {
518 if let Some(results) = analyses.dead_code_results_mut() {
519 comparison.dead_code.annotate_results(results);
520 }
521 if let Some(report) = analyses.health_report_mut() {
522 for ((finding, introduced), class) in report
523 .findings
524 .iter_mut()
525 .zip(comparison.health.introduced())
526 .zip(&comparison.health_classes)
527 {
528 finding.introduced = Some(introduced);
529 finding.attribution = Some(class.attribution());
530 finding.worsened_metrics.clone_from(&class.worsened_metrics);
531 }
532 }
533 }
534
535 AuditOutcome {
536 verdict,
537 summary,
538 attribution,
539 comparison,
540 base_snapshot,
541 base_snapshot_skipped: skipped,
542 dupe_demotion_diff_source,
543 type_aware_degrade_warning,
544 diff_risk: fallow_output::DiffRiskReport::default(),
545 }
546}
547
548fn diff_risk_of<A: AuditAnalyses>(
550 analyses: &A,
551 outcome: &AuditOutcome,
552 root: &Path,
553 base_ref: &str,
554 changed_files: &FxHashSet<PathBuf>,
555 renames: &[RenamedFile],
556) -> fallow_output::DiffRiskReport {
557 let view = analyses.view();
558 let default_rules = RulesConfig::default();
559 let rules = if let Some(dead_code) = view.dead_code.as_ref() {
560 crate::diff_risk::DiffRiskRules::Config(dead_code.config)
561 } else if let Some(health) = view.health.as_ref() {
562 crate::diff_risk::DiffRiskRules::Rules(health.rules)
563 } else {
564 crate::diff_risk::DiffRiskRules::Rules(&default_rules)
565 };
566 let head_clone_groups = view
567 .duplication
568 .as_ref()
569 .map(|duplication| {
570 duplication
571 .clone_groups
572 .iter()
573 .map(|group| crate::diff_risk::clone_spans(group, duplication.root))
574 .collect()
575 })
576 .unwrap_or_default();
577 let base_clone_groups = if outcome.base_snapshot_skipped || view.duplication.is_none() {
579 None
580 } else {
581 outcome
582 .base_snapshot
583 .as_ref()
584 .map(|snapshot| snapshot.dupe_groups.as_slice())
585 };
586 crate::diff_risk::detect(&crate::diff_risk::DiffRiskInput {
587 root,
588 base_ref,
589 changed_files,
590 renames,
591 rules,
592 base_clone_groups,
593 head_clone_groups,
594 })
595}
596
597fn inherited_snapshots<B: AuditBackend>(
603 backend: &B,
604 root: &Path,
605 refs: &[String],
606 focus: &FxHashSet<PathBuf>,
607) -> Vec<AuditKeySnapshot> {
608 refs.iter()
609 .filter_map(|inherited_ref| inherited_snapshot(backend, root, inherited_ref, focus).ok())
610 .collect()
611}
612
613fn inherited_snapshot<B: AuditBackend>(
614 backend: &B,
615 root: &Path,
616 inherited_ref: &str,
617 focus: &FxHashSet<PathBuf>,
618) -> Result<AuditKeySnapshot, B::Error> {
619 let cache_key = backend.base_cache_key(inherited_ref, focus)?;
620 if let Some(cached) = cache_key
621 .as_ref()
622 .and_then(|key| backend.load_cached_base(key))
623 {
624 return Ok(remapped(cached, root, inherited_ref));
625 }
626 let base_sha = cache_key
627 .as_ref()
628 .and_then(|key| backend.cached_base_sha(key));
629 let snapshot = base_snapshot(backend, root, inherited_ref, focus, base_sha)?;
630 if let Some(key) = cache_key.as_ref() {
631 backend.save_cached_base(key, &snapshot);
632 }
633 Ok(remapped(snapshot, root, inherited_ref))
634}
635
636fn remapped(mut snapshot: AuditKeySnapshot, root: &Path, base_ref: &str) -> AuditKeySnapshot {
637 snapshot.remap_for_renames(&renamed_files(root, base_ref), root);
638 snapshot
639}
640
641fn base_snapshot<B: AuditBackend>(
643 backend: &B,
644 root: &Path,
645 base_ref: &str,
646 focus: &FxHashSet<PathBuf>,
647 base_sha: Option<&str>,
648) -> Result<AuditKeySnapshot, B::Error> {
649 let checkout = backend.create_base_checkout(base_ref, base_sha)?;
650 let base_root = match resolve_base_analysis_root(root, checkout.path()) {
651 BaseAnalysisRoot::Present(base_root) => {
655 dunce::canonicalize(&base_root).unwrap_or(base_root)
656 }
657 BaseAnalysisRoot::NewInHead(_) => return Ok(AuditKeySnapshot::default()),
661 };
662 let base_focus = remap_focus_files(focus, root, &base_root);
663 let mut base = backend.run_base(&base_root, base_focus.as_ref())?;
664 if let Some(focus) = base_focus.as_ref() {
665 scope_dependency_findings_of(&mut base, focus);
666 }
667 let snapshot = AuditKeySnapshot::from_view(&base.view());
668 drop(checkout);
669 Ok(snapshot)
670}
671
672fn scope_dependency_findings_of<A: AuditAnalyses>(
674 analyses: &mut A,
675 changed_files: &FxHashSet<PathBuf>,
676) {
677 let Some(root) = analyses
678 .view()
679 .dead_code
680 .map(|dead_code| dead_code.root.to_path_buf())
681 else {
682 return;
683 };
684 if let Some(results) = analyses.dead_code_results_mut() {
685 scope_dependency_findings(results, &root, changed_files);
686 }
687}