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