Skip to main content

fallow_api/runtime/
dead_code.rs

1use std::path::Path;
2use std::time::Instant;
3
4use fallow_config::ProductionAnalysis;
5use fallow_engine::{
6    change_scope::ChangeScope,
7    dead_code::DeadCodeAnalysisArtifacts,
8    project_config::{ProjectConfig, ProjectConfigOptions},
9    session::AnalysisSession,
10};
11use fallow_output::{
12    CHECK_SCHEMA_VERSION, CheckOutputInput, DeadCodeNextStepsInput, build_check_output,
13    build_dead_code_next_steps, check_meta,
14};
15use fallow_types::output_format::OutputFormat;
16use fallow_types::path_util::is_absolute_path_any_platform;
17use fallow_types::results::AnalysisResults;
18use rustc_hash::FxHashSet;
19
20use crate::{
21    AnalysisOptions, BoundaryViolationsProgrammaticOutput, CircularDependenciesProgrammaticOutput,
22    DeadCodeFilters, DeadCodeOptions, DeadCodeProgrammaticOutput, ProgrammaticError,
23    analysis_context::{
24        ProgrammaticAnalysisContext, changed_files_for_run,
25        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
26    },
27    next_steps::{
28        audit_changed_applicable, default_workspace_ref_for_workspaces, setup_pointer_applicable,
29        suggestions_enabled,
30    },
31};
32
33use super::ProgrammaticResult;
34
35pub(super) struct DeadCodeProgrammaticRunWithArtifacts {
36    pub output: DeadCodeProgrammaticOutput,
37    pub artifacts: DeadCodeAnalysisArtifacts,
38}
39
40/// Run dead-code analysis and return typed API output before serialization.
41///
42/// # Errors
43///
44/// Returns a structured programmatic error for unsupported options, invalid
45/// options, config load failures, analysis failures, or git changed-file
46/// failures.
47pub fn run_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
48    run_dead_code_with_baseline(options, None)
49}
50
51/// Run dead-code analysis and hide the findings of a saved dead-code baseline.
52///
53/// The baseline is the file that `fallow dead-code --save-baseline` writes. It
54/// is applied by the same engine function as `fallow dead-code --baseline`, so
55/// both hide the same findings. A file that another command saved suppresses
56/// nothing, as on the CLI.
57///
58/// # Errors
59///
60/// Returns the errors of [`run_dead_code`], and a structured error when the
61/// baseline cannot be read, is not valid, or was saved with an incompatible
62/// analysis identity.
63pub fn run_dead_code_with_baseline(
64    options: &DeadCodeOptions,
65    baseline: Option<&Path>,
66) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
67    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
68    run_dead_code_in_context(options, baseline, &resolved)
69}
70
71/// Run dead-code analysis in a context that the caller resolved. Audit uses
72/// this to hand the change scope of every section to itself.
73pub(super) fn run_dead_code_in_context(
74    options: &DeadCodeOptions,
75    baseline: Option<&Path>,
76    resolved: &ProgrammaticAnalysisContext,
77) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
78    resolved.install(|| {
79        let start = Instant::now();
80        resolved.ensure_not_cancelled("config load and file discovery")?;
81        let finding_ids = parse_finding_ids(options)?;
82        let session = load_dead_code_session(options, resolved)?;
83        resolve_package_map_before_analysis(resolved, &session)?;
84        let (mut results, finished) =
85            analyze_dead_code_results(options, resolved, &session, finding_ids)?;
86        let FinishedDeadCode {
87            type_aware_meta,
88            mut finding_id_trace,
89            change_reason,
90            package_baselines,
91        } = finished;
92        if let Some(trace) = finding_id_trace.as_mut() {
93            trace.start_stage(&mut results);
94        }
95        if let Some(baseline) = baseline {
96            apply_baseline(
97                &mut results,
98                baseline,
99                session.root(),
100                type_aware_meta.as_ref(),
101            )?;
102        }
103        let finding_id_query = finding_id_trace.map(|mut trace| {
104            trace.end_stage(&mut results);
105            trace.finish(
106                &mut results,
107                session.config(),
108                finding_id_run_reasons(
109                    options,
110                    resolved,
111                    &session,
112                    RunScope {
113                        change_reason,
114                        baseline: baseline.is_some(),
115                    },
116                ),
117            )
118        });
119        let mut output = build_dead_code_programmatic_output(
120            options,
121            resolved,
122            &session,
123            DeadCodeReport {
124                results,
125                type_aware_meta,
126                package_baselines,
127            },
128            start,
129        );
130        output.output.finding_id_query = finding_id_query;
131        Ok(output)
132    })
133}
134
135/// Validate `finding_ids`. A malformed id is an error, so a typo never reads
136/// as "the finding is gone".
137fn parse_finding_ids(
138    options: &DeadCodeOptions,
139) -> ProgrammaticResult<Option<fallow_engine::dead_code::FindingIdFilter>> {
140    fallow_engine::dead_code::FindingIdFilter::parse(&options.finding_ids).map_err(|message| {
141        ProgrammaticError::new(message, 2)
142            .with_code("FALLOW_INVALID_FINDING_ID")
143            .with_context("findingIds")
144    })
145}
146
147/// Refuse `finding_ids` on the analyses that narrow the result to one issue
148/// family: a missing id there says nothing about the other families.
149fn reject_finding_ids(options: &DeadCodeOptions) -> ProgrammaticResult<()> {
150    if options.finding_ids.is_empty() {
151        return Ok(());
152    }
153    Err(
154        ProgrammaticError::new("findingIds is supported only by the dead-code analysis", 2)
155            .with_code("FALLOW_UNSUPPORTED_OPTION")
156            .with_context("findingIds"),
157    )
158}
159
160/// The run facts that finding-id reasons read beyond the options.
161#[derive(Clone, Copy)]
162struct RunScope {
163    /// The change channel that narrowed the run: a global ref or the package
164    /// map.
165    change_reason: Option<fallow_output::ScopeReason>,
166    /// A saved baseline hid findings.
167    baseline: bool,
168}
169
170/// The options of this run that can hide a finding without a fix. Mirrors the
171/// CLI list: the scope channels, the issue-type filters, production mode and
172/// the baseline.
173fn finding_id_run_reasons(
174    options: &DeadCodeOptions,
175    resolved: &ProgrammaticAnalysisContext,
176    session: &AnalysisSession,
177    run: RunScope,
178) -> Vec<fallow_output::FindingIdQueryReason> {
179    use fallow_output::FindingIdQueryReason as Reason;
180
181    [
182        (resolved.diff.is_some(), Reason::Diff),
183        (
184            run.change_reason == Some(fallow_output::ScopeReason::ChangedSince)
185                || options.analysis.ambient_changed_since.is_some(),
186            Reason::ChangedSince,
187        ),
188        (
189            run.change_reason == Some(fallow_output::ScopeReason::PackageBaselines),
190            Reason::PackageBaselines,
191        ),
192        (resolved.workspace().is_some(), Reason::Workspace),
193        (
194            resolved.changed_workspaces().is_some(),
195            Reason::ChangedWorkspaces,
196        ),
197        (!options.files.is_empty(), Reason::File),
198        (options.filters.any_active(), Reason::IssueTypeFilter),
199        (session.config().production, Reason::Production),
200        (
201            session.config().include_entry_exports,
202            Reason::IncludeEntryExports,
203        ),
204        (run.baseline, Reason::Baseline),
205    ]
206    .into_iter()
207    .filter_map(|(active, reason)| active.then_some(reason))
208    .collect()
209}
210
211/// Turn an engine failure into a programmatic error, keeping a cancelled run
212/// distinguishable from a failed one.
213///
214/// The engine's cancellation message already names the pipeline boundary the
215/// run stopped at, so it is carried through rather than replaced.
216pub(super) fn map_engine_error(
217    err: &fallow_engine::EngineError,
218    failure_message: &str,
219    failure_code: &'static str,
220    context: &'static str,
221) -> ProgrammaticError {
222    if err.is_cancelled() {
223        return crate::analysis_context::cancelled_error_message(err.message())
224            .with_context(context);
225    }
226    ProgrammaticError::new(format!("{failure_message}: {err}"), 2)
227        .with_code(failure_code)
228        .with_context(context)
229}
230
231/// Run circular-dependency analysis and return typed API output before JSON.
232///
233/// # Errors
234///
235/// Returns the same structured errors as [`run_dead_code`].
236pub fn run_circular_dependencies(
237    options: &DeadCodeOptions,
238) -> ProgrammaticResult<CircularDependenciesProgrammaticOutput> {
239    reject_finding_ids(options)?;
240    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
241    resolved.install(|| {
242        run_dead_code_inner(options, &resolved, keep_circular_dependencies).map(Into::into)
243    })
244}
245
246/// Run boundary-family analysis and return typed API output before JSON.
247///
248/// # Errors
249///
250/// Returns the same structured errors as [`run_dead_code`].
251pub fn run_boundary_violations(
252    options: &DeadCodeOptions,
253) -> ProgrammaticResult<BoundaryViolationsProgrammaticOutput> {
254    reject_finding_ids(options)?;
255    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
256    resolved.install(|| {
257        run_dead_code_inner(options, &resolved, keep_boundary_violations).map(Into::into)
258    })
259}
260
261fn run_dead_code_inner(
262    options: &DeadCodeOptions,
263    resolved: &ProgrammaticAnalysisContext,
264    post_filter: impl FnOnce(&mut AnalysisResults),
265) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
266    let start = Instant::now();
267    resolved.ensure_not_cancelled("config load and file discovery")?;
268    let session = load_dead_code_session(options, resolved)?;
269    resolve_package_map_before_analysis(resolved, &session)?;
270    run_dead_code_with_session(options, resolved, &session, None, post_filter, start)
271}
272
273pub(super) fn run_dead_code_with_session(
274    options: &DeadCodeOptions,
275    resolved: &ProgrammaticAnalysisContext,
276    session: &AnalysisSession,
277    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
278    post_filter: impl FnOnce(&mut AnalysisResults),
279    start: Instant,
280) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
281    resolved.ensure_not_cancelled("dead-code analysis")?;
282    let mut results = analyze_session_dead_code(session)?;
283    let unfiltered_unused_files = results.unused_files.clone();
284    let finished = finish_dead_code_results(
285        DeadCodeReportInputs {
286            options,
287            resolved,
288            session,
289            changed_files,
290            unfiltered_unused_files,
291            finding_ids: None,
292        },
293        &mut results,
294        post_filter,
295    )?;
296    Ok(build_dead_code_programmatic_output(
297        options,
298        resolved,
299        session,
300        finished.into_report(results),
301        start,
302    ))
303}
304
305/// The reported dead-code findings of one session run and what the reporting
306/// tail learned about them.
307fn analyze_dead_code_results(
308    options: &DeadCodeOptions,
309    resolved: &ProgrammaticAnalysisContext,
310    session: &AnalysisSession,
311    finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
312) -> ProgrammaticResult<(AnalysisResults, FinishedDeadCode)> {
313    resolved.ensure_not_cancelled("dead-code analysis")?;
314    let mut results = analyze_session_dead_code(session)?;
315    let unfiltered_unused_files = results.unused_files.clone();
316
317    let finished = finish_dead_code_results(
318        DeadCodeReportInputs {
319            options,
320            resolved,
321            session,
322            changed_files: None,
323            unfiltered_unused_files,
324            finding_ids,
325        },
326        &mut results,
327        |_| {},
328    )?;
329    Ok((results, finished))
330}
331
332fn analyze_session_dead_code(session: &AnalysisSession) -> ProgrammaticResult<AnalysisResults> {
333    session
334        .analyze_dead_code()
335        .map(|analysis| analysis.results)
336        .map_err(|err| {
337            map_engine_error(
338                &err,
339                "dead-code analysis failed",
340                "FALLOW_DEAD_CODE_FAILED",
341                "dead-code",
342            )
343        })
344}
345
346/// Hide the findings of a saved dead-code baseline, as `--baseline` does.
347fn apply_baseline(
348    results: &mut AnalysisResults,
349    baseline: &Path,
350    root: &Path,
351    type_aware_meta: Option<&fallow_types::envelope::TypeAwareMeta>,
352) -> ProgrammaticResult<()> {
353    use fallow_engine::baseline::{DeadCodeBaselineError, apply_dead_code_baseline};
354
355    let path = if is_absolute_path_any_platform(baseline) {
356        baseline.to_path_buf()
357    } else {
358        root.join(baseline)
359    };
360    let content = std::fs::read_to_string(&path).map_err(|err| {
361        ProgrammaticError::new(
362            format!("failed to read baseline {}: {err}", path.display()),
363            2,
364        )
365        .with_code("FALLOW_BASELINE_READ_FAILED")
366        .with_context("baseline")
367    })?;
368    let identity = type_aware_meta
369        .and_then(|meta| meta.identity.clone())
370        .unwrap_or_default();
371    apply_dead_code_baseline(results, &content, root, &identity, false)
372        .map(|_| ())
373        .map_err(|err| match err {
374            DeadCodeBaselineError::Parse(message) => ProgrammaticError::new(
375                format!("failed to parse baseline {}: {message}", path.display()),
376                2,
377            )
378            .with_code("FALLOW_BASELINE_INVALID")
379            .with_context("baseline"),
380            DeadCodeBaselineError::IncompatibleIdentity(fields) => ProgrammaticError::new(
381                format!(
382                    "baseline analysis identity is incompatible in: {}. Save it again with the same analysis options",
383                    fields.join(", ")
384                ),
385                2,
386            )
387            .with_code("FALLOW_BASELINE_IDENTITY_INCOMPATIBLE")
388            .with_context("baseline"),
389        })
390}
391
392pub(super) fn run_dead_code_with_session_artifacts(
393    options: &DeadCodeOptions,
394    resolved: &ProgrammaticAnalysisContext,
395    session: &AnalysisSession,
396    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
397    post_filter: impl FnOnce(&mut AnalysisResults),
398    start: Instant,
399) -> ProgrammaticResult<DeadCodeProgrammaticRunWithArtifacts> {
400    resolved.ensure_not_cancelled("dead-code analysis")?;
401    let mut artifacts = session
402        .analyze_dead_code_with_artifacts(true, true)
403        .map_err(|err| {
404            map_engine_error(
405                &err,
406                "dead-code analysis failed",
407                "FALLOW_DEAD_CODE_FAILED",
408                "dead-code",
409            )
410        })?;
411    let unfiltered_unused_files = artifacts.results.unused_files.clone();
412
413    let finished = finish_dead_code_results(
414        DeadCodeReportInputs {
415            options,
416            resolved,
417            session,
418            changed_files,
419            unfiltered_unused_files,
420            finding_ids: None,
421        },
422        &mut artifacts.results,
423        post_filter,
424    )?;
425
426    Ok(build_dead_code_run_with_artifacts(
427        options, resolved, session, artifacts, finished, start,
428    ))
429}
430
431pub(super) fn run_dead_code_from_artifacts(
432    options: &DeadCodeOptions,
433    resolved: &ProgrammaticAnalysisContext,
434    session: &AnalysisSession,
435    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
436    mut artifacts: DeadCodeAnalysisArtifacts,
437    start: Instant,
438) -> ProgrammaticResult<DeadCodeProgrammaticRunWithArtifacts> {
439    let unfiltered_unused_files = artifacts.results.unused_files.clone();
440    let finished = finish_dead_code_results(
441        DeadCodeReportInputs {
442            options,
443            resolved,
444            session,
445            changed_files,
446            unfiltered_unused_files,
447            finding_ids: None,
448        },
449        &mut artifacts.results,
450        |_| {},
451    )?;
452
453    Ok(build_dead_code_run_with_artifacts(
454        options, resolved, session, artifacts, finished, start,
455    ))
456}
457
458/// Inputs the reporting tail needs from whichever entry point produced the
459/// analysis.
460struct DeadCodeReportInputs<'a> {
461    options: &'a DeadCodeOptions,
462    resolved: &'a ProgrammaticAnalysisContext,
463    session: &'a AnalysisSession,
464    changed_files: Option<&'a FxHashSet<std::path::PathBuf>>,
465    /// Unused files as the engine reported them, before scope narrowing, which
466    /// type-aware refinement needs to reason about the whole project.
467    unfiltered_unused_files: Vec<fallow_types::output_dead_code::UnusedFileFinding>,
468    /// The requested finding ids, when the caller asked for some. The trace
469    /// starts on the full result set, before the scope.
470    finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
471}
472
473/// What the reporting tail learned about one run.
474struct FinishedDeadCode {
475    type_aware_meta: Option<fallow_types::envelope::TypeAwareMeta>,
476    finding_id_trace: Option<fallow_engine::dead_code::FindingIdTrace>,
477    /// The change channel that narrowed the run.
478    change_reason: Option<fallow_output::ScopeReason>,
479    /// The package baselines that narrowed the run.
480    package_baselines: Vec<fallow_output::PackageBaselineStatus>,
481}
482
483impl FinishedDeadCode {
484    fn into_report(self, results: AnalysisResults) -> DeadCodeReport {
485        DeadCodeReport {
486            results,
487            type_aware_meta: self.type_aware_meta,
488            package_baselines: self.package_baselines,
489        }
490    }
491}
492
493/// The findings of one run and the envelope facts that travel with them.
494struct DeadCodeReport {
495    results: AnalysisResults,
496    type_aware_meta: Option<fallow_types::envelope::TypeAwareMeta>,
497    package_baselines: Vec<fallow_output::PackageBaselineStatus>,
498}
499
500/// Shared reporting tail for every programmatic dead-code entry point: scope,
501/// issue-type filters, effective rule severities, the caller's family filter,
502/// and type-aware refinement.
503///
504/// The severity pass belongs here rather than at each entry point. Spelling
505/// the sequence out per entry point is what let the programmatic runtime
506/// report findings for rules a project had turned off while the CLI and the
507/// editor did not. The change scope is resolved here once for the same reason.
508fn finish_dead_code_results(
509    inputs: DeadCodeReportInputs<'_>,
510    results: &mut AnalysisResults,
511    post_filter: impl FnOnce(&mut AnalysisResults),
512) -> ProgrammaticResult<FinishedDeadCode> {
513    let DeadCodeReportInputs {
514        options,
515        resolved,
516        session,
517        changed_files,
518        unfiltered_unused_files,
519        finding_ids,
520    } = inputs;
521    let mut finding_id_trace =
522        finding_ids.map(|filter| fallow_engine::dead_code::FindingIdTrace::start(filter, results));
523    let resolved_changed_files = if changed_files.is_some() {
524        None
525    } else {
526        changed_files_for_run(resolved)?
527    };
528    let global_files = changed_files.or(resolved_changed_files.as_ref());
529    if global_files.is_some() {
530        resolved
531            .measure_changed_since_scope(session.files().iter().map(|file| file.path.as_path()));
532    }
533    let change_scope =
534        resolved.change_scope(global_files, session.config(), session.workspaces())?;
535    apply_dead_code_scope(options, resolved, session, &change_scope, results)?;
536    apply_dead_code_filters(&options.filters, results);
537    fallow_engine::dead_code::apply_rule_severities(results, session.config());
538    post_filter(results);
539    if let Some(trace) = finding_id_trace.as_mut() {
540        trace.end_stage(results);
541    }
542    let type_aware_meta = refine_with_unfiltered_unused_files(
543        &options.analysis.type_aware,
544        &options.filters,
545        session,
546        results,
547        unfiltered_unused_files,
548    )?;
549    if type_aware_meta.is_some() {
550        // Refinement can add findings, such as private-type leaks, anywhere in
551        // the project. The scope narrows the final result, so it runs again.
552        apply_dead_code_scope(options, resolved, session, &change_scope, results)?;
553    }
554    Ok(FinishedDeadCode {
555        type_aware_meta,
556        finding_id_trace,
557        change_reason: change_scope.scope_reason(),
558        package_baselines: change_scope.package_baselines(),
559    })
560}
561
562fn refine_with_unfiltered_unused_files(
563    options: &crate::TypeAwareOptions,
564    filters: &DeadCodeFilters,
565    session: &AnalysisSession,
566    results: &mut AnalysisResults,
567    unfiltered_unused_files: Vec<fallow_types::output_dead_code::UnusedFileFinding>,
568) -> ProgrammaticResult<Option<fallow_types::envelope::TypeAwareMeta>> {
569    let reported_unused_files =
570        std::mem::replace(&mut results.unused_files, unfiltered_unused_files);
571    let outcome =
572        crate::type_aware::refine_programmatic_dead_code(options, filters, session, results);
573    results.unused_files = reported_unused_files;
574    if options.enabled {
575        // Reconciliation can add findings, so rule severities are resolved
576        // again over the refined set. The pass removes findings and writes
577        // each gate severity again, so repeating it is idempotent. Mirrors
578        // EditorAnalysisSession.
579        fallow_engine::dead_code::apply_rule_severities(results, session.config());
580    }
581    outcome
582}
583
584fn build_dead_code_run_with_artifacts(
585    options: &DeadCodeOptions,
586    resolved: &ProgrammaticAnalysisContext,
587    session: &AnalysisSession,
588    artifacts: DeadCodeAnalysisArtifacts,
589    finished: FinishedDeadCode,
590    start: Instant,
591) -> DeadCodeProgrammaticRunWithArtifacts {
592    let output = build_dead_code_programmatic_output(
593        options,
594        resolved,
595        session,
596        finished.into_report(artifacts.results.clone()),
597        start,
598    );
599    DeadCodeProgrammaticRunWithArtifacts { output, artifacts }
600}
601
602fn build_dead_code_programmatic_output(
603    options: &DeadCodeOptions,
604    resolved: &ProgrammaticAnalysisContext,
605    session: &AnalysisSession,
606    report: DeadCodeReport,
607    start: Instant,
608) -> DeadCodeProgrammaticOutput {
609    let DeadCodeReport {
610        results,
611        type_aware_meta,
612        package_baselines,
613    } = report;
614    let root = session.root();
615    let next_steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
616        suggestions_enabled: suggestions_enabled(),
617        results: &results,
618        root,
619        offer_setup: setup_pointer_applicable(root),
620        impact_digest: None,
621        workspace_ref: default_workspace_ref_for_workspaces(root, session.workspaces()).as_deref(),
622        audit_changed: audit_changed_applicable(root),
623        has_external_plugins: !fallow_config::discover_external_plugins(root, &[]).is_empty(),
624        // The programmatic runtime loads no baseline, so there is never one to
625        // re-check.
626        baseline_recheck: None,
627    });
628    let config_fixable =
629        fallow_config::is_config_fixable(&resolved.root, resolved.config_path.as_ref());
630    let mut meta = options.analysis.explain.then(check_meta);
631    if let Some(type_aware) = type_aware_meta {
632        meta.get_or_insert_with(Default::default).type_aware = Some(type_aware);
633    }
634    // A `files` run drops dependency findings, like a filter without the
635    // dependency issue types. The CLI applies the same rule.
636    let reports_dependencies =
637        options.filters.reports_dependency_findings() && options.files.is_empty();
638    let workspace_diagnostics = fallow_types::workspace::merge_workspace_diagnostics(
639        session.current_workspace_diagnostics(),
640        fallow_engine::dead_code::config_pattern_diagnostics(
641            session.config(),
642            reports_dependencies,
643        ),
644    );
645    let mut output = build_check_output(CheckOutputInput {
646        schema_version: CHECK_SCHEMA_VERSION,
647        version: env!("CARGO_PKG_VERSION").to_string(),
648        elapsed: start.elapsed(),
649        results,
650        config_fixable,
651        meta,
652        workspace_diagnostics,
653        next_steps,
654    });
655    output.request_outcomes = resolved.request_outcomes();
656    output.package_baselines = package_baselines;
657    DeadCodeProgrammaticOutput {
658        output,
659        root: session.root().to_path_buf(),
660        config_fixable,
661        telemetry_analysis_run_id: None,
662    }
663}
664
665fn keep_circular_dependencies(results: &mut AnalysisResults) {
666    let entry_point_summary = results.entry_point_summary.take();
667    let circular_dependencies = std::mem::take(&mut results.circular_dependencies);
668    *results = AnalysisResults::default();
669    results.entry_point_summary = entry_point_summary;
670    results.circular_dependencies = circular_dependencies;
671}
672
673fn keep_boundary_violations(results: &mut AnalysisResults) {
674    let entry_point_summary = results.entry_point_summary.take();
675    let boundary_violations = std::mem::take(&mut results.boundary_violations);
676    let boundary_coverage_violations = std::mem::take(&mut results.boundary_coverage_violations);
677    let boundary_call_violations = std::mem::take(&mut results.boundary_call_violations);
678    *results = AnalysisResults::default();
679    results.entry_point_summary = entry_point_summary;
680    results.boundary_violations = boundary_violations;
681    results.boundary_coverage_violations = boundary_coverage_violations;
682    results.boundary_call_violations = boundary_call_violations;
683}
684
685pub(super) fn load_dead_code_session(
686    options: &DeadCodeOptions,
687    resolved: &ProgrammaticAnalysisContext,
688) -> ProgrammaticResult<AnalysisSession> {
689    let project_config = fallow_engine::project_config::config_for_project_analysis(
690        &resolved.root,
691        resolved.config_path.as_deref(),
692        ProjectConfigOptions {
693            output: OutputFormat::Json,
694            no_cache: resolved.no_cache,
695            threads: resolved.threads,
696            production_override: resolved.production_override,
697            quiet: true,
698            analysis: ProductionAnalysis::DeadCode,
699            allow_remote_extends: resolved.allow_remote_extends,
700        },
701    )
702    .map_err(|err| {
703        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
704            .with_code("FALLOW_CONFIG_LOAD_FAILED")
705            .with_context("analysis.configPath")
706    })?;
707    let project_config = configure_project_for_dead_code(project_config, options);
708    Ok(attach_cancellation(
709        AnalysisSession::from_config(project_config),
710        resolved,
711    ))
712}
713
714/// Resolve the package map before the analysis starts, so a malformed map
715/// fails fast. The call context keeps the result for the scope step.
716///
717/// Only the entry points that apply the change scope call this. Their changed
718/// files come from the call's own ref, so the request here matches the one
719/// the scope step makes. Trace and decision-surface sessions never read the map.
720pub(super) fn resolve_package_map_before_analysis(
721    resolved: &ProgrammaticAnalysisContext,
722    session: &AnalysisSession,
723) -> ProgrammaticResult<()> {
724    resolved
725        .change_scope(None, session.config(), session.workspaces())
726        .map(drop)
727}
728
729/// Hand the caller's cancellation token to the engine session.
730///
731/// Without this the session runs to completion no matter what the API layer
732/// checks between its own stages.
733pub(super) fn attach_cancellation(
734    session: AnalysisSession,
735    resolved: &ProgrammaticAnalysisContext,
736) -> AnalysisSession {
737    match resolved.cancellation() {
738        Some(cancellation) => session.with_cancellation(std::sync::Arc::clone(cancellation)),
739        None => session,
740    }
741}
742
743pub(super) fn default_dead_code_options_for_context(
744    resolved: &ProgrammaticAnalysisContext,
745) -> DeadCodeOptions {
746    DeadCodeOptions {
747        finding_ids: Vec::new(),
748        analysis: AnalysisOptions {
749            root: Some(resolved.root().to_path_buf()),
750            config_path: resolved.config_path().clone(),
751            no_cache: resolved.no_cache(),
752            threads: Some(resolved.threads()),
753            production_override: resolved.production_override(),
754            changed_since: resolved.changed_since().map(str::to_owned),
755            workspace: resolved.workspace().map(<[String]>::to_vec),
756            changed_workspaces: resolved.changed_workspaces().map(str::to_owned),
757            explain: resolved.explain_enabled(),
758            ..AnalysisOptions::default()
759        },
760        filters: DeadCodeFilters::default(),
761        files: Vec::new(),
762        include_entry_exports: false,
763    }
764}
765
766fn configure_project_for_dead_code(
767    mut project_config: ProjectConfig,
768    options: &DeadCodeOptions,
769) -> ProjectConfig {
770    if options.include_entry_exports {
771        project_config.config.include_entry_exports = true;
772    }
773    activate_explicit_dead_code_opt_ins(&options.filters, &mut project_config.config.rules);
774    project_config
775}
776
777fn activate_explicit_dead_code_opt_ins(
778    filters: &DeadCodeFilters,
779    rules: &mut fallow_config::RulesConfig,
780) {
781    if filters.private_type_leaks && rules.private_type_leaks == fallow_config::Severity::Off {
782        rules.private_type_leaks = fallow_config::Severity::Warn;
783    }
784    if filters.deprecated_exports_in_use
785        && rules.deprecated_exports_in_use == fallow_config::Severity::Off
786    {
787        rules.deprecated_exports_in_use = fallow_config::Severity::Warn;
788    }
789}
790
791fn apply_dead_code_scope(
792    options: &DeadCodeOptions,
793    resolved: &ProgrammaticAnalysisContext,
794    session: &AnalysisSession,
795    change_scope: &ChangeScope,
796    results: &mut AnalysisResults,
797) -> ProgrammaticResult<()> {
798    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
799    let files = file_scope(options, session.root());
800    fallow_engine::dead_code::apply_scope(
801        results,
802        &fallow_engine::dead_code::DeadCodeScope {
803            workspace_roots: workspace_roots.as_deref(),
804            changes: Some(change_scope),
805            diff: resolved.diff.as_ref().map(|diff| (diff, session.root())),
806            files: files.as_ref(),
807        },
808        session.config(),
809    );
810    Ok(())
811}
812
813/// The `files` option resolved against the root, or `None` when it is empty.
814fn file_scope(options: &DeadCodeOptions, root: &Path) -> Option<FxHashSet<std::path::PathBuf>> {
815    if options.files.is_empty() {
816        return None;
817    }
818    Some(
819        options
820            .files
821            .iter()
822            .map(|path| {
823                if is_absolute_path_any_platform(path) {
824                    path.clone()
825                } else {
826                    root.join(path)
827                }
828            })
829            .collect(),
830    )
831}
832
833fn apply_dead_code_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
834    if !dead_code_filters_active(filters) {
835        return;
836    }
837    apply_dead_code_core_filters(filters, results);
838    apply_dead_code_component_filters(filters, results);
839    apply_dead_code_graph_filters(filters, results);
840    apply_dead_code_policy_filters(filters, results);
841    apply_dead_code_catalog_filters(filters, results);
842}
843
844fn dead_code_filters_active(filters: &DeadCodeFilters) -> bool {
845    filters.unused_files
846        || filters.unused_exports
847        || filters.unused_deps
848        || filters.unused_types
849        || filters.private_type_leaks
850        || filters.deprecated_exports_in_use
851        || filters.unused_enum_members
852        || filters.unused_class_members
853        || filters.unused_store_members
854        || filters.unprovided_injects
855        || filters.unrendered_components
856        || filters.unused_component_props
857        || filters.unused_component_emits
858        || filters.unused_component_inputs
859        || filters.unused_component_outputs
860        || filters.unused_svelte_events
861        || filters.unused_server_actions
862        || filters.unused_load_data_keys
863        || filters.unresolved_imports
864        || filters.unlisted_deps
865        || filters.duplicate_exports
866        || filters.circular_deps
867        || filters.re_export_cycles
868        || filters.package_cycles
869        || filters.boundary_violations
870        || filters.policy_violations
871        || filters.stale_suppressions
872        || filters.unused_catalog_entries
873        || filters.empty_catalog_groups
874        || filters.unresolved_catalog_references
875        || filters.unused_dependency_overrides
876        || filters.misconfigured_dependency_overrides
877}
878
879fn apply_dead_code_core_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
880    if !filters.unused_files {
881        results.unused_files.clear();
882    }
883    if !filters.unused_exports {
884        results.unused_exports.clear();
885    }
886    if !filters.unused_types {
887        results.unused_types.clear();
888    }
889    if !filters.private_type_leaks {
890        results.private_type_leaks.clear();
891    }
892    if !filters.deprecated_exports_in_use {
893        results.deprecated_exports_in_use.clear();
894    }
895    if !filters.unused_deps {
896        clear_dead_code_dependency_findings(results);
897    }
898    if !filters.unused_enum_members {
899        results.unused_enum_members.clear();
900    }
901    if !filters.unused_class_members {
902        results.unused_class_members.clear();
903    }
904    if !filters.unused_store_members {
905        results.unused_store_members.clear();
906    }
907    if !filters.unlisted_deps {
908        results.unlisted_dependencies.clear();
909    }
910}
911
912fn clear_dead_code_dependency_findings(results: &mut AnalysisResults) {
913    results.unused_dependencies.clear();
914    results.unused_dev_dependencies.clear();
915    results.unused_optional_dependencies.clear();
916    results.type_only_dependencies.clear();
917    results.test_only_dependencies.clear();
918    results.dev_dependencies_in_production.clear();
919}
920
921fn apply_dead_code_component_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
922    if !filters.unprovided_injects {
923        results.unprovided_injects.clear();
924    }
925    if !filters.unrendered_components {
926        results.unrendered_components.clear();
927    }
928    if !filters.unused_component_props {
929        results.unused_component_props.clear();
930    }
931    if !filters.unused_component_emits {
932        results.unused_component_emits.clear();
933    }
934    if !filters.unused_component_inputs {
935        results.unused_component_inputs.clear();
936    }
937    if !filters.unused_component_outputs {
938        results.unused_component_outputs.clear();
939    }
940    if !filters.unused_svelte_events {
941        results.unused_svelte_events.clear();
942    }
943    if !filters.unused_server_actions {
944        results.unused_server_actions.clear();
945    }
946    if !filters.unused_load_data_keys {
947        results.unused_load_data_keys.clear();
948    }
949    if !filters.unresolved_imports {
950        results.unresolved_imports.clear();
951    }
952}
953
954fn apply_dead_code_graph_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
955    if !filters.duplicate_exports {
956        results.duplicate_exports.clear();
957    }
958    if !filters.circular_deps {
959        results.circular_dependencies.clear();
960    }
961    if !filters.re_export_cycles {
962        results.re_export_cycles.clear();
963    }
964    if !filters.package_cycles {
965        results.package_cycles.clear();
966    }
967    if !filters.boundary_violations {
968        results.boundary_violations.clear();
969        results.boundary_coverage_violations.clear();
970        results.boundary_call_violations.clear();
971    }
972}
973
974fn apply_dead_code_policy_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
975    if !filters.policy_violations {
976        results.policy_violations.clear();
977    }
978    if !filters.stale_suppressions {
979        results.stale_suppressions.clear();
980    }
981}
982
983fn apply_dead_code_catalog_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
984    if !filters.unused_catalog_entries {
985        results.unused_catalog_entries.clear();
986    }
987    if !filters.empty_catalog_groups {
988        results.empty_catalog_groups.clear();
989    }
990    if !filters.unresolved_catalog_references {
991        results.unresolved_catalog_references.clear();
992    }
993    if !filters.unused_dependency_overrides {
994        results.unused_dependency_overrides.clear();
995    }
996    if !filters.misconfigured_dependency_overrides {
997        results.misconfigured_dependency_overrides.clear();
998    }
999}