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, 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};
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 base_ref: &'a str,
226 pub cache_dir: Option<&'a Path>,
229 pub changed_files: FxHashSet<PathBuf>,
231}
232
233#[derive(Debug, Default)]
235pub struct AuditBase {
236 pub snapshot: Option<AuditKeySnapshot>,
238 pub skipped: bool,
241}
242
243pub struct AuditAttributionInput<'a> {
245 pub root: &'a Path,
247 pub gate: AuditGate,
249 pub base_ref: &'a str,
251 pub base: AuditBase,
253 pub renames: &'a [RenamedFile],
255 pub shared_diff: Option<SharedDiff<'a>>,
257}
258
259#[derive(Debug)]
261pub struct AuditOutcome {
262 pub verdict: AuditVerdict,
264 pub summary: AuditSummary,
266 pub attribution: AuditAttribution,
268 pub comparison: AuditComparison,
270 pub base_snapshot: Option<AuditKeySnapshot>,
272 pub base_snapshot_skipped: bool,
274 pub dupe_demotion_diff_source: Option<DupeDemotionDiffSource>,
277 pub type_aware_degrade_warning: Option<String>,
280}
281
282pub(crate) fn programmatic_base_snapshot(
285 outcome: &AuditOutcome,
286) -> Option<crate::AuditProgrammaticKeySnapshot> {
287 outcome
288 .base_snapshot
289 .as_ref()
290 .map(AuditKeySnapshot::to_programmatic)
291}
292
293pub struct AuditRun<A> {
295 pub analyses: A,
298 pub changed_files: FxHashSet<PathBuf>,
300 pub outcome: AuditOutcome,
302}
303
304pub fn run<B: AuditBackend>(
311 backend: &B,
312 input: AuditRunInput<'_>,
313) -> Result<Option<AuditRun<B::Analyses>>, B::Error> {
314 let AuditRunInput {
315 root,
316 gate,
317 base_ref,
318 cache_dir,
319 changed_files,
320 } = input;
321 if changed_files.is_empty() {
322 return Ok(None);
323 }
324 backend.prepare();
325
326 let needs_real_base = matches!(gate, AuditGate::NewOnly)
327 && !can_reuse_current_as_base(root, cache_dir, base_ref, &changed_files);
328 let renames = if needs_real_base {
329 renamed_files(root, base_ref)
330 } else {
331 Vec::new()
332 };
333 let focus = base_focus_files(&changed_files, &renames);
334 let cache_key = if needs_real_base {
335 backend.base_cache_key(base_ref, &focus)?
336 } else {
337 None
338 };
339 let cached = cache_key
340 .as_ref()
341 .and_then(|key| backend.load_cached_base(key));
342
343 let (head, fresh_base) = if needs_real_base && cached.is_none() {
344 let base_sha = cache_key
345 .as_ref()
346 .and_then(|key| backend.cached_base_sha(key));
347 let (head, base) = rayon::join(
348 || backend.run_head(&changed_files),
349 || base_snapshot(backend, root, base_ref, &focus, base_sha),
350 );
351 (head, Some(base))
352 } else {
353 (backend.run_head(&changed_files), None)
354 };
355 let mut analyses = head?;
356 scope_dependency_findings_of(&mut analyses, &changed_files);
357
358 let base = if !matches!(gate, AuditGate::NewOnly) {
359 AuditBase::default()
360 } else if let Some(snapshot) = cached {
361 AuditBase {
362 snapshot: Some(snapshot),
363 skipped: false,
364 }
365 } else if let Some(fresh) = fresh_base {
366 let snapshot = fresh?;
367 if let Some(key) = cache_key.as_ref() {
368 backend.save_cached_base(key, &snapshot);
369 }
370 AuditBase {
371 snapshot: Some(snapshot),
372 skipped: false,
373 }
374 } else {
375 AuditBase {
376 snapshot: Some(AuditKeySnapshot::from_view(&analyses.view())),
377 skipped: true,
378 }
379 };
380
381 let outcome = attribute(
382 &mut analyses,
383 AuditAttributionInput {
384 root,
385 gate,
386 base_ref,
387 base,
388 renames: &renames,
389 shared_diff: backend.shared_diff(),
390 },
391 );
392 Ok(Some(AuditRun {
393 analyses,
394 changed_files,
395 outcome,
396 }))
397}
398
399pub fn attribute<A: AuditAnalyses>(
402 analyses: &mut A,
403 input: AuditAttributionInput<'_>,
404) -> AuditOutcome {
405 let AuditAttributionInput {
406 root,
407 gate,
408 base_ref,
409 base,
410 renames,
411 shared_diff,
412 } = input;
413 let AuditBase {
414 snapshot: mut base_snapshot,
415 skipped,
416 } = base;
417 if !skipped && let Some(snapshot) = base_snapshot.as_mut() {
419 snapshot.remap_for_renames(renames, root);
420 }
421 let degrade_reason = {
422 let view = analyses.view();
423 type_aware_attribution_degrade_reason(
424 base_snapshot.as_ref(),
425 view.dead_code
426 .as_ref()
427 .and_then(|dead_code| dead_code.type_aware),
428 )
429 };
430 let type_aware_degrade_warning = degrade_reason.map(type_aware_degrade_warning);
431 if let Some(warning) = type_aware_degrade_warning.as_deref() {
432 analyses.record_type_aware_warning(warning);
433 }
434
435 let (comparison, dupe_demotion_diff_source, (attribution, verdict, summary)) = {
436 let view = analyses.view();
437 let mut comparison = compare(&view, base_snapshot.as_ref(), degrade_reason.is_some());
438 let source = demote_preexisting_dupe_introductions(
439 &mut comparison,
440 &view,
441 root,
442 base_ref,
443 shared_diff,
444 );
445 let result = outcome(gate, &view, &comparison, base_snapshot.is_some());
446 (comparison, source, result)
447 };
448
449 if base_snapshot.is_some() {
450 if let Some(results) = analyses.dead_code_results_mut() {
451 comparison.dead_code.annotate_results(results);
452 }
453 if let Some(report) = analyses.health_report_mut() {
454 for (finding, introduced) in report
455 .findings
456 .iter_mut()
457 .zip(comparison.health.introduced())
458 {
459 finding.introduced = Some(introduced);
460 }
461 }
462 }
463
464 AuditOutcome {
465 verdict,
466 summary,
467 attribution,
468 comparison,
469 base_snapshot,
470 base_snapshot_skipped: skipped,
471 dupe_demotion_diff_source,
472 type_aware_degrade_warning,
473 }
474}
475
476fn base_snapshot<B: AuditBackend>(
478 backend: &B,
479 root: &Path,
480 base_ref: &str,
481 focus: &FxHashSet<PathBuf>,
482 base_sha: Option<&str>,
483) -> Result<AuditKeySnapshot, B::Error> {
484 let checkout = backend.create_base_checkout(base_ref, base_sha)?;
485 let base_root = match resolve_base_analysis_root(root, checkout.path()) {
486 BaseAnalysisRoot::Present(base_root) => {
490 dunce::canonicalize(&base_root).unwrap_or(base_root)
491 }
492 BaseAnalysisRoot::NewInHead(_) => return Ok(AuditKeySnapshot::default()),
496 };
497 let base_focus = remap_focus_files(focus, root, &base_root);
498 let mut base = backend.run_base(&base_root, base_focus.as_ref())?;
499 if let Some(focus) = base_focus.as_ref() {
500 scope_dependency_findings_of(&mut base, focus);
501 }
502 let snapshot = AuditKeySnapshot::from_view(&base.view());
503 drop(checkout);
504 Ok(snapshot)
505}
506
507fn scope_dependency_findings_of<A: AuditAnalyses>(
509 analyses: &mut A,
510 changed_files: &FxHashSet<PathBuf>,
511) {
512 let Some(root) = analyses
513 .view()
514 .dead_code
515 .map(|dead_code| dead_code.root.to_path_buf())
516 else {
517 return;
518 };
519 if let Some(results) = analyses.dead_code_results_mut() {
520 scope_dependency_findings(results, &root, changed_files);
521 }
522}