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