1pub mod analyze;
14pub mod cache;
15pub mod changed_files;
16pub mod churn;
17pub mod cross_reference;
18pub mod discover;
19pub mod duplicates;
20pub(crate) mod errors;
21mod external_style_usage;
22pub mod extract;
23pub mod git_env;
24pub mod plugins;
25pub(crate) mod progress;
26pub mod results;
27pub(crate) mod scripts;
28pub mod suppress;
29pub mod trace;
30
31pub use fallow_graph::graph;
33pub use fallow_graph::project;
34pub use fallow_graph::resolve;
35
36use std::path::Path;
37use std::time::Instant;
38
39use errors::FallowError;
40use fallow_config::{
41 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
42 find_undeclared_workspaces_with_ignores,
43};
44use rayon::prelude::*;
45use results::AnalysisResults;
46use rustc_hash::FxHashSet;
47use trace::PipelineTimings;
48
49const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
50type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
51
52fn record_graph_package_usage(
53 graph: &mut graph::ModuleGraph,
54 package_name: &str,
55 file_id: discover::FileId,
56 is_type_only: bool,
57) {
58 graph
59 .package_usage
60 .entry(package_name.to_owned())
61 .or_default()
62 .push(file_id);
63 if is_type_only {
64 graph
65 .type_only_package_usage
66 .entry(package_name.to_owned())
67 .or_default()
68 .push(file_id);
69 }
70}
71
72fn workspace_package_name<'a>(
73 source: &str,
74 workspace_names: &'a FxHashSet<&str>,
75) -> Option<&'a str> {
76 if !resolve::is_bare_specifier(source) {
77 return None;
78 }
79 let package_name = resolve::extract_package_name(source);
80 workspace_names.get(package_name.as_str()).copied()
81}
82
83fn credit_workspace_package_usage(
84 graph: &mut graph::ModuleGraph,
85 resolved: &[resolve::ResolvedModule],
86 workspaces: &[fallow_config::WorkspaceInfo],
87) {
88 if workspaces.is_empty() {
89 return;
90 }
91
92 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
93 for module in resolved {
94 for import in module.all_resolved_imports() {
95 if matches!(import.target, resolve::ResolveResult::InternalModule(_))
96 && let Some(package_name) =
97 workspace_package_name(&import.info.source, &workspace_names)
98 {
99 record_graph_package_usage(
100 graph,
101 package_name,
102 module.file_id,
103 import.info.is_type_only,
104 );
105 }
106 }
107
108 for re_export in &module.re_exports {
109 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
110 && let Some(package_name) =
111 workspace_package_name(&re_export.info.source, &workspace_names)
112 {
113 record_graph_package_usage(
114 graph,
115 package_name,
116 module.file_id,
117 re_export.info.is_type_only,
118 );
119 }
120 }
121 }
122}
123
124pub struct AnalysisOutput {
126 pub results: AnalysisResults,
127 pub timings: Option<PipelineTimings>,
128 pub graph: Option<graph::ModuleGraph>,
129 pub modules: Option<Vec<extract::ModuleInfo>>,
132 pub files: Option<Vec<discover::DiscoveredFile>>,
134 pub script_used_packages: rustc_hash::FxHashSet<String>,
139}
140
141fn update_cache(
143 store: &mut cache::CacheStore,
144 modules: &[extract::ModuleInfo],
145 files: &[discover::DiscoveredFile],
146) {
147 for module in modules {
148 if let Some(file) = files.get(module.file_id.0 as usize) {
149 let (mt, sz) = file_mtime_and_size(&file.path);
150 if let Some(cached) = store.get_by_path_only(&file.path)
152 && cached.content_hash == module.content_hash
153 {
154 if cached.mtime_secs != mt || cached.file_size != sz {
155 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
156 }
157 continue;
158 }
159 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
160 }
161 }
162 store.retain_paths(files);
163}
164
165fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
167 std::fs::metadata(path).map_or((0, 0), |m| {
168 let mt = m
169 .modified()
170 .ok()
171 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
172 .map_or(0, |d| d.as_secs());
173 (mt, m.len())
174 })
175}
176
177fn format_undeclared_workspace_warning(
178 root: &Path,
179 undeclared: &[fallow_config::WorkspaceDiagnostic],
180) -> Option<String> {
181 if undeclared.is_empty() {
182 return None;
183 }
184
185 let preview = undeclared
186 .iter()
187 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
188 .map(|diag| {
189 diag.path
190 .strip_prefix(root)
191 .unwrap_or(&diag.path)
192 .display()
193 .to_string()
194 .replace('\\', "/")
195 })
196 .collect::<Vec<_>>();
197 let remaining = undeclared
198 .len()
199 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
200 let tail = if remaining > 0 {
201 format!(" (and {remaining} more)")
202 } else {
203 String::new()
204 };
205 let noun = if undeclared.len() == 1 {
206 "directory with package.json is"
207 } else {
208 "directories with package.json are"
209 };
210 let guidance = if undeclared.len() == 1 {
211 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
212 } else {
213 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
214 };
215
216 Some(format!(
217 "{} {} not declared as {}: {}{}. {}",
218 undeclared.len(),
219 noun,
220 if undeclared.len() == 1 {
221 "a workspace"
222 } else {
223 "workspaces"
224 },
225 preview.join(", "),
226 tail,
227 guidance
228 ))
229}
230
231fn warn_undeclared_workspaces(
232 root: &Path,
233 workspaces_vec: &[fallow_config::WorkspaceInfo],
234 ignore_patterns: &globset::GlobSet,
235 quiet: bool,
236) {
237 if quiet {
238 return;
239 }
240
241 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
242 if let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
243 tracing::warn!("{message}");
244 }
245}
246
247#[deprecated(
253 since = "2.76.0",
254 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
255)]
256pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
257 let output = analyze_full(config, false, false, false, false)?;
258 Ok(output.results)
259}
260
261#[deprecated(
267 since = "2.76.0",
268 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
269)]
270pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
271 let output = analyze_full(config, false, true, false, false)?;
272 Ok(output.results)
273}
274
275#[deprecated(
281 since = "2.76.0",
282 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow check --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
283)]
284pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
285 analyze_full(config, true, false, false, false)
286}
287
288#[deprecated(
298 since = "2.76.0",
299 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
300)]
301pub fn analyze_retaining_modules(
302 config: &ResolvedConfig,
303 need_complexity: bool,
304 retain_graph: bool,
305) -> Result<AnalysisOutput, FallowError> {
306 analyze_full(config, retain_graph, false, need_complexity, true)
307}
308
309#[allow(
320 clippy::too_many_lines,
321 reason = "pipeline orchestration stays easier to audit in one place"
322)]
323#[deprecated(
324 since = "2.76.0",
325 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: pre-parsed module reuse is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
326)]
327pub fn analyze_with_parse_result(
328 config: &ResolvedConfig,
329 modules: &[extract::ModuleInfo],
330) -> Result<AnalysisOutput, FallowError> {
331 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
332 let pipeline_start = Instant::now();
333
334 let show_progress = !config.quiet
335 && std::io::IsTerminal::is_terminal(&std::io::stderr())
336 && matches!(
337 config.output,
338 fallow_config::OutputFormat::Human
339 | fallow_config::OutputFormat::Compact
340 | fallow_config::OutputFormat::Markdown
341 );
342 let progress = progress::AnalysisProgress::new(show_progress);
343
344 if !config.root.join("node_modules").is_dir() {
345 tracing::warn!(
346 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
347 );
348 }
349
350 let t = Instant::now();
352 let workspaces_vec = discover_workspaces(&config.root);
353 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
354 if !workspaces_vec.is_empty() {
355 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
356 }
357
358 warn_undeclared_workspaces(
360 &config.root,
361 &workspaces_vec,
362 &config.ignore_patterns,
363 config.quiet,
364 );
365 let root_pkg = load_root_package_json(config);
366 let discovery_hidden_dir_scopes =
367 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
368
369 let t = Instant::now();
371 let pb = progress.stage_spinner("Discovering files...");
372 let discovered_files =
373 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
374 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
375 pb.finish_and_clear();
376
377 let project = project::ProjectState::new(discovered_files, workspaces_vec);
378 let files = project.files();
379 let workspaces = project.workspaces();
380 let workspace_pkgs = load_workspace_packages(workspaces);
381
382 let t = Instant::now();
384 let pb = progress.stage_spinner("Detecting plugins...");
385 let mut plugin_result = run_plugins(
386 config,
387 files,
388 workspaces,
389 root_pkg.as_ref(),
390 &workspace_pkgs,
391 );
392 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
393 pb.finish_and_clear();
394
395 let t = Instant::now();
397 analyze_all_scripts(
398 config,
399 workspaces,
400 root_pkg.as_ref(),
401 &workspace_pkgs,
402 &mut plugin_result,
403 );
404 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
405
406 let t = Instant::now();
410 let entry_points = discover_all_entry_points(
411 config,
412 files,
413 workspaces,
414 root_pkg.as_ref(),
415 &workspace_pkgs,
416 &plugin_result,
417 );
418 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
419
420 let ep_summary = summarize_entry_points(&entry_points.all);
422
423 let t = Instant::now();
425 let pb = progress.stage_spinner("Resolving imports...");
426 let mut resolved = resolve::resolve_all_imports(
427 modules,
428 files,
429 workspaces,
430 &plugin_result.active_plugins,
431 &plugin_result.path_aliases,
432 &plugin_result.scss_include_paths,
433 &config.root,
434 &config.resolve.conditions,
435 );
436 external_style_usage::augment_external_style_package_usage(
437 &mut resolved,
438 config,
439 workspaces,
440 &plugin_result,
441 );
442 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
443 pb.finish_and_clear();
444
445 let t = Instant::now();
447 let pb = progress.stage_spinner("Building module graph...");
448 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
449 &resolved,
450 &entry_points.all,
451 &entry_points.runtime,
452 &entry_points.test,
453 files,
454 );
455 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
456 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
457 pb.finish_and_clear();
458
459 let t = Instant::now();
461 let pb = progress.stage_spinner("Analyzing...");
462 #[expect(
463 deprecated,
464 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
465 )]
466 let mut result = analyze::find_dead_code_full(
467 &graph,
468 config,
469 &resolved,
470 Some(&plugin_result),
471 workspaces,
472 modules,
473 false,
474 );
475 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
476 pb.finish_and_clear();
477 progress.finish();
478
479 result.entry_point_summary = Some(ep_summary);
480
481 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
482
483 tracing::debug!(
484 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
485 │ discover files: {:>8.1}ms ({} files)\n\
486 │ workspaces: {:>8.1}ms\n\
487 │ plugins: {:>8.1}ms\n\
488 │ script analysis: {:>8.1}ms\n\
489 │ parse/extract: SKIPPED (reused {} modules)\n\
490 │ entry points: {:>8.1}ms ({} entries)\n\
491 │ resolve imports: {:>8.1}ms\n\
492 │ build graph: {:>8.1}ms\n\
493 │ analyze: {:>8.1}ms\n\
494 │ ────────────────────────────────────────────\n\
495 │ TOTAL: {:>8.1}ms\n\
496 └─────────────────────────────────────────────────",
497 discover_ms,
498 files.len(),
499 workspaces_ms,
500 plugins_ms,
501 scripts_ms,
502 modules.len(),
503 entry_points_ms,
504 entry_points.all.len(),
505 resolve_ms,
506 graph_ms,
507 analyze_ms,
508 total_ms,
509 );
510
511 let timings = Some(PipelineTimings {
512 discover_files_ms: discover_ms,
513 file_count: files.len(),
514 workspaces_ms,
515 workspace_count: workspaces.len(),
516 plugins_ms,
517 script_analysis_ms: scripts_ms,
518 parse_extract_ms: 0.0, module_count: modules.len(),
520 cache_hits: 0,
521 cache_misses: 0,
522 cache_update_ms: 0.0,
523 entry_points_ms,
524 entry_point_count: entry_points.all.len(),
525 resolve_imports_ms: resolve_ms,
526 build_graph_ms: graph_ms,
527 analyze_ms,
528 duplication_ms: None,
529 total_ms,
530 });
531
532 Ok(AnalysisOutput {
533 results: result,
534 timings,
535 graph: Some(graph),
536 modules: None,
537 files: None,
538 script_used_packages: plugin_result.script_used_packages.clone(),
539 })
540}
541
542#[expect(
543 clippy::unnecessary_wraps,
544 reason = "Result kept for future error handling"
545)]
546#[expect(
547 clippy::too_many_lines,
548 reason = "main pipeline function; sequential phases are held together for clarity"
549)]
550fn analyze_full(
551 config: &ResolvedConfig,
552 retain: bool,
553 collect_usages: bool,
554 need_complexity: bool,
555 retain_modules: bool,
556) -> Result<AnalysisOutput, FallowError> {
557 let _span = tracing::info_span!("fallow_analyze").entered();
558 let pipeline_start = Instant::now();
559
560 let show_progress = !config.quiet
564 && std::io::IsTerminal::is_terminal(&std::io::stderr())
565 && matches!(
566 config.output,
567 fallow_config::OutputFormat::Human
568 | fallow_config::OutputFormat::Compact
569 | fallow_config::OutputFormat::Markdown
570 );
571 let progress = progress::AnalysisProgress::new(show_progress);
572
573 if !config.root.join("node_modules").is_dir() {
575 tracing::warn!(
576 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
577 );
578 }
579
580 let t = Instant::now();
582 let workspaces_vec = discover_workspaces(&config.root);
583 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
584 if !workspaces_vec.is_empty() {
585 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
586 }
587
588 warn_undeclared_workspaces(
590 &config.root,
591 &workspaces_vec,
592 &config.ignore_patterns,
593 config.quiet,
594 );
595 let root_pkg = load_root_package_json(config);
596 let discovery_hidden_dir_scopes =
597 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
598
599 let t = Instant::now();
601 let pb = progress.stage_spinner("Discovering files...");
602 let discovered_files =
603 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
604 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
605 pb.finish_and_clear();
606
607 let project = project::ProjectState::new(discovered_files, workspaces_vec);
610 let files = project.files();
611 let workspaces = project.workspaces();
612 let workspace_pkgs = load_workspace_packages(workspaces);
613
614 let t = Instant::now();
616 let pb = progress.stage_spinner("Detecting plugins...");
617 let mut plugin_result = run_plugins(
618 config,
619 files,
620 workspaces,
621 root_pkg.as_ref(),
622 &workspace_pkgs,
623 );
624 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
625 pb.finish_and_clear();
626
627 let t = Instant::now();
629 analyze_all_scripts(
630 config,
631 workspaces,
632 root_pkg.as_ref(),
633 &workspace_pkgs,
634 &mut plugin_result,
635 );
636 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
637
638 let t = Instant::now();
640 let pb = progress.stage_spinner(&format!("Parsing {} files...", files.len()));
641 let mut cache_store = if config.no_cache {
642 None
643 } else {
644 cache::CacheStore::load(&config.cache_dir)
645 };
646
647 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
648 let modules = parse_result.modules;
649 let cache_hits = parse_result.cache_hits;
650 let cache_misses = parse_result.cache_misses;
651 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
652 pb.finish_and_clear();
653
654 let t = Instant::now();
656 if !config.no_cache {
657 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
658 update_cache(store, &modules, files);
659 if let Err(e) = store.save(&config.cache_dir) {
660 tracing::warn!("Failed to save cache: {e}");
661 }
662 }
663 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
664
665 let t = Instant::now();
667 let entry_points = discover_all_entry_points(
668 config,
669 files,
670 workspaces,
671 root_pkg.as_ref(),
672 &workspace_pkgs,
673 &plugin_result,
674 );
675 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
676
677 let t = Instant::now();
679 let pb = progress.stage_spinner("Resolving imports...");
680 let mut resolved = resolve::resolve_all_imports(
681 &modules,
682 files,
683 workspaces,
684 &plugin_result.active_plugins,
685 &plugin_result.path_aliases,
686 &plugin_result.scss_include_paths,
687 &config.root,
688 &config.resolve.conditions,
689 );
690 external_style_usage::augment_external_style_package_usage(
691 &mut resolved,
692 config,
693 workspaces,
694 &plugin_result,
695 );
696 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
697 pb.finish_and_clear();
698
699 let t = Instant::now();
701 let pb = progress.stage_spinner("Building module graph...");
702 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
703 &resolved,
704 &entry_points.all,
705 &entry_points.runtime,
706 &entry_points.test,
707 files,
708 );
709 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
710 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
711 pb.finish_and_clear();
712
713 let ep_summary = summarize_entry_points(&entry_points.all);
715
716 let t = Instant::now();
718 let pb = progress.stage_spinner("Analyzing...");
719 #[expect(
720 deprecated,
721 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
722 )]
723 let mut result = analyze::find_dead_code_full(
724 &graph,
725 config,
726 &resolved,
727 Some(&plugin_result),
728 workspaces,
729 &modules,
730 collect_usages,
731 );
732 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
733 pb.finish_and_clear();
734 progress.finish();
735
736 result.entry_point_summary = Some(ep_summary);
737
738 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
739
740 let cache_summary = if cache_hits > 0 {
741 format!(" ({cache_hits} cached, {cache_misses} parsed)")
742 } else {
743 String::new()
744 };
745
746 tracing::debug!(
747 "\n┌─ Pipeline Profile ─────────────────────────────\n\
748 │ discover files: {:>8.1}ms ({} files)\n\
749 │ workspaces: {:>8.1}ms\n\
750 │ plugins: {:>8.1}ms\n\
751 │ script analysis: {:>8.1}ms\n\
752 │ parse/extract: {:>8.1}ms ({} modules{})\n\
753 │ cache update: {:>8.1}ms\n\
754 │ entry points: {:>8.1}ms ({} entries)\n\
755 │ resolve imports: {:>8.1}ms\n\
756 │ build graph: {:>8.1}ms\n\
757 │ analyze: {:>8.1}ms\n\
758 │ ────────────────────────────────────────────\n\
759 │ TOTAL: {:>8.1}ms\n\
760 └─────────────────────────────────────────────────",
761 discover_ms,
762 files.len(),
763 workspaces_ms,
764 plugins_ms,
765 scripts_ms,
766 parse_ms,
767 modules.len(),
768 cache_summary,
769 cache_ms,
770 entry_points_ms,
771 entry_points.all.len(),
772 resolve_ms,
773 graph_ms,
774 analyze_ms,
775 total_ms,
776 );
777
778 let timings = if retain {
779 Some(PipelineTimings {
780 discover_files_ms: discover_ms,
781 file_count: files.len(),
782 workspaces_ms,
783 workspace_count: workspaces.len(),
784 plugins_ms,
785 script_analysis_ms: scripts_ms,
786 parse_extract_ms: parse_ms,
787 module_count: modules.len(),
788 cache_hits,
789 cache_misses,
790 cache_update_ms: cache_ms,
791 entry_points_ms,
792 entry_point_count: entry_points.all.len(),
793 resolve_imports_ms: resolve_ms,
794 build_graph_ms: graph_ms,
795 analyze_ms,
796 duplication_ms: None,
797 total_ms,
798 })
799 } else {
800 None
801 };
802
803 Ok(AnalysisOutput {
804 results: result,
805 timings,
806 graph: if retain { Some(graph) } else { None },
807 modules: if retain_modules { Some(modules) } else { None },
808 files: if retain_modules {
809 Some(files.to_vec())
810 } else {
811 None
812 },
813 script_used_packages: plugin_result.script_used_packages,
814 })
815}
816
817fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
822 PackageJson::load(&config.root.join("package.json")).ok()
823}
824
825fn load_workspace_packages(
826 workspaces: &[fallow_config::WorkspaceInfo],
827) -> Vec<LoadedWorkspacePackage<'_>> {
828 workspaces
829 .iter()
830 .filter_map(|ws| {
831 PackageJson::load(&ws.root.join("package.json"))
832 .ok()
833 .map(|pkg| (ws, pkg))
834 })
835 .collect()
836}
837
838fn analyze_all_scripts(
839 config: &ResolvedConfig,
840 workspaces: &[fallow_config::WorkspaceInfo],
841 root_pkg: Option<&PackageJson>,
842 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
843 plugin_result: &mut plugins::AggregatedPluginResult,
844) {
845 let mut all_dep_names: Vec<String> = Vec::new();
849 if let Some(pkg) = root_pkg {
850 all_dep_names.extend(pkg.all_dependency_names());
851 }
852 for (_, ws_pkg) in workspace_pkgs {
853 all_dep_names.extend(ws_pkg.all_dependency_names());
854 }
855 all_dep_names.sort_unstable();
856 all_dep_names.dedup();
857
858 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
861 if config.root.join("node_modules").is_dir() {
862 nm_roots.push(&config.root);
863 }
864 for ws in workspaces {
865 if ws.root.join("node_modules").is_dir() {
866 nm_roots.push(&ws.root);
867 }
868 }
869 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
870
871 if let Some(pkg) = root_pkg
872 && let Some(ref pkg_scripts) = pkg.scripts
873 {
874 let scripts_to_analyze = if config.production {
875 scripts::filter_production_scripts(pkg_scripts)
876 } else {
877 pkg_scripts.clone()
878 };
879 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root, &bin_map);
880 plugin_result.script_used_packages = script_analysis.used_packages;
881
882 for config_file in &script_analysis.config_files {
883 plugin_result
884 .discovered_always_used
885 .push((config_file.clone(), "scripts".to_string()));
886 }
887 for entry in &script_analysis.entry_files {
888 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
889 plugin_result
890 .entry_patterns
891 .push((plugins::PathRule::new(pat), "scripts".to_string()));
892 }
893 }
894 }
895 use rayon::prelude::*;
896 type WsScriptOut = (
897 Vec<String>,
898 Vec<(String, String)>,
899 Vec<(plugins::PathRule, String)>,
900 );
901 let ws_results: Vec<WsScriptOut> = workspace_pkgs
902 .par_iter()
903 .map(|(ws, ws_pkg)| {
904 let mut used_packages = Vec::new();
905 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
906 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
907 if let Some(ref ws_scripts) = ws_pkg.scripts {
908 let scripts_to_analyze = if config.production {
909 scripts::filter_production_scripts(ws_scripts)
910 } else {
911 ws_scripts.clone()
912 };
913 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root, &bin_map);
914 used_packages.extend(ws_analysis.used_packages);
915
916 let ws_prefix = ws
917 .root
918 .strip_prefix(&config.root)
919 .unwrap_or(&ws.root)
920 .to_string_lossy();
921 for config_file in &ws_analysis.config_files {
922 discovered_always_used
923 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
924 }
925 for entry in &ws_analysis.entry_files {
926 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
927 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
928 }
929 }
930 }
931 (used_packages, discovered_always_used, entry_patterns)
932 })
933 .collect();
934 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
935 plugin_result.script_used_packages.extend(used_packages);
936 plugin_result
937 .discovered_always_used
938 .extend(discovered_always_used);
939 plugin_result.entry_patterns.extend(entry_patterns);
940 }
941
942 let ci_analysis = scripts::ci::analyze_ci_files(&config.root, &bin_map);
949 plugin_result
950 .script_used_packages
951 .extend(ci_analysis.used_packages);
952 for entry in &ci_analysis.entry_files {
953 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
954 plugin_result
955 .entry_patterns
956 .push((plugins::PathRule::new(pat), "scripts".to_string()));
957 }
958 }
959 plugin_result
960 .entry_point_roles
961 .entry("scripts".to_string())
962 .or_insert(EntryPointRole::Support);
963}
964
965fn discover_all_entry_points(
967 config: &ResolvedConfig,
968 files: &[discover::DiscoveredFile],
969 workspaces: &[fallow_config::WorkspaceInfo],
970 root_pkg: Option<&PackageJson>,
971 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
972 plugin_result: &plugins::AggregatedPluginResult,
973) -> discover::CategorizedEntryPoints {
974 let mut entry_points = discover::CategorizedEntryPoints::default();
975 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
976 config,
977 files,
978 root_pkg,
979 workspaces.is_empty(),
980 );
981
982 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
983 workspace_pkgs
984 .iter()
985 .map(|(ws, pkg)| (ws.root.clone(), pkg))
986 .collect();
987
988 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
989 .par_iter()
990 .map(|ws| {
991 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
992 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
993 })
994 .collect();
995 let mut skipped_entries = rustc_hash::FxHashMap::default();
996 entry_points.extend_runtime(root_discovery.entries);
997 for (path, count) in root_discovery.skipped_entries {
998 *skipped_entries.entry(path).or_insert(0) += count;
999 }
1000 let mut ws_entries = Vec::new();
1001 for workspace in workspace_discovery {
1002 ws_entries.extend(workspace.entries);
1003 for (path, count) in workspace.skipped_entries {
1004 *skipped_entries.entry(path).or_insert(0) += count;
1005 }
1006 }
1007 discover::warn_skipped_entry_summary(&skipped_entries);
1008 entry_points.extend_runtime(ws_entries);
1009
1010 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1011 entry_points.extend(plugin_entries);
1012
1013 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1014 entry_points.extend_runtime(infra_entries);
1015
1016 if !config.dynamically_loaded.is_empty() {
1018 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1019 entry_points.extend_runtime(dynamic_entries);
1020 }
1021
1022 entry_points.dedup()
1023}
1024
1025fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1027 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1028 for ep in entry_points {
1029 let category = match &ep.source {
1030 discover::EntryPointSource::PackageJsonMain
1031 | discover::EntryPointSource::PackageJsonModule
1032 | discover::EntryPointSource::PackageJsonExports
1033 | discover::EntryPointSource::PackageJsonBin
1034 | discover::EntryPointSource::PackageJsonScript => "package.json",
1035 discover::EntryPointSource::Plugin { .. } => "plugin",
1036 discover::EntryPointSource::TestFile => "test file",
1037 discover::EntryPointSource::DefaultIndex => "default index",
1038 discover::EntryPointSource::ManualEntry => "manual entry",
1039 discover::EntryPointSource::InfrastructureConfig => "config",
1040 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1041 };
1042 *counts.entry(category.to_string()).or_insert(0) += 1;
1043 }
1044 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1045 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1046 results::EntryPointSummary {
1047 total: entry_points.len(),
1048 by_source,
1049 }
1050}
1051
1052fn run_plugins(
1054 config: &ResolvedConfig,
1055 files: &[discover::DiscoveredFile],
1056 workspaces: &[fallow_config::WorkspaceInfo],
1057 root_pkg: Option<&PackageJson>,
1058 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1059) -> plugins::AggregatedPluginResult {
1060 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1061 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1062 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1063 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1064 .iter()
1065 .map(std::path::PathBuf::as_path)
1066 .collect();
1067
1068 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1070 registry.run_with_search_roots(
1071 pkg,
1072 &config.root,
1073 &file_paths,
1074 &root_config_search_root_refs,
1075 config.production,
1076 )
1077 });
1078
1079 if workspaces.is_empty() {
1080 return result;
1081 }
1082
1083 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1084 result.active_plugins.iter().map(String::as_str).collect();
1085
1086 let precompiled_matchers = registry.precompile_config_matchers();
1090 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1091
1092 let ws_results: Vec<_> = workspace_pkgs
1094 .par_iter()
1095 .zip(workspace_relative_files.par_iter())
1096 .filter_map(|((ws, ws_pkg), relative_files)| {
1097 let ws_result = registry.run_workspace_fast(
1098 ws_pkg,
1099 &ws.root,
1100 &config.root,
1101 &precompiled_matchers,
1102 relative_files,
1103 &root_active_plugins,
1104 config.production,
1105 );
1106 if ws_result.active_plugins.is_empty() {
1107 return None;
1108 }
1109 let ws_prefix = ws
1110 .root
1111 .strip_prefix(&config.root)
1112 .unwrap_or(&ws.root)
1113 .to_string_lossy()
1114 .into_owned();
1115 Some((ws_result, ws_prefix))
1116 })
1117 .collect();
1118
1119 let mut seen_plugins: rustc_hash::FxHashSet<String> =
1122 result.active_plugins.iter().cloned().collect();
1123 let mut seen_prefixes: rustc_hash::FxHashSet<String> =
1124 result.virtual_module_prefixes.iter().cloned().collect();
1125 let mut seen_generated: rustc_hash::FxHashSet<String> =
1126 result.generated_import_patterns.iter().cloned().collect();
1127 let mut seen_suffixes: rustc_hash::FxHashSet<String> =
1128 result.virtual_package_suffixes.iter().cloned().collect();
1129
1130 fn extend_unique(
1131 target: &mut Vec<String>,
1132 seen: &mut rustc_hash::FxHashSet<String>,
1133 items: Vec<String>,
1134 ) {
1135 for item in items {
1136 if seen.insert(item.clone()) {
1137 target.push(item);
1138 }
1139 }
1140 }
1141 for (ws_result, ws_prefix) in ws_results {
1142 let prefix_if_needed = |pat: &str| -> String {
1147 if pat.starts_with(ws_prefix.as_str()) || pat.starts_with('/') {
1148 pat.to_string()
1149 } else {
1150 format!("{ws_prefix}/{pat}")
1151 }
1152 };
1153
1154 for (rule, pname) in &ws_result.entry_patterns {
1155 result
1156 .entry_patterns
1157 .push((rule.prefixed(&ws_prefix), pname.clone()));
1158 }
1159 for (plugin_name, role) in ws_result.entry_point_roles {
1160 result.entry_point_roles.entry(plugin_name).or_insert(role);
1161 }
1162 for (pat, pname) in &ws_result.always_used {
1163 result
1164 .always_used
1165 .push((prefix_if_needed(pat), pname.clone()));
1166 }
1167 for (pat, pname) in &ws_result.discovered_always_used {
1168 result
1169 .discovered_always_used
1170 .push((prefix_if_needed(pat), pname.clone()));
1171 }
1172 for (pat, pname) in &ws_result.fixture_patterns {
1173 result
1174 .fixture_patterns
1175 .push((prefix_if_needed(pat), pname.clone()));
1176 }
1177 for rule in &ws_result.used_exports {
1178 result.used_exports.push(rule.prefixed(&ws_prefix));
1179 }
1180 for plugin_name in ws_result.active_plugins {
1182 if !seen_plugins.contains(&plugin_name) {
1183 seen_plugins.insert(plugin_name.clone());
1184 result.active_plugins.push(plugin_name);
1185 }
1186 }
1187 result
1189 .referenced_dependencies
1190 .extend(ws_result.referenced_dependencies);
1191 result.setup_files.extend(ws_result.setup_files);
1192 result
1193 .tooling_dependencies
1194 .extend(ws_result.tooling_dependencies);
1195 extend_unique(
1200 &mut result.virtual_module_prefixes,
1201 &mut seen_prefixes,
1202 ws_result.virtual_module_prefixes,
1203 );
1204 extend_unique(
1205 &mut result.generated_import_patterns,
1206 &mut seen_generated,
1207 ws_result.generated_import_patterns,
1208 );
1209 extend_unique(
1210 &mut result.virtual_package_suffixes,
1211 &mut seen_suffixes,
1212 ws_result.virtual_package_suffixes,
1213 );
1214 for (prefix, replacement) in ws_result.path_aliases {
1217 result
1218 .path_aliases
1219 .push((prefix, format!("{ws_prefix}/{replacement}")));
1220 }
1221 }
1222
1223 result
1224}
1225
1226fn bucket_files_by_workspace(
1227 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1228 file_paths: &[std::path::PathBuf],
1229) -> Vec<Vec<(std::path::PathBuf, String)>> {
1230 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1231
1232 for file_path in file_paths {
1233 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1234 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1235 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1236 break;
1237 }
1238 }
1239 }
1240
1241 buckets
1242}
1243
1244fn collect_config_search_roots(
1245 root: &Path,
1246 file_paths: &[std::path::PathBuf],
1247) -> Vec<std::path::PathBuf> {
1248 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1249 roots.insert(root.to_path_buf());
1250
1251 for file_path in file_paths {
1252 let mut current = file_path.parent();
1253 while let Some(dir) = current {
1254 if !dir.starts_with(root) {
1255 break;
1256 }
1257 roots.insert(dir.to_path_buf());
1258 if dir == root {
1259 break;
1260 }
1261 current = dir.parent();
1262 }
1263 }
1264
1265 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1266 roots_vec.sort();
1267 roots_vec
1268}
1269
1270#[deprecated(
1276 since = "2.76.0",
1277 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead (build a `DeadCodeOptions { analysis: AnalysisOptions { root, ..default() }, ..default() }`). See docs/fallow-core-migration.md and ADR-008."
1278)]
1279pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1280 let config = default_config(root);
1281 #[expect(
1282 deprecated,
1283 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1284 )]
1285 analyze_with_usages(&config)
1286}
1287
1288pub fn config_for_project(
1296 root: &Path,
1297 config_path: Option<&Path>,
1298) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1299 let user_config = if let Some(path) = config_path {
1300 Some((
1301 fallow_config::FallowConfig::load(path)
1302 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1303 path.to_path_buf(),
1304 ))
1305 } else {
1306 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1307 };
1308
1309 let config = match user_config {
1310 Some((mut config, path)) => {
1311 let dead_code_production = config
1312 .production
1313 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1314 config.production = dead_code_production.into();
1315 (
1316 config.resolve(
1317 root.to_path_buf(),
1318 fallow_config::OutputFormat::Human,
1319 num_cpus(),
1320 false,
1321 true, ),
1323 Some(path),
1324 )
1325 }
1326 None => (
1327 fallow_config::FallowConfig::default().resolve(
1328 root.to_path_buf(),
1329 fallow_config::OutputFormat::Human,
1330 num_cpus(),
1331 false,
1332 true,
1333 ),
1334 None,
1335 ),
1336 };
1337
1338 Ok(config)
1339}
1340
1341pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1352 config_for_project(root, None).map_or_else(
1353 |_| {
1354 fallow_config::FallowConfig::default().resolve(
1355 root.to_path_buf(),
1356 fallow_config::OutputFormat::Human,
1357 num_cpus(),
1358 false,
1359 true,
1360 )
1361 },
1362 |(config, _)| config,
1363 )
1364}
1365
1366fn num_cpus() -> usize {
1367 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::{
1373 bucket_files_by_workspace, collect_config_search_roots, format_undeclared_workspace_warning,
1374 };
1375 use std::path::{Path, PathBuf};
1376
1377 use fallow_config::WorkspaceDiagnostic;
1378
1379 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1380 WorkspaceDiagnostic {
1381 path: root.join(relative),
1382 message: String::new(),
1383 }
1384 }
1385
1386 #[test]
1387 fn undeclared_workspace_warning_is_singular_for_one_path() {
1388 let root = Path::new("/repo");
1389 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1390 .expect("warning should be rendered");
1391
1392 assert_eq!(
1393 warning,
1394 "1 directory with package.json is not declared as a workspace: packages/api. Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
1395 );
1396 }
1397
1398 #[test]
1399 fn undeclared_workspace_warning_summarizes_many_paths() {
1400 let root = PathBuf::from("/repo");
1401 let diagnostics = [
1402 "examples/a",
1403 "examples/b",
1404 "examples/c",
1405 "examples/d",
1406 "examples/e",
1407 "examples/f",
1408 ]
1409 .into_iter()
1410 .map(|path| diag(&root, path))
1411 .collect::<Vec<_>>();
1412
1413 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1414 .expect("warning should be rendered");
1415
1416 assert_eq!(
1417 warning,
1418 "6 directories with package.json are not declared as workspaces: examples/a, examples/b, examples/c, examples/d, examples/e (and 1 more). Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
1419 );
1420 }
1421
1422 #[test]
1423 fn collect_config_search_roots_includes_file_ancestors_once() {
1424 let root = PathBuf::from("/repo");
1425 let search_roots = collect_config_search_roots(
1426 &root,
1427 &[
1428 root.join("apps/query/src/main.ts"),
1429 root.join("packages/shared/lib/index.ts"),
1430 ],
1431 );
1432
1433 assert_eq!(
1434 search_roots,
1435 vec![
1436 root.clone(),
1437 root.join("apps"),
1438 root.join("apps/query"),
1439 root.join("apps/query/src"),
1440 root.join("packages"),
1441 root.join("packages/shared"),
1442 root.join("packages/shared/lib"),
1443 ]
1444 );
1445 }
1446
1447 #[test]
1448 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1449 let root = PathBuf::from("/repo");
1450 let ui = fallow_config::WorkspaceInfo {
1451 root: root.join("apps/ui"),
1452 name: "ui".to_string(),
1453 is_internal_dependency: false,
1454 };
1455 let api = fallow_config::WorkspaceInfo {
1456 root: root.join("apps/api"),
1457 name: "api".to_string(),
1458 is_internal_dependency: false,
1459 };
1460 let workspace_pkgs = vec![
1461 (
1462 &ui,
1463 fallow_config::PackageJson {
1464 name: Some("ui".to_string()),
1465 ..Default::default()
1466 },
1467 ),
1468 (
1469 &api,
1470 fallow_config::PackageJson {
1471 name: Some("api".to_string()),
1472 ..Default::default()
1473 },
1474 ),
1475 ];
1476 let files = vec![
1477 root.join("apps/ui/vite.config.ts"),
1478 root.join("apps/ui/src/main.ts"),
1479 root.join("apps/api/src/server.ts"),
1480 root.join("tools/build.ts"),
1481 ];
1482
1483 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1484
1485 assert_eq!(
1486 buckets[0],
1487 vec![
1488 (
1489 root.join("apps/ui/vite.config.ts"),
1490 "vite.config.ts".to_string()
1491 ),
1492 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1493 ]
1494 );
1495 assert_eq!(
1496 buckets[1],
1497 vec![(
1498 root.join("apps/api/src/server.ts"),
1499 "src/server.ts".to_string()
1500 )]
1501 );
1502 }
1503}