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