Skip to main content

fallow_api/runtime/
audit.rs

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
31/// Run changed-code audit through typed programmatic runners.
32///
33/// # Errors
34///
35/// Returns a structured error for invalid options, base-ref discovery failures,
36/// unsupported CLI-only audit surfaces, or analysis failures.
37pub 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        &current_snapshot,
68        base_snapshot.as_ref(),
69    );
70    let verdict = compute_programmatic_audit_verdict(
71        options.gate,
72        &summary,
73        &head.duplication,
74        &current_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, changed_files);
277    }
278
279    if production_modes.dead_code == production_modes.health {
280        return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
281    }
282
283    if production_modes.dead_code == production_modes.dupes {
284        return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
285    }
286
287    Ok(AuditSubanalyses {
288        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
289        duplication: run_duplication(&subanalysis_options.duplication)?,
290        complexity: run_health(&subanalysis_options.complexity)?,
291    })
292}
293
294fn run_shared_project_audit_subanalyses(
295    options: &AuditSubanalysisOptions,
296    changed_files: Option<&FxHashSet<PathBuf>>,
297) -> ProgrammaticResult<AuditSubanalyses> {
298    let resolved =
299        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
300    resolved.install(|| {
301        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
302        run_all_audit_subanalyses_with_project_artifacts(
303            &options.dead_code,
304            &options.duplication,
305            &options.complexity,
306            &resolved,
307            &session,
308            changed_files,
309        )
310    })
311}
312
313fn run_shared_dead_code_health_audit_subanalyses(
314    options: &AuditSubanalysisOptions,
315    changed_files: Option<&FxHashSet<PathBuf>>,
316) -> ProgrammaticResult<AuditSubanalyses> {
317    let resolved =
318        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
319    resolved.install(|| {
320        let dead_code_options = &options.dead_code;
321        let duplication_options = &options.duplication;
322        let complexity_options = &options.complexity;
323        let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
324        let (dead_code, complexity) = run_dead_code_and_health_with_session(
325            dead_code_options,
326            complexity_options,
327            &resolved,
328            &session,
329            changed_files,
330        )?;
331        Ok(AuditSubanalyses {
332            dead_code,
333            duplication: run_duplication(duplication_options)?,
334            complexity,
335        })
336    })
337}
338
339fn run_shared_dead_code_dupes_audit_subanalyses(
340    options: &AuditSubanalysisOptions,
341    changed_files: Option<&FxHashSet<PathBuf>>,
342) -> ProgrammaticResult<AuditSubanalyses> {
343    let resolved =
344        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
345    resolved.install(|| {
346        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
347        let (dead_code, duplication, _, _) =
348            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
349                dead_code_options: &options.dead_code,
350                duplication_options: &options.duplication,
351                resolved: &resolved,
352                session: &session,
353                changed_files,
354                retain_dead_code_artifacts: false,
355                retain_duplication_artifacts: false,
356            })?;
357        Ok(AuditSubanalyses {
358            dead_code,
359            duplication,
360            complexity: run_health(&options.complexity)?,
361        })
362    })
363}
364
365fn run_dead_code_and_duplication_with_project_artifacts(
366    input: ProjectArtifactAuditInput<'_>,
367) -> ProgrammaticResult<(
368    crate::DeadCodeProgrammaticOutput,
369    crate::DuplicationProgrammaticOutput,
370    Option<DeadCodeAnalysisArtifacts>,
371    Option<fallow_engine::duplicates::DuplicationReport>,
372)> {
373    let dupes_config = super::duplication::build_dupes_config(
374        input.duplication_options,
375        &input.session.config().duplicates,
376    );
377    let section_start = Instant::now();
378    let project = input
379        .session
380        .analyze_project_with_artifacts(
381            &dupes_config,
382            ProjectAnalysisArtifactOptions {
383                retain_complexity_artifacts: input.retain_dead_code_artifacts,
384                retain_graph: input.retain_dead_code_artifacts,
385                changed_files: input.changed_files.cloned(),
386                collect_source_fingerprints: false,
387            },
388        )
389        .map_err(|err| {
390            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
391                .with_code("FALLOW_AUDIT_FAILED")
392                .with_context("audit")
393        })?;
394    let duplication_artifacts = input
395        .retain_duplication_artifacts
396        .then(|| project.duplication.clone());
397    let dead_code = super::dead_code::run_dead_code_from_artifacts(
398        input.dead_code_options,
399        input.resolved,
400        input.session,
401        input.changed_files,
402        project.dead_code,
403        section_start,
404    )?;
405    let duplication = super::duplication::run_duplication_report_with_session(
406        input.duplication_options,
407        input.resolved,
408        input.session,
409        project.duplication,
410        section_start,
411    )?;
412    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
413        output: dead_code,
414        artifacts,
415    } = dead_code;
416    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
417    Ok((
418        dead_code,
419        duplication,
420        dead_code_artifacts,
421        duplication_artifacts,
422    ))
423}
424
425#[derive(Clone, Copy)]
426struct ProjectArtifactAuditInput<'a> {
427    dead_code_options: &'a DeadCodeOptions,
428    duplication_options: &'a DuplicationOptions,
429    resolved: &'a ProgrammaticAnalysisContext,
430    session: &'a AnalysisSession,
431    changed_files: Option<&'a FxHashSet<PathBuf>>,
432    retain_dead_code_artifacts: bool,
433    retain_duplication_artifacts: bool,
434}
435
436fn run_all_audit_subanalyses_with_project_artifacts(
437    dead_code_options: &DeadCodeOptions,
438    duplication_options: &DuplicationOptions,
439    complexity_options: &ComplexityOptions,
440    resolved: &ProgrammaticAnalysisContext,
441    session: &AnalysisSession,
442    changed_files: Option<&FxHashSet<PathBuf>>,
443) -> ProgrammaticResult<AuditSubanalyses> {
444    let retain_dead_code_artifacts =
445        health_may_consume_dead_code_artifacts(complexity_options, session.config());
446    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
447    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
448        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
449            dead_code_options,
450            duplication_options,
451            resolved,
452            session,
453            changed_files,
454            retain_dead_code_artifacts,
455            retain_duplication_artifacts,
456        })?;
457    let complexity = run_health_with_session_artifacts(
458        complexity_options,
459        resolved,
460        session,
461        changed_files,
462        dead_code_artifacts,
463        duplication_artifacts,
464    )?;
465    Ok(AuditSubanalyses {
466        dead_code,
467        duplication,
468        complexity,
469    })
470}
471
472fn run_dead_code_and_health_with_session(
473    dead_code_options: &DeadCodeOptions,
474    complexity_options: &ComplexityOptions,
475    resolved: &ProgrammaticAnalysisContext,
476    session: &AnalysisSession,
477    changed_files: Option<&FxHashSet<PathBuf>>,
478) -> ProgrammaticResult<(
479    crate::DeadCodeProgrammaticOutput,
480    crate::HealthProgrammaticOutput,
481)> {
482    let reuse_dead_code_artifacts =
483        health_may_consume_dead_code_artifacts(complexity_options, session.config());
484    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
485        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
486            dead_code_options,
487            resolved,
488            session,
489            changed_files,
490            |_| {},
491            Instant::now(),
492        )?;
493        (dead_code.output, Some(dead_code.artifacts))
494    } else {
495        (
496            super::dead_code::run_dead_code_with_session(
497                dead_code_options,
498                resolved,
499                session,
500                changed_files,
501                |_| {},
502                Instant::now(),
503            )?,
504            None,
505        )
506    };
507    let complexity = run_health_with_session_artifacts(
508        complexity_options,
509        resolved,
510        session,
511        changed_files,
512        dead_code_artifacts,
513        None,
514    )?;
515    Ok((dead_code, complexity))
516}
517
518fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
519    let dead_code_issues = analyses.dead_code.output.results.total_issues();
520    AuditSummary {
521        dead_code_issues,
522        dead_code_has_errors: dead_code_issues > 0,
523        complexity_findings: analyses.complexity.report.findings.len(),
524        max_cyclomatic: analyses
525            .complexity
526            .report
527            .findings
528            .iter()
529            .map(|finding| finding.cyclomatic)
530            .max(),
531        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
532    }
533}
534
535fn compute_programmatic_audit_verdict(
536    gate: AuditGate,
537    summary: &AuditSummary,
538    duplication: &crate::DuplicationProgrammaticOutput,
539    current: &AuditProgrammaticKeySnapshot,
540    base: Option<&AuditProgrammaticKeySnapshot>,
541) -> AuditVerdict {
542    if matches!(gate, AuditGate::NewOnly) {
543        return compute_programmatic_introduced_verdict(summary, duplication, current, base);
544    }
545    if summary.dead_code_has_errors || summary.complexity_findings > 0 {
546        return AuditVerdict::Fail;
547    }
548    if summary.duplication_clone_groups > 0 {
549        let pct = duplication.output.report.stats.duplication_percentage;
550        if duplication.threshold > 0.0 && pct > duplication.threshold {
551            return AuditVerdict::Fail;
552        }
553        return AuditVerdict::Warn;
554    }
555    AuditVerdict::Pass
556}
557
558fn compute_programmatic_introduced_verdict(
559    summary: &AuditSummary,
560    duplication: &crate::DuplicationProgrammaticOutput,
561    current: &AuditProgrammaticKeySnapshot,
562    base: Option<&AuditProgrammaticKeySnapshot>,
563) -> AuditVerdict {
564    let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
565    if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
566        return AuditVerdict::Fail;
567    }
568    if attribution.duplication_introduced > 0 {
569        let pct = duplication.output.report.stats.duplication_percentage;
570        if duplication.threshold > 0.0 && pct > duplication.threshold {
571            return AuditVerdict::Fail;
572        }
573        return AuditVerdict::Warn;
574    }
575    if summary.dead_code_issues == 0
576        && summary.complexity_findings == 0
577        && summary.duplication_clone_groups == 0
578    {
579        return AuditVerdict::Pass;
580    }
581    AuditVerdict::Pass
582}
583
584fn compute_programmatic_audit_attribution(
585    gate: AuditGate,
586    current: &AuditProgrammaticKeySnapshot,
587    base: Option<&AuditProgrammaticKeySnapshot>,
588) -> AuditAttribution {
589    let dead_code = count_introduced(&current.dead_code, base.map(|snapshot| &snapshot.dead_code));
590    let complexity = count_introduced(&current.health, base.map(|snapshot| &snapshot.health));
591    let duplication = count_introduced(&current.dupes, base.map(|snapshot| &snapshot.dupes));
592    AuditAttribution {
593        gate,
594        dead_code_introduced: dead_code.0,
595        dead_code_inherited: dead_code.1,
596        complexity_introduced: complexity.0,
597        complexity_inherited: complexity.1,
598        duplication_introduced: duplication.0,
599        duplication_inherited: duplication.1,
600    }
601}
602
603fn count_introduced(
604    keys: &rustc_hash::FxHashSet<String>,
605    base: Option<&rustc_hash::FxHashSet<String>>,
606) -> (usize, usize) {
607    let Some(base) = base else {
608        return (0, 0);
609    };
610    keys.iter().fold((0, 0), |(introduced, inherited), key| {
611        if base.contains(key) {
612            (introduced, inherited + 1)
613        } else {
614            (introduced + 1, inherited)
615        }
616    })
617}
618
619fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
620    AuditProgrammaticKeySnapshot {
621        dead_code: crate::audit_keys::dead_code_keys(
622            &analyses.dead_code.output.results,
623            &analyses.dead_code.root,
624        ),
625        health: crate::audit_keys::health_keys(
626            &analyses.complexity.report,
627            &analyses.complexity.root,
628        ),
629        dupes: analyses
630            .duplication
631            .output
632            .report
633            .clone_groups
634            .iter()
635            .map(|group| {
636                crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
637            })
638            .collect(),
639    }
640}
641
642fn compute_base_snapshot(
643    options: &AuditOptions,
644    base_ref: &str,
645) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
646    let current_root = analysis_root_from_options(options)?;
647    let worktree = TemporaryBaseWorktree::create(&current_root, base_ref).map_err(|err| {
648        ProgrammaticError::new(err.to_string(), 2)
649            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
650            .with_context("audit.base")
651    })?;
652    let base_root = repo_refs::base_analysis_root(&current_root, worktree.path());
653    let current_config_path = options
654        .analysis
655        .config_path
656        .clone()
657        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
658    let base_analysis = AnalysisOptions {
659        root: Some(base_root),
660        config_path: current_config_path,
661        changed_since: None,
662        explain: false,
663        ..options.analysis.clone()
664    };
665    let base = run_audit_subanalyses(options, &base_analysis, None)?;
666    Ok(snapshot_from_analyses(&base))
667}
668
669fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
670    match options.analysis.root.clone() {
671        Some(root) => Ok(root),
672        None => std::env::current_dir().map_err(|err| {
673            ProgrammaticError::new(
674                format!("failed to resolve current working directory: {err}"),
675                2,
676            )
677            .with_code("FALLOW_CWD_UNAVAILABLE")
678            .with_context("analysis.root")
679        }),
680    }
681}
682
683fn audit_next_steps(
684    dead_code: &crate::DeadCodeProgrammaticOutput,
685    complexity: &crate::HealthProgrammaticOutput,
686) -> Vec<NextStep> {
687    let input = fallow_output::build_audit_next_steps_input(
688        Some((&dead_code.output.results, dead_code.root.as_path())),
689        Some(&complexity.report),
690        crate::next_steps::suggestions_enabled(),
691    );
692    build_audit_next_steps(&input)
693}
694
695fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
696    fallow_engine::validate::validate_git_ref(value)
697        .map(|_| ())
698        .map_err(|err| {
699            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
700                .with_code("FALLOW_INVALID_GIT_REF")
701                .with_context(context)
702        })
703}
704
705fn audit_base_env_override() -> Option<String> {
706    std::env::var("FALLOW_AUDIT_BASE")
707        .ok()
708        .map(|value| value.trim().to_string())
709        .filter(|value| !value.is_empty())
710}
711
712#[cfg(test)]
713mod tests {
714    use std::process::Command;
715
716    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
717    use fallow_types::output_format::OutputFormat;
718
719    use super::*;
720
721    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
722        FallowConfig {
723            health: HealthConfig {
724                max_crap,
725                ..HealthConfig::default()
726            },
727            ..FallowConfig::default()
728        }
729        .resolve(
730            std::env::temp_dir().join("fallow-api-runtime-test"),
731            OutputFormat::Json,
732            1,
733            true,
734            true,
735            None,
736        )
737    }
738
739    #[test]
740    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
741        let options = ComplexityOptions {
742            complexity: true,
743            ..ComplexityOptions::default()
744        };
745        let config = resolved_config_with_max_crap(0.0);
746
747        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
748    }
749
750    #[test]
751    fn audit_health_artifact_reuse_tracks_config_max_crap() {
752        let options = ComplexityOptions {
753            complexity: true,
754            ..ComplexityOptions::default()
755        };
756        let config = resolved_config_with_max_crap(30.0);
757
758        assert!(health_may_consume_dead_code_artifacts(&options, &config));
759    }
760
761    #[test]
762    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
763        let config = resolved_config_with_max_crap(0.0);
764        for options in [
765            ComplexityOptions {
766                file_scores: true,
767                ..ComplexityOptions::default()
768            },
769            ComplexityOptions {
770                coverage_gaps: true,
771                ..ComplexityOptions::default()
772            },
773            ComplexityOptions {
774                targets: true,
775                ..ComplexityOptions::default()
776            },
777            ComplexityOptions {
778                score: true,
779                ..ComplexityOptions::default()
780            },
781            ComplexityOptions {
782                max_crap: Some(30.0),
783                complexity: true,
784                ..ComplexityOptions::default()
785            },
786        ] {
787            assert!(health_may_consume_dead_code_artifacts(&options, &config));
788        }
789    }
790
791    #[test]
792    fn audit_health_duplication_reuse_tracks_score_and_targets() {
793        for options in [
794            ComplexityOptions {
795                score: true,
796                ..ComplexityOptions::default()
797            },
798            ComplexityOptions {
799                targets: true,
800                ..ComplexityOptions::default()
801            },
802        ] {
803            assert!(health_may_consume_duplication_report(&options));
804        }
805
806        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
807            complexity: true,
808            ..ComplexityOptions::default()
809        }));
810    }
811
812    #[test]
813    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
814        let project = audit_fixture();
815        let output = run_audit(&AuditOptions {
816            analysis: AnalysisOptions {
817                root: Some(project.path().to_path_buf()),
818                no_cache: true,
819                explain: true,
820                ..AnalysisOptions::default()
821            },
822            base: Some("HEAD".to_string()),
823            gate: AuditGate::NewOnly,
824            ..AuditOptions::default()
825        })
826        .expect("audit output");
827
828        assert_eq!(output.verdict, AuditVerdict::Fail);
829        assert_eq!(output.summary.dead_code_issues, 1);
830        assert_eq!(output.attribution.dead_code_introduced, 1);
831        assert!(output.base_snapshot.is_some());
832
833        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
834        assert_eq!(
835            json["dead_code"]["unused_files"][0]["path"],
836            "src/feature.ts"
837        );
838        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
839    }
840
841    #[test]
842    fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
843        let project = audit_workspace_modes_fixture();
844
845        for mask in 0_u8..8 {
846            let production_dead_code = mask & 0b001 != 0;
847            let production_health = mask & 0b010 != 0;
848            let production_dupes = mask & 0b100 != 0;
849            let output = run_audit(&AuditOptions {
850                analysis: AnalysisOptions {
851                    root: Some(project.path().to_path_buf()),
852                    workspace: Some(vec!["@audit/a".to_string()]),
853                    no_cache: true,
854                    ..AnalysisOptions::default()
855                },
856                base: Some("HEAD".to_string()),
857                gate: AuditGate::All,
858                production_dead_code: Some(production_dead_code),
859                production_health: Some(production_health),
860                production_dupes: Some(production_dupes),
861                include_entry_exports: true,
862                ..AuditOptions::default()
863            })
864            .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
865            let json = crate::serialize_audit_programmatic_json(output)
866                .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
867
868            let dead_code = json["dead_code"].to_string();
869            let complexity = json["complexity"].to_string();
870            let duplication = json["duplication"].to_string();
871            assert_eq!(
872                dead_code.contains("mode-sentinel.test.ts"),
873                !production_dead_code,
874                "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
875            );
876            assert_eq!(
877                complexity.contains("mode-sentinel.test.ts"),
878                !production_health,
879                "health scope mismatch for mask {mask:03b}: {complexity}"
880            );
881            assert_eq!(
882                duplication.contains("mode-sentinel.test.ts"),
883                !production_dupes,
884                "duplication scope mismatch for mask {mask:03b}: {duplication}"
885            );
886
887            let rendered = json.to_string();
888            assert!(
889                !rendered.contains("packages/b"),
890                "workspace B leaked into mask {mask:03b}: {rendered}"
891            );
892        }
893    }
894
895    #[test]
896    fn empty_audit_output_uses_resolved_root_for_head_sha() {
897        let project = audit_fixture();
898        let output = empty_audit_output(
899            &AuditOptions {
900                analysis: AnalysisOptions {
901                    root: None,
902                    ..AnalysisOptions::default()
903                },
904                base: Some("HEAD".to_string()),
905                gate: AuditGate::NewOnly,
906                ..AuditOptions::default()
907            },
908            ResolvedAuditBase {
909                git_ref: "HEAD".to_string(),
910                description: None,
911            },
912            project.path(),
913            0,
914            std::time::Duration::ZERO,
915        );
916
917        assert!(output.head_sha.is_some());
918    }
919
920    fn audit_fixture() -> tempfile::TempDir {
921        let project = tempfile::tempdir().expect("project");
922        std::fs::create_dir_all(project.path().join("src")).expect("create src");
923        std::fs::write(
924            project.path().join("package.json"),
925            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
926        )
927        .expect("write package");
928        std::fs::write(
929            project.path().join("src/index.ts"),
930            "console.log('entry');\n",
931        )
932        .expect("write entry");
933        git(project.path(), &["init"]);
934        git(project.path(), &["add", "."]);
935        git(
936            project.path(),
937            &[
938                "-c",
939                "user.email=test@example.com",
940                "-c",
941                "user.name=Test",
942                "-c",
943                "commit.gpgsign=false",
944                "commit",
945                "-m",
946                "initial",
947            ],
948        );
949        std::fs::write(
950            project.path().join("src/feature.ts"),
951            "export const unused = 1;\n",
952        )
953        .expect("write changed source");
954        project
955    }
956
957    fn audit_workspace_modes_fixture() -> tempfile::TempDir {
958        let project = tempfile::tempdir().expect("project");
959        std::fs::write(
960            project.path().join("package.json"),
961            r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
962        )
963        .expect("write root package");
964        std::fs::write(
965            project.path().join(".fallowrc.json"),
966            r#"{
967  "duplicates": {
968    "minTokens": 10,
969    "minLines": 2,
970    "ignoreDefaults": false
971  },
972  "health": {
973    "maxCyclomatic": 2,
974    "maxCognitive": 2,
975    "maxCrap": 2.0,
976    "maxUnitSize": 3
977  }
978}"#,
979        )
980        .expect("write config");
981
982        for name in ["a", "b"] {
983            let package = project.path().join("packages").join(name);
984            std::fs::create_dir_all(package.join("src")).expect("create package source");
985            std::fs::write(
986                package.join("package.json"),
987                format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
988            )
989            .expect("write package manifest");
990            std::fs::write(
991                package.join("src/index.ts"),
992                format!("export const {name}Entry = true;\n"),
993            )
994            .expect("write package entry");
995        }
996
997        git(project.path(), &["init"]);
998        git(project.path(), &["add", "."]);
999        git(
1000            project.path(),
1001            &[
1002                "-c",
1003                "user.email=test@example.com",
1004                "-c",
1005                "user.name=Test",
1006                "-c",
1007                "commit.gpgsign=false",
1008                "commit",
1009                "-m",
1010                "initial",
1011            ],
1012        );
1013
1014        let sentinel = r"export function auditModeSentinel(value: number) {
1015  let result = value;
1016  if (value > 0) result += 1;
1017  if (value > 1) result += 2;
1018  if (value > 2) result += 3;
1019  if (value > 3) result += 4;
1020  return result;
1021}
1022";
1023        for name in ["a", "b"] {
1024            let source = project.path().join("packages").join(name).join("src");
1025            std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1026                .expect("write test sentinel");
1027            std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1028                .expect("write duplicate test sentinel");
1029        }
1030
1031        project
1032    }
1033
1034    fn git(root: &Path, args: &[&str]) {
1035        let status = Command::new("git")
1036            .args(args)
1037            .current_dir(root)
1038            .status()
1039            .expect("git command");
1040        assert!(status.success(), "git {args:?} failed");
1041    }
1042}