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::{AnalysisSession, ProjectConfig, ProjectConfigOptions};
6use fallow_output::{
7    CHECK_SCHEMA_VERSION, CheckOutputInput, DeadCodeNextStepsInput, DiffIndex, build_check_output,
8    build_dead_code_next_steps, check_meta, relative_to_diff_path,
9};
10use fallow_types::output_format::OutputFormat;
11use fallow_types::path_util::is_absolute_path_any_platform;
12use fallow_types::results::{AnalysisResults, TraceHopRole};
13use rustc_hash::FxHashSet;
14
15use crate::{
16    AnalysisOptions, BoundaryViolationsProgrammaticOutput, CircularDependenciesProgrammaticOutput,
17    DeadCodeFilters, DeadCodeOptions, DeadCodeProgrammaticOutput, ProgrammaticError,
18    analysis_context::{
19        ProgrammaticAnalysisContext, changed_files_for_run, resolve_programmatic_analysis_context,
20    },
21    next_steps::{default_workspace_ref, setup_pointer_applicable, suggestions_enabled},
22};
23
24use super::{ProgrammaticResult, root_envelope_mode};
25
26/// Run dead-code analysis and return typed API output before serialization.
27///
28/// # Errors
29///
30/// Returns a structured programmatic error for unsupported options, invalid
31/// options, config load failures, analysis failures, or git changed-file
32/// failures.
33pub fn run_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
34    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
35    resolved.install(|| run_dead_code_inner(options, &resolved, |_| {}))
36}
37
38/// Run circular-dependency analysis and return typed API output before JSON.
39///
40/// # Errors
41///
42/// Returns the same structured errors as [`run_dead_code`].
43pub fn run_circular_dependencies(
44    options: &DeadCodeOptions,
45) -> ProgrammaticResult<CircularDependenciesProgrammaticOutput> {
46    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
47    resolved.install(|| {
48        run_dead_code_inner(options, &resolved, keep_circular_dependencies).map(Into::into)
49    })
50}
51
52/// Run boundary-family analysis and return typed API output before JSON.
53///
54/// # Errors
55///
56/// Returns the same structured errors as [`run_dead_code`].
57pub fn run_boundary_violations(
58    options: &DeadCodeOptions,
59) -> ProgrammaticResult<BoundaryViolationsProgrammaticOutput> {
60    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
61    resolved.install(|| {
62        run_dead_code_inner(options, &resolved, keep_boundary_violations).map(Into::into)
63    })
64}
65
66fn run_dead_code_inner(
67    options: &DeadCodeOptions,
68    resolved: &ProgrammaticAnalysisContext,
69    post_filter: impl FnOnce(&mut AnalysisResults),
70) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
71    let start = Instant::now();
72    let session = load_dead_code_session(options, resolved)?;
73    run_dead_code_with_session(options, resolved, &session, None, post_filter, start)
74}
75
76pub(super) fn run_dead_code_with_session(
77    options: &DeadCodeOptions,
78    resolved: &ProgrammaticAnalysisContext,
79    session: &AnalysisSession,
80    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
81    post_filter: impl FnOnce(&mut AnalysisResults),
82    start: Instant,
83) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
84    let analysis = session.analyze_dead_code().map_err(|err| {
85        ProgrammaticError::new(format!("dead-code analysis failed: {err}"), 2)
86            .with_code("FALLOW_DEAD_CODE_FAILED")
87            .with_context("dead-code")
88    })?;
89    let mut results = analysis.results;
90
91    apply_dead_code_scope(options, resolved, session, changed_files, &mut results)?;
92    apply_dead_code_filters(&options.filters, &mut results);
93    post_filter(&mut results);
94
95    let root = session.root();
96    let next_steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
97        suggestions_enabled: suggestions_enabled(),
98        results: &results,
99        root,
100        offer_setup: setup_pointer_applicable(root),
101        impact_digest: None,
102        workspace_ref: default_workspace_ref(root).as_deref(),
103        audit_changed: fallow_engine::is_git_repo(root),
104    });
105    let output = build_check_output(CheckOutputInput {
106        schema_version: CHECK_SCHEMA_VERSION,
107        version: env!("CARGO_PKG_VERSION").to_string(),
108        elapsed: start.elapsed(),
109        results,
110        config_fixable: fallow_config::is_config_fixable(
111            &resolved.root,
112            resolved.config_path.as_ref(),
113        ),
114        meta: options.analysis.explain.then(check_meta),
115        workspace_diagnostics: session.workspace_diagnostics().to_vec(),
116        next_steps,
117    });
118    Ok(DeadCodeProgrammaticOutput {
119        output,
120        root: session.root().to_path_buf(),
121        envelope_mode: root_envelope_mode(),
122        telemetry_analysis_run_id: None,
123    })
124}
125
126fn keep_circular_dependencies(results: &mut AnalysisResults) {
127    let entry_point_summary = results.entry_point_summary.take();
128    let circular_dependencies = std::mem::take(&mut results.circular_dependencies);
129    *results = AnalysisResults::default();
130    results.entry_point_summary = entry_point_summary;
131    results.circular_dependencies = circular_dependencies;
132}
133
134fn keep_boundary_violations(results: &mut AnalysisResults) {
135    let entry_point_summary = results.entry_point_summary.take();
136    let boundary_violations = std::mem::take(&mut results.boundary_violations);
137    let boundary_coverage_violations = std::mem::take(&mut results.boundary_coverage_violations);
138    let boundary_call_violations = std::mem::take(&mut results.boundary_call_violations);
139    *results = AnalysisResults::default();
140    results.entry_point_summary = entry_point_summary;
141    results.boundary_violations = boundary_violations;
142    results.boundary_coverage_violations = boundary_coverage_violations;
143    results.boundary_call_violations = boundary_call_violations;
144}
145
146pub(super) fn load_dead_code_session(
147    options: &DeadCodeOptions,
148    resolved: &ProgrammaticAnalysisContext,
149) -> ProgrammaticResult<AnalysisSession> {
150    let project_config = fallow_engine::config_for_project_analysis(
151        &resolved.root,
152        resolved.config_path.as_deref(),
153        ProjectConfigOptions {
154            output: OutputFormat::Json,
155            no_cache: resolved.no_cache,
156            threads: resolved.threads,
157            production_override: resolved.production_override,
158            quiet: true,
159            analysis: ProductionAnalysis::DeadCode,
160        },
161    )
162    .map_err(|err| {
163        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
164            .with_code("FALLOW_CONFIG_LOAD_FAILED")
165            .with_context("analysis.configPath")
166    })?;
167    let project_config = configure_project_for_dead_code(project_config, options);
168    Ok(AnalysisSession::from_config(project_config))
169}
170
171pub(super) fn default_dead_code_options_for_context(
172    resolved: &ProgrammaticAnalysisContext,
173) -> DeadCodeOptions {
174    DeadCodeOptions {
175        analysis: AnalysisOptions {
176            root: Some(resolved.root().to_path_buf()),
177            config_path: resolved.config_path().clone(),
178            no_cache: resolved.no_cache(),
179            threads: Some(resolved.threads()),
180            production_override: resolved.production_override(),
181            changed_since: resolved.changed_since().map(str::to_owned),
182            workspace: resolved.workspace().map(<[String]>::to_vec),
183            changed_workspaces: resolved.changed_workspaces().map(str::to_owned),
184            explain: resolved.explain_enabled(),
185            ..AnalysisOptions::default()
186        },
187        filters: DeadCodeFilters::default(),
188        files: Vec::new(),
189        include_entry_exports: false,
190    }
191}
192
193fn configure_project_for_dead_code(
194    mut project_config: ProjectConfig,
195    options: &DeadCodeOptions,
196) -> ProjectConfig {
197    if options.include_entry_exports {
198        project_config.config.include_entry_exports = true;
199    }
200    activate_explicit_dead_code_opt_ins(&options.filters, &mut project_config.config.rules);
201    project_config
202}
203
204fn activate_explicit_dead_code_opt_ins(
205    filters: &DeadCodeFilters,
206    rules: &mut fallow_config::RulesConfig,
207) {
208    if filters.private_type_leaks && rules.private_type_leaks == fallow_config::Severity::Off {
209        rules.private_type_leaks = fallow_config::Severity::Warn;
210    }
211}
212
213fn apply_dead_code_scope(
214    options: &DeadCodeOptions,
215    resolved: &ProgrammaticAnalysisContext,
216    session: &AnalysisSession,
217    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
218    results: &mut AnalysisResults,
219) -> ProgrammaticResult<()> {
220    if let Some(workspace_roots) = resolved.workspace_roots.as_ref() {
221        fallow_engine::filter_to_workspaces(results, workspace_roots);
222    }
223    let resolved_changed_files = if changed_files.is_some() {
224        None
225    } else {
226        changed_files_for_run(resolved)?
227    };
228    if let Some(changed_files) = changed_files.or(resolved_changed_files.as_ref()) {
229        fallow_engine::filter_by_changed_files(results, changed_files);
230    }
231    if let Some(diff) = resolved.diff.as_ref() {
232        filter_dead_code_by_diff(results, diff, session.root());
233    }
234    apply_dead_code_file_filter(options, session.root(), results);
235    Ok(())
236}
237
238fn filter_dead_code_by_diff(results: &mut AnalysisResults, diff: &DiffIndex, root: &Path) {
239    let touches_file = |path: &Path| -> bool {
240        relative_to_diff_path(path, root).is_none_or(|rel| diff.touches_file(&rel))
241    };
242    let line_in_diff = |path: &Path, line: u32| -> bool {
243        relative_to_diff_path(path, root)
244            .is_none_or(|rel| diff.line_is_added(&rel, u64::from(line)))
245    };
246
247    filter_dead_code_source_findings(results, &touches_file, &line_in_diff);
248    filter_dead_code_security_findings(results, &touches_file, &line_in_diff);
249    filter_dead_code_dependency_findings(results, &line_in_diff);
250    filter_dead_code_graph_findings(results, &touches_file, &line_in_diff);
251    filter_dead_code_framework_findings(results, &line_in_diff);
252}
253
254fn filter_dead_code_source_findings(
255    results: &mut AnalysisResults,
256    touches_file: &dyn Fn(&Path) -> bool,
257    line_in_diff: &dyn Fn(&Path, u32) -> bool,
258) {
259    results
260        .unused_files
261        .retain(|finding| touches_file(&finding.file.path));
262    results
263        .unused_exports
264        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
265    results
266        .unused_types
267        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
268    results
269        .private_type_leaks
270        .retain(|finding| line_in_diff(&finding.leak.path, finding.leak.line));
271    results
272        .unused_enum_members
273        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
274    results
275        .unused_class_members
276        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
277    results
278        .unused_store_members
279        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
280    results
281        .unprovided_injects
282        .retain(|finding| line_in_diff(&finding.inject.path, finding.inject.line));
283    results
284        .unrendered_components
285        .retain(|finding| line_in_diff(&finding.component.path, finding.component.line));
286    results
287        .unused_component_props
288        .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
289    results
290        .unused_component_emits
291        .retain(|finding| line_in_diff(&finding.emit.path, finding.emit.line));
292    results
293        .unused_component_inputs
294        .retain(|finding| line_in_diff(&finding.input.path, finding.input.line));
295    results
296        .unused_component_outputs
297        .retain(|finding| line_in_diff(&finding.output.path, finding.output.line));
298    results
299        .unused_svelte_events
300        .retain(|finding| line_in_diff(&finding.event.path, finding.event.line));
301    results
302        .unused_server_actions
303        .retain(|finding| line_in_diff(&finding.action.path, finding.action.line));
304    results
305        .unused_load_data_keys
306        .retain(|finding| line_in_diff(&finding.key.path, finding.key.line));
307    results
308        .unresolved_imports
309        .retain(|finding| line_in_diff(&finding.import.path, finding.import.line));
310}
311
312fn filter_dead_code_security_findings(
313    results: &mut AnalysisResults,
314    touches_file: &dyn Fn(&Path) -> bool,
315    line_in_diff: &dyn Fn(&Path, u32) -> bool,
316) {
317    results.security_findings.retain(|finding| {
318        line_in_diff(&finding.path, finding.line)
319            || finding.trace.iter().any(|hop| {
320                line_in_diff(&hop.path, hop.line)
321                    || (matches!(hop.role, TraceHopRole::SecretSource) && touches_file(&hop.path))
322            })
323            || finding.reachability.as_ref().is_some_and(|reachability| {
324                reachability
325                    .untrusted_source_trace
326                    .iter()
327                    .any(|hop| line_in_diff(&hop.path, hop.line))
328            })
329    });
330    results
331        .security_unresolved_callee_diagnostics
332        .retain(|finding| line_in_diff(&finding.path, finding.line));
333}
334
335fn filter_dead_code_dependency_findings(
336    results: &mut AnalysisResults,
337    line_in_diff: &dyn Fn(&Path, u32) -> bool,
338) {
339    for finding in &mut results.unlisted_dependencies {
340        finding
341            .dep
342            .imported_from
343            .retain(|source| line_in_diff(&source.path, source.line));
344    }
345    results
346        .unlisted_dependencies
347        .retain(|finding| !finding.dep.imported_from.is_empty());
348}
349
350fn filter_dead_code_graph_findings(
351    results: &mut AnalysisResults,
352    touches_file: &dyn Fn(&Path) -> bool,
353    line_in_diff: &dyn Fn(&Path, u32) -> bool,
354) {
355    results.duplicate_exports.retain(|finding| {
356        finding
357            .export
358            .locations
359            .iter()
360            .any(|location| line_in_diff(&location.path, location.line))
361    });
362    results
363        .circular_dependencies
364        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
365    results
366        .re_export_cycles
367        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
368    results
369        .boundary_violations
370        .retain(|finding| line_in_diff(&finding.violation.from_path, finding.violation.line));
371    results
372        .stale_suppressions
373        .retain(|finding| line_in_diff(&finding.path, finding.line));
374}
375
376fn filter_dead_code_framework_findings(
377    results: &mut AnalysisResults,
378    line_in_diff: &dyn Fn(&Path, u32) -> bool,
379) {
380    results
381        .invalid_client_exports
382        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
383    results
384        .mixed_client_server_barrels
385        .retain(|finding| line_in_diff(&finding.barrel.path, finding.barrel.line));
386    results
387        .misplaced_directives
388        .retain(|finding| line_in_diff(&finding.directive_site.path, finding.directive_site.line));
389    results
390        .route_collisions
391        .retain(|finding| line_in_diff(&finding.collision.path, finding.collision.line));
392    results
393        .dynamic_segment_name_conflicts
394        .retain(|finding| line_in_diff(&finding.conflict.path, finding.conflict.line));
395}
396
397fn apply_dead_code_file_filter(
398    options: &DeadCodeOptions,
399    root: &Path,
400    results: &mut AnalysisResults,
401) {
402    if options.files.is_empty() {
403        return;
404    }
405    let file_set = options
406        .files
407        .iter()
408        .map(|path| {
409            if is_absolute_path_any_platform(path) {
410                path.clone()
411            } else {
412                root.join(path)
413            }
414        })
415        .collect::<FxHashSet<_>>();
416    fallow_engine::filter_by_changed_files(results, &file_set);
417    clear_dead_code_dependency_findings(results);
418}
419
420fn apply_dead_code_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
421    if !dead_code_filters_active(filters) {
422        return;
423    }
424    apply_dead_code_core_filters(filters, results);
425    apply_dead_code_component_filters(filters, results);
426    apply_dead_code_graph_filters(filters, results);
427    apply_dead_code_policy_filters(filters, results);
428    apply_dead_code_catalog_filters(filters, results);
429}
430
431fn dead_code_filters_active(filters: &DeadCodeFilters) -> bool {
432    filters.unused_files
433        || filters.unused_exports
434        || filters.unused_deps
435        || filters.unused_types
436        || filters.private_type_leaks
437        || filters.unused_enum_members
438        || filters.unused_class_members
439        || filters.unused_store_members
440        || filters.unprovided_injects
441        || filters.unrendered_components
442        || filters.unused_component_props
443        || filters.unused_component_emits
444        || filters.unused_component_inputs
445        || filters.unused_component_outputs
446        || filters.unused_svelte_events
447        || filters.unused_server_actions
448        || filters.unused_load_data_keys
449        || filters.unresolved_imports
450        || filters.unlisted_deps
451        || filters.duplicate_exports
452        || filters.circular_deps
453        || filters.re_export_cycles
454        || filters.boundary_violations
455        || filters.policy_violations
456        || filters.stale_suppressions
457        || filters.unused_catalog_entries
458        || filters.empty_catalog_groups
459        || filters.unresolved_catalog_references
460        || filters.unused_dependency_overrides
461        || filters.misconfigured_dependency_overrides
462}
463
464fn apply_dead_code_core_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
465    if !filters.unused_files {
466        results.unused_files.clear();
467    }
468    if !filters.unused_exports {
469        results.unused_exports.clear();
470    }
471    if !filters.unused_types {
472        results.unused_types.clear();
473    }
474    if !filters.private_type_leaks {
475        results.private_type_leaks.clear();
476    }
477    if !filters.unused_deps {
478        clear_dead_code_dependency_findings(results);
479    }
480    if !filters.unused_enum_members {
481        results.unused_enum_members.clear();
482    }
483    if !filters.unused_class_members {
484        results.unused_class_members.clear();
485    }
486    if !filters.unused_store_members {
487        results.unused_store_members.clear();
488    }
489    if !filters.unlisted_deps {
490        results.unlisted_dependencies.clear();
491    }
492}
493
494fn clear_dead_code_dependency_findings(results: &mut AnalysisResults) {
495    results.unused_dependencies.clear();
496    results.unused_dev_dependencies.clear();
497    results.unused_optional_dependencies.clear();
498    results.type_only_dependencies.clear();
499    results.test_only_dependencies.clear();
500}
501
502fn apply_dead_code_component_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
503    if !filters.unprovided_injects {
504        results.unprovided_injects.clear();
505    }
506    if !filters.unrendered_components {
507        results.unrendered_components.clear();
508    }
509    if !filters.unused_component_props {
510        results.unused_component_props.clear();
511    }
512    if !filters.unused_component_emits {
513        results.unused_component_emits.clear();
514    }
515    if !filters.unused_component_inputs {
516        results.unused_component_inputs.clear();
517    }
518    if !filters.unused_component_outputs {
519        results.unused_component_outputs.clear();
520    }
521    if !filters.unused_svelte_events {
522        results.unused_svelte_events.clear();
523    }
524    if !filters.unused_server_actions {
525        results.unused_server_actions.clear();
526    }
527    if !filters.unused_load_data_keys {
528        results.unused_load_data_keys.clear();
529    }
530    if !filters.unresolved_imports {
531        results.unresolved_imports.clear();
532    }
533}
534
535fn apply_dead_code_graph_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
536    if !filters.duplicate_exports {
537        results.duplicate_exports.clear();
538    }
539    if !filters.circular_deps {
540        results.circular_dependencies.clear();
541    }
542    if !filters.re_export_cycles {
543        results.re_export_cycles.clear();
544    }
545    if !filters.boundary_violations {
546        results.boundary_violations.clear();
547        results.boundary_coverage_violations.clear();
548        results.boundary_call_violations.clear();
549    }
550}
551
552fn apply_dead_code_policy_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
553    if !filters.policy_violations {
554        results.policy_violations.clear();
555    }
556    if !filters.stale_suppressions {
557        results.stale_suppressions.clear();
558    }
559}
560
561fn apply_dead_code_catalog_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
562    if !filters.unused_catalog_entries {
563        results.unused_catalog_entries.clear();
564    }
565    if !filters.empty_catalog_groups {
566        results.empty_catalog_groups.clear();
567    }
568    if !filters.unresolved_catalog_references {
569        results.unresolved_catalog_references.clear();
570    }
571    if !filters.unused_dependency_overrides {
572        results.unused_dependency_overrides.clear();
573    }
574    if !filters.misconfigured_dependency_overrides {
575        results.misconfigured_dependency_overrides.clear();
576    }
577}