1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_config::AuditGate;
5use fallow_engine::{
6 dead_code::DeadCodeAnalysisArtifacts,
7 project_analysis::ProjectAnalysisArtifactOptions,
8 repo_refs::{self, ResolvedAuditBase, TemporaryBaseWorktree},
9 session::AnalysisSession,
10};
11use fallow_output::build_audit_next_steps;
12use fallow_types::output::NextStep;
13use rustc_hash::FxHashSet;
14
15use crate::{
16 AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
17 AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
18 DeadCodeOptions, DuplicationOptions, ProgrammaticError,
19 analysis_context::{
20 ProgrammaticAnalysisContext, changed_files_for_run,
21 resolve_programmatic_analysis_context_deferred_workspace,
22 },
23};
24
25use super::{
26 ProgrammaticResult, health_may_consume_dead_code_artifacts,
27 health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
28 run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
29};
30
31pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
38 validate_audit_api_options(options)?;
39 let start = Instant::now();
40 let resolved_base = resolve_audit_base_ref(options)?;
41 let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
42 let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
43 let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
44 let changed_files_count = changed_files.len();
45
46 if changed_files.is_empty() {
47 return Ok(empty_audit_output(
48 options,
49 resolved_base,
50 resolved.root(),
51 changed_files_count,
52 start.elapsed(),
53 ));
54 }
55
56 let head =
57 run_audit_subanalyses_with_context(options, &analysis, &resolved, Some(&changed_files))?;
58 let current_snapshot = snapshot_from_analyses(&head);
59 let base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
60 Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
61 } else {
62 None
63 };
64 let summary = build_programmatic_audit_summary(&head);
65 let attribution = compute_programmatic_audit_attribution(
66 options.gate,
67 ¤t_snapshot,
68 base_snapshot.as_ref(),
69 );
70 let verdict = compute_programmatic_audit_verdict(
71 options.gate,
72 &summary,
73 &head.duplication,
74 ¤t_snapshot,
75 base_snapshot.as_ref(),
76 );
77 let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
78
79 Ok(AuditProgrammaticOutput {
80 verdict,
81 summary,
82 attribution,
83 changed_files_count,
84 base_ref: resolved_base.git_ref,
85 base_description: resolved_base.description,
86 head_sha: repo_refs::short_head_sha(resolved.root()),
87 elapsed: start.elapsed(),
88 base_snapshot_skipped: None,
89 base_snapshot,
90 dead_code: Some(head.dead_code),
91 duplication: Some(head.duplication),
92 complexity: Some(head.complexity),
93 next_steps,
94 envelope_mode: root_envelope_mode(),
95 telemetry_analysis_run_id: None,
96 })
97}
98
99fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
100 if let Err(err) =
101 fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
102 {
103 return Err(ProgrammaticError::new(err, 2)
104 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
105 .with_context("audit.coverageRoot"));
106 }
107 if options.runtime_coverage.is_some() {
108 return Err(ProgrammaticError::new(
109 "programmatic audit does not yet support runtime coverage; use the CLI path",
110 2,
111 )
112 .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
113 .with_context("audit.runtimeCoverage"));
114 }
115 Ok(())
116}
117
118pub(super) fn resolve_audit_base_ref(
119 options: &AuditOptions,
120) -> ProgrammaticResult<ResolvedAuditBase> {
121 if let Some(ref_str) = options
122 .base
123 .as_deref()
124 .or(options.analysis.changed_since.as_deref())
125 {
126 validate_git_ref(ref_str, "audit.base")?;
127 return Ok(ResolvedAuditBase {
128 git_ref: (*ref_str).to_string(),
129 description: None,
130 });
131 }
132 if let Some(env_ref) = audit_base_env_override() {
133 validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
134 return Ok(ResolvedAuditBase {
135 description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
136 git_ref: env_ref,
137 });
138 }
139 let root = options
140 .analysis
141 .root
142 .clone()
143 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
144 repo_refs::auto_detect_audit_base_ref(&root).ok_or_else(|| {
145 ProgrammaticError::new(
146 "could not detect base branch. Set audit.base to specify the comparison target",
147 2,
148 )
149 .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
150 .with_context("audit.base")
151 })
152}
153
154fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
155 AnalysisOptions {
156 changed_since: Some(base_ref.to_string()),
157 production: options.production,
158 production_override: options.production.then_some(true),
159 ..options.analysis.clone()
160 }
161}
162
163fn analysis_with_production(
164 analysis: &AnalysisOptions,
165 production_override: Option<bool>,
166) -> AnalysisOptions {
167 AnalysisOptions {
168 production: production_override.unwrap_or(analysis.production),
169 production_override: production_override.or(analysis.production_override),
170 ..analysis.clone()
171 }
172}
173
174fn empty_audit_output(
175 options: &AuditOptions,
176 base: ResolvedAuditBase,
177 root: &Path,
178 changed_files_count: usize,
179 elapsed: std::time::Duration,
180) -> AuditProgrammaticOutput {
181 AuditProgrammaticOutput {
182 verdict: AuditVerdict::Pass,
183 summary: AuditSummary {
184 dead_code_issues: 0,
185 dead_code_has_errors: false,
186 complexity_findings: 0,
187 max_cyclomatic: None,
188 duplication_clone_groups: 0,
189 },
190 attribution: AuditAttribution {
191 gate: options.gate,
192 ..AuditAttribution::default()
193 },
194 changed_files_count,
195 base_ref: base.git_ref,
196 base_description: base.description,
197 head_sha: repo_refs::short_head_sha(root),
198 elapsed,
199 base_snapshot_skipped: None,
200 base_snapshot: None,
201 dead_code: None,
202 duplication: None,
203 complexity: None,
204 next_steps: Vec::new(),
205 envelope_mode: root_envelope_mode(),
206 telemetry_analysis_run_id: None,
207 }
208}
209
210struct AuditSubanalyses {
211 dead_code: crate::DeadCodeProgrammaticOutput,
212 duplication: crate::DuplicationProgrammaticOutput,
213 complexity: crate::HealthProgrammaticOutput,
214}
215
216struct AuditSubanalysisOptions {
217 dead_code: DeadCodeOptions,
218 duplication: DuplicationOptions,
219 complexity: ComplexityOptions,
220}
221
222fn audit_subanalysis_options(
223 options: &AuditOptions,
224 analysis: &AnalysisOptions,
225) -> AuditSubanalysisOptions {
226 AuditSubanalysisOptions {
227 dead_code: DeadCodeOptions {
228 analysis: analysis_with_production(analysis, options.production_dead_code),
229 filters: DeadCodeFilters::default(),
230 files: Vec::new(),
231 include_entry_exports: options.include_entry_exports,
232 },
233 duplication: DuplicationOptions {
234 analysis: analysis_with_production(analysis, options.production_dupes),
235 ..DuplicationOptions::default()
236 },
237 complexity: ComplexityOptions {
238 analysis: analysis_with_production(analysis, options.production_health),
239 max_crap: options.max_crap,
240 complexity: true,
241 css: options.css.unwrap_or(true),
242 css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
243 coverage: options.coverage.clone(),
244 coverage_root: options.coverage_root.clone(),
245 ..ComplexityOptions::default()
246 },
247 }
248}
249
250fn run_audit_subanalyses(
251 options: &AuditOptions,
252 analysis: &AnalysisOptions,
253 changed_files: Option<&FxHashSet<PathBuf>>,
254) -> ProgrammaticResult<AuditSubanalyses> {
255 let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
256 run_audit_subanalyses_with_context(options, analysis, &resolved, changed_files)
257}
258
259fn run_audit_subanalyses_with_context(
260 options: &AuditOptions,
261 analysis: &AnalysisOptions,
262 resolved: &ProgrammaticAnalysisContext,
263 changed_files: Option<&FxHashSet<PathBuf>>,
264) -> ProgrammaticResult<AuditSubanalyses> {
265 let subanalysis_options = audit_subanalysis_options(options, analysis);
266 let production_modes = resolve_effective_production_modes(
267 resolved,
268 options.production_dead_code,
269 options.production_health,
270 options.production_dupes,
271 )?;
272
273 if production_modes.dead_code == production_modes.dupes
274 && production_modes.dead_code == production_modes.health
275 {
276 return run_shared_project_audit_subanalyses(&subanalysis_options, resolved, changed_files);
277 }
278
279 if production_modes.dead_code == production_modes.health {
280 return run_shared_dead_code_health_audit_subanalyses(
281 &subanalysis_options,
282 resolved,
283 changed_files,
284 );
285 }
286
287 if production_modes.dead_code == production_modes.dupes {
288 return run_shared_dead_code_dupes_audit_subanalyses(
289 &subanalysis_options,
290 resolved,
291 changed_files,
292 );
293 }
294
295 Ok(AuditSubanalyses {
296 dead_code: run_dead_code(&subanalysis_options.dead_code)?,
297 duplication: run_duplication(&subanalysis_options.duplication)?,
298 complexity: run_health(&subanalysis_options.complexity)?,
299 })
300}
301
302fn run_shared_project_audit_subanalyses(
303 options: &AuditSubanalysisOptions,
304 resolved: &ProgrammaticAnalysisContext,
305 changed_files: Option<&FxHashSet<PathBuf>>,
306) -> ProgrammaticResult<AuditSubanalyses> {
307 resolved.install(|| {
308 let session = super::dead_code::load_dead_code_session(&options.dead_code, resolved)?;
309 run_all_audit_subanalyses_with_project_artifacts(
310 &options.dead_code,
311 &options.duplication,
312 &options.complexity,
313 resolved,
314 &session,
315 changed_files,
316 )
317 })
318}
319
320fn run_shared_dead_code_health_audit_subanalyses(
321 options: &AuditSubanalysisOptions,
322 resolved: &ProgrammaticAnalysisContext,
323 changed_files: Option<&FxHashSet<PathBuf>>,
324) -> ProgrammaticResult<AuditSubanalyses> {
325 resolved.install(|| {
326 let dead_code_options = &options.dead_code;
327 let duplication_options = &options.duplication;
328 let complexity_options = &options.complexity;
329 let session = super::dead_code::load_dead_code_session(dead_code_options, resolved)?;
330 let (dead_code, complexity) = run_dead_code_and_health_with_session(
331 dead_code_options,
332 complexity_options,
333 resolved,
334 &session,
335 changed_files,
336 )?;
337 Ok(AuditSubanalyses {
338 dead_code,
339 duplication: run_duplication(duplication_options)?,
340 complexity,
341 })
342 })
343}
344
345fn run_shared_dead_code_dupes_audit_subanalyses(
346 options: &AuditSubanalysisOptions,
347 resolved: &ProgrammaticAnalysisContext,
348 changed_files: Option<&FxHashSet<PathBuf>>,
349) -> ProgrammaticResult<AuditSubanalyses> {
350 resolved.install(|| {
351 let session = super::dead_code::load_dead_code_session(&options.dead_code, resolved)?;
352 let (dead_code, duplication, _, _) =
353 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
354 dead_code_options: &options.dead_code,
355 duplication_options: &options.duplication,
356 resolved,
357 session: &session,
358 changed_files,
359 retain_dead_code_artifacts: false,
360 retain_duplication_artifacts: false,
361 })?;
362 Ok(AuditSubanalyses {
363 dead_code,
364 duplication,
365 complexity: run_health(&options.complexity)?,
366 })
367 })
368}
369
370fn run_dead_code_and_duplication_with_project_artifacts(
371 input: ProjectArtifactAuditInput<'_>,
372) -> ProgrammaticResult<(
373 crate::DeadCodeProgrammaticOutput,
374 crate::DuplicationProgrammaticOutput,
375 Option<DeadCodeAnalysisArtifacts>,
376 Option<fallow_engine::duplicates::DuplicationReport>,
377)> {
378 let dupes_config = super::duplication::build_dupes_config(
379 input.duplication_options,
380 &input.session.config().duplicates,
381 );
382 let section_start = Instant::now();
383 let project = input
384 .session
385 .analyze_project_with_artifacts(
386 &dupes_config,
387 ProjectAnalysisArtifactOptions {
388 retain_complexity_artifacts: input.retain_dead_code_artifacts,
389 retain_graph: input.retain_dead_code_artifacts,
390 changed_files: input.changed_files.cloned(),
391 collect_source_fingerprints: false,
392 },
393 )
394 .map_err(|err| {
395 ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
396 .with_code("FALLOW_AUDIT_FAILED")
397 .with_context("audit")
398 })?;
399 let duplication_artifacts = input
400 .retain_duplication_artifacts
401 .then(|| project.duplication.clone());
402 let dead_code = super::dead_code::run_dead_code_from_artifacts(
403 input.dead_code_options,
404 input.resolved,
405 input.session,
406 input.changed_files,
407 project.dead_code,
408 section_start,
409 )?;
410 let duplication = super::duplication::run_duplication_report_with_session(
411 input.duplication_options,
412 input.resolved,
413 input.session,
414 project.duplication,
415 section_start,
416 )?;
417 let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
418 output: dead_code,
419 artifacts,
420 } = dead_code;
421 let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
422 Ok((
423 dead_code,
424 duplication,
425 dead_code_artifacts,
426 duplication_artifacts,
427 ))
428}
429
430#[derive(Clone, Copy)]
431struct ProjectArtifactAuditInput<'a> {
432 dead_code_options: &'a DeadCodeOptions,
433 duplication_options: &'a DuplicationOptions,
434 resolved: &'a ProgrammaticAnalysisContext,
435 session: &'a AnalysisSession,
436 changed_files: Option<&'a FxHashSet<PathBuf>>,
437 retain_dead_code_artifacts: bool,
438 retain_duplication_artifacts: bool,
439}
440
441fn run_all_audit_subanalyses_with_project_artifacts(
442 dead_code_options: &DeadCodeOptions,
443 duplication_options: &DuplicationOptions,
444 complexity_options: &ComplexityOptions,
445 resolved: &ProgrammaticAnalysisContext,
446 session: &AnalysisSession,
447 changed_files: Option<&FxHashSet<PathBuf>>,
448) -> ProgrammaticResult<AuditSubanalyses> {
449 let retain_dead_code_artifacts =
450 health_may_consume_dead_code_artifacts(complexity_options, session.config());
451 let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
452 let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
453 run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
454 dead_code_options,
455 duplication_options,
456 resolved,
457 session,
458 changed_files,
459 retain_dead_code_artifacts,
460 retain_duplication_artifacts,
461 })?;
462 let complexity = run_health_with_session_artifacts(
463 complexity_options,
464 resolved,
465 session,
466 changed_files,
467 dead_code_artifacts,
468 duplication_artifacts,
469 )?;
470 Ok(AuditSubanalyses {
471 dead_code,
472 duplication,
473 complexity,
474 })
475}
476
477fn run_dead_code_and_health_with_session(
478 dead_code_options: &DeadCodeOptions,
479 complexity_options: &ComplexityOptions,
480 resolved: &ProgrammaticAnalysisContext,
481 session: &AnalysisSession,
482 changed_files: Option<&FxHashSet<PathBuf>>,
483) -> ProgrammaticResult<(
484 crate::DeadCodeProgrammaticOutput,
485 crate::HealthProgrammaticOutput,
486)> {
487 let reuse_dead_code_artifacts =
488 health_may_consume_dead_code_artifacts(complexity_options, session.config());
489 let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
490 let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
491 dead_code_options,
492 resolved,
493 session,
494 changed_files,
495 |_| {},
496 Instant::now(),
497 )?;
498 (dead_code.output, Some(dead_code.artifacts))
499 } else {
500 (
501 super::dead_code::run_dead_code_with_session(
502 dead_code_options,
503 resolved,
504 session,
505 changed_files,
506 |_| {},
507 Instant::now(),
508 )?,
509 None,
510 )
511 };
512 let complexity = run_health_with_session_artifacts(
513 complexity_options,
514 resolved,
515 session,
516 changed_files,
517 dead_code_artifacts,
518 None,
519 )?;
520 Ok((dead_code, complexity))
521}
522
523fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
524 let dead_code_issues = analyses.dead_code.output.results.total_issues();
525 AuditSummary {
526 dead_code_issues,
527 dead_code_has_errors: dead_code_issues > 0,
528 complexity_findings: analyses.complexity.report.findings.len(),
529 max_cyclomatic: analyses
530 .complexity
531 .report
532 .findings
533 .iter()
534 .map(|finding| finding.cyclomatic)
535 .max(),
536 duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
537 }
538}
539
540fn compute_programmatic_audit_verdict(
541 gate: AuditGate,
542 summary: &AuditSummary,
543 duplication: &crate::DuplicationProgrammaticOutput,
544 current: &AuditProgrammaticKeySnapshot,
545 base: Option<&AuditProgrammaticKeySnapshot>,
546) -> AuditVerdict {
547 if matches!(gate, AuditGate::NewOnly) {
548 return compute_programmatic_introduced_verdict(summary, duplication, current, base);
549 }
550 if summary.dead_code_has_errors || summary.complexity_findings > 0 {
551 return AuditVerdict::Fail;
552 }
553 if summary.duplication_clone_groups > 0 {
554 let pct = duplication.output.report.stats.duplication_percentage;
555 if duplication.threshold > 0.0 && pct > duplication.threshold {
556 return AuditVerdict::Fail;
557 }
558 return AuditVerdict::Warn;
559 }
560 AuditVerdict::Pass
561}
562
563fn compute_programmatic_introduced_verdict(
564 summary: &AuditSummary,
565 duplication: &crate::DuplicationProgrammaticOutput,
566 current: &AuditProgrammaticKeySnapshot,
567 base: Option<&AuditProgrammaticKeySnapshot>,
568) -> AuditVerdict {
569 let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
570 if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
571 return AuditVerdict::Fail;
572 }
573 if attribution.duplication_introduced > 0 {
574 let pct = duplication.output.report.stats.duplication_percentage;
575 if duplication.threshold > 0.0 && pct > duplication.threshold {
576 return AuditVerdict::Fail;
577 }
578 return AuditVerdict::Warn;
579 }
580 if summary.dead_code_issues == 0
581 && summary.complexity_findings == 0
582 && summary.duplication_clone_groups == 0
583 {
584 return AuditVerdict::Pass;
585 }
586 AuditVerdict::Pass
587}
588
589fn compute_programmatic_audit_attribution(
590 gate: AuditGate,
591 current: &AuditProgrammaticKeySnapshot,
592 base: Option<&AuditProgrammaticKeySnapshot>,
593) -> AuditAttribution {
594 let dead_code = count_introduced(¤t.dead_code, base.map(|snapshot| &snapshot.dead_code));
595 let complexity = count_introduced(¤t.health, base.map(|snapshot| &snapshot.health));
596 let duplication = count_introduced(¤t.dupes, base.map(|snapshot| &snapshot.dupes));
597 AuditAttribution {
598 gate,
599 dead_code_introduced: dead_code.0,
600 dead_code_inherited: dead_code.1,
601 complexity_introduced: complexity.0,
602 complexity_inherited: complexity.1,
603 duplication_introduced: duplication.0,
604 duplication_inherited: duplication.1,
605 }
606}
607
608fn count_introduced(
609 keys: &rustc_hash::FxHashSet<String>,
610 base: Option<&rustc_hash::FxHashSet<String>>,
611) -> (usize, usize) {
612 let Some(base) = base else {
613 return (0, 0);
614 };
615 keys.iter().fold((0, 0), |(introduced, inherited), key| {
616 if base.contains(key) {
617 (introduced, inherited + 1)
618 } else {
619 (introduced + 1, inherited)
620 }
621 })
622}
623
624fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
625 AuditProgrammaticKeySnapshot {
626 dead_code: crate::audit_keys::dead_code_keys(
627 &analyses.dead_code.output.results,
628 &analyses.dead_code.root,
629 ),
630 health: crate::audit_keys::health_keys(
631 &analyses.complexity.report,
632 &analyses.complexity.root,
633 ),
634 dupes: analyses
635 .duplication
636 .output
637 .report
638 .clone_groups
639 .iter()
640 .map(|group| {
641 crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
642 })
643 .collect(),
644 }
645}
646
647fn compute_base_snapshot(
648 options: &AuditOptions,
649 base_ref: &str,
650) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
651 let current_root = analysis_root_from_options(options)?;
652 let worktree = TemporaryBaseWorktree::create(¤t_root, base_ref).map_err(|err| {
653 ProgrammaticError::new(err.to_string(), 2)
654 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
655 .with_context("audit.base")
656 })?;
657 let base_root = repo_refs::base_analysis_root(¤t_root, worktree.path());
658 let current_config_path = options
659 .analysis
660 .config_path
661 .clone()
662 .or_else(|| fallow_config::FallowConfig::find_config_path(¤t_root));
663 let base_analysis = AnalysisOptions {
664 root: Some(base_root),
665 config_path: current_config_path,
666 changed_since: None,
667 explain: false,
668 ..options.analysis.clone()
669 };
670 let base = run_audit_subanalyses(options, &base_analysis, None)?;
671 Ok(snapshot_from_analyses(&base))
672}
673
674fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
675 match options.analysis.root.clone() {
676 Some(root) => Ok(root),
677 None => std::env::current_dir().map_err(|err| {
678 ProgrammaticError::new(
679 format!("failed to resolve current working directory: {err}"),
680 2,
681 )
682 .with_code("FALLOW_CWD_UNAVAILABLE")
683 .with_context("analysis.root")
684 }),
685 }
686}
687
688fn audit_next_steps(
689 dead_code: &crate::DeadCodeProgrammaticOutput,
690 complexity: &crate::HealthProgrammaticOutput,
691) -> Vec<NextStep> {
692 let input = fallow_output::build_audit_next_steps_input(
693 Some((&dead_code.output.results, dead_code.root.as_path())),
694 Some(&complexity.report),
695 crate::next_steps::suggestions_enabled(),
696 );
697 build_audit_next_steps(&input)
698}
699
700fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
701 fallow_engine::validate::validate_git_ref(value)
702 .map(|_| ())
703 .map_err(|err| {
704 ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
705 .with_code("FALLOW_INVALID_GIT_REF")
706 .with_context(context)
707 })
708}
709
710fn audit_base_env_override() -> Option<String> {
711 std::env::var("FALLOW_AUDIT_BASE")
712 .ok()
713 .map(|value| value.trim().to_string())
714 .filter(|value| !value.is_empty())
715}
716
717#[cfg(test)]
718mod tests {
719 use std::process::Command;
720
721 use fallow_config::{AuditGate, FallowConfig, HealthConfig};
722 use fallow_types::output_format::OutputFormat;
723
724 use super::*;
725
726 fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
727 FallowConfig {
728 health: HealthConfig {
729 max_crap,
730 ..HealthConfig::default()
731 },
732 ..FallowConfig::default()
733 }
734 .resolve(
735 std::env::temp_dir().join("fallow-api-runtime-test"),
736 OutputFormat::Json,
737 1,
738 true,
739 true,
740 None,
741 )
742 }
743
744 #[test]
745 fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
746 let options = ComplexityOptions {
747 complexity: true,
748 ..ComplexityOptions::default()
749 };
750 let config = resolved_config_with_max_crap(0.0);
751
752 assert!(!health_may_consume_dead_code_artifacts(&options, &config));
753 }
754
755 #[test]
756 fn audit_health_artifact_reuse_tracks_config_max_crap() {
757 let options = ComplexityOptions {
758 complexity: true,
759 ..ComplexityOptions::default()
760 };
761 let config = resolved_config_with_max_crap(30.0);
762
763 assert!(health_may_consume_dead_code_artifacts(&options, &config));
764 }
765
766 #[test]
767 fn audit_health_artifact_reuse_tracks_file_score_inputs() {
768 let config = resolved_config_with_max_crap(0.0);
769 for options in [
770 ComplexityOptions {
771 file_scores: true,
772 ..ComplexityOptions::default()
773 },
774 ComplexityOptions {
775 coverage_gaps: true,
776 ..ComplexityOptions::default()
777 },
778 ComplexityOptions {
779 targets: true,
780 ..ComplexityOptions::default()
781 },
782 ComplexityOptions {
783 score: true,
784 ..ComplexityOptions::default()
785 },
786 ComplexityOptions {
787 max_crap: Some(30.0),
788 complexity: true,
789 ..ComplexityOptions::default()
790 },
791 ] {
792 assert!(health_may_consume_dead_code_artifacts(&options, &config));
793 }
794 }
795
796 #[test]
797 fn audit_health_duplication_reuse_tracks_score_and_targets() {
798 for options in [
799 ComplexityOptions {
800 score: true,
801 ..ComplexityOptions::default()
802 },
803 ComplexityOptions {
804 targets: true,
805 ..ComplexityOptions::default()
806 },
807 ] {
808 assert!(health_may_consume_duplication_report(&options));
809 }
810
811 assert!(!health_may_consume_duplication_report(&ComplexityOptions {
812 complexity: true,
813 ..ComplexityOptions::default()
814 }));
815 }
816
817 #[test]
818 fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
819 let project = audit_fixture();
820 let output = run_audit(&AuditOptions {
821 analysis: AnalysisOptions {
822 root: Some(project.path().to_path_buf()),
823 no_cache: true,
824 explain: true,
825 ..AnalysisOptions::default()
826 },
827 base: Some("HEAD".to_string()),
828 gate: AuditGate::NewOnly,
829 ..AuditOptions::default()
830 })
831 .expect("audit output");
832
833 assert_eq!(output.verdict, AuditVerdict::Fail);
834 assert_eq!(output.summary.dead_code_issues, 1);
835 assert_eq!(output.attribution.dead_code_introduced, 1);
836 assert!(output.base_snapshot.is_some());
837
838 let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
839 assert_eq!(
840 json["dead_code"]["unused_files"][0]["path"],
841 "src/feature.ts"
842 );
843 assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
844 }
845
846 #[test]
847 fn empty_audit_output_uses_resolved_root_for_head_sha() {
848 let project = audit_fixture();
849 let output = empty_audit_output(
850 &AuditOptions {
851 analysis: AnalysisOptions {
852 root: None,
853 ..AnalysisOptions::default()
854 },
855 base: Some("HEAD".to_string()),
856 gate: AuditGate::NewOnly,
857 ..AuditOptions::default()
858 },
859 ResolvedAuditBase {
860 git_ref: "HEAD".to_string(),
861 description: None,
862 },
863 project.path(),
864 0,
865 std::time::Duration::ZERO,
866 );
867
868 assert!(output.head_sha.is_some());
869 }
870
871 fn audit_fixture() -> tempfile::TempDir {
872 let project = tempfile::tempdir().expect("project");
873 std::fs::create_dir_all(project.path().join("src")).expect("create src");
874 std::fs::write(
875 project.path().join("package.json"),
876 r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
877 )
878 .expect("write package");
879 std::fs::write(
880 project.path().join("src/index.ts"),
881 "console.log('entry');\n",
882 )
883 .expect("write entry");
884 git(project.path(), &["init"]);
885 git(project.path(), &["add", "."]);
886 git(
887 project.path(),
888 &[
889 "-c",
890 "user.email=test@example.com",
891 "-c",
892 "user.name=Test",
893 "-c",
894 "commit.gpgsign=false",
895 "commit",
896 "-m",
897 "initial",
898 ],
899 );
900 std::fs::write(
901 project.path().join("src/feature.ts"),
902 "export const unused = 1;\n",
903 )
904 .expect("write changed source");
905 project
906 }
907
908 fn git(root: &Path, args: &[&str]) {
909 let status = Command::new("git")
910 .args(args)
911 .current_dir(root)
912 .status()
913 .expect("git command");
914 assert!(status.success(), "git {args:?} failed");
915 }
916}