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