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