Skip to main content

fallow_api/runtime/
audit.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_config::{ProductionAnalysis, ResolvedConfig};
5use fallow_engine::{
6    dead_code::DeadCodeAnalysisArtifacts,
7    project_analysis::ProjectAnalysisArtifactOptions,
8    project_config::ProjectConfigOptions,
9    repo_refs::{self, ResolvedAuditBase, TemporaryBaseWorktree},
10    session::AnalysisSession,
11};
12use fallow_output::build_audit_next_steps;
13use fallow_types::{
14    envelope::AuditIntroduced, output::NextStep, output_format::OutputFormat,
15    results::AnalysisResults,
16};
17use rustc_hash::FxHashSet;
18
19use crate::{
20    AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticOutput, AuditSummary,
21    AuditVerdict, ComplexityOptions, DeadCodeFilters, DeadCodeOptions, DuplicationOptions,
22    ProgrammaticError,
23    analysis_context::{
24        ProgrammaticAnalysisContext, changed_files_for_run,
25        resolve_programmatic_analysis_context_deferred_workspace,
26    },
27    audit_run::{
28        AuditAnalyses, AuditAnalysesView, AuditBackend, AuditRun, AuditRunInput, DeadCodeView,
29        DuplicationView, HealthView,
30    },
31};
32
33use super::{
34    ProgrammaticResult, health_may_consume_dead_code_artifacts,
35    health_may_consume_duplication_report, resolve_effective_production_modes, run_dead_code,
36    run_duplication, run_health, run_health_with_session_artifacts,
37};
38
39/// Run changed-code audit through typed programmatic runners.
40///
41/// The audit itself is [`crate::audit_run::run`], the same implementation as
42/// `fallow audit`. This function supplies the typed runners and builds the
43/// programmatic output.
44///
45/// # Errors
46///
47/// Returns a structured error for invalid options, base-ref discovery failures,
48/// unsupported CLI-only audit surfaces, or analysis failures.
49pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
50    validate_audit_api_options(options)?;
51    let start = Instant::now();
52    let resolved_base = resolve_audit_base_ref(options)?;
53    let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
54    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
55    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
56    let changed_files_count = changed_files.len();
57
58    if changed_files.is_empty() {
59        return Ok(empty_audit_output(
60            options,
61            resolved_base,
62            resolved.root(),
63            changed_files_count,
64            start.elapsed(),
65        ));
66    }
67
68    let config = load_programmatic_audit_config(&resolved)?;
69    let backend = ProgrammaticAuditBackend {
70        options,
71        analysis: &analysis,
72        resolved: &resolved,
73        config: &config,
74    };
75    let Some(AuditRun {
76        analyses,
77        outcome,
78        changed_files: _,
79    }) = crate::audit_run::run(
80        &backend,
81        AuditRunInput {
82            root: resolved.root(),
83            gate: options.gate,
84            base_ref: &resolved_base.git_ref,
85            cache_dir: Some(&config.cache_dir),
86            changed_files,
87        },
88    )?
89    else {
90        return Ok(empty_audit_output(
91            options,
92            resolved_base,
93            resolved.root(),
94            changed_files_count,
95            start.elapsed(),
96        ));
97    };
98
99    let mut head = analyses.subanalyses;
100    if outcome.base_snapshot.is_some() {
101        for ((group, introduced), demoted) in head
102            .duplication
103            .output
104            .report
105            .clone_groups
106            .iter_mut()
107            .zip(outcome.comparison.dupes.introduced())
108            .zip(outcome.comparison.dupes.demoted())
109        {
110            group.introduced = Some(AuditIntroduced(introduced));
111            group.demotion_reason = demoted.then_some(crate::CloneDemotionReason::NoAddedLines);
112        }
113    }
114    let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
115    let base_snapshot = crate::audit_run::programmatic_base_snapshot(&outcome);
116
117    Ok(AuditProgrammaticOutput {
118        verdict: outcome.verdict,
119        summary: outcome.summary,
120        attribution: outcome.attribution,
121        changed_files_count,
122        base_ref: resolved_base.git_ref,
123        base_description: resolved_base.description,
124        head_sha: repo_refs::short_head_sha(resolved.root()),
125        elapsed: start.elapsed(),
126        base_snapshot_skipped: None,
127        base_snapshot,
128        dead_code: Some(head.dead_code),
129        duplication: Some(head.duplication),
130        complexity: Some(head.complexity),
131        next_steps,
132        telemetry_analysis_run_id: None,
133    })
134}
135
136/// The typed runners of the programmatic audit.
137struct ProgrammaticAuditBackend<'a> {
138    options: &'a AuditOptions,
139    analysis: &'a AnalysisOptions,
140    resolved: &'a ProgrammaticAnalysisContext,
141    config: &'a ResolvedConfig,
142}
143
144impl<'a> AuditBackend for ProgrammaticAuditBackend<'a> {
145    type Analyses = ProgrammaticAuditAnalyses<'a>;
146    type Checkout = TemporaryBaseWorktree;
147    type CacheKey = ();
148    type Error = ProgrammaticError;
149
150    fn run_head(
151        &self,
152        changed_files: &FxHashSet<PathBuf>,
153    ) -> ProgrammaticResult<ProgrammaticAuditAnalyses<'a>> {
154        let subanalyses = run_audit_subanalyses_with_context(
155            self.options,
156            self.analysis,
157            self.resolved,
158            Some(changed_files),
159        )?;
160        Ok(ProgrammaticAuditAnalyses {
161            subanalyses,
162            config: self.config,
163        })
164    }
165
166    fn create_base_checkout(
167        &self,
168        base_ref: &str,
169        _base_sha: Option<&str>,
170    ) -> ProgrammaticResult<TemporaryBaseWorktree> {
171        TemporaryBaseWorktree::create(self.resolved.root(), base_ref).map_err(|err| {
172            ProgrammaticError::new(err.to_string(), 2)
173                .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
174                .with_context("audit.base")
175        })
176    }
177
178    fn run_base(
179        &self,
180        base_root: &Path,
181        focus: Option<&FxHashSet<PathBuf>>,
182    ) -> ProgrammaticResult<ProgrammaticAuditAnalyses<'a>> {
183        let head_root = self.resolved.root();
184        let config_path = self
185            .options
186            .analysis
187            .config_path
188            .clone()
189            .or_else(|| fallow_config::FallowConfig::find_config_path(head_root));
190        let base_analysis = AnalysisOptions {
191            root: Some(base_root.to_path_buf()),
192            config_path,
193            changed_since: None,
194            explain: false,
195            ..self.options.analysis.clone()
196        };
197        let coverage = crate::audit_run::base_coverage_inputs(
198            head_root,
199            self.options.coverage.as_deref(),
200            self.options.coverage_root.as_deref(),
201        );
202        let base_options = AuditOptions {
203            coverage: coverage.coverage,
204            coverage_root: coverage.coverage_root,
205            ..self.options.clone()
206        };
207        let subanalyses = run_audit_subanalyses(&base_options, &base_analysis, focus, true)?;
208        Ok(ProgrammaticAuditAnalyses {
209            subanalyses,
210            config: self.config,
211        })
212    }
213}
214
215/// The typed analyses of one audit side. `config` is the head config, which
216/// decides severities and styling gates.
217struct ProgrammaticAuditAnalyses<'a> {
218    subanalyses: AuditSubanalyses,
219    config: &'a ResolvedConfig,
220}
221
222impl AuditAnalyses for ProgrammaticAuditAnalyses<'_> {
223    fn view(&self) -> AuditAnalysesView<'_> {
224        let AuditSubanalyses {
225            dead_code,
226            duplication,
227            complexity,
228        } = &self.subanalyses;
229        AuditAnalysesView {
230            dead_code: Some(DeadCodeView {
231                results: &dead_code.output.results,
232                config: self.config,
233                root: &dead_code.root,
234                type_aware: dead_code
235                    .output
236                    .meta
237                    .as_ref()
238                    .and_then(|meta| meta.type_aware.as_ref()),
239                syntactic_keys: None,
240                public_api: None,
241            }),
242            duplication: Some(DuplicationView {
243                clone_groups: duplication
244                    .output
245                    .report
246                    .clone_groups
247                    .iter()
248                    .map(|group| &group.group)
249                    .collect(),
250                root: &duplication.root,
251                duplication_percentage: duplication.output.report.stats.duplication_percentage,
252                threshold: duplication.threshold,
253            }),
254            health: Some(HealthView {
255                report: &complexity.report,
256                root: &complexity.root,
257                rules: &self.config.rules,
258                branching: None,
259            }),
260        }
261    }
262
263    fn dead_code_results_mut(&mut self) -> Option<&mut AnalysisResults> {
264        Some(&mut self.subanalyses.dead_code.output.results)
265    }
266
267    fn health_report_mut(&mut self) -> Option<&mut fallow_output::HealthReport> {
268        Some(&mut self.subanalyses.complexity.report)
269    }
270
271    fn record_type_aware_warning(&mut self, warning: &str) {
272        if let Some(meta) = self
273            .subanalyses
274            .dead_code
275            .output
276            .meta
277            .as_mut()
278            .and_then(|meta| meta.type_aware.as_mut())
279        {
280            meta.warnings.push(warning.to_owned());
281            meta.warning_count = meta.warnings.len();
282        }
283    }
284}
285
286fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
287    if let Err(err) =
288        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
289    {
290        return Err(ProgrammaticError::new(err, 2)
291            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
292            .with_context("audit.coverageRoot"));
293    }
294    if options.runtime_coverage.is_some() {
295        return Err(ProgrammaticError::new(
296            "programmatic audit does not yet support runtime coverage; use the CLI path",
297            2,
298        )
299        .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
300        .with_context("audit.runtimeCoverage"));
301    }
302    Ok(())
303}
304
305pub(super) fn resolve_audit_base_ref(
306    options: &AuditOptions,
307) -> ProgrammaticResult<ResolvedAuditBase> {
308    let explicit = options
309        .base
310        .as_deref()
311        .or(options.analysis.changed_since.as_deref());
312    let root = options
313        .analysis
314        .root
315        .clone()
316        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
317    crate::audit_run::resolve_audit_base(&root, explicit).map_err(|error| match error {
318        crate::audit_run::AuditBaseError::InvalidRef {
319            origin,
320            value,
321            reason,
322        } => ProgrammaticError::new(format!("invalid git ref `{value}`: {reason}"), 2)
323            .with_code("FALLOW_INVALID_GIT_REF")
324            .with_context(match origin {
325                crate::audit_run::AuditBaseOrigin::Environment => "FALLOW_AUDIT_BASE",
326                crate::audit_run::AuditBaseOrigin::Explicit
327                | crate::audit_run::AuditBaseOrigin::Detected => "audit.base",
328            }),
329        crate::audit_run::AuditBaseError::NotDetected => ProgrammaticError::new(
330            "could not detect base branch. Set audit.base to specify the comparison target",
331            2,
332        )
333        .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
334        .with_context("audit.base"),
335    })
336}
337
338fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
339    let production_override = options
340        .analysis
341        .production_override
342        .or_else(|| options.production.then_some(true));
343    AnalysisOptions {
344        changed_since: Some(base_ref.to_string()),
345        production: production_override.unwrap_or(options.production),
346        production_override,
347        ..options.analysis.clone()
348    }
349}
350
351fn analysis_with_production(
352    analysis: &AnalysisOptions,
353    production_override: Option<bool>,
354) -> AnalysisOptions {
355    AnalysisOptions {
356        production: production_override.unwrap_or(analysis.production),
357        production_override: production_override.or(analysis.production_override),
358        ..analysis.clone()
359    }
360}
361
362fn empty_audit_output(
363    options: &AuditOptions,
364    base: ResolvedAuditBase,
365    root: &Path,
366    changed_files_count: usize,
367    elapsed: std::time::Duration,
368) -> AuditProgrammaticOutput {
369    AuditProgrammaticOutput {
370        verdict: AuditVerdict::Pass,
371        summary: AuditSummary {
372            dead_code_issues: 0,
373            dead_code_has_errors: false,
374            complexity_findings: 0,
375            max_cyclomatic: None,
376            duplication_clone_groups: 0,
377        },
378        attribution: AuditAttribution {
379            gate: options.gate,
380            ..AuditAttribution::default()
381        },
382        changed_files_count,
383        base_ref: base.git_ref,
384        base_description: base.description,
385        head_sha: repo_refs::short_head_sha(root),
386        elapsed,
387        base_snapshot_skipped: None,
388        base_snapshot: None,
389        dead_code: None,
390        duplication: None,
391        complexity: None,
392        next_steps: Vec::new(),
393        telemetry_analysis_run_id: None,
394    }
395}
396
397struct AuditSubanalyses {
398    dead_code: crate::DeadCodeProgrammaticOutput,
399    duplication: crate::DuplicationProgrammaticOutput,
400    complexity: crate::HealthProgrammaticOutput,
401}
402
403struct AuditSubanalysisOptions {
404    dead_code: DeadCodeOptions,
405    duplication: DuplicationOptions,
406    complexity: ComplexityOptions,
407}
408
409fn audit_subanalysis_options(
410    options: &AuditOptions,
411    analysis: &AnalysisOptions,
412    coverage_relocated: bool,
413) -> AuditSubanalysisOptions {
414    AuditSubanalysisOptions {
415        dead_code: DeadCodeOptions {
416            analysis: analysis_with_production(analysis, options.production_dead_code),
417            filters: DeadCodeFilters::default(),
418            files: Vec::new(),
419            include_entry_exports: options.include_entry_exports,
420        },
421        duplication: DuplicationOptions {
422            analysis: analysis_with_production(analysis, options.production_dupes),
423            ..DuplicationOptions::default()
424        },
425        complexity: ComplexityOptions {
426            analysis: analysis_with_production(analysis, options.production_health),
427            max_crap: options.max_crap,
428            complexity: true,
429            css: options.css.unwrap_or(true),
430            css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
431            coverage: options.coverage.clone(),
432            coverage_root: options.coverage_root.clone(),
433            coverage_relocated,
434            ..ComplexityOptions::default()
435        },
436    }
437}
438
439fn run_audit_subanalyses(
440    options: &AuditOptions,
441    analysis: &AnalysisOptions,
442    changed_files: Option<&FxHashSet<PathBuf>>,
443    coverage_relocated: bool,
444) -> ProgrammaticResult<AuditSubanalyses> {
445    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
446    run_audit_subanalyses_in_context(
447        options,
448        analysis,
449        &resolved,
450        changed_files,
451        coverage_relocated,
452    )
453}
454
455fn run_audit_subanalyses_with_context(
456    options: &AuditOptions,
457    analysis: &AnalysisOptions,
458    resolved: &ProgrammaticAnalysisContext,
459    changed_files: Option<&FxHashSet<PathBuf>>,
460) -> ProgrammaticResult<AuditSubanalyses> {
461    run_audit_subanalyses_in_context(options, analysis, resolved, changed_files, false)
462}
463
464fn run_audit_subanalyses_in_context(
465    options: &AuditOptions,
466    analysis: &AnalysisOptions,
467    resolved: &ProgrammaticAnalysisContext,
468    changed_files: Option<&FxHashSet<PathBuf>>,
469    coverage_relocated: bool,
470) -> ProgrammaticResult<AuditSubanalyses> {
471    let subanalysis_options = audit_subanalysis_options(options, analysis, coverage_relocated);
472    let production_modes = resolve_effective_production_modes(
473        resolved,
474        options.production_dead_code,
475        options.production_health,
476        options.production_dupes,
477    )?;
478
479    if production_modes.all_match() {
480        return run_shared_project_audit_subanalyses(&subanalysis_options, changed_files);
481    }
482
483    if production_modes.dead_code_matches_health() {
484        return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
485    }
486
487    if production_modes.dead_code_matches_dupes() {
488        return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
489    }
490
491    Ok(AuditSubanalyses {
492        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
493        duplication: run_duplication(&subanalysis_options.duplication)?,
494        complexity: run_health(&subanalysis_options.complexity)?,
495    })
496}
497
498fn run_shared_project_audit_subanalyses(
499    options: &AuditSubanalysisOptions,
500    changed_files: Option<&FxHashSet<PathBuf>>,
501) -> ProgrammaticResult<AuditSubanalyses> {
502    let resolved =
503        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
504    resolved.install(|| {
505        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
506        run_all_audit_subanalyses_with_project_artifacts(
507            &options.dead_code,
508            &options.duplication,
509            &options.complexity,
510            &resolved,
511            &session,
512            changed_files,
513        )
514    })
515}
516
517fn run_shared_dead_code_health_audit_subanalyses(
518    options: &AuditSubanalysisOptions,
519    changed_files: Option<&FxHashSet<PathBuf>>,
520) -> ProgrammaticResult<AuditSubanalyses> {
521    let resolved =
522        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
523    resolved.install(|| {
524        let dead_code_options = &options.dead_code;
525        let duplication_options = &options.duplication;
526        let complexity_options = &options.complexity;
527        let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
528        let (dead_code, complexity) = run_dead_code_and_health_with_session(
529            dead_code_options,
530            complexity_options,
531            &resolved,
532            &session,
533            changed_files,
534        )?;
535        Ok(AuditSubanalyses {
536            dead_code,
537            duplication: run_duplication(duplication_options)?,
538            complexity,
539        })
540    })
541}
542
543fn run_shared_dead_code_dupes_audit_subanalyses(
544    options: &AuditSubanalysisOptions,
545    changed_files: Option<&FxHashSet<PathBuf>>,
546) -> ProgrammaticResult<AuditSubanalyses> {
547    let resolved =
548        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
549    resolved.install(|| {
550        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
551        let (dead_code, duplication, _, _) =
552            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
553                dead_code_options: &options.dead_code,
554                duplication_options: &options.duplication,
555                resolved: &resolved,
556                session: &session,
557                changed_files,
558                retain_dead_code_artifacts: false,
559                retain_duplication_artifacts: false,
560            })?;
561        Ok(AuditSubanalyses {
562            dead_code,
563            duplication,
564            complexity: run_health(&options.complexity)?,
565        })
566    })
567}
568
569fn run_dead_code_and_duplication_with_project_artifacts(
570    input: ProjectArtifactAuditInput<'_>,
571) -> ProgrammaticResult<(
572    crate::DeadCodeProgrammaticOutput,
573    crate::DuplicationProgrammaticOutput,
574    Option<DeadCodeAnalysisArtifacts>,
575    Option<fallow_engine::duplicates::DuplicationReport>,
576)> {
577    let dupes_config = super::duplication::build_dupes_config(
578        input.duplication_options,
579        &input.session.config().duplicates,
580    );
581    let section_start = Instant::now();
582    let project = input
583        .session
584        .analyze_project_with_artifacts(
585            &dupes_config,
586            ProjectAnalysisArtifactOptions {
587                retain_complexity_artifacts: input.retain_dead_code_artifacts,
588                retain_graph: input.retain_dead_code_artifacts,
589                changed_files: input.changed_files.cloned(),
590                collect_source_fingerprints: false,
591            },
592        )
593        .map_err(|err| {
594            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
595                .with_code("FALLOW_AUDIT_FAILED")
596                .with_context("audit")
597        })?;
598    let duplication_artifacts = input
599        .retain_duplication_artifacts
600        .then(|| project.duplication.clone());
601    let dead_code = super::dead_code::run_dead_code_from_artifacts(
602        input.dead_code_options,
603        input.resolved,
604        input.session,
605        input.changed_files,
606        project.dead_code,
607        section_start,
608    )?;
609    let duplication = super::duplication::run_duplication_report_with_session(
610        input.duplication_options,
611        input.resolved,
612        input.session,
613        project.duplication,
614        section_start,
615    )?;
616    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
617        output: dead_code,
618        artifacts,
619    } = dead_code;
620    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
621    Ok((
622        dead_code,
623        duplication,
624        dead_code_artifacts,
625        duplication_artifacts,
626    ))
627}
628
629#[derive(Clone, Copy)]
630struct ProjectArtifactAuditInput<'a> {
631    dead_code_options: &'a DeadCodeOptions,
632    duplication_options: &'a DuplicationOptions,
633    resolved: &'a ProgrammaticAnalysisContext,
634    session: &'a AnalysisSession,
635    changed_files: Option<&'a FxHashSet<PathBuf>>,
636    retain_dead_code_artifacts: bool,
637    retain_duplication_artifacts: bool,
638}
639
640fn run_all_audit_subanalyses_with_project_artifacts(
641    dead_code_options: &DeadCodeOptions,
642    duplication_options: &DuplicationOptions,
643    complexity_options: &ComplexityOptions,
644    resolved: &ProgrammaticAnalysisContext,
645    session: &AnalysisSession,
646    changed_files: Option<&FxHashSet<PathBuf>>,
647) -> ProgrammaticResult<AuditSubanalyses> {
648    let retain_dead_code_artifacts =
649        health_may_consume_dead_code_artifacts(complexity_options, session.config());
650    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
651    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
652        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
653            dead_code_options,
654            duplication_options,
655            resolved,
656            session,
657            changed_files,
658            retain_dead_code_artifacts,
659            retain_duplication_artifacts,
660        })?;
661    let complexity = run_health_with_session_artifacts(
662        complexity_options,
663        resolved,
664        session,
665        changed_files,
666        dead_code_artifacts,
667        duplication_artifacts,
668    )?;
669    Ok(AuditSubanalyses {
670        dead_code,
671        duplication,
672        complexity,
673    })
674}
675
676fn run_dead_code_and_health_with_session(
677    dead_code_options: &DeadCodeOptions,
678    complexity_options: &ComplexityOptions,
679    resolved: &ProgrammaticAnalysisContext,
680    session: &AnalysisSession,
681    changed_files: Option<&FxHashSet<PathBuf>>,
682) -> ProgrammaticResult<(
683    crate::DeadCodeProgrammaticOutput,
684    crate::HealthProgrammaticOutput,
685)> {
686    let reuse_dead_code_artifacts =
687        health_may_consume_dead_code_artifacts(complexity_options, session.config());
688    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
689        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
690            dead_code_options,
691            resolved,
692            session,
693            changed_files,
694            |_| {},
695            Instant::now(),
696        )?;
697        (dead_code.output, Some(dead_code.artifacts))
698    } else {
699        (
700            super::dead_code::run_dead_code_with_session(
701                dead_code_options,
702                resolved,
703                session,
704                changed_files,
705                |_| {},
706                Instant::now(),
707            )?,
708            None,
709        )
710    };
711    let complexity = run_health_with_session_artifacts(
712        complexity_options,
713        resolved,
714        session,
715        changed_files,
716        dead_code_artifacts,
717        None,
718    )?;
719    Ok((dead_code, complexity))
720}
721
722fn load_programmatic_audit_config(
723    resolved: &ProgrammaticAnalysisContext,
724) -> ProgrammaticResult<fallow_config::ResolvedConfig> {
725    fallow_engine::project_config::config_for_project_analysis(
726        resolved.root(),
727        resolved.config_path().as_deref(),
728        ProjectConfigOptions {
729            output: OutputFormat::Json,
730            no_cache: resolved.no_cache(),
731            threads: resolved.threads(),
732            production_override: resolved.production_override(),
733            quiet: true,
734            analysis: ProductionAnalysis::DeadCode,
735            allow_remote_extends: resolved.allow_remote_extends(),
736        },
737    )
738    .map(|project| project.config)
739    .map_err(|err| {
740        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
741            .with_code("FALLOW_CONFIG_LOAD_FAILED")
742            .with_context("analysis.configPath")
743    })
744}
745
746fn audit_next_steps(
747    dead_code: &crate::DeadCodeProgrammaticOutput,
748    complexity: &crate::HealthProgrammaticOutput,
749) -> Vec<NextStep> {
750    let input = fallow_output::build_audit_next_steps_input(
751        Some((&dead_code.output.results, dead_code.root.as_path())),
752        Some(&complexity.report),
753        crate::next_steps::suggestions_enabled(),
754    );
755    build_audit_next_steps(&input)
756}
757
758#[cfg(test)]
759mod tests {
760    use std::process::Command;
761
762    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
763    use fallow_types::output_format::OutputFormat;
764
765    use super::*;
766
767    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
768        FallowConfig {
769            health: HealthConfig {
770                max_crap,
771                ..HealthConfig::default()
772            },
773            ..FallowConfig::default()
774        }
775        .resolve(
776            std::env::temp_dir().join("fallow-api-runtime-test"),
777            OutputFormat::Json,
778            1,
779            true,
780            true,
781            None,
782        )
783    }
784
785    #[test]
786    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
787        let options = ComplexityOptions {
788            complexity: true,
789            ..ComplexityOptions::default()
790        };
791        let config = resolved_config_with_max_crap(0.0);
792
793        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
794    }
795
796    #[test]
797    fn audit_health_artifact_reuse_tracks_config_max_crap() {
798        let options = ComplexityOptions {
799            complexity: true,
800            ..ComplexityOptions::default()
801        };
802        let config = resolved_config_with_max_crap(30.0);
803
804        assert!(health_may_consume_dead_code_artifacts(&options, &config));
805    }
806
807    #[test]
808    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
809        let config = resolved_config_with_max_crap(0.0);
810        for options in [
811            ComplexityOptions {
812                file_scores: true,
813                ..ComplexityOptions::default()
814            },
815            ComplexityOptions {
816                coverage_gaps: true,
817                ..ComplexityOptions::default()
818            },
819            ComplexityOptions {
820                targets: true,
821                ..ComplexityOptions::default()
822            },
823            ComplexityOptions {
824                score: true,
825                ..ComplexityOptions::default()
826            },
827            ComplexityOptions {
828                max_crap: Some(30.0),
829                complexity: true,
830                ..ComplexityOptions::default()
831            },
832        ] {
833            assert!(health_may_consume_dead_code_artifacts(&options, &config));
834        }
835    }
836
837    #[test]
838    fn audit_analysis_preserves_explicit_false_production_override() {
839        let options = AuditOptions {
840            production: false,
841            analysis: AnalysisOptions {
842                production: true,
843                production_override: Some(false),
844                ..AnalysisOptions::default()
845            },
846            ..AuditOptions::default()
847        };
848
849        let analysis = analysis_options_for_audit(&options, "HEAD");
850
851        assert_eq!(analysis.production_override, Some(false));
852        assert!(!analysis.production);
853    }
854
855    #[test]
856    fn audit_health_duplication_reuse_tracks_score_and_targets() {
857        for options in [
858            ComplexityOptions {
859                score: true,
860                ..ComplexityOptions::default()
861            },
862            ComplexityOptions {
863                targets: true,
864                ..ComplexityOptions::default()
865            },
866        ] {
867            assert!(health_may_consume_duplication_report(&options));
868        }
869
870        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
871            complexity: true,
872            ..ComplexityOptions::default()
873        }));
874    }
875
876    #[test]
877    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
878        let project = audit_fixture();
879        let output = run_audit(&AuditOptions {
880            analysis: AnalysisOptions {
881                root: Some(project.path().to_path_buf()),
882                no_cache: true,
883                explain: true,
884                ..AnalysisOptions::default()
885            },
886            base: Some("HEAD".to_string()),
887            gate: AuditGate::NewOnly,
888            ..AuditOptions::default()
889        })
890        .expect("audit output");
891
892        assert_eq!(output.verdict, AuditVerdict::Fail);
893        assert_eq!(output.summary.dead_code_issues, 1);
894        assert_eq!(output.attribution.dead_code_introduced, 1);
895        assert!(output.base_snapshot.is_some());
896
897        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
898        assert_eq!(json["schema_version"], fallow_output::AUDIT_SCHEMA_VERSION);
899        assert_eq!(
900            json["dead_code"]["unused_files"][0]["path"],
901            "src/feature.ts"
902        );
903        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
904    }
905
906    #[test]
907    fn run_audit_warn_only_dead_code_matches_cli_verdict_semantics() {
908        let project = audit_fixture();
909        std::fs::write(
910            project.path().join(".fallowrc.json"),
911            r#"{"rules":{"unused-files":"warn"}}"#,
912        )
913        .expect("write config");
914
915        let output = run_audit(&AuditOptions {
916            analysis: AnalysisOptions {
917                root: Some(project.path().to_path_buf()),
918                no_cache: true,
919                ..AnalysisOptions::default()
920            },
921            base: Some("HEAD".to_string()),
922            gate: AuditGate::All,
923            ..AuditOptions::default()
924        })
925        .expect("audit output");
926
927        assert_eq!(output.verdict, AuditVerdict::Warn);
928        assert!(!output.summary.dead_code_has_errors);
929    }
930
931    #[test]
932    fn run_audit_styling_error_matches_cli_for_new_only_and_all_gates() {
933        let project = audit_styling_fixture();
934        let root = project.path();
935        std::fs::write(
936            root.join("src/styles.css"),
937            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n",
938        )
939        .expect("write inherited-only change");
940
941        let all = run_audit(&AuditOptions {
942            analysis: AnalysisOptions {
943                root: Some(root.to_path_buf()),
944                no_cache: true,
945                ..AnalysisOptions::default()
946            },
947            base: Some("HEAD".to_string()),
948            gate: AuditGate::All,
949            ..AuditOptions::default()
950        })
951        .expect("all-gate audit");
952        assert_eq!(all.verdict, AuditVerdict::Fail);
953
954        let inherited_only = run_audit(&AuditOptions {
955            analysis: AnalysisOptions {
956                root: Some(root.to_path_buf()),
957                no_cache: true,
958                ..AnalysisOptions::default()
959            },
960            base: Some("HEAD".to_string()),
961            gate: AuditGate::NewOnly,
962            ..AuditOptions::default()
963        })
964        .expect("new-only inherited audit");
965        assert_eq!(inherited_only.verdict, AuditVerdict::Pass);
966        assert!(inherited_only.base_snapshot.is_some());
967        let inherited_json =
968            crate::serialize_audit_programmatic_json(inherited_only).expect("inherited audit JSON");
969        assert_eq!(
970            inherited_json["complexity"]["styling_findings"][0]["introduced"],
971            false
972        );
973
974        std::fs::write(
975            root.join("src/styles.css"),
976            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n#app .introduced .title { color: green; }\n",
977        )
978        .expect("write introduced styling change");
979        let introduced = run_audit(&AuditOptions {
980            analysis: AnalysisOptions {
981                root: Some(root.to_path_buf()),
982                no_cache: true,
983                ..AnalysisOptions::default()
984            },
985            base: Some("HEAD".to_string()),
986            gate: AuditGate::NewOnly,
987            ..AuditOptions::default()
988        })
989        .expect("new-only introduced audit");
990        assert_eq!(introduced.verdict, AuditVerdict::Fail);
991        let introduced_json =
992            crate::serialize_audit_programmatic_json(introduced).expect("introduced audit JSON");
993        let styling = introduced_json["complexity"]["styling_findings"]
994            .as_array()
995            .expect("styling findings");
996        assert!(
997            styling
998                .iter()
999                .any(|finding| finding["line"] == 1 && finding["introduced"] == false)
1000        );
1001        assert!(
1002            styling
1003                .iter()
1004                .any(|finding| finding["line"] == 3 && finding["introduced"] == true)
1005        );
1006    }
1007
1008    /// #2347: a pre-existing high-CRAP function must stay `introduced: false`
1009    /// when Istanbul coverage is supplied and an unrelated edit touches its
1010    /// file. The base snapshot analyzes a temporary worktree, so the coverage
1011    /// entries (recorded against the HEAD checkout) must be rebased onto that
1012    /// worktree; otherwise the base side falls back to the reachability
1013    /// estimate, scores below threshold, and the unchanged finding flips the
1014    /// new-only gate.
1015    #[test]
1016    fn run_audit_coverage_keeps_unchanged_function_inherited() {
1017        let project = tempfile::tempdir().expect("project");
1018        let root = project.path();
1019        std::fs::create_dir_all(root.join("src")).expect("create src");
1020        std::fs::write(
1021            root.join("package.json"),
1022            r#"{"name":"audit-api-coverage","type":"module","main":"src/index.ts","devDependencies":{"vitest":"^3.0.0"}}"#,
1023        )
1024        .expect("write package");
1025        std::fs::write(root.join("src/index.ts"), "console.log('entry');\n").expect("write entry");
1026        std::fs::write(
1027            root.join("src/branchy.ts"),
1028            "export function branchy(n: number): number {\n\
1029             \x20 if (n < 0) return -1;\n\
1030             \x20 if (n === 0) return 0;\n\
1031             \x20 if (n < 10) return 1;\n\
1032             \x20 if (n < 100) return 2;\n\
1033             \x20 if (n < 1000) return 3;\n\
1034             \x20 if (n < 10000) return 4;\n\
1035             \x20 return 5;\n\
1036             }\n",
1037        )
1038        .expect("write branchy");
1039        std::fs::write(
1040            root.join("src/branchy.test.ts"),
1041            "import { branchy } from './branchy';\nbranchy(1);\n",
1042        )
1043        .expect("write test reference");
1044        git(root, &["init"]);
1045        git(root, &["add", "."]);
1046        git(
1047            root,
1048            &[
1049                "-c",
1050                "user.email=test@example.com",
1051                "-c",
1052                "user.name=Test",
1053                "-c",
1054                "commit.gpgsign=false",
1055                "commit",
1056                "-m",
1057                "initial",
1058            ],
1059        );
1060        let mut source = std::fs::read_to_string(root.join("src/branchy.ts")).expect("branchy");
1061        source.push_str("branchy(-1);\n");
1062        std::fs::write(root.join("src/branchy.ts"), source).expect("append unrelated statement");
1063
1064        std::fs::create_dir_all(root.join("artifacts")).expect("create artifacts");
1065        let recorded = root.join("src/branchy.ts");
1066        let recorded = recorded.to_string_lossy().replace('\\', "\\\\");
1067        std::fs::write(
1068            root.join("artifacts/coverage-final.json"),
1069            format!(
1070                r#"{{"{recorded}":{{"path":"{recorded}","statementMap":{{}},"fnMap":{{"0":{{"name":"branchy","line":1,"decl":{{"start":{{"line":1,"column":16}},"end":{{"line":1,"column":23}}}},"loc":{{"start":{{"line":1,"column":44}},"end":{{"line":9,"column":1}}}}}}}},"branchMap":{{}},"s":{{}},"f":{{"0":0}},"b":{{}}}}}}"#
1071            ),
1072        )
1073        .expect("write coverage");
1074
1075        let output = run_audit(&AuditOptions {
1076            analysis: AnalysisOptions {
1077                root: Some(root.to_path_buf()),
1078                no_cache: true,
1079                ..AnalysisOptions::default()
1080            },
1081            base: Some("HEAD".to_string()),
1082            gate: AuditGate::NewOnly,
1083            max_crap: Some(10.0),
1084            coverage: Some(root.join("artifacts/coverage-final.json")),
1085            ..AuditOptions::default()
1086        })
1087        .expect("audit output");
1088
1089        assert_eq!(output.attribution.complexity_introduced, 0);
1090        assert_eq!(output.attribution.complexity_inherited, 1);
1091        assert_eq!(output.verdict, AuditVerdict::Pass);
1092        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
1093        let findings = json["complexity"]["findings"]
1094            .as_array()
1095            .expect("complexity findings");
1096        let branchy = findings
1097            .iter()
1098            .find(|finding| finding["name"] == "branchy")
1099            .expect("branchy reported above the CRAP threshold with 0% measured coverage");
1100        assert_eq!(branchy["introduced"], false);
1101        assert_eq!(branchy["coverage_source"], "istanbul");
1102    }
1103
1104    #[test]
1105    fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
1106        let project = audit_workspace_modes_fixture("");
1107
1108        for mask in 0_u8..8 {
1109            let modes = ProductionModesMask::from(mask);
1110            let output = run_audit(&AuditOptions {
1111                production_dead_code: Some(modes.dead_code),
1112                production_health: Some(modes.health),
1113                production_dupes: Some(modes.dupes),
1114                ..workspace_modes_audit_options(project.path())
1115            })
1116            .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1117            assert_audit_sections_follow_modes(output, modes, mask);
1118        }
1119    }
1120
1121    /// The per-section modes may also come only from the config file. Audit
1122    /// must compare the modes after config resolution: with no overrides the
1123    /// raw options are all `None` and look equal even when the sections
1124    /// differ.
1125    #[test]
1126    fn audit_config_production_modes_scope_each_section() {
1127        for mask in 0_u8..8 {
1128            let modes = ProductionModesMask::from(mask);
1129            let project = audit_workspace_modes_fixture(&format!(
1130                r#","production":{{"deadCode":{},"health":{},"dupes":{}}}"#,
1131                modes.dead_code, modes.health, modes.dupes
1132            ));
1133            let output = run_audit(&workspace_modes_audit_options(project.path()))
1134                .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1135            assert_audit_sections_follow_modes(output, modes, mask);
1136        }
1137    }
1138
1139    #[derive(Clone, Copy)]
1140    struct ProductionModesMask {
1141        dead_code: bool,
1142        health: bool,
1143        dupes: bool,
1144    }
1145
1146    impl From<u8> for ProductionModesMask {
1147        fn from(mask: u8) -> Self {
1148            Self {
1149                dead_code: mask & 0b001 != 0,
1150                health: mask & 0b010 != 0,
1151                dupes: mask & 0b100 != 0,
1152            }
1153        }
1154    }
1155
1156    fn workspace_modes_audit_options(root: &Path) -> AuditOptions {
1157        AuditOptions {
1158            analysis: AnalysisOptions {
1159                root: Some(root.to_path_buf()),
1160                workspace: Some(vec!["@audit/a".to_string()]),
1161                no_cache: true,
1162                ..AnalysisOptions::default()
1163            },
1164            base: Some("HEAD".to_string()),
1165            gate: AuditGate::All,
1166            include_entry_exports: true,
1167            ..AuditOptions::default()
1168        }
1169    }
1170
1171    fn assert_audit_sections_follow_modes(
1172        output: AuditProgrammaticOutput,
1173        modes: ProductionModesMask,
1174        mask: u8,
1175    ) {
1176        let json = crate::serialize_audit_programmatic_json(output)
1177            .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
1178
1179        let dead_code = json["dead_code"].to_string();
1180        let complexity = json["complexity"].to_string();
1181        let duplication = json["duplication"].to_string();
1182        assert_eq!(
1183            dead_code.contains("mode-sentinel.test.ts"),
1184            !modes.dead_code,
1185            "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
1186        );
1187        assert_eq!(
1188            complexity.contains("mode-sentinel.test.ts"),
1189            !modes.health,
1190            "health scope mismatch for mask {mask:03b}: {complexity}"
1191        );
1192        assert_eq!(
1193            duplication.contains("mode-sentinel.test.ts"),
1194            !modes.dupes,
1195            "duplication scope mismatch for mask {mask:03b}: {duplication}"
1196        );
1197
1198        for section in [&dead_code, &complexity] {
1199            assert!(
1200                !section.contains("packages/b"),
1201                "workspace B leaked into mask {mask:03b}: {section}"
1202            );
1203        }
1204        // A clone group is in scope when one of its instances is, and it
1205        // keeps every instance, so a copy in workspace B may show next to
1206        // the copy in workspace A. No group may be only in workspace B.
1207        for group in json["duplication"]["clone_groups"]
1208            .as_array()
1209            .into_iter()
1210            .flatten()
1211        {
1212            assert!(
1213                group["instances"]
1214                    .as_array()
1215                    .into_iter()
1216                    .flatten()
1217                    .any(|instance| instance["file"]
1218                        .as_str()
1219                        .is_some_and(|file| file.starts_with("packages/a/"))),
1220                "a clone group outside workspace A leaked into mask {mask:03b}: {group}"
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn empty_audit_output_uses_resolved_root_for_head_sha() {
1227        let project = audit_fixture();
1228        let output = empty_audit_output(
1229            &AuditOptions {
1230                analysis: AnalysisOptions {
1231                    root: None,
1232                    ..AnalysisOptions::default()
1233                },
1234                base: Some("HEAD".to_string()),
1235                gate: AuditGate::NewOnly,
1236                ..AuditOptions::default()
1237            },
1238            ResolvedAuditBase {
1239                git_ref: "HEAD".to_string(),
1240                description: None,
1241            },
1242            project.path(),
1243            0,
1244            std::time::Duration::ZERO,
1245        );
1246
1247        assert!(output.head_sha.is_some());
1248    }
1249
1250    fn audit_fixture() -> tempfile::TempDir {
1251        let project = tempfile::tempdir().expect("project");
1252        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1253        std::fs::write(
1254            project.path().join("package.json"),
1255            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1256        )
1257        .expect("write package");
1258        std::fs::write(
1259            project.path().join("src/index.ts"),
1260            "console.log('entry');\n",
1261        )
1262        .expect("write entry");
1263        git(project.path(), &["init"]);
1264        git(project.path(), &["add", "."]);
1265        git(
1266            project.path(),
1267            &[
1268                "-c",
1269                "user.email=test@example.com",
1270                "-c",
1271                "user.name=Test",
1272                "-c",
1273                "commit.gpgsign=false",
1274                "commit",
1275                "-m",
1276                "initial",
1277            ],
1278        );
1279        std::fs::write(
1280            project.path().join("src/feature.ts"),
1281            "export const unused = 1;\n",
1282        )
1283        .expect("write changed source");
1284        project
1285    }
1286
1287    fn audit_styling_fixture() -> tempfile::TempDir {
1288        let project = tempfile::tempdir().expect("project");
1289        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1290        std::fs::write(
1291            project.path().join("package.json"),
1292            r#"{"name":"audit-api-styling","type":"module","main":"src/index.ts"}"#,
1293        )
1294        .expect("write package");
1295        std::fs::write(
1296            project.path().join(".fallowrc.json"),
1297            r#"{"rules":{"css-selector-complexity":"error"}}"#,
1298        )
1299        .expect("write config");
1300        std::fs::write(
1301            project.path().join("src/index.ts"),
1302            "console.log('entry');\n",
1303        )
1304        .expect("write entry");
1305        std::fs::write(
1306            project.path().join("src/styles.css"),
1307            "#app .legacy .title { color: red; }\n",
1308        )
1309        .expect("write inherited styling");
1310        git(project.path(), &["init"]);
1311        git(project.path(), &["add", "."]);
1312        git(
1313            project.path(),
1314            &[
1315                "-c",
1316                "user.email=test@example.com",
1317                "-c",
1318                "user.name=Test",
1319                "-c",
1320                "commit.gpgsign=false",
1321                "commit",
1322                "-m",
1323                "initial",
1324            ],
1325        );
1326        project
1327    }
1328
1329    /// `extra_config` is appended to the top-level `.fallowrc.json` object.
1330    fn audit_workspace_modes_fixture(extra_config: &str) -> tempfile::TempDir {
1331        let project = tempfile::tempdir().expect("project");
1332        std::fs::write(
1333            project.path().join("package.json"),
1334            r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
1335        )
1336        .expect("write root package");
1337        std::fs::write(
1338            project.path().join(".fallowrc.json"),
1339            format!(
1340                r#"{{
1341  "duplicates": {{
1342    "minTokens": 10,
1343    "minLines": 2,
1344    "ignoreDefaults": false
1345  }},
1346  "health": {{
1347    "maxCyclomatic": 2,
1348    "maxCognitive": 2,
1349    "maxCrap": 2.0,
1350    "maxUnitSize": 3
1351  }}{extra_config}
1352}}"#
1353            ),
1354        )
1355        .expect("write config");
1356
1357        for name in ["a", "b"] {
1358            let package = project.path().join("packages").join(name);
1359            std::fs::create_dir_all(package.join("src")).expect("create package source");
1360            std::fs::write(
1361                package.join("package.json"),
1362                format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
1363            )
1364            .expect("write package manifest");
1365            std::fs::write(
1366                package.join("src/index.ts"),
1367                format!("export const {name}Entry = true;\n"),
1368            )
1369            .expect("write package entry");
1370        }
1371
1372        git(project.path(), &["init"]);
1373        git(project.path(), &["add", "."]);
1374        git(
1375            project.path(),
1376            &[
1377                "-c",
1378                "user.email=test@example.com",
1379                "-c",
1380                "user.name=Test",
1381                "-c",
1382                "commit.gpgsign=false",
1383                "commit",
1384                "-m",
1385                "initial",
1386            ],
1387        );
1388
1389        let sentinel = r"export function auditModeSentinel(value: number) {
1390  let result = value;
1391  if (value > 0) result += 1;
1392  if (value > 1) result += 2;
1393  if (value > 2) result += 3;
1394  if (value > 3) result += 4;
1395  return result;
1396}
1397";
1398        for name in ["a", "b"] {
1399            let source = project.path().join("packages").join(name).join("src");
1400            std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1401                .expect("write test sentinel");
1402            std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1403                .expect("write duplicate test sentinel");
1404        }
1405
1406        project
1407    }
1408
1409    fn git(root: &Path, args: &[&str]) {
1410        let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
1411            .args(args)
1412            .current_dir(root)
1413            .status()
1414            .expect("git command");
1415        assert!(status.success(), "git {args:?} failed");
1416    }
1417
1418    /// #2699: the auto-detect fallthrough gets the same ref validation as the
1419    /// explicit and environment paths, so a malformed detection surfaces as a
1420    /// base-ref error instead of a changed-files failure deeper in the run.
1421    #[test]
1422    fn resolve_audit_base_ref_validates_the_auto_detected_ref() {
1423        let project = tempfile::tempdir().expect("temp dir");
1424        let root = project.path();
1425        std::fs::write(root.join("index.ts"), "export const used = 1;\n").expect("write entry");
1426        git(root, &["init", "-b", "main"]);
1427        git(root, &["add", "."]);
1428        git(
1429            root,
1430            &[
1431                "-c",
1432                "user.email=test@example.com",
1433                "-c",
1434                "user.name=Test",
1435                "-c",
1436                "commit.gpgsign=false",
1437                "commit",
1438                "-m",
1439                "initial",
1440            ],
1441        );
1442        git(root, &["update-ref", "refs/remotes/origin/main", "main"]);
1443        git(
1444            root,
1445            &[
1446                "symbolic-ref",
1447                "refs/remotes/origin/HEAD",
1448                "refs/remotes/origin/main",
1449            ],
1450        );
1451        let options = AuditOptions {
1452            analysis: AnalysisOptions {
1453                root: Some(root.to_path_buf()),
1454                ..AnalysisOptions::default()
1455            },
1456            ..AuditOptions::default()
1457        };
1458
1459        let resolved = resolve_audit_base_ref(&options).expect("base ref resolves");
1460
1461        assert!(
1462            fallow_engine::validate::validate_git_ref(&resolved.git_ref).is_ok(),
1463            "auto-detected ref must be usable as a git ref: {:?}",
1464            resolved.git_ref
1465        );
1466        assert_eq!(
1467            resolved.description.as_deref(),
1468            Some("merge-base with origin/main")
1469        );
1470    }
1471}