Skip to main content

fallow_api/runtime/
audit.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_config::{AuditGate, ProductionAnalysis};
5use fallow_engine::{
6    dead_code::DeadCodeAnalysisArtifacts,
7    project_analysis::ProjectAnalysisArtifactOptions,
8    project_config::ProjectConfigOptions,
9    repo_refs::{self, ResolvedAuditBase, TemporaryBaseWorktree},
10    session::AnalysisSession,
11};
12use fallow_output::build_audit_next_steps;
13use fallow_types::{envelope::AuditIntroduced, output::NextStep, output_format::OutputFormat};
14use rustc_hash::FxHashSet;
15
16use crate::{
17    AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
18    AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
19    DeadCodeOptions, DuplicationOptions, ProgrammaticError,
20    analysis_context::{
21        ProgrammaticAnalysisContext, changed_files_for_run,
22        resolve_programmatic_analysis_context_deferred_workspace,
23    },
24};
25
26use super::{
27    ProgrammaticResult, health_may_consume_dead_code_artifacts,
28    health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
29    run_dead_code, run_duplication, run_health, run_health_with_session_artifacts,
30};
31
32/// Run changed-code audit through typed programmatic runners.
33///
34/// # Errors
35///
36/// Returns a structured error for invalid options, base-ref discovery failures,
37/// unsupported CLI-only audit surfaces, or analysis failures.
38pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
39    validate_audit_api_options(options)?;
40    let start = Instant::now();
41    let resolved_base = resolve_audit_base_ref(options)?;
42    let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
43    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
44    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
45    let changed_files_count = changed_files.len();
46
47    if changed_files.is_empty() {
48        return Ok(empty_audit_output(
49            options,
50            resolved_base,
51            resolved.root(),
52            changed_files_count,
53            start.elapsed(),
54        ));
55    }
56
57    let mut head =
58        run_audit_subanalyses_with_context(options, &analysis, &resolved, Some(&changed_files))?;
59    let runtime_base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
60        Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
61    } else {
62        None
63    };
64    let config = load_programmatic_audit_config(&resolved)?;
65    let comparison =
66        build_programmatic_audit_comparison(&head, &config, runtime_base_snapshot.as_ref());
67    let summary = build_programmatic_audit_summary(&head, &comparison);
68    let attribution =
69        comparison_attribution(options.gate, &comparison, runtime_base_snapshot.is_some());
70    let verdict = comparison_verdict(
71        options.gate,
72        &summary,
73        &head.duplication,
74        &head.complexity,
75        &config,
76        &comparison,
77    );
78    if runtime_base_snapshot.is_some() {
79        comparison.annotate_typed_findings(
80            &mut head.dead_code.output.results,
81            &mut head.complexity.report,
82        );
83        for (group, introduced) in head
84            .duplication
85            .output
86            .report
87            .clone_groups
88            .iter_mut()
89            .zip(comparison.dupes.introduced())
90        {
91            group.introduced = Some(AuditIntroduced(introduced));
92        }
93    }
94    let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
95    let base_snapshot = runtime_base_snapshot.map(|snapshot| snapshot.public);
96
97    Ok(AuditProgrammaticOutput {
98        verdict,
99        summary,
100        attribution,
101        changed_files_count,
102        base_ref: resolved_base.git_ref,
103        base_description: resolved_base.description,
104        head_sha: repo_refs::short_head_sha(resolved.root()),
105        elapsed: start.elapsed(),
106        base_snapshot_skipped: None,
107        base_snapshot,
108        dead_code: Some(head.dead_code),
109        duplication: Some(head.duplication),
110        complexity: Some(head.complexity),
111        next_steps,
112        envelope_mode: root_envelope_mode(),
113        telemetry_analysis_run_id: None,
114    })
115}
116
117fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
118    if let Err(err) =
119        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
120    {
121        return Err(ProgrammaticError::new(err, 2)
122            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
123            .with_context("audit.coverageRoot"));
124    }
125    if options.runtime_coverage.is_some() {
126        return Err(ProgrammaticError::new(
127            "programmatic audit does not yet support runtime coverage; use the CLI path",
128            2,
129        )
130        .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
131        .with_context("audit.runtimeCoverage"));
132    }
133    Ok(())
134}
135
136pub(super) fn resolve_audit_base_ref(
137    options: &AuditOptions,
138) -> ProgrammaticResult<ResolvedAuditBase> {
139    if let Some(ref_str) = options
140        .base
141        .as_deref()
142        .or(options.analysis.changed_since.as_deref())
143    {
144        validate_git_ref(ref_str, "audit.base")?;
145        return Ok(ResolvedAuditBase {
146            git_ref: (*ref_str).to_string(),
147            description: None,
148        });
149    }
150    if let Some(env_ref) = audit_base_env_override() {
151        validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
152        return Ok(ResolvedAuditBase {
153            description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
154            git_ref: env_ref,
155        });
156    }
157    let root = options
158        .analysis
159        .root
160        .clone()
161        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
162    repo_refs::auto_detect_audit_base_ref(&root).ok_or_else(|| {
163        ProgrammaticError::new(
164            "could not detect base branch. Set audit.base to specify the comparison target",
165            2,
166        )
167        .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
168        .with_context("audit.base")
169    })
170}
171
172fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
173    AnalysisOptions {
174        changed_since: Some(base_ref.to_string()),
175        production: options.production,
176        production_override: options.production.then_some(true),
177        ..options.analysis.clone()
178    }
179}
180
181fn analysis_with_production(
182    analysis: &AnalysisOptions,
183    production_override: Option<bool>,
184) -> AnalysisOptions {
185    AnalysisOptions {
186        production: production_override.unwrap_or(analysis.production),
187        production_override: production_override.or(analysis.production_override),
188        ..analysis.clone()
189    }
190}
191
192fn empty_audit_output(
193    options: &AuditOptions,
194    base: ResolvedAuditBase,
195    root: &Path,
196    changed_files_count: usize,
197    elapsed: std::time::Duration,
198) -> AuditProgrammaticOutput {
199    AuditProgrammaticOutput {
200        verdict: AuditVerdict::Pass,
201        summary: AuditSummary {
202            dead_code_issues: 0,
203            dead_code_has_errors: false,
204            complexity_findings: 0,
205            max_cyclomatic: None,
206            duplication_clone_groups: 0,
207        },
208        attribution: AuditAttribution {
209            gate: options.gate,
210            ..AuditAttribution::default()
211        },
212        changed_files_count,
213        base_ref: base.git_ref,
214        base_description: base.description,
215        head_sha: repo_refs::short_head_sha(root),
216        elapsed,
217        base_snapshot_skipped: None,
218        base_snapshot: None,
219        dead_code: None,
220        duplication: None,
221        complexity: None,
222        next_steps: Vec::new(),
223        envelope_mode: root_envelope_mode(),
224        telemetry_analysis_run_id: None,
225    }
226}
227
228struct AuditSubanalyses {
229    dead_code: crate::DeadCodeProgrammaticOutput,
230    duplication: crate::DuplicationProgrammaticOutput,
231    complexity: crate::HealthProgrammaticOutput,
232}
233
234struct AuditRuntimeKeySnapshot {
235    public: AuditProgrammaticKeySnapshot,
236    styling: FxHashSet<String>,
237}
238
239struct AuditSubanalysisOptions {
240    dead_code: DeadCodeOptions,
241    duplication: DuplicationOptions,
242    complexity: ComplexityOptions,
243}
244
245fn audit_subanalysis_options(
246    options: &AuditOptions,
247    analysis: &AnalysisOptions,
248) -> AuditSubanalysisOptions {
249    AuditSubanalysisOptions {
250        dead_code: DeadCodeOptions {
251            analysis: analysis_with_production(analysis, options.production_dead_code),
252            filters: DeadCodeFilters::default(),
253            files: Vec::new(),
254            include_entry_exports: options.include_entry_exports,
255        },
256        duplication: DuplicationOptions {
257            analysis: analysis_with_production(analysis, options.production_dupes),
258            ..DuplicationOptions::default()
259        },
260        complexity: ComplexityOptions {
261            analysis: analysis_with_production(analysis, options.production_health),
262            max_crap: options.max_crap,
263            complexity: true,
264            css: options.css.unwrap_or(true),
265            css_deep: options.css.unwrap_or(true) && options.css_deep.unwrap_or(true),
266            coverage: options.coverage.clone(),
267            coverage_root: options.coverage_root.clone(),
268            ..ComplexityOptions::default()
269        },
270    }
271}
272
273fn run_audit_subanalyses(
274    options: &AuditOptions,
275    analysis: &AnalysisOptions,
276    changed_files: Option<&FxHashSet<PathBuf>>,
277) -> ProgrammaticResult<AuditSubanalyses> {
278    let resolved = resolve_programmatic_analysis_context_deferred_workspace(analysis)?;
279    run_audit_subanalyses_with_context(options, analysis, &resolved, changed_files)
280}
281
282fn run_audit_subanalyses_with_context(
283    options: &AuditOptions,
284    analysis: &AnalysisOptions,
285    resolved: &ProgrammaticAnalysisContext,
286    changed_files: Option<&FxHashSet<PathBuf>>,
287) -> ProgrammaticResult<AuditSubanalyses> {
288    let subanalysis_options = audit_subanalysis_options(options, analysis);
289    let production_modes = resolve_effective_production_modes(
290        resolved,
291        options.production_dead_code,
292        options.production_health,
293        options.production_dupes,
294    )?;
295
296    if production_modes.dead_code == production_modes.dupes
297        && production_modes.dead_code == production_modes.health
298    {
299        return run_shared_project_audit_subanalyses(&subanalysis_options, changed_files);
300    }
301
302    if production_modes.dead_code == production_modes.health {
303        return run_shared_dead_code_health_audit_subanalyses(&subanalysis_options, changed_files);
304    }
305
306    if production_modes.dead_code == production_modes.dupes {
307        return run_shared_dead_code_dupes_audit_subanalyses(&subanalysis_options, changed_files);
308    }
309
310    Ok(AuditSubanalyses {
311        dead_code: run_dead_code(&subanalysis_options.dead_code)?,
312        duplication: run_duplication(&subanalysis_options.duplication)?,
313        complexity: run_health(&subanalysis_options.complexity)?,
314    })
315}
316
317fn run_shared_project_audit_subanalyses(
318    options: &AuditSubanalysisOptions,
319    changed_files: Option<&FxHashSet<PathBuf>>,
320) -> ProgrammaticResult<AuditSubanalyses> {
321    let resolved =
322        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
323    resolved.install(|| {
324        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
325        run_all_audit_subanalyses_with_project_artifacts(
326            &options.dead_code,
327            &options.duplication,
328            &options.complexity,
329            &resolved,
330            &session,
331            changed_files,
332        )
333    })
334}
335
336fn run_shared_dead_code_health_audit_subanalyses(
337    options: &AuditSubanalysisOptions,
338    changed_files: Option<&FxHashSet<PathBuf>>,
339) -> ProgrammaticResult<AuditSubanalyses> {
340    let resolved =
341        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
342    resolved.install(|| {
343        let dead_code_options = &options.dead_code;
344        let duplication_options = &options.duplication;
345        let complexity_options = &options.complexity;
346        let session = super::dead_code::load_dead_code_session(dead_code_options, &resolved)?;
347        let (dead_code, complexity) = run_dead_code_and_health_with_session(
348            dead_code_options,
349            complexity_options,
350            &resolved,
351            &session,
352            changed_files,
353        )?;
354        Ok(AuditSubanalyses {
355            dead_code,
356            duplication: run_duplication(duplication_options)?,
357            complexity,
358        })
359    })
360}
361
362fn run_shared_dead_code_dupes_audit_subanalyses(
363    options: &AuditSubanalysisOptions,
364    changed_files: Option<&FxHashSet<PathBuf>>,
365) -> ProgrammaticResult<AuditSubanalyses> {
366    let resolved =
367        resolve_programmatic_analysis_context_deferred_workspace(&options.dead_code.analysis)?;
368    resolved.install(|| {
369        let session = super::dead_code::load_dead_code_session(&options.dead_code, &resolved)?;
370        let (dead_code, duplication, _, _) =
371            run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
372                dead_code_options: &options.dead_code,
373                duplication_options: &options.duplication,
374                resolved: &resolved,
375                session: &session,
376                changed_files,
377                retain_dead_code_artifacts: false,
378                retain_duplication_artifacts: false,
379            })?;
380        Ok(AuditSubanalyses {
381            dead_code,
382            duplication,
383            complexity: run_health(&options.complexity)?,
384        })
385    })
386}
387
388fn run_dead_code_and_duplication_with_project_artifacts(
389    input: ProjectArtifactAuditInput<'_>,
390) -> ProgrammaticResult<(
391    crate::DeadCodeProgrammaticOutput,
392    crate::DuplicationProgrammaticOutput,
393    Option<DeadCodeAnalysisArtifacts>,
394    Option<fallow_engine::duplicates::DuplicationReport>,
395)> {
396    let dupes_config = super::duplication::build_dupes_config(
397        input.duplication_options,
398        &input.session.config().duplicates,
399    );
400    let section_start = Instant::now();
401    let project = input
402        .session
403        .analyze_project_with_artifacts(
404            &dupes_config,
405            ProjectAnalysisArtifactOptions {
406                retain_complexity_artifacts: input.retain_dead_code_artifacts,
407                retain_graph: input.retain_dead_code_artifacts,
408                changed_files: input.changed_files.cloned(),
409                collect_source_fingerprints: false,
410            },
411        )
412        .map_err(|err| {
413            ProgrammaticError::new(format!("audit analysis failed: {err}"), 2)
414                .with_code("FALLOW_AUDIT_FAILED")
415                .with_context("audit")
416        })?;
417    let duplication_artifacts = input
418        .retain_duplication_artifacts
419        .then(|| project.duplication.clone());
420    let dead_code = super::dead_code::run_dead_code_from_artifacts(
421        input.dead_code_options,
422        input.resolved,
423        input.session,
424        input.changed_files,
425        project.dead_code,
426        section_start,
427    )?;
428    let duplication = super::duplication::run_duplication_report_with_session(
429        input.duplication_options,
430        input.resolved,
431        input.session,
432        project.duplication,
433        section_start,
434    )?;
435    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
436        output: dead_code,
437        artifacts,
438    } = dead_code;
439    let dead_code_artifacts = input.retain_dead_code_artifacts.then_some(artifacts);
440    Ok((
441        dead_code,
442        duplication,
443        dead_code_artifacts,
444        duplication_artifacts,
445    ))
446}
447
448#[derive(Clone, Copy)]
449struct ProjectArtifactAuditInput<'a> {
450    dead_code_options: &'a DeadCodeOptions,
451    duplication_options: &'a DuplicationOptions,
452    resolved: &'a ProgrammaticAnalysisContext,
453    session: &'a AnalysisSession,
454    changed_files: Option<&'a FxHashSet<PathBuf>>,
455    retain_dead_code_artifacts: bool,
456    retain_duplication_artifacts: bool,
457}
458
459fn run_all_audit_subanalyses_with_project_artifacts(
460    dead_code_options: &DeadCodeOptions,
461    duplication_options: &DuplicationOptions,
462    complexity_options: &ComplexityOptions,
463    resolved: &ProgrammaticAnalysisContext,
464    session: &AnalysisSession,
465    changed_files: Option<&FxHashSet<PathBuf>>,
466) -> ProgrammaticResult<AuditSubanalyses> {
467    let retain_dead_code_artifacts =
468        health_may_consume_dead_code_artifacts(complexity_options, session.config());
469    let retain_duplication_artifacts = health_may_consume_duplication_report(complexity_options);
470    let (dead_code, duplication, dead_code_artifacts, duplication_artifacts) =
471        run_dead_code_and_duplication_with_project_artifacts(ProjectArtifactAuditInput {
472            dead_code_options,
473            duplication_options,
474            resolved,
475            session,
476            changed_files,
477            retain_dead_code_artifacts,
478            retain_duplication_artifacts,
479        })?;
480    let complexity = run_health_with_session_artifacts(
481        complexity_options,
482        resolved,
483        session,
484        changed_files,
485        dead_code_artifacts,
486        duplication_artifacts,
487    )?;
488    Ok(AuditSubanalyses {
489        dead_code,
490        duplication,
491        complexity,
492    })
493}
494
495fn run_dead_code_and_health_with_session(
496    dead_code_options: &DeadCodeOptions,
497    complexity_options: &ComplexityOptions,
498    resolved: &ProgrammaticAnalysisContext,
499    session: &AnalysisSession,
500    changed_files: Option<&FxHashSet<PathBuf>>,
501) -> ProgrammaticResult<(
502    crate::DeadCodeProgrammaticOutput,
503    crate::HealthProgrammaticOutput,
504)> {
505    let reuse_dead_code_artifacts =
506        health_may_consume_dead_code_artifacts(complexity_options, session.config());
507    let (dead_code, dead_code_artifacts) = if reuse_dead_code_artifacts {
508        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
509            dead_code_options,
510            resolved,
511            session,
512            changed_files,
513            |_| {},
514            Instant::now(),
515        )?;
516        (dead_code.output, Some(dead_code.artifacts))
517    } else {
518        (
519            super::dead_code::run_dead_code_with_session(
520                dead_code_options,
521                resolved,
522                session,
523                changed_files,
524                |_| {},
525                Instant::now(),
526            )?,
527            None,
528        )
529    };
530    let complexity = run_health_with_session_artifacts(
531        complexity_options,
532        resolved,
533        session,
534        changed_files,
535        dead_code_artifacts,
536        None,
537    )?;
538    Ok((dead_code, complexity))
539}
540
541fn load_programmatic_audit_config(
542    resolved: &ProgrammaticAnalysisContext,
543) -> ProgrammaticResult<fallow_config::ResolvedConfig> {
544    fallow_engine::project_config::config_for_project_analysis(
545        resolved.root(),
546        resolved.config_path().as_deref(),
547        ProjectConfigOptions {
548            output: OutputFormat::Json,
549            no_cache: resolved.no_cache(),
550            threads: resolved.threads(),
551            production_override: resolved.production_override(),
552            quiet: true,
553            analysis: ProductionAnalysis::DeadCode,
554            allow_remote_extends: resolved.allow_remote_extends(),
555        },
556    )
557    .map(|project| project.config)
558    .map_err(|err| {
559        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
560            .with_code("FALLOW_CONFIG_LOAD_FAILED")
561            .with_context("analysis.configPath")
562    })
563}
564
565fn build_programmatic_audit_comparison(
566    analyses: &AuditSubanalyses,
567    config: &fallow_config::ResolvedConfig,
568    base: Option<&AuditRuntimeKeySnapshot>,
569) -> crate::audit_keys::AuditComparison {
570    let dupe_keys = analyses
571        .duplication
572        .output
573        .report
574        .clone_groups
575        .iter()
576        .map(|group| crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root))
577        .collect();
578    let styling_keys = analyses
579        .complexity
580        .report
581        .styling_findings
582        .iter()
583        .map(|finding| crate::audit_keys::styling_finding_key(finding, &analyses.complexity.root))
584        .collect();
585    crate::audit_keys::AuditComparison::build(crate::audit_keys::AuditComparisonInput {
586        results: &analyses.dead_code.output.results,
587        config,
588        root: &analyses.dead_code.root,
589        health: &analyses.complexity.report,
590        health_root: &analyses.complexity.root,
591        dupe_keys,
592        styling_keys,
593        base_dead_code: base.map(|snapshot| &snapshot.public.dead_code),
594        base_health: base.map(|snapshot| &snapshot.public.health),
595        base_dupes: base.map(|snapshot| &snapshot.public.dupes),
596        base_styling: base.map(|snapshot| &snapshot.styling),
597    })
598}
599
600fn build_programmatic_audit_summary(
601    analyses: &AuditSubanalyses,
602    comparison: &crate::audit_keys::AuditComparison,
603) -> AuditSummary {
604    let dead_code_issues = comparison.dead_code.visible_count();
605    AuditSummary {
606        dead_code_issues,
607        dead_code_has_errors: comparison.dead_code.has_errors(),
608        complexity_findings: analyses.complexity.report.findings.len(),
609        max_cyclomatic: analyses
610            .complexity
611            .report
612            .findings
613            .iter()
614            .map(|finding| finding.cyclomatic)
615            .max(),
616        duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
617    }
618}
619
620fn styling_finding_gates(rules: &fallow_config::RulesConfig, code: &str) -> bool {
621    let severity = match code {
622        "css-token-drift" => rules.css_token_drift,
623        "css-duplicate-block" => rules.css_duplicate_block,
624        "css-selector-complexity" => rules.css_selector_complexity,
625        "css-dead-surface" => rules.css_dead_surface,
626        "css-broken-reference" => rules.css_broken_reference,
627        _ => fallow_config::Severity::Warn,
628    };
629    severity == fallow_config::Severity::Error
630}
631
632fn comparison_verdict(
633    gate: AuditGate,
634    summary: &AuditSummary,
635    duplication: &crate::DuplicationProgrammaticOutput,
636    complexity: &crate::HealthProgrammaticOutput,
637    config: &fallow_config::ResolvedConfig,
638    comparison: &crate::audit_keys::AuditComparison,
639) -> AuditVerdict {
640    let new_only = matches!(gate, AuditGate::NewOnly);
641    let dead_code_errors = if new_only {
642        comparison.dead_code.has_introduced_errors()
643    } else {
644        comparison.dead_code.has_errors()
645    };
646    let dead_code_warnings = if new_only {
647        comparison.dead_code.has_introduced_warnings()
648    } else {
649        comparison
650            .dead_code
651            .records()
652            .iter()
653            .any(|record| record.effective_severity == fallow_config::Severity::Warn)
654    };
655    let complexity_findings = if new_only {
656        comparison.health.introduced_count()
657    } else {
658        summary.complexity_findings
659    };
660    let styling_errors = complexity
661        .report
662        .styling_findings
663        .iter()
664        .zip(comparison.styling.introduced())
665        .any(|(finding, introduced)| {
666            (!new_only || introduced) && styling_finding_gates(&config.rules, &finding.code)
667        });
668    if dead_code_errors || complexity_findings > 0 || styling_errors {
669        return AuditVerdict::Fail;
670    }
671    let duplication_findings = if new_only {
672        comparison.dupes.introduced_count()
673    } else {
674        summary.duplication_clone_groups
675    };
676    if duplication_findings > 0 {
677        let pct = duplication.output.report.stats.duplication_percentage;
678        if duplication.threshold > 0.0 && pct > duplication.threshold {
679            return AuditVerdict::Fail;
680        }
681        return AuditVerdict::Warn;
682    }
683    if dead_code_warnings {
684        return AuditVerdict::Warn;
685    }
686    AuditVerdict::Pass
687}
688
689fn comparison_attribution(
690    gate: AuditGate,
691    comparison: &crate::audit_keys::AuditComparison,
692    has_base: bool,
693) -> AuditAttribution {
694    if !has_base {
695        return AuditAttribution {
696            gate,
697            ..AuditAttribution::default()
698        };
699    }
700    AuditAttribution {
701        gate,
702        dead_code_introduced: comparison.dead_code.introduced_count(),
703        dead_code_inherited: comparison.dead_code.inherited_count(),
704        complexity_introduced: comparison.health.introduced_count(),
705        complexity_inherited: comparison.health.inherited_count(),
706        duplication_introduced: comparison.dupes.introduced_count(),
707        duplication_inherited: comparison.dupes.inherited_count(),
708    }
709}
710
711fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditRuntimeKeySnapshot {
712    let styling =
713        crate::audit_keys::styling_keys(&analyses.complexity.report, &analyses.complexity.root);
714    let mut health =
715        crate::audit_keys::health_keys(&analyses.complexity.report, &analyses.complexity.root);
716    health.extend(styling.iter().cloned());
717    AuditRuntimeKeySnapshot {
718        public: AuditProgrammaticKeySnapshot {
719            dead_code: crate::audit_keys::dead_code_keys(
720                &analyses.dead_code.output.results,
721                &analyses.dead_code.root,
722            ),
723            health,
724            dupes: analyses
725                .duplication
726                .output
727                .report
728                .clone_groups
729                .iter()
730                .map(|group| {
731                    crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
732                })
733                .collect(),
734        },
735        styling,
736    }
737}
738
739fn compute_base_snapshot(
740    options: &AuditOptions,
741    base_ref: &str,
742) -> ProgrammaticResult<AuditRuntimeKeySnapshot> {
743    let current_root = analysis_root_from_options(options)?;
744    let worktree = TemporaryBaseWorktree::create(&current_root, base_ref).map_err(|err| {
745        ProgrammaticError::new(err.to_string(), 2)
746            .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
747            .with_context("audit.base")
748    })?;
749    let base_root = repo_refs::base_analysis_root(&current_root, worktree.path());
750    let current_config_path = options
751        .analysis
752        .config_path
753        .clone()
754        .or_else(|| fallow_config::FallowConfig::find_config_path(&current_root));
755    let base_analysis = AnalysisOptions {
756        root: Some(base_root),
757        config_path: current_config_path,
758        changed_since: None,
759        explain: false,
760        ..options.analysis.clone()
761    };
762    let base = run_audit_subanalyses(options, &base_analysis, None)?;
763    Ok(snapshot_from_analyses(&base))
764}
765
766fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
767    match options.analysis.root.clone() {
768        Some(root) => Ok(root),
769        None => std::env::current_dir().map_err(|err| {
770            ProgrammaticError::new(
771                format!("failed to resolve current working directory: {err}"),
772                2,
773            )
774            .with_code("FALLOW_CWD_UNAVAILABLE")
775            .with_context("analysis.root")
776        }),
777    }
778}
779
780fn audit_next_steps(
781    dead_code: &crate::DeadCodeProgrammaticOutput,
782    complexity: &crate::HealthProgrammaticOutput,
783) -> Vec<NextStep> {
784    let input = fallow_output::build_audit_next_steps_input(
785        Some((&dead_code.output.results, dead_code.root.as_path())),
786        Some(&complexity.report),
787        crate::next_steps::suggestions_enabled(),
788    );
789    build_audit_next_steps(&input)
790}
791
792fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
793    fallow_engine::validate::validate_git_ref(value)
794        .map(|_| ())
795        .map_err(|err| {
796            ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
797                .with_code("FALLOW_INVALID_GIT_REF")
798                .with_context(context)
799        })
800}
801
802fn audit_base_env_override() -> Option<String> {
803    std::env::var("FALLOW_AUDIT_BASE")
804        .ok()
805        .map(|value| value.trim().to_string())
806        .filter(|value| !value.is_empty())
807}
808
809#[cfg(test)]
810mod tests {
811    use std::process::Command;
812
813    use fallow_config::{AuditGate, FallowConfig, HealthConfig};
814    use fallow_types::output_format::OutputFormat;
815
816    use super::*;
817
818    fn resolved_config_with_max_crap(max_crap: f64) -> fallow_config::ResolvedConfig {
819        FallowConfig {
820            health: HealthConfig {
821                max_crap,
822                ..HealthConfig::default()
823            },
824            ..FallowConfig::default()
825        }
826        .resolve(
827            std::env::temp_dir().join("fallow-api-runtime-test"),
828            OutputFormat::Json,
829            1,
830            true,
831            true,
832            None,
833        )
834    }
835
836    #[test]
837    fn audit_complexity_only_health_does_not_retain_dead_code_artifacts() {
838        let options = ComplexityOptions {
839            complexity: true,
840            ..ComplexityOptions::default()
841        };
842        let config = resolved_config_with_max_crap(0.0);
843
844        assert!(!health_may_consume_dead_code_artifacts(&options, &config));
845    }
846
847    #[test]
848    fn audit_health_artifact_reuse_tracks_config_max_crap() {
849        let options = ComplexityOptions {
850            complexity: true,
851            ..ComplexityOptions::default()
852        };
853        let config = resolved_config_with_max_crap(30.0);
854
855        assert!(health_may_consume_dead_code_artifacts(&options, &config));
856    }
857
858    #[test]
859    fn audit_health_artifact_reuse_tracks_file_score_inputs() {
860        let config = resolved_config_with_max_crap(0.0);
861        for options in [
862            ComplexityOptions {
863                file_scores: true,
864                ..ComplexityOptions::default()
865            },
866            ComplexityOptions {
867                coverage_gaps: true,
868                ..ComplexityOptions::default()
869            },
870            ComplexityOptions {
871                targets: true,
872                ..ComplexityOptions::default()
873            },
874            ComplexityOptions {
875                score: true,
876                ..ComplexityOptions::default()
877            },
878            ComplexityOptions {
879                max_crap: Some(30.0),
880                complexity: true,
881                ..ComplexityOptions::default()
882            },
883        ] {
884            assert!(health_may_consume_dead_code_artifacts(&options, &config));
885        }
886    }
887
888    #[test]
889    fn audit_health_duplication_reuse_tracks_score_and_targets() {
890        for options in [
891            ComplexityOptions {
892                score: true,
893                ..ComplexityOptions::default()
894            },
895            ComplexityOptions {
896                targets: true,
897                ..ComplexityOptions::default()
898            },
899        ] {
900            assert!(health_may_consume_duplication_report(&options));
901        }
902
903        assert!(!health_may_consume_duplication_report(&ComplexityOptions {
904            complexity: true,
905            ..ComplexityOptions::default()
906        }));
907    }
908
909    #[test]
910    fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
911        let project = audit_fixture();
912        let output = run_audit(&AuditOptions {
913            analysis: AnalysisOptions {
914                root: Some(project.path().to_path_buf()),
915                no_cache: true,
916                explain: true,
917                ..AnalysisOptions::default()
918            },
919            base: Some("HEAD".to_string()),
920            gate: AuditGate::NewOnly,
921            ..AuditOptions::default()
922        })
923        .expect("audit output");
924
925        assert_eq!(output.verdict, AuditVerdict::Fail);
926        assert_eq!(output.summary.dead_code_issues, 1);
927        assert_eq!(output.attribution.dead_code_introduced, 1);
928        assert!(output.base_snapshot.is_some());
929
930        let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
931        assert_eq!(
932            json["dead_code"]["unused_files"][0]["path"],
933            "src/feature.ts"
934        );
935        assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
936    }
937
938    #[test]
939    fn run_audit_warn_only_dead_code_matches_cli_verdict_semantics() {
940        let project = audit_fixture();
941        std::fs::write(
942            project.path().join(".fallowrc.json"),
943            r#"{"rules":{"unused-files":"warn"}}"#,
944        )
945        .expect("write config");
946
947        let output = run_audit(&AuditOptions {
948            analysis: AnalysisOptions {
949                root: Some(project.path().to_path_buf()),
950                no_cache: true,
951                ..AnalysisOptions::default()
952            },
953            base: Some("HEAD".to_string()),
954            gate: AuditGate::All,
955            ..AuditOptions::default()
956        })
957        .expect("audit output");
958
959        assert_eq!(output.verdict, AuditVerdict::Warn);
960        assert!(!output.summary.dead_code_has_errors);
961    }
962
963    #[test]
964    fn run_audit_styling_error_matches_cli_for_new_only_and_all_gates() {
965        let project = audit_styling_fixture();
966        let root = project.path();
967        std::fs::write(
968            root.join("src/styles.css"),
969            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n",
970        )
971        .expect("write inherited-only change");
972
973        let all = run_audit(&AuditOptions {
974            analysis: AnalysisOptions {
975                root: Some(root.to_path_buf()),
976                no_cache: true,
977                ..AnalysisOptions::default()
978            },
979            base: Some("HEAD".to_string()),
980            gate: AuditGate::All,
981            ..AuditOptions::default()
982        })
983        .expect("all-gate audit");
984        assert_eq!(all.verdict, AuditVerdict::Fail);
985
986        let inherited_only = run_audit(&AuditOptions {
987            analysis: AnalysisOptions {
988                root: Some(root.to_path_buf()),
989                no_cache: true,
990                ..AnalysisOptions::default()
991            },
992            base: Some("HEAD".to_string()),
993            gate: AuditGate::NewOnly,
994            ..AuditOptions::default()
995        })
996        .expect("new-only inherited audit");
997        assert_eq!(inherited_only.verdict, AuditVerdict::Pass);
998        assert!(inherited_only.base_snapshot.is_some());
999        let inherited_json =
1000            crate::serialize_audit_programmatic_json(inherited_only).expect("inherited audit JSON");
1001        assert_eq!(
1002            inherited_json["complexity"]["styling_findings"][0]["introduced"],
1003            false
1004        );
1005
1006        std::fs::write(
1007            root.join("src/styles.css"),
1008            "#app .legacy .title { color: red; }\n.plain { color: blue; }\n#app .introduced .title { color: green; }\n",
1009        )
1010        .expect("write introduced styling change");
1011        let introduced = run_audit(&AuditOptions {
1012            analysis: AnalysisOptions {
1013                root: Some(root.to_path_buf()),
1014                no_cache: true,
1015                ..AnalysisOptions::default()
1016            },
1017            base: Some("HEAD".to_string()),
1018            gate: AuditGate::NewOnly,
1019            ..AuditOptions::default()
1020        })
1021        .expect("new-only introduced audit");
1022        assert_eq!(introduced.verdict, AuditVerdict::Fail);
1023        let introduced_json =
1024            crate::serialize_audit_programmatic_json(introduced).expect("introduced audit JSON");
1025        let styling = introduced_json["complexity"]["styling_findings"]
1026            .as_array()
1027            .expect("styling findings");
1028        assert!(
1029            styling
1030                .iter()
1031                .any(|finding| finding["line"] == 1 && finding["introduced"] == false)
1032        );
1033        assert!(
1034            styling
1035                .iter()
1036                .any(|finding| finding["line"] == 3 && finding["introduced"] == true)
1037        );
1038    }
1039
1040    #[test]
1041    fn audit_production_mode_branches_preserve_per_section_workspace_scope() {
1042        let project = audit_workspace_modes_fixture();
1043
1044        for mask in 0_u8..8 {
1045            let production_dead_code = mask & 0b001 != 0;
1046            let production_health = mask & 0b010 != 0;
1047            let production_dupes = mask & 0b100 != 0;
1048            let output = run_audit(&AuditOptions {
1049                analysis: AnalysisOptions {
1050                    root: Some(project.path().to_path_buf()),
1051                    workspace: Some(vec!["@audit/a".to_string()]),
1052                    no_cache: true,
1053                    ..AnalysisOptions::default()
1054                },
1055                base: Some("HEAD".to_string()),
1056                gate: AuditGate::All,
1057                production_dead_code: Some(production_dead_code),
1058                production_health: Some(production_health),
1059                production_dupes: Some(production_dupes),
1060                include_entry_exports: true,
1061                ..AuditOptions::default()
1062            })
1063            .unwrap_or_else(|error| panic!("audit mask {mask:03b} failed: {error}"));
1064            let json = crate::serialize_audit_programmatic_json(output)
1065                .unwrap_or_else(|error| panic!("serialize mask {mask:03b}: {error}"));
1066
1067            let dead_code = json["dead_code"].to_string();
1068            let complexity = json["complexity"].to_string();
1069            let duplication = json["duplication"].to_string();
1070            assert_eq!(
1071                dead_code.contains("mode-sentinel.test.ts"),
1072                !production_dead_code,
1073                "dead-code scope mismatch for mask {mask:03b}: {dead_code}"
1074            );
1075            assert_eq!(
1076                complexity.contains("mode-sentinel.test.ts"),
1077                !production_health,
1078                "health scope mismatch for mask {mask:03b}: {complexity}"
1079            );
1080            assert_eq!(
1081                duplication.contains("mode-sentinel.test.ts"),
1082                !production_dupes,
1083                "duplication scope mismatch for mask {mask:03b}: {duplication}"
1084            );
1085
1086            let rendered = json.to_string();
1087            assert!(
1088                !rendered.contains("packages/b"),
1089                "workspace B leaked into mask {mask:03b}: {rendered}"
1090            );
1091        }
1092    }
1093
1094    #[test]
1095    fn empty_audit_output_uses_resolved_root_for_head_sha() {
1096        let project = audit_fixture();
1097        let output = empty_audit_output(
1098            &AuditOptions {
1099                analysis: AnalysisOptions {
1100                    root: None,
1101                    ..AnalysisOptions::default()
1102                },
1103                base: Some("HEAD".to_string()),
1104                gate: AuditGate::NewOnly,
1105                ..AuditOptions::default()
1106            },
1107            ResolvedAuditBase {
1108                git_ref: "HEAD".to_string(),
1109                description: None,
1110            },
1111            project.path(),
1112            0,
1113            std::time::Duration::ZERO,
1114        );
1115
1116        assert!(output.head_sha.is_some());
1117    }
1118
1119    fn audit_fixture() -> tempfile::TempDir {
1120        let project = tempfile::tempdir().expect("project");
1121        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1122        std::fs::write(
1123            project.path().join("package.json"),
1124            r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
1125        )
1126        .expect("write package");
1127        std::fs::write(
1128            project.path().join("src/index.ts"),
1129            "console.log('entry');\n",
1130        )
1131        .expect("write entry");
1132        git(project.path(), &["init"]);
1133        git(project.path(), &["add", "."]);
1134        git(
1135            project.path(),
1136            &[
1137                "-c",
1138                "user.email=test@example.com",
1139                "-c",
1140                "user.name=Test",
1141                "-c",
1142                "commit.gpgsign=false",
1143                "commit",
1144                "-m",
1145                "initial",
1146            ],
1147        );
1148        std::fs::write(
1149            project.path().join("src/feature.ts"),
1150            "export const unused = 1;\n",
1151        )
1152        .expect("write changed source");
1153        project
1154    }
1155
1156    fn audit_styling_fixture() -> tempfile::TempDir {
1157        let project = tempfile::tempdir().expect("project");
1158        std::fs::create_dir_all(project.path().join("src")).expect("create src");
1159        std::fs::write(
1160            project.path().join("package.json"),
1161            r#"{"name":"audit-api-styling","type":"module","main":"src/index.ts"}"#,
1162        )
1163        .expect("write package");
1164        std::fs::write(
1165            project.path().join(".fallowrc.json"),
1166            r#"{"rules":{"css-selector-complexity":"error"}}"#,
1167        )
1168        .expect("write config");
1169        std::fs::write(
1170            project.path().join("src/index.ts"),
1171            "console.log('entry');\n",
1172        )
1173        .expect("write entry");
1174        std::fs::write(
1175            project.path().join("src/styles.css"),
1176            "#app .legacy .title { color: red; }\n",
1177        )
1178        .expect("write inherited styling");
1179        git(project.path(), &["init"]);
1180        git(project.path(), &["add", "."]);
1181        git(
1182            project.path(),
1183            &[
1184                "-c",
1185                "user.email=test@example.com",
1186                "-c",
1187                "user.name=Test",
1188                "-c",
1189                "commit.gpgsign=false",
1190                "commit",
1191                "-m",
1192                "initial",
1193            ],
1194        );
1195        project
1196    }
1197
1198    fn audit_workspace_modes_fixture() -> tempfile::TempDir {
1199        let project = tempfile::tempdir().expect("project");
1200        std::fs::write(
1201            project.path().join("package.json"),
1202            r#"{"name":"audit-root","private":true,"workspaces":["packages/*"]}"#,
1203        )
1204        .expect("write root package");
1205        std::fs::write(
1206            project.path().join(".fallowrc.json"),
1207            r#"{
1208  "duplicates": {
1209    "minTokens": 10,
1210    "minLines": 2,
1211    "ignoreDefaults": false
1212  },
1213  "health": {
1214    "maxCyclomatic": 2,
1215    "maxCognitive": 2,
1216    "maxCrap": 2.0,
1217    "maxUnitSize": 3
1218  }
1219}"#,
1220        )
1221        .expect("write config");
1222
1223        for name in ["a", "b"] {
1224            let package = project.path().join("packages").join(name);
1225            std::fs::create_dir_all(package.join("src")).expect("create package source");
1226            std::fs::write(
1227                package.join("package.json"),
1228                format!(r#"{{"name":"@audit/{name}","type":"module","main":"src/index.ts"}}"#),
1229            )
1230            .expect("write package manifest");
1231            std::fs::write(
1232                package.join("src/index.ts"),
1233                format!("export const {name}Entry = true;\n"),
1234            )
1235            .expect("write package entry");
1236        }
1237
1238        git(project.path(), &["init"]);
1239        git(project.path(), &["add", "."]);
1240        git(
1241            project.path(),
1242            &[
1243                "-c",
1244                "user.email=test@example.com",
1245                "-c",
1246                "user.name=Test",
1247                "-c",
1248                "commit.gpgsign=false",
1249                "commit",
1250                "-m",
1251                "initial",
1252            ],
1253        );
1254
1255        let sentinel = r"export function auditModeSentinel(value: number) {
1256  let result = value;
1257  if (value > 0) result += 1;
1258  if (value > 1) result += 2;
1259  if (value > 2) result += 3;
1260  if (value > 3) result += 4;
1261  return result;
1262}
1263";
1264        for name in ["a", "b"] {
1265            let source = project.path().join("packages").join(name).join("src");
1266            std::fs::write(source.join("mode-sentinel.test.ts"), sentinel)
1267                .expect("write test sentinel");
1268            std::fs::write(source.join("mode-sentinel-copy.test.ts"), sentinel)
1269                .expect("write duplicate test sentinel");
1270        }
1271
1272        project
1273    }
1274
1275    fn git(root: &Path, args: &[&str]) {
1276        let status = Command::new("git")
1277            .args(args)
1278            .current_dir(root)
1279            .status()
1280            .expect("git command");
1281        assert!(status.success(), "git {args:?} failed");
1282    }
1283}