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