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