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