1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14#![cfg_attr(
15 test,
16 allow(
17 clippy::unwrap_used,
18 clippy::expect_used,
19 reason = "tests use unwrap and expect to keep fixture setup concise"
20 )
21)]
22
23pub mod analyze;
24pub mod cache;
25pub mod changed_files;
26pub mod churn;
27pub mod cross_reference;
28pub mod discover;
29pub mod duplicates;
30pub(crate) mod errors;
31mod external_style_usage;
32pub mod extract;
33pub mod git_env;
34mod package_assets;
35pub mod plugins;
36pub(crate) mod progress;
37pub mod results;
38pub(crate) mod scripts;
39pub(crate) mod spawn;
40pub mod suppress;
41pub mod trace;
42pub mod trace_chain;
43
44pub use fallow_graph::cache as graph_cache;
45pub use fallow_graph::graph;
46pub use fallow_graph::project;
47pub use fallow_graph::resolve;
48
49use std::path::{Path, PathBuf};
50use std::time::Instant;
51
52use errors::FallowError;
53use fallow_config::{
54 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
55 find_undeclared_workspaces_with_ignores,
56};
57use rayon::prelude::*;
58use results::AnalysisResults;
59use rustc_hash::FxHashSet;
60use trace::PipelineTimings;
61
62const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
63type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
64
65fn record_graph_package_usage(
66 graph: &mut graph::ModuleGraph,
67 package_name: &str,
68 file_id: discover::FileId,
69 is_type_only: bool,
70) {
71 graph
72 .package_usage
73 .entry(package_name.to_owned())
74 .or_default()
75 .push(file_id);
76 if is_type_only {
77 graph
78 .type_only_package_usage
79 .entry(package_name.to_owned())
80 .or_default()
81 .push(file_id);
82 }
83}
84
85fn workspace_package_name<'a>(
86 source: &str,
87 workspace_names: &'a FxHashSet<&str>,
88) -> Option<&'a str> {
89 if !resolve::is_bare_specifier(source) {
90 return None;
91 }
92 let package_name = resolve::extract_package_name(source);
93 workspace_names.get(package_name.as_str()).copied()
94}
95
96fn credit_workspace_package_usage(
97 graph: &mut graph::ModuleGraph,
98 resolved: &[resolve::ResolvedModule],
99 workspaces: &[fallow_config::WorkspaceInfo],
100) {
101 if workspaces.is_empty() {
102 return;
103 }
104
105 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
106 for module in resolved {
107 for import in module.all_resolved_imports() {
108 if matches!(import.target, resolve::ResolveResult::InternalModule(_))
109 && let Some(package_name) =
110 workspace_package_name(&import.info.source, &workspace_names)
111 {
112 record_graph_package_usage(
113 graph,
114 package_name,
115 module.file_id,
116 import.info.is_type_only,
117 );
118 }
119 }
120
121 for re_export in &module.re_exports {
122 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
123 && let Some(package_name) =
124 workspace_package_name(&re_export.info.source, &workspace_names)
125 {
126 record_graph_package_usage(
127 graph,
128 package_name,
129 module.file_id,
130 re_export.info.is_type_only,
131 );
132 }
133 }
134 }
135}
136
137fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
138 for module in modules {
139 for package_name in &module.package_path_references {
140 record_graph_package_usage(graph, package_name, module.file_id, false);
141 }
142 }
143}
144
145pub struct AnalysisOutput {
147 pub results: AnalysisResults,
148 pub timings: Option<PipelineTimings>,
149 pub graph: Option<graph::ModuleGraph>,
150 pub modules: Option<Vec<extract::ModuleInfo>>,
154 pub files: Option<Vec<discover::DiscoveredFile>>,
156 pub script_used_packages: rustc_hash::FxHashSet<String>,
161 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
170}
171
172fn update_cache(
174 store: &mut cache::CacheStore,
175 modules: &[extract::ModuleInfo],
176 files: &[discover::DiscoveredFile],
177) -> bool {
178 let mut dirty = false;
179 for module in modules {
180 if let Some(file) = files.get(module.file_id.0 as usize) {
181 let fingerprint = file_fingerprint(&file.path);
182 if let Some(cached) = store.get_by_path_only(&file.path)
183 && cached.content_hash == module.content_hash
184 {
185 if cached.source_fingerprint() != fingerprint {
186 let preserved_last_access = cached.last_access_secs;
187 let mut refreshed = cache::module_to_cached(module, fingerprint);
188 refreshed.last_access_secs = preserved_last_access;
189 store.insert(&file.path, refreshed);
190 dirty = true;
191 }
192 continue;
193 }
194 store.insert(&file.path, cache::module_to_cached(module, fingerprint));
195 dirty = true;
196 }
197 }
198 let removed_stale_paths = store.retain_paths(files);
199 dirty || removed_stale_paths
200}
201
202#[must_use]
210pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
211 config
212 .cache_max_size_mb
213 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
214 (mb as usize).saturating_mul(1024 * 1024)
215 })
216}
217
218fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
220 std::fs::metadata(path).map_or(
221 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
222 |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
223 )
224}
225
226fn format_undeclared_workspace_warning(
227 root: &Path,
228 undeclared: &[fallow_config::WorkspaceDiagnostic],
229) -> Option<String> {
230 if undeclared.is_empty() {
231 return None;
232 }
233
234 let preview = undeclared
235 .iter()
236 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
237 .map(|diag| {
238 diag.path
239 .strip_prefix(root)
240 .unwrap_or(&diag.path)
241 .display()
242 .to_string()
243 .replace('\\', "/")
244 })
245 .collect::<Vec<_>>();
246 let remaining = undeclared
247 .len()
248 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
249 let tail = if remaining > 0 {
250 format!(" (and {remaining} more)")
251 } else {
252 String::new()
253 };
254 let noun = if undeclared.len() == 1 {
255 "directory with package.json is"
256 } else {
257 "directories with package.json are"
258 };
259 let guidance = if undeclared.len() == 1 {
260 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
261 } else {
262 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
263 };
264
265 Some(format!(
266 "{} {} not declared as {}: {}{}. {}",
267 undeclared.len(),
268 noun,
269 if undeclared.len() == 1 {
270 "a workspace"
271 } else {
272 "workspaces"
273 },
274 preview.join(", "),
275 tail,
276 guidance
277 ))
278}
279
280fn warn_undeclared_workspaces(
281 root: &Path,
282 workspaces_vec: &[fallow_config::WorkspaceInfo],
283 ignore_patterns: &globset::GlobSet,
284 quiet: bool,
285) {
286 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
287 if undeclared.is_empty() {
288 return;
289 }
290
291 let existing = fallow_config::workspace_diagnostics_for(root);
292 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
293 .iter()
294 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
295 .collect();
296 let undeclared: Vec<_> = undeclared
297 .into_iter()
298 .filter(|diag| {
299 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
300 !already_flagged.contains(&canonical)
301 })
302 .collect();
303 if undeclared.is_empty() {
304 return;
305 }
306
307 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
308
309 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
310 tracing::warn!("{message}");
311 }
312}
313
314#[deprecated(
320 since = "2.76.0",
321 note = "fallow_core is internal; use fallow_api::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
322)]
323pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
324 let output = analyze_full(config, false, false, false, false)?;
325 Ok(output.results)
326}
327
328#[deprecated(
334 since = "2.76.0",
335 note = "fallow_core is internal; use fallow_api::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."
336)]
337pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
338 let output = analyze_full(config, false, true, false, false)?;
339 Ok(output.results)
340}
341
342pub fn analyze_with_usages_from_discovery(
349 config: &ResolvedConfig,
350 discovery: &AnalysisDiscovery,
351) -> Result<AnalysisResults, FallowError> {
352 let output = analyze_full_from_discovery(config, discovery, false, true, false, false)?;
353 Ok(output.results)
354}
355
356#[deprecated(
367 since = "2.90.0",
368 note = "fallow_core is internal; use fallow_api::detect_dead_code and `compute_complexity` instead. NOTE: this combined LSP-only typed surface is not exposed externally. See docs/fallow-core-migration.md and ADR-008."
369)]
370pub fn analyze_with_usages_and_complexity(
371 config: &ResolvedConfig,
372) -> Result<AnalysisOutput, FallowError> {
373 analyze_full(config, false, true, true, true)
374}
375
376pub fn analyze_with_usages_and_complexity_from_discovery(
383 config: &ResolvedConfig,
384 discovery: &AnalysisDiscovery,
385) -> Result<AnalysisOutput, FallowError> {
386 analyze_full_from_discovery(config, discovery, false, true, true, true)
387}
388
389#[deprecated(
395 since = "2.76.0",
396 note = "fallow_core is internal; use fallow_api::detect_dead_code instead. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow dead-code --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
397)]
398pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
399 analyze_full(config, true, false, false, false)
400}
401
402#[deprecated(
411 since = "2.76.0",
412 note = "fallow_core is internal; the CLI fix command uses this via the workspace path dependency. External embedders should use fallow_api::detect_dead_code. See docs/fallow-core-migration.md and ADR-008."
413)]
414pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
415 analyze_full(config, false, false, false, false)
416}
417
418#[deprecated(
428 since = "2.76.0",
429 note = "fallow_core is internal; use fallow_api::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."
430)]
431pub fn analyze_retaining_modules(
432 config: &ResolvedConfig,
433 need_complexity: bool,
434 retain_graph: bool,
435) -> Result<AnalysisOutput, FallowError> {
436 analyze_full(config, retain_graph, false, need_complexity, true)
437}
438
439pub fn analyze_retaining_modules_from_discovery(
446 config: &ResolvedConfig,
447 discovery: &AnalysisDiscovery,
448 need_complexity: bool,
449 retain_graph: bool,
450) -> Result<AnalysisOutput, FallowError> {
451 analyze_full_from_discovery(
452 config,
453 discovery,
454 retain_graph,
455 false,
456 need_complexity,
457 true,
458 )
459}
460
461fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
462 let show_progress = !config.quiet
463 && std::io::IsTerminal::is_terminal(&std::io::stderr())
464 && matches!(
465 config.output,
466 fallow_config::OutputFormat::Human
467 | fallow_config::OutputFormat::Compact
468 | fallow_config::OutputFormat::Markdown
469 );
470 progress::AnalysisProgress::new(show_progress)
471}
472
473fn warn_missing_node_modules(config: &ResolvedConfig) {
474 if config.root.join("node_modules").is_dir() {
475 return;
476 }
477
478 tracing::warn!(
479 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
480 );
481}
482
483fn discover_analysis_workspaces(
484 config: &ResolvedConfig,
485) -> (Vec<fallow_config::WorkspaceInfo>, f64) {
486 let t = Instant::now();
487 let workspaces = discover_workspaces(&config.root);
488 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
489 if !workspaces.is_empty() {
490 tracing::info!(count = workspaces.len(), "workspaces discovered");
491 }
492
493 warn_undeclared_workspaces(
494 &config.root,
495 &workspaces,
496 &config.ignore_patterns,
497 config.quiet,
498 );
499
500 (workspaces, workspaces_ms)
501}
502
503struct AnalysisSetup {
507 progress: progress::AnalysisProgress,
508 project: project::ProjectState,
509 root_pkg: Option<PackageJson>,
510 config_candidates: Vec<std::path::PathBuf>,
515 discover_ms: f64,
516 workspaces_ms: f64,
517}
518
519#[derive(Debug, Clone)]
525pub struct AnalysisDiscovery {
526 files: Vec<discover::DiscoveredFile>,
527 workspaces: Vec<fallow_config::WorkspaceInfo>,
528 root_pkg: Option<PackageJson>,
529 config_candidates: Vec<std::path::PathBuf>,
530 discover_ms: f64,
531 workspaces_ms: f64,
532}
533
534impl AnalysisDiscovery {
535 #[must_use]
537 pub fn files(&self) -> &[discover::DiscoveredFile] {
538 &self.files
539 }
540
541 #[must_use]
543 pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
544 self.files
545 }
546}
547
548pub(crate) struct AnalysisSession<'a> {
554 config: &'a ResolvedConfig,
555 pipeline_start: Instant,
556 progress: progress::AnalysisProgress,
557 project: project::ProjectState,
558 root_pkg: Option<PackageJson>,
559 config_candidates: Vec<std::path::PathBuf>,
560 discover_ms: f64,
561 workspaces_ms: f64,
562}
563
564impl<'a> AnalysisSession<'a> {
565 fn new(config: &'a ResolvedConfig) -> Self {
566 let pipeline_start = Instant::now();
567 let AnalysisSetup {
568 progress,
569 project,
570 root_pkg,
571 config_candidates,
572 discover_ms,
573 workspaces_ms,
574 } = run_analysis_setup(config);
575
576 Self {
577 config,
578 pipeline_start,
579 progress,
580 project,
581 root_pkg,
582 config_candidates,
583 discover_ms,
584 workspaces_ms,
585 }
586 }
587
588 fn from_discovery(config: &'a ResolvedConfig, discovery: AnalysisDiscovery) -> Self {
589 Self {
590 config,
591 pipeline_start: Instant::now(),
592 progress: new_analysis_progress(config),
593 project: project::ProjectState::new(discovery.files, discovery.workspaces),
594 root_pkg: discovery.root_pkg,
595 config_candidates: discovery.config_candidates,
596 discover_ms: discovery.discover_ms,
597 workspaces_ms: discovery.workspaces_ms,
598 }
599 }
600
601 fn files(&self) -> &[discover::DiscoveredFile] {
602 self.project.files()
603 }
604
605 fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
606 self.project.workspaces()
607 }
608
609 fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage<'_>> {
610 load_workspace_packages(self.workspaces())
611 }
612
613 fn run_plugins_and_scripts(
614 &self,
615 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
616 ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
617 run_plugins_and_scripts(&PluginScriptInput {
618 config: self.config,
619 progress: &self.progress,
620 files: self.files(),
621 workspaces: self.workspaces(),
622 root_pkg: self.root_pkg.as_ref(),
623 workspace_pkgs,
624 config_candidates: &self.config_candidates,
625 })
626 }
627
628 fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
629 PreludeTimings {
630 discover_ms: self.discover_ms,
631 workspaces_ms: self.workspaces_ms,
632 plugins_ms,
633 scripts_ms,
634 }
635 }
636
637 fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
638 let t = Instant::now();
639 self.progress
640 .set_stage(&format!("parsing {} files...", self.files().len()));
641 parse_analysis_modules(self.config, self.files(), need_complexity, t)
642 }
643
644 fn run_owned_core(
645 &self,
646 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
647 plugin_result: &plugins::AggregatedPluginResult,
648 mut modules: Vec<extract::ModuleInfo>,
649 collect_usages: bool,
650 ) -> OwnedAnalysisCore {
651 let shared = AnalysisCoreSharedInput {
652 config: self.config,
653 progress: &self.progress,
654 files: self.files(),
655 workspaces: self.workspaces(),
656 root_pkg: self.root_pkg.as_ref(),
657 workspace_pkgs,
658 plugin_result,
659 };
660
661 let entry_points = discover_analysis_entry_points(&shared);
662 let resolved = resolve_analysis_imports_timed(&shared, &modules);
663 let graph =
664 build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, &modules);
665 release_resolution_payloads(&mut modules);
666 let analysis = analyze_dead_code_timed(
667 &shared,
668 &graph.graph,
669 &resolved.resolved,
670 &modules,
671 collect_usages,
672 entry_points.summary,
673 );
674
675 OwnedAnalysisCore {
676 result: analysis.result,
677 graph: graph.graph,
678 modules,
679 entry_point_count: entry_points.count,
680 entry_points_ms: entry_points.elapsed_ms,
681 resolve_ms: resolved.elapsed_ms,
682 graph_ms: graph.elapsed_ms,
683 analyze_ms: analysis.elapsed_ms,
684 }
685 }
686
687 fn run_full(
688 self,
689 retain: bool,
690 collect_usages: bool,
691 need_complexity: bool,
692 retain_modules: bool,
693 ) -> Result<AnalysisOutput, FallowError> {
694 let workspace_pkgs = self.load_workspace_packages();
695 let (plugin_result, plugins_ms, scripts_ms) =
696 self.run_plugins_and_scripts(&workspace_pkgs)?;
697
698 let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
699 let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
700 self.progress.finish();
701
702 let profile = full_analysis_pipeline_profile(
703 &self.prelude_timings(plugins_ms, scripts_ms),
704 self.pipeline_start,
705 self.files(),
706 self.workspaces(),
707 &core,
708 &metrics,
709 );
710 trace_pipeline_profile(&profile);
711
712 Ok(assemble_full_output(
713 core,
714 plugin_result,
715 &profile,
716 self.files(),
717 retain,
718 retain_modules,
719 ))
720 }
721
722 fn run_with_parse_result(
723 self,
724 modules: &[extract::ModuleInfo],
725 ) -> Result<AnalysisOutput, FallowError> {
726 let workspace_pkgs = self.load_workspace_packages();
727 let (plugin_result, plugins_ms, scripts_ms) =
728 self.run_plugins_and_scripts(&workspace_pkgs)?;
729
730 let core = run_reused_analysis_core(&ReusedAnalysisCoreInput {
731 config: self.config,
732 progress: &self.progress,
733 files: self.files(),
734 workspaces: self.workspaces(),
735 root_pkg: self.root_pkg.as_ref(),
736 workspace_pkgs: &workspace_pkgs,
737 plugin_result: &plugin_result,
738 modules,
739 });
740 self.progress.finish();
741
742 let timings = self.prelude_timings(plugins_ms, scripts_ms);
743 let prelude = prelude_metrics(
744 &timings,
745 self.pipeline_start,
746 self.files(),
747 self.workspaces(),
748 modules.len(),
749 );
750 let profile = reused_pipeline_profile(&prelude, &core);
751 trace_reused_pipeline_profile(&profile);
752
753 Ok(AnalysisOutput {
754 results: core.result,
755 timings: retained_pipeline_timings(true, &profile),
756 graph: Some(core.graph),
757 modules: None,
758 files: None,
759 script_used_packages: plugin_result.script_used_packages,
760 file_hashes: collect_file_hashes(modules, self.files()),
761 })
762 }
763}
764
765fn run_analysis_setup(config: &ResolvedConfig) -> AnalysisSetup {
768 let progress = new_analysis_progress(config);
769 warn_missing_node_modules(config);
770
771 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config);
772 let root_pkg = load_root_package_json(config);
773 let discovery_hidden_dir_scopes =
774 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
775
776 let t = Instant::now();
777 progress.set_stage("discovering files...");
778 let (discovered_files, config_candidates) =
779 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
780 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
781
782 let project = project::ProjectState::new(discovered_files, workspaces_vec);
783
784 AnalysisSetup {
785 progress,
786 project,
787 root_pkg,
788 config_candidates,
789 discover_ms,
790 workspaces_ms,
791 }
792}
793
794#[must_use]
802pub fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
803 let AnalysisSetup {
804 progress,
805 project,
806 root_pkg,
807 config_candidates,
808 discover_ms,
809 workspaces_ms,
810 } = run_analysis_setup(config);
811 progress.finish();
812
813 AnalysisDiscovery {
814 files: project.files().to_vec(),
815 workspaces: project.workspaces().to_vec(),
816 root_pkg,
817 config_candidates,
818 discover_ms,
819 workspaces_ms,
820 }
821}
822
823struct PluginScriptInput<'a> {
825 config: &'a ResolvedConfig,
826 progress: &'a progress::AnalysisProgress,
827 files: &'a [discover::DiscoveredFile],
828 workspaces: &'a [fallow_config::WorkspaceInfo],
829 root_pkg: Option<&'a PackageJson>,
830 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
831 config_candidates: &'a [std::path::PathBuf],
832}
833
834fn run_plugins_and_scripts(
837 input: &PluginScriptInput<'_>,
838) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
839 let t = Instant::now();
840 input.progress.set_stage("detecting plugins...");
841 let mut plugin_result = run_plugins(
842 input.config,
843 input.files,
844 input.workspaces,
845 input.root_pkg,
846 input.workspace_pkgs,
847 input.config_candidates,
848 )?;
849 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
850
851 let t = Instant::now();
852 analyze_all_scripts(
853 input.config,
854 input.workspaces,
855 input.root_pkg,
856 input.workspace_pkgs,
857 &mut plugin_result,
858 );
859 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
860
861 Ok((plugin_result, plugins_ms, scripts_ms))
862}
863
864#[deprecated(
876 since = "2.76.0",
877 note = "fallow_core is internal; use fallow_api::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."
878)]
879pub fn analyze_with_parse_result(
880 config: &ResolvedConfig,
881 modules: &[extract::ModuleInfo],
882) -> Result<AnalysisOutput, FallowError> {
883 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
884 AnalysisSession::new(config).run_with_parse_result(modules)
885}
886
887struct PreludeMetrics {
890 discover_ms: f64,
891 workspaces_ms: f64,
892 plugins_ms: f64,
893 scripts_ms: f64,
894 total_ms: f64,
895 file_count: usize,
896 workspace_count: usize,
897 module_count: usize,
898}
899
900#[expect(
902 clippy::struct_field_names,
903 reason = "timings are all milliseconds; the _ms suffix is the unit"
904)]
905struct PreludeTimings {
906 discover_ms: f64,
907 workspaces_ms: f64,
908 plugins_ms: f64,
909 scripts_ms: f64,
910}
911
912fn prelude_metrics(
915 timings: &PreludeTimings,
916 pipeline_start: Instant,
917 files: &[discover::DiscoveredFile],
918 workspaces: &[fallow_config::WorkspaceInfo],
919 module_count: usize,
920) -> PreludeMetrics {
921 PreludeMetrics {
922 discover_ms: timings.discover_ms,
923 workspaces_ms: timings.workspaces_ms,
924 plugins_ms: timings.plugins_ms,
925 scripts_ms: timings.scripts_ms,
926 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
927 file_count: files.len(),
928 workspace_count: workspaces.len(),
929 module_count,
930 }
931}
932
933fn reused_pipeline_profile(prelude: &PreludeMetrics, core: &ReusedAnalysisCore) -> PipelineProfile {
936 PipelineProfile {
937 discover_ms: prelude.discover_ms,
938 workspaces_ms: prelude.workspaces_ms,
939 plugins_ms: prelude.plugins_ms,
940 scripts_ms: prelude.scripts_ms,
941 parse_ms: 0.0,
942 cache_ms: 0.0,
943 entry_points_ms: core.entry_points_ms,
944 resolve_ms: core.resolve_ms,
945 graph_ms: core.graph_ms,
946 analyze_ms: core.analyze_ms,
947 total_ms: prelude.total_ms,
948 file_count: prelude.file_count,
949 workspace_count: prelude.workspace_count,
950 module_count: prelude.module_count,
951 entry_point_count: core.entry_point_count,
952 cache_hits: 0,
953 cache_misses: 0,
954 parse_cpu_ms: 0.0,
955 }
956}
957
958struct ReusedAnalysisCoreInput<'a> {
960 config: &'a ResolvedConfig,
961 progress: &'a progress::AnalysisProgress,
962 files: &'a [discover::DiscoveredFile],
963 workspaces: &'a [fallow_config::WorkspaceInfo],
964 root_pkg: Option<&'a PackageJson>,
965 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
966 plugin_result: &'a plugins::AggregatedPluginResult,
967 modules: &'a [extract::ModuleInfo],
968}
969
970struct ReusedAnalysisCore {
973 result: AnalysisResults,
974 graph: graph::ModuleGraph,
975 entry_point_count: usize,
976 entry_points_ms: f64,
977 resolve_ms: f64,
978 graph_ms: f64,
979 analyze_ms: f64,
980}
981
982struct AnalysisCoreSharedInput<'a> {
983 config: &'a ResolvedConfig,
984 progress: &'a progress::AnalysisProgress,
985 files: &'a [discover::DiscoveredFile],
986 workspaces: &'a [fallow_config::WorkspaceInfo],
987 root_pkg: Option<&'a PackageJson>,
988 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
989 plugin_result: &'a plugins::AggregatedPluginResult,
990}
991
992struct TimedEntryPoints {
993 entry_points: discover::CategorizedEntryPoints,
994 summary: results::EntryPointSummary,
995 count: usize,
996 elapsed_ms: f64,
997}
998
999struct TimedResolvedModules {
1000 resolved: Vec<resolve::ResolvedModule>,
1001 elapsed_ms: f64,
1002}
1003
1004struct TimedGraph {
1005 graph: graph::ModuleGraph,
1006 elapsed_ms: f64,
1007}
1008
1009struct TimedAnalysis {
1010 result: AnalysisResults,
1011 elapsed_ms: f64,
1012}
1013
1014fn run_reused_analysis_core(input: &ReusedAnalysisCoreInput<'_>) -> ReusedAnalysisCore {
1017 let &ReusedAnalysisCoreInput {
1018 config,
1019 progress,
1020 files,
1021 workspaces,
1022 root_pkg,
1023 workspace_pkgs,
1024 plugin_result,
1025 modules,
1026 } = input;
1027 let shared = AnalysisCoreSharedInput {
1028 config,
1029 progress,
1030 files,
1031 workspaces,
1032 root_pkg,
1033 workspace_pkgs,
1034 plugin_result,
1035 };
1036
1037 let entry_points = discover_analysis_entry_points(&shared);
1038 let resolved = resolve_analysis_imports_timed(&shared, modules);
1039 let graph = build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, modules);
1040
1041 let mut analysis_modules = modules.to_vec();
1042 release_resolution_payloads(&mut analysis_modules);
1043 let analysis = analyze_dead_code_timed(
1044 &shared,
1045 &graph.graph,
1046 &resolved.resolved,
1047 &analysis_modules,
1048 false,
1049 entry_points.summary,
1050 );
1051
1052 ReusedAnalysisCore {
1053 result: analysis.result,
1054 graph: graph.graph,
1055 entry_point_count: entry_points.count,
1056 entry_points_ms: entry_points.elapsed_ms,
1057 resolve_ms: resolved.elapsed_ms,
1058 graph_ms: graph.elapsed_ms,
1059 analyze_ms: analysis.elapsed_ms,
1060 }
1061}
1062
1063fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1064 let t = Instant::now();
1065 let entry_points = discover_all_entry_points(
1066 input.config,
1067 input.files,
1068 input.workspaces,
1069 input.root_pkg,
1070 input.workspace_pkgs,
1071 input.plugin_result,
1072 );
1073 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1074 let summary = summarize_entry_points(&entry_points.all);
1075 let count = entry_points.all.len();
1076
1077 TimedEntryPoints {
1078 entry_points,
1079 summary,
1080 count,
1081 elapsed_ms,
1082 }
1083}
1084
1085fn resolve_analysis_imports_timed(
1086 input: &AnalysisCoreSharedInput<'_>,
1087 modules: &[extract::ModuleInfo],
1088) -> TimedResolvedModules {
1089 let t = Instant::now();
1090 input.progress.set_stage("resolving imports...");
1091 let resolved = resolve_analysis_imports(
1092 modules,
1093 input.files,
1094 input.workspaces,
1095 input.plugin_result,
1096 input.config,
1097 );
1098 TimedResolvedModules {
1099 resolved,
1100 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1101 }
1102}
1103
1104fn build_analysis_graph_timed(
1105 input: &AnalysisCoreSharedInput<'_>,
1106 resolved: &[resolve::ResolvedModule],
1107 entry_points: &TimedEntryPoints,
1108 modules: &[extract::ModuleInfo],
1109) -> TimedGraph {
1110 let t = Instant::now();
1111 input.progress.set_stage("building module graph...");
1112 let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1113 config: input.config,
1114 plugin_result: input.plugin_result,
1115 resolved,
1116 entry_points: &entry_points.entry_points,
1117 files: input.files,
1118 modules,
1119 workspaces: input.workspaces,
1120 });
1121 TimedGraph {
1122 graph,
1123 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1124 }
1125}
1126
1127fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1128 for module in modules {
1129 module.release_resolution_payload();
1130 }
1131}
1132
1133fn analyze_dead_code_timed(
1134 input: &AnalysisCoreSharedInput<'_>,
1135 graph: &graph::ModuleGraph,
1136 resolved: &[resolve::ResolvedModule],
1137 modules: &[extract::ModuleInfo],
1138 collect_usages: bool,
1139 entry_point_summary: results::EntryPointSummary,
1140) -> TimedAnalysis {
1141 let t = Instant::now();
1142 input.progress.set_stage("analyzing...");
1143 #[expect(
1144 deprecated,
1145 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
1146 )]
1147 let mut result = analyze::find_dead_code_full(
1148 graph,
1149 input.config,
1150 resolved,
1151 Some(input.plugin_result),
1152 input.workspaces,
1153 modules,
1154 collect_usages,
1155 );
1156 result.entry_point_summary = Some(entry_point_summary);
1157 TimedAnalysis {
1158 result,
1159 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1160 }
1161}
1162
1163fn analyze_full(
1164 config: &ResolvedConfig,
1165 retain: bool,
1166 collect_usages: bool,
1167 need_complexity: bool,
1168 retain_modules: bool,
1169) -> Result<AnalysisOutput, FallowError> {
1170 let _span = tracing::info_span!("fallow_analyze").entered();
1171 AnalysisSession::new(config).run_full(retain, collect_usages, need_complexity, retain_modules)
1172}
1173
1174fn analyze_full_from_discovery(
1175 config: &ResolvedConfig,
1176 discovery: &AnalysisDiscovery,
1177 retain: bool,
1178 collect_usages: bool,
1179 need_complexity: bool,
1180 retain_modules: bool,
1181) -> Result<AnalysisOutput, FallowError> {
1182 let _span = tracing::info_span!("fallow_analyze").entered();
1183 AnalysisSession::from_discovery(config, discovery.clone()).run_full(
1184 retain,
1185 collect_usages,
1186 need_complexity,
1187 retain_modules,
1188 )
1189}
1190
1191fn full_analysis_pipeline_profile(
1192 timings: &PreludeTimings,
1193 pipeline_start: Instant,
1194 files: &[discover::DiscoveredFile],
1195 workspaces: &[fallow_config::WorkspaceInfo],
1196 core: &OwnedAnalysisCore,
1197 metrics: &ParseMetrics,
1198) -> PipelineProfile {
1199 let prelude = prelude_metrics(
1200 timings,
1201 pipeline_start,
1202 files,
1203 workspaces,
1204 core.modules.len(),
1205 );
1206 full_pipeline_profile(&prelude, core, metrics)
1207}
1208
1209fn assemble_full_output(
1212 core: OwnedAnalysisCore,
1213 plugin_result: plugins::AggregatedPluginResult,
1214 profile: &PipelineProfile,
1215 files: &[discover::DiscoveredFile],
1216 retain: bool,
1217 retain_modules: bool,
1218) -> AnalysisOutput {
1219 let file_hashes = collect_file_hashes(&core.modules, files);
1220 AnalysisOutput {
1221 results: core.result,
1222 timings: retained_pipeline_timings(retain, profile),
1223 graph: if retain { Some(core.graph) } else { None },
1224 modules: if retain_modules {
1225 Some(core.modules)
1226 } else {
1227 None
1228 },
1229 files: if retain_modules {
1230 Some(files.to_vec())
1231 } else {
1232 None
1233 },
1234 script_used_packages: plugin_result.script_used_packages,
1235 file_hashes,
1236 }
1237}
1238
1239struct OwnedAnalysisCore {
1242 result: AnalysisResults,
1243 graph: graph::ModuleGraph,
1244 modules: Vec<extract::ModuleInfo>,
1245 entry_point_count: usize,
1246 entry_points_ms: f64,
1247 resolve_ms: f64,
1248 graph_ms: f64,
1249 analyze_ms: f64,
1250}
1251
1252fn full_pipeline_profile(
1254 prelude: &PreludeMetrics,
1255 core: &OwnedAnalysisCore,
1256 parse: &ParseMetrics,
1257) -> PipelineProfile {
1258 PipelineProfile {
1259 discover_ms: prelude.discover_ms,
1260 workspaces_ms: prelude.workspaces_ms,
1261 plugins_ms: prelude.plugins_ms,
1262 scripts_ms: prelude.scripts_ms,
1263 parse_ms: parse.parse_ms,
1264 cache_ms: parse.cache_ms,
1265 entry_points_ms: core.entry_points_ms,
1266 resolve_ms: core.resolve_ms,
1267 graph_ms: core.graph_ms,
1268 analyze_ms: core.analyze_ms,
1269 total_ms: prelude.total_ms,
1270 file_count: prelude.file_count,
1271 workspace_count: prelude.workspace_count,
1272 module_count: prelude.module_count,
1273 entry_point_count: core.entry_point_count,
1274 cache_hits: parse.cache_hits,
1275 cache_misses: parse.cache_misses,
1276 parse_cpu_ms: parse.parse_cpu_ms,
1277 }
1278}
1279
1280#[derive(Clone, Copy)]
1281struct PipelineProfile {
1282 discover_ms: f64,
1283 workspaces_ms: f64,
1284 plugins_ms: f64,
1285 scripts_ms: f64,
1286 parse_ms: f64,
1287 cache_ms: f64,
1288 entry_points_ms: f64,
1289 resolve_ms: f64,
1290 graph_ms: f64,
1291 analyze_ms: f64,
1292 total_ms: f64,
1293 file_count: usize,
1294 workspace_count: usize,
1295 module_count: usize,
1296 entry_point_count: usize,
1297 cache_hits: usize,
1298 cache_misses: usize,
1299 parse_cpu_ms: f64,
1300}
1301
1302struct AnalysisParseOutput {
1303 modules: Vec<extract::ModuleInfo>,
1304 metrics: ParseMetrics,
1305}
1306
1307struct ParseMetrics {
1309 parse_ms: f64,
1310 cache_ms: f64,
1311 cache_hits: usize,
1312 cache_misses: usize,
1313 parse_cpu_ms: f64,
1314}
1315
1316fn parse_analysis_modules(
1317 config: &ResolvedConfig,
1318 files: &[discover::DiscoveredFile],
1319 need_complexity: bool,
1320 start: Instant,
1321) -> AnalysisParseOutput {
1322 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1323 let mut cache_store = if config.no_cache {
1324 None
1325 } else {
1326 cache::CacheStore::load(
1327 &config.cache_dir,
1328 config.cache_config_hash,
1329 cache_max_size_bytes,
1330 )
1331 };
1332
1333 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1334 let modules = parse_result.modules;
1335 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1336 let cache_ms = update_parse_cache_if_enabled(
1337 config,
1338 &mut cache_store,
1339 &modules,
1340 files,
1341 cache_max_size_bytes,
1342 );
1343
1344 AnalysisParseOutput {
1345 modules,
1346 metrics: ParseMetrics {
1347 parse_ms,
1348 cache_ms,
1349 cache_hits: parse_result.cache_hits,
1350 cache_misses: parse_result.cache_misses,
1351 parse_cpu_ms: parse_result.parse_cpu_ms,
1352 },
1353 }
1354}
1355
1356fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1357 retain.then_some(PipelineTimings {
1358 discover_files_ms: profile.discover_ms,
1359 file_count: profile.file_count,
1360 workspaces_ms: profile.workspaces_ms,
1361 workspace_count: profile.workspace_count,
1362 plugins_ms: profile.plugins_ms,
1363 script_analysis_ms: profile.scripts_ms,
1364 parse_extract_ms: profile.parse_ms,
1365 parse_cpu_ms: profile.parse_cpu_ms,
1366 module_count: profile.module_count,
1367 cache_hits: profile.cache_hits,
1368 cache_misses: profile.cache_misses,
1369 cache_update_ms: profile.cache_ms,
1370 entry_points_ms: profile.entry_points_ms,
1371 entry_point_count: profile.entry_point_count,
1372 resolve_imports_ms: profile.resolve_ms,
1373 build_graph_ms: profile.graph_ms,
1374 analyze_ms: profile.analyze_ms,
1375 duplication_ms: None,
1376 total_ms: profile.total_ms,
1377 })
1378}
1379
1380fn trace_reused_pipeline_profile(profile: &PipelineProfile) {
1381 tracing::debug!(
1382 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
1383 │ discover files: {:>8.1}ms ({} files)\n\
1384 │ workspaces: {:>8.1}ms\n\
1385 │ plugins: {:>8.1}ms\n\
1386 │ script analysis: {:>8.1}ms\n\
1387 │ parse/extract: SKIPPED (reused {} modules)\n\
1388 │ entry points: {:>8.1}ms ({} entries)\n\
1389 │ resolve imports: {:>8.1}ms\n\
1390 │ build graph: {:>8.1}ms\n\
1391 │ analyze: {:>8.1}ms\n\
1392 │ ────────────────────────────────────────────\n\
1393 │ TOTAL: {:>8.1}ms\n\
1394 └─────────────────────────────────────────────────",
1395 profile.discover_ms,
1396 profile.file_count,
1397 profile.workspaces_ms,
1398 profile.plugins_ms,
1399 profile.scripts_ms,
1400 profile.module_count,
1401 profile.entry_points_ms,
1402 profile.entry_point_count,
1403 profile.resolve_ms,
1404 profile.graph_ms,
1405 profile.analyze_ms,
1406 profile.total_ms,
1407 );
1408}
1409
1410fn update_parse_cache_if_enabled(
1411 config: &ResolvedConfig,
1412 cache_store: &mut Option<cache::CacheStore>,
1413 modules: &[extract::ModuleInfo],
1414 files: &[discover::DiscoveredFile],
1415 cache_max_size_bytes: usize,
1416) -> f64 {
1417 let t = Instant::now();
1418 if !config.no_cache {
1419 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1420 if update_cache(store, modules, files)
1421 && let Err(error) = store.save(
1422 &config.cache_dir,
1423 config.cache_config_hash,
1424 cache_max_size_bytes,
1425 )
1426 {
1427 tracing::warn!("Failed to save cache: {error}");
1428 }
1429 }
1430 t.elapsed().as_secs_f64() * 1000.0
1431}
1432
1433fn resolve_analysis_imports(
1434 modules: &[extract::ModuleInfo],
1435 files: &[discover::DiscoveredFile],
1436 workspaces: &[fallow_config::WorkspaceInfo],
1437 plugin_result: &plugins::AggregatedPluginResult,
1438 config: &ResolvedConfig,
1439) -> Vec<resolve::ResolvedModule> {
1440 let mut resolved = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1441 modules,
1442 files,
1443 workspaces,
1444 active_plugins: &plugin_result.active_plugins,
1445 path_aliases: &plugin_result.path_aliases,
1446 auto_imports: &plugin_result.auto_imports,
1447 scss_include_paths: &plugin_result.scss_include_paths,
1448 static_dir_mappings: &plugin_result.static_dir_mappings,
1449 root: &config.root,
1450 extra_conditions: &config.resolve.conditions,
1451 });
1452 external_style_usage::augment_external_style_package_usage(
1453 &mut resolved,
1454 config,
1455 workspaces,
1456 plugin_result,
1457 );
1458 resolved
1459}
1460
1461struct BuildAnalysisGraphInput<'a> {
1462 config: &'a ResolvedConfig,
1463 plugin_result: &'a plugins::AggregatedPluginResult,
1464 resolved: &'a [resolve::ResolvedModule],
1465 entry_points: &'a discover::CategorizedEntryPoints,
1466 files: &'a [discover::DiscoveredFile],
1467 modules: &'a [extract::ModuleInfo],
1468 workspaces: &'a [fallow_config::WorkspaceInfo],
1469}
1470
1471fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1483 let caching_enabled = !input.config.no_cache;
1484
1485 let current_manifest = caching_enabled.then(|| build_graph_cache_manifest(input));
1486
1487 if let Some(current) = current_manifest.as_ref()
1488 && let Some(store) = graph_cache::GraphCacheStore::load(&input.config.cache_dir)
1489 && store.manifest.matches_inputs(current)
1490 {
1491 tracing::debug!("Graph cache hit: skipping graph build");
1495 return store.graph;
1496 }
1497
1498 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
1499 input.resolved,
1500 &input.entry_points.all,
1501 &input.entry_points.runtime,
1502 &input.entry_points.test,
1503 input.files,
1504 );
1505 credit_package_path_references(&mut graph, input.modules);
1506 credit_workspace_package_usage(&mut graph, input.resolved, input.workspaces);
1507
1508 if let Some(manifest) = current_manifest {
1509 let store = graph_cache::GraphCacheStore {
1510 version: graph_cache::GRAPH_CACHE_VERSION,
1511 manifest,
1512 graph,
1513 };
1514 store.save(&input.config.cache_dir);
1515 return store.graph;
1520 }
1521
1522 graph
1523}
1524
1525fn build_graph_cache_manifest(
1528 input: &BuildAnalysisGraphInput<'_>,
1529) -> graph_cache::GraphCacheManifest {
1530 let mode = graph_cache::GraphCacheMode::new(
1531 resolver_options_hash(input.config),
1532 entry_points_hash(input.entry_points),
1533 plugin_config_hash(input.plugin_result),
1534 );
1535 graph_cache::GraphCacheManifest::from_discovered_files(
1536 &input.config.root,
1537 input.files,
1538 mode,
1539 |path| {
1540 std::fs::metadata(path).map_or(
1541 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1542 |metadata| {
1543 fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1544 },
1545 )
1546 },
1547 )
1548}
1549
1550fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1558 use std::hash::{Hash, Hasher};
1559 let mut hasher = rustc_hash::FxHasher::default();
1560 config.root.hash(&mut hasher);
1561 config.cache_config_hash.hash(&mut hasher);
1562 config.resolve.conditions.hash(&mut hasher);
1563 hasher.finish()
1564}
1565
1566fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1569 use std::hash::{Hash, Hasher};
1570 let mut hasher = rustc_hash::FxHasher::default();
1571 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1572 let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1573 paths.sort_unstable();
1574 paths.len().hash(&mut hasher);
1575 for path in paths {
1576 path.hash(&mut hasher);
1577 }
1578 }
1579 hasher.finish()
1580}
1581
1582fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1584 use std::hash::{Hash, Hasher};
1585 let mut hasher = rustc_hash::FxHasher::default();
1586
1587 let mut active: Vec<&str> = plugin_result
1588 .active_plugins
1589 .iter()
1590 .map(String::as_str)
1591 .collect();
1592 active.sort_unstable();
1593 active.len().hash(&mut hasher);
1594 for name in active {
1595 name.hash(&mut hasher);
1596 }
1597
1598 let mut aliases: Vec<(&str, &str)> = plugin_result
1599 .path_aliases
1600 .iter()
1601 .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1602 .collect();
1603 aliases.sort_unstable();
1604 aliases.len().hash(&mut hasher);
1605 for (prefix, replacement) in aliases {
1606 prefix.hash(&mut hasher);
1607 replacement.hash(&mut hasher);
1608 }
1609
1610 let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1611 plugin_result
1612 .auto_imports
1613 .iter()
1614 .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1615 .collect();
1616 auto_imports.sort_unstable_by(|a, b| {
1617 a.0.cmp(b.0)
1618 .then_with(|| a.1.cmp(b.1))
1619 .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1620 });
1621 auto_imports.len().hash(&mut hasher);
1622 for (name, source, kind) in auto_imports {
1623 name.hash(&mut hasher);
1624 source.hash(&mut hasher);
1625 auto_import_kind_rank(kind).hash(&mut hasher);
1626 }
1627
1628 let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1629 .scss_include_paths
1630 .iter()
1631 .map(std::path::PathBuf::as_path)
1632 .collect();
1633 scss_include_paths.sort_unstable();
1634 scss_include_paths.len().hash(&mut hasher);
1635 for path in scss_include_paths {
1636 path.hash(&mut hasher);
1637 }
1638
1639 let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1640 .static_dir_mappings
1641 .iter()
1642 .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1643 .collect();
1644 static_dir_mappings.sort_unstable();
1645 static_dir_mappings.len().hash(&mut hasher);
1646 for (from_dir, mount) in static_dir_mappings {
1647 from_dir.hash(&mut hasher);
1648 mount.hash(&mut hasher);
1649 }
1650
1651 hasher.finish()
1652}
1653
1654fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1655 match kind {
1656 fallow_config::AutoImportKind::Named => 0,
1657 fallow_config::AutoImportKind::Default => 1,
1658 fallow_config::AutoImportKind::DefaultComponent => 2,
1659 }
1660}
1661
1662fn collect_file_hashes(
1663 modules: &[extract::ModuleInfo],
1664 files: &[discover::DiscoveredFile],
1665) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1666 modules
1667 .iter()
1668 .filter_map(|module| {
1669 files
1670 .get(module.file_id.0 as usize)
1671 .map(|file| (file.path.clone(), module.content_hash))
1672 })
1673 .collect()
1674}
1675
1676fn trace_pipeline_profile(profile: &PipelineProfile) {
1677 let PipelineProfile {
1678 discover_ms,
1679 workspaces_ms,
1680 plugins_ms,
1681 scripts_ms,
1682 parse_ms,
1683 cache_ms,
1684 entry_points_ms,
1685 resolve_ms,
1686 graph_ms,
1687 analyze_ms,
1688 total_ms,
1689 file_count,
1690 module_count,
1691 entry_point_count,
1692 cache_hits,
1693 cache_misses,
1694 ..
1695 } = *profile;
1696 let cache_summary = if cache_hits > 0 {
1697 format!(" ({cache_hits} cached, {cache_misses} parsed)")
1698 } else {
1699 String::new()
1700 };
1701
1702 tracing::debug!(
1703 "\n┌─ Pipeline Profile ─────────────────────────────\n\
1704 │ discover files: {:>8.1}ms ({} files)\n\
1705 │ workspaces: {:>8.1}ms\n\
1706 │ plugins: {:>8.1}ms\n\
1707 │ script analysis: {:>8.1}ms\n\
1708 │ parse/extract: {:>8.1}ms ({} modules{})\n\
1709 │ cache update: {:>8.1}ms\n\
1710 │ entry points: {:>8.1}ms ({} entries)\n\
1711 │ resolve imports: {:>8.1}ms\n\
1712 │ build graph: {:>8.1}ms\n\
1713 │ analyze: {:>8.1}ms\n\
1714 │ ────────────────────────────────────────────\n\
1715 │ TOTAL: {:>8.1}ms\n\
1716 └─────────────────────────────────────────────────",
1717 discover_ms,
1718 file_count,
1719 workspaces_ms,
1720 plugins_ms,
1721 scripts_ms,
1722 parse_ms,
1723 module_count,
1724 cache_summary,
1725 cache_ms,
1726 entry_points_ms,
1727 entry_point_count,
1728 resolve_ms,
1729 graph_ms,
1730 analyze_ms,
1731 total_ms,
1732 );
1733}
1734
1735fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1740 PackageJson::load(&config.root.join("package.json")).ok()
1741}
1742
1743fn load_workspace_packages(
1744 workspaces: &[fallow_config::WorkspaceInfo],
1745) -> Vec<LoadedWorkspacePackage<'_>> {
1746 workspaces
1747 .iter()
1748 .filter_map(|ws| {
1749 PackageJson::load(&ws.root.join("package.json"))
1750 .ok()
1751 .map(|pkg| (ws, pkg))
1752 })
1753 .collect()
1754}
1755
1756fn analyze_all_scripts(
1757 config: &ResolvedConfig,
1758 workspaces: &[fallow_config::WorkspaceInfo],
1759 root_pkg: Option<&PackageJson>,
1760 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1761 plugin_result: &mut plugins::AggregatedPluginResult,
1762) {
1763 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1764 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1765 let all_script_names = collect_all_script_names(root_pkg, workspace_pkgs);
1766
1767 let nm_roots = collect_node_modules_roots(config, workspaces);
1768 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1769
1770 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1771 analyze_workspace_scripts(
1772 config,
1773 workspace_pkgs,
1774 &bin_map,
1775 &all_dep_set,
1776 plugin_result,
1777 );
1778 analyze_ci_scripts(
1779 config,
1780 &bin_map,
1781 &all_dep_set,
1782 &all_script_names,
1783 plugin_result,
1784 );
1785
1786 plugin_result
1787 .entry_point_roles
1788 .entry("scripts".to_string())
1789 .or_insert(EntryPointRole::Support);
1790}
1791
1792fn collect_all_dependency_names(
1794 root_pkg: Option<&PackageJson>,
1795 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1796) -> Vec<String> {
1797 let mut all_dep_names: Vec<String> = Vec::new();
1798 if let Some(pkg) = root_pkg {
1799 all_dep_names.extend(pkg.all_dependency_names());
1800 }
1801 for (_, ws_pkg) in workspace_pkgs {
1802 all_dep_names.extend(ws_pkg.all_dependency_names());
1803 }
1804 all_dep_names.sort_unstable();
1805 all_dep_names.dedup();
1806 all_dep_names
1807}
1808
1809fn collect_all_script_names(
1811 root_pkg: Option<&PackageJson>,
1812 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1813) -> FxHashSet<String> {
1814 let mut all_script_names: FxHashSet<String> = FxHashSet::default();
1815 if let Some(pkg) = root_pkg
1816 && let Some(ref pkg_scripts) = pkg.scripts
1817 {
1818 all_script_names.extend(pkg_scripts.keys().cloned());
1819 }
1820 for (_, ws_pkg) in workspace_pkgs {
1821 if let Some(ref ws_scripts) = ws_pkg.scripts {
1822 all_script_names.extend(ws_scripts.keys().cloned());
1823 }
1824 }
1825 all_script_names
1826}
1827
1828fn collect_node_modules_roots<'a>(
1830 config: &'a ResolvedConfig,
1831 workspaces: &'a [fallow_config::WorkspaceInfo],
1832) -> Vec<&'a std::path::Path> {
1833 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1834 if config.root.join("node_modules").is_dir() {
1835 nm_roots.push(&config.root);
1836 }
1837 for ws in workspaces {
1838 if ws.root.join("node_modules").is_dir() {
1839 nm_roots.push(&ws.root);
1840 }
1841 }
1842 nm_roots
1843}
1844
1845fn analyze_root_scripts(
1847 config: &ResolvedConfig,
1848 root_pkg: Option<&PackageJson>,
1849 bin_map: &rustc_hash::FxHashMap<String, String>,
1850 all_dep_set: &FxHashSet<String>,
1851 plugin_result: &mut plugins::AggregatedPluginResult,
1852) {
1853 let Some(pkg) = root_pkg else {
1854 return;
1855 };
1856 let Some(ref pkg_scripts) = pkg.scripts else {
1857 return;
1858 };
1859 let scripts_to_analyze = if config.production {
1860 scripts::filter_production_scripts(pkg_scripts)
1861 } else {
1862 pkg_scripts.clone()
1863 };
1864 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1865 let script_analysis = scripts::analyze_scripts_with_dependency_context(
1866 &scripts_to_analyze,
1867 &config.root,
1868 bin_map,
1869 all_dep_set,
1870 &script_names,
1871 );
1872 plugin_result.script_used_packages = script_analysis.used_packages;
1873
1874 for config_file in &script_analysis.config_files {
1875 plugin_result
1876 .discovered_always_used
1877 .push((config_file.clone(), "scripts".to_string()));
1878 }
1879 for entry in &script_analysis.entry_files {
1880 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1881 plugin_result
1882 .entry_patterns
1883 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1884 }
1885 }
1886}
1887
1888type WsScriptOut = (
1890 Vec<String>,
1891 Vec<(String, String)>,
1892 Vec<(plugins::PathRule, String)>,
1893);
1894
1895fn analyze_workspace_scripts(
1896 config: &ResolvedConfig,
1897 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1898 bin_map: &rustc_hash::FxHashMap<String, String>,
1899 all_dep_set: &FxHashSet<String>,
1900 plugin_result: &mut plugins::AggregatedPluginResult,
1901) {
1902 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1903 .par_iter()
1904 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1905 .collect();
1906 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1907 plugin_result.script_used_packages.extend(used_packages);
1908 plugin_result
1909 .discovered_always_used
1910 .extend(discovered_always_used);
1911 plugin_result.entry_patterns.extend(entry_patterns);
1912 }
1913}
1914
1915fn analyze_one_workspace_scripts(
1918 config: &ResolvedConfig,
1919 ws: &fallow_config::WorkspaceInfo,
1920 ws_pkg: &PackageJson,
1921 bin_map: &rustc_hash::FxHashMap<String, String>,
1922 all_dep_set: &FxHashSet<String>,
1923) -> WsScriptOut {
1924 let mut used_packages = Vec::new();
1925 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1926 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1927 let Some(ref ws_scripts) = ws_pkg.scripts else {
1928 return (used_packages, discovered_always_used, entry_patterns);
1929 };
1930 let scripts_to_analyze = if config.production {
1931 scripts::filter_production_scripts(ws_scripts)
1932 } else {
1933 ws_scripts.clone()
1934 };
1935 let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1936 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1937 &scripts_to_analyze,
1938 &ws.root,
1939 bin_map,
1940 all_dep_set,
1941 &script_names,
1942 );
1943 used_packages.extend(ws_analysis.used_packages);
1944
1945 let ws_prefix = ws
1946 .root
1947 .strip_prefix(&config.root)
1948 .unwrap_or(&ws.root)
1949 .to_string_lossy();
1950 for config_file in &ws_analysis.config_files {
1951 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1952 }
1953 for entry in &ws_analysis.entry_files {
1954 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1955 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1956 }
1957 }
1958 (used_packages, discovered_always_used, entry_patterns)
1959}
1960
1961fn analyze_ci_scripts(
1963 config: &ResolvedConfig,
1964 bin_map: &rustc_hash::FxHashMap<String, String>,
1965 all_dep_set: &FxHashSet<String>,
1966 all_script_names: &FxHashSet<String>,
1967 plugin_result: &mut plugins::AggregatedPluginResult,
1968) {
1969 let ci_analysis =
1970 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_script_names);
1971 plugin_result
1972 .script_used_packages
1973 .extend(ci_analysis.used_packages);
1974 for entry in &ci_analysis.entry_files {
1975 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1976 plugin_result
1977 .entry_patterns
1978 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1979 }
1980 }
1981}
1982
1983fn discover_all_entry_points(
1985 config: &ResolvedConfig,
1986 files: &[discover::DiscoveredFile],
1987 workspaces: &[fallow_config::WorkspaceInfo],
1988 root_pkg: Option<&PackageJson>,
1989 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1990 plugin_result: &plugins::AggregatedPluginResult,
1991) -> discover::CategorizedEntryPoints {
1992 let mut entry_points = discover::CategorizedEntryPoints::default();
1993 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1994 config,
1995 files,
1996 root_pkg,
1997 workspaces.is_empty(),
1998 );
1999
2000 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
2001 workspace_pkgs
2002 .iter()
2003 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2004 .collect();
2005
2006 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
2007 .par_iter()
2008 .map(|ws| {
2009 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2010 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
2011 })
2012 .collect();
2013 let mut skipped_entries = rustc_hash::FxHashMap::default();
2014 entry_points.extend_runtime(root_discovery.entries);
2015 for (path, count) in root_discovery.skipped_entries {
2016 *skipped_entries.entry(path).or_insert(0) += count;
2017 }
2018 let mut ws_entries = Vec::new();
2019 for workspace in workspace_discovery {
2020 ws_entries.extend(workspace.entries);
2021 for (path, count) in workspace.skipped_entries {
2022 *skipped_entries.entry(path).or_insert(0) += count;
2023 }
2024 }
2025 discover::warn_skipped_entry_summary(&skipped_entries);
2026 entry_points.extend_runtime(ws_entries);
2027
2028 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
2029 entry_points.extend(plugin_entries);
2030
2031 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
2032 entry_points.extend_runtime(infra_entries);
2033
2034 if !config.dynamically_loaded.is_empty() {
2035 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
2036 entry_points.extend_runtime(dynamic_entries);
2037 }
2038
2039 entry_points.dedup()
2040}
2041
2042fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2044 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2045 for ep in entry_points {
2046 let category = match &ep.source {
2047 discover::EntryPointSource::PackageJsonMain
2048 | discover::EntryPointSource::PackageJsonModule
2049 | discover::EntryPointSource::PackageJsonExports
2050 | discover::EntryPointSource::PackageJsonBin
2051 | discover::EntryPointSource::PackageJsonScript => "package.json",
2052 discover::EntryPointSource::Plugin { .. } => "plugin",
2053 discover::EntryPointSource::TestFile => "test file",
2054 discover::EntryPointSource::DefaultIndex => "default index",
2055 discover::EntryPointSource::ManualEntry => "manual entry",
2056 discover::EntryPointSource::InfrastructureConfig => "config",
2057 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2058 };
2059 *counts.entry(category.to_string()).or_insert(0) += 1;
2060 }
2061 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2062 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2063 results::EntryPointSummary {
2064 total: entry_points.len(),
2065 by_source,
2066 }
2067}
2068
2069fn append_package_file_asset_patterns(
2070 result: &mut plugins::AggregatedPluginResult,
2071 prefix: &str,
2072 pkg: &PackageJson,
2073) {
2074 let prefix = prefix.trim_matches('/');
2075 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2076 let pattern = if prefix.is_empty() {
2077 pattern
2078 } else {
2079 format!("{prefix}/{pattern}")
2080 };
2081 result
2082 .discovered_always_used
2083 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2084 }
2085}
2086
2087fn append_workspace_package_file_asset_patterns(
2088 result: &mut plugins::AggregatedPluginResult,
2089 config: &ResolvedConfig,
2090 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2091) {
2092 for (ws, ws_pkg) in workspace_pkgs {
2093 let ws_prefix = ws
2094 .root
2095 .strip_prefix(&config.root)
2096 .unwrap_or(&ws.root)
2097 .to_string_lossy()
2098 .replace('\\', "/");
2099 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2100 }
2101}
2102
2103fn run_plugins(
2105 config: &ResolvedConfig,
2106 files: &[discover::DiscoveredFile],
2107 workspaces: &[fallow_config::WorkspaceInfo],
2108 root_pkg: Option<&PackageJson>,
2109 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2110 config_candidates: &[std::path::PathBuf],
2111) -> Result<plugins::AggregatedPluginResult, FallowError> {
2112 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2113 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2114
2115 let candidate_index = (!config.production).then(|| {
2120 plugins::registry::ConfigCandidateIndex::build(
2121 file_paths
2122 .iter()
2123 .map(std::path::PathBuf::as_path)
2124 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2125 )
2126 });
2127
2128 let mut result = run_root_plugins(
2129 ®istry,
2130 config,
2131 root_pkg,
2132 &file_paths,
2133 candidate_index.as_ref(),
2134 )?;
2135
2136 if workspaces.is_empty() {
2137 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2138 return Ok(result);
2139 }
2140
2141 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2142
2143 let ws_results = run_workspace_plugins(
2144 ®istry,
2145 config,
2146 workspace_pkgs,
2147 &file_paths,
2148 &result.active_plugins,
2149 candidate_index.as_ref(),
2150 );
2151 merge_workspace_plugin_results(&mut result, ws_results)?;
2152
2153 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2154
2155 Ok(result)
2156}
2157
2158type WorkspacePluginResult = Result<
2159 (plugins::AggregatedPluginResult, String),
2160 Vec<plugins::registry::PluginRegexValidationError>,
2161>;
2162
2163fn run_root_plugins(
2165 registry: &plugins::PluginRegistry,
2166 config: &ResolvedConfig,
2167 root_pkg: Option<&PackageJson>,
2168 file_paths: &[std::path::PathBuf],
2169 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2170) -> Result<plugins::AggregatedPluginResult, FallowError> {
2171 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2172 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2173 .iter()
2174 .map(std::path::PathBuf::as_path)
2175 .collect();
2176
2177 let mut result = if let Some(pkg) = root_pkg {
2178 registry
2179 .try_run_with_search_roots(
2180 pkg,
2181 &config.root,
2182 file_paths,
2183 &root_config_search_root_refs,
2184 config.production,
2185 candidate_index,
2186 )
2187 .map_err(|errors| {
2188 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2189 })?
2190 } else {
2191 plugins::AggregatedPluginResult::default()
2192 };
2193 if let Some(pkg) = root_pkg {
2194 append_package_file_asset_patterns(&mut result, "", pkg);
2195 }
2196 Ok(result)
2197}
2198
2199fn run_workspace_plugins(
2202 registry: &plugins::PluginRegistry,
2203 config: &ResolvedConfig,
2204 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2205 file_paths: &[std::path::PathBuf],
2206 root_active_plugins: &[String],
2207 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2208) -> Vec<WorkspacePluginResult> {
2209 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2210 root_active_plugins.iter().map(String::as_str).collect();
2211
2212 let precompiled_matchers = registry.precompile_config_matchers();
2213 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2214
2215 workspace_pkgs
2216 .par_iter()
2217 .zip(workspace_relative_files.par_iter())
2218 .filter_map(|((ws, ws_pkg), relative_files)| {
2219 let ws_result =
2220 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2221 pkg: ws_pkg,
2222 root: &ws.root,
2223 project_root: &config.root,
2224 precompiled_config_matchers: &precompiled_matchers,
2225 relative_files,
2226 skip_config_plugins: &root_active_plugins,
2227 production_mode: config.production,
2228 candidate_index,
2229 }) {
2230 Ok(result) => result,
2231 Err(errors) => return Some(Err(errors)),
2232 };
2233 if ws_result.active_plugins.is_empty() {
2234 return None;
2235 }
2236 let ws_prefix = ws
2237 .root
2238 .strip_prefix(&config.root)
2239 .unwrap_or(&ws.root)
2240 .to_string_lossy()
2241 .into_owned();
2242 Some(Ok((ws_result, ws_prefix)))
2243 })
2244 .collect::<Vec<_>>()
2245}
2246
2247fn merge_workspace_plugin_results(
2250 result: &mut plugins::AggregatedPluginResult,
2251 ws_results: Vec<WorkspacePluginResult>,
2252) -> Result<(), FallowError> {
2253 let mut regex_errors = Vec::new();
2254 for ws_result in ws_results {
2255 match ws_result {
2256 Ok((mut ws_result, ws_prefix)) => {
2257 ws_result.apply_workspace_prefix(&ws_prefix);
2258 ws_result.config_patterns.clear();
2259 ws_result.script_used_packages.clear();
2260 result.merge_into(ws_result);
2261 }
2262 Err(mut errors) => regex_errors.append(&mut errors),
2263 }
2264 }
2265 if !regex_errors.is_empty() {
2266 return Err(FallowError::config(
2267 plugins::registry::format_plugin_regex_errors(®ex_errors),
2268 ));
2269 }
2270 Ok(())
2271}
2272
2273fn gate_auto_import_entry_patterns(
2279 result: &mut plugins::AggregatedPluginResult,
2280 config: &ResolvedConfig,
2281 workspaces: &[fallow_config::WorkspaceInfo],
2282) {
2283 if !config.auto_imports {
2284 return;
2285 }
2286 if !result.active_plugins.iter().any(|name| name == "nuxt") {
2287 return;
2288 }
2289 let components_custom = plugins::nuxt::config_declares_components(&config.root)
2290 || workspaces
2291 .iter()
2292 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2293 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2294 || workspaces
2295 .iter()
2296 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2297 result.entry_patterns.retain(|(rule, plugin)| {
2298 if plugin != "nuxt" {
2299 return true;
2300 }
2301 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2302 return false;
2303 }
2304 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2305 return false;
2306 }
2307 true
2308 });
2309}
2310
2311fn bucket_files_by_workspace(
2312 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2313 file_paths: &[std::path::PathBuf],
2314) -> Vec<Vec<(std::path::PathBuf, String)>> {
2315 use rayon::prelude::*;
2316
2317 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2326 .par_iter()
2327 .map(|file_path| {
2328 workspace_pkgs
2329 .iter()
2330 .enumerate()
2331 .find_map(|(idx, (ws, _))| {
2332 file_path.strip_prefix(&ws.root).ok().map(|relative| {
2333 (
2334 idx,
2335 file_path.clone(),
2336 relative.to_string_lossy().into_owned(),
2337 )
2338 })
2339 })
2340 })
2341 .collect();
2342
2343 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
2344 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2345 buckets[idx].push((file_path, relative));
2346 }
2347
2348 buckets
2349}
2350
2351fn collect_config_search_roots(
2352 root: &Path,
2353 file_paths: &[std::path::PathBuf],
2354) -> Vec<std::path::PathBuf> {
2355 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2356 roots.insert(root.to_path_buf());
2357
2358 for file_path in file_paths {
2359 let mut current = file_path.parent();
2360 while let Some(dir) = current {
2361 if !dir.starts_with(root) {
2362 break;
2363 }
2364 roots.insert(dir.to_path_buf());
2365 if dir == root {
2366 break;
2367 }
2368 current = dir.parent();
2369 }
2370 }
2371
2372 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2373 roots_vec.sort();
2374 roots_vec
2375}
2376
2377#[deprecated(
2383 since = "2.76.0",
2384 note = "fallow_core is internal; use fallow_api::detect_dead_code instead (build a `DeadCodeOptions { analysis: AnalysisOptions { root, ..default() }, ..default() }`). See docs/fallow-core-migration.md and ADR-008."
2385)]
2386pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
2387 let config = default_config(root);
2388 #[expect(
2389 deprecated,
2390 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
2391 )]
2392 analyze_with_usages(&config)
2393}
2394
2395pub fn config_for_project(
2403 root: &Path,
2404 config_path: Option<&Path>,
2405) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2406 let user_config = if let Some(path) = config_path {
2407 Some((
2408 fallow_config::FallowConfig::load(path)
2409 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2410 path.to_path_buf(),
2411 ))
2412 } else {
2413 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2414 };
2415
2416 let config = match user_config {
2417 Some((config, path)) => resolve_user_config(config, path, root)?,
2418 None => (
2419 fallow_config::FallowConfig::default().resolve(
2420 root.to_path_buf(),
2421 fallow_config::OutputFormat::Human,
2422 num_cpus(),
2423 false,
2424 true,
2425 None,
2426 ),
2427 None,
2428 ),
2429 };
2430
2431 Ok(config)
2432}
2433
2434fn resolve_user_config(
2437 mut config: fallow_config::FallowConfig,
2438 path: std::path::PathBuf,
2439 root: &Path,
2440) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2441 let dead_code_production = config
2442 .production
2443 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2444 config.production = dead_code_production.into();
2445 config
2446 .validate_resolved_boundaries(root)
2447 .map_err(|errors| {
2448 let joined = errors
2449 .iter()
2450 .map(ToString::to_string)
2451 .collect::<Vec<_>>()
2452 .join("\n - ");
2453 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
2454 })?;
2455 fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2456 let joined = errors
2457 .iter()
2458 .map(ToString::to_string)
2459 .collect::<Vec<_>>()
2460 .join("\n - ");
2461 FallowError::config(format!("invalid rule pack:\n - {joined}"))
2462 })?;
2463 Ok((
2464 config.resolve(
2465 root.to_path_buf(),
2466 fallow_config::OutputFormat::Human,
2467 num_cpus(),
2468 false,
2469 true, None, ),
2472 Some(path),
2473 ))
2474}
2475
2476pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2487 config_for_project(root, None).map_or_else(
2488 |_| {
2489 fallow_config::FallowConfig::default().resolve(
2490 root.to_path_buf(),
2491 fallow_config::OutputFormat::Human,
2492 num_cpus(),
2493 false,
2494 true,
2495 None,
2496 )
2497 },
2498 |(config, _)| config,
2499 )
2500}
2501
2502fn num_cpus() -> usize {
2503 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2504}
2505
2506#[cfg(test)]
2507mod tests {
2508 use super::{
2509 AnalysisSession, bucket_files_by_workspace, collect_config_search_roots, default_config,
2510 format_undeclared_workspace_warning, plugin_config_hash, resolver_options_hash,
2511 warn_undeclared_workspaces,
2512 };
2513 use std::path::{Path, PathBuf};
2514
2515 use fallow_config::{
2516 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2517 };
2518
2519 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2520 let mut result = crate::plugins::AggregatedPluginResult::default();
2521 result.active_plugins.push("nuxt".to_string());
2522 result
2523 .path_aliases
2524 .push(("@/".to_string(), "src/".to_string()));
2525 result
2526 }
2527
2528 #[test]
2529 fn graph_cache_resolver_hash_includes_project_root() {
2530 let dir_a = tempfile::tempdir().expect("create temp dir a");
2531 let dir_b = tempfile::tempdir().expect("create temp dir b");
2532 let config_a = session_config(dir_a.path());
2533 let config_b = session_config(dir_b.path());
2534
2535 assert_ne!(
2536 resolver_options_hash(&config_a),
2537 resolver_options_hash(&config_b),
2538 "shared cache dirs must not reuse graphs across project roots"
2539 );
2540 }
2541
2542 #[test]
2543 fn graph_cache_plugin_hash_includes_auto_imports() {
2544 let mut without_auto_import = plugin_result();
2545 let mut with_auto_import = plugin_result();
2546 with_auto_import.auto_imports.push(AutoImportRule {
2547 name: "useCounter".to_string(),
2548 source: PathBuf::from("/project/composables/useCounter.ts"),
2549 kind: AutoImportKind::Named,
2550 });
2551
2552 assert_ne!(
2553 plugin_config_hash(&without_auto_import),
2554 plugin_config_hash(&with_auto_import),
2555 "auto-import edge changes must invalidate the graph cache"
2556 );
2557
2558 without_auto_import.auto_imports.push(AutoImportRule {
2559 name: "useCounter".to_string(),
2560 source: PathBuf::from("/project/composables/useCounter.ts"),
2561 kind: AutoImportKind::Default,
2562 });
2563 assert_ne!(
2564 plugin_config_hash(&without_auto_import),
2565 plugin_config_hash(&with_auto_import),
2566 "auto-import kind changes must invalidate the graph cache"
2567 );
2568 }
2569
2570 #[test]
2571 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2572 let base = plugin_result();
2573 let mut with_scss = base.clone();
2574 with_scss
2575 .scss_include_paths
2576 .push(PathBuf::from("/project/styles"));
2577 assert_ne!(
2578 plugin_config_hash(&base),
2579 plugin_config_hash(&with_scss),
2580 "SCSS include path changes must invalidate the graph cache"
2581 );
2582
2583 let mut with_static_dir = base.clone();
2584 with_static_dir
2585 .static_dir_mappings
2586 .push((PathBuf::from("/project/public"), "/".to_string()));
2587 assert_ne!(
2588 plugin_config_hash(&base),
2589 plugin_config_hash(&with_static_dir),
2590 "static directory mapping changes must invalidate the graph cache"
2591 );
2592 }
2593
2594 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2595 WorkspaceDiagnostic::new(
2596 root,
2597 root.join(relative),
2598 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2599 )
2600 }
2601
2602 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2603 let mut config = default_config(root);
2604 config.no_cache = true;
2605 config.quiet = true;
2606 config
2607 }
2608
2609 fn write_session_fixture(root: &Path) {
2610 let src = root.join("src");
2611 std::fs::create_dir_all(&src).expect("create src");
2612 std::fs::write(
2613 root.join("package.json"),
2614 r#"{"name":"session-fixture","type":"module"}"#,
2615 )
2616 .expect("write package json");
2617 std::fs::write(
2618 src.join("index.ts"),
2619 "import { used } from './used';\nconsole.log(used);\n",
2620 )
2621 .expect("write index");
2622 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2623 }
2624
2625 #[test]
2626 fn analysis_session_discovers_project_files() {
2627 let dir = tempfile::tempdir().expect("create temp dir");
2628 write_session_fixture(dir.path());
2629 let config = session_config(dir.path());
2630
2631 let session = AnalysisSession::new(&config);
2632
2633 assert!(
2634 session
2635 .files()
2636 .iter()
2637 .any(|file| file.path.ends_with("src/index.ts")),
2638 "session should own discovered project files"
2639 );
2640 assert_eq!(session.workspaces().len(), 0);
2641 }
2642
2643 #[test]
2644 fn analysis_session_parses_owned_modules() {
2645 let dir = tempfile::tempdir().expect("create temp dir");
2646 write_session_fixture(dir.path());
2647 let config = session_config(dir.path());
2648
2649 let session = AnalysisSession::new(&config);
2650 let parsed = session.parse_modules(false);
2651
2652 assert!(
2653 parsed
2654 .modules
2655 .iter()
2656 .any(|module| session.files()[module.file_id.0 as usize]
2657 .path
2658 .ends_with("src/index.ts")),
2659 "session parsing should return modules keyed to session files"
2660 );
2661 }
2662
2663 #[test]
2664 fn analysis_session_reuses_parse_result_with_matching_results() {
2665 let dir = tempfile::tempdir().expect("create temp dir");
2666 write_session_fixture(dir.path());
2667 let config = session_config(dir.path());
2668
2669 let fresh = AnalysisSession::new(&config)
2670 .run_full(true, false, false, true)
2671 .expect("fresh analysis succeeds");
2672 let modules = fresh.modules.as_ref().expect("fresh modules retained");
2673 let reused = AnalysisSession::new(&config)
2674 .run_with_parse_result(modules)
2675 .expect("reused analysis succeeds");
2676
2677 assert!(reused.graph.is_some());
2678 assert!(reused.timings.is_some());
2679 assert_eq!(reused.file_hashes, fresh.file_hashes);
2680 assert_eq!(
2681 serde_json::to_value(&reused.results).expect("serialize reused results"),
2682 serde_json::to_value(&fresh.results).expect("serialize fresh results")
2683 );
2684 }
2685
2686 #[test]
2687 fn undeclared_workspace_warning_is_singular_for_one_path() {
2688 let root = Path::new("/repo");
2689 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2690 .expect("warning should be rendered");
2691
2692 assert_eq!(
2693 warning,
2694 "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."
2695 );
2696 }
2697
2698 #[test]
2699 fn undeclared_workspace_warning_summarizes_many_paths() {
2700 let root = PathBuf::from("/repo");
2701 let diagnostics = [
2702 "examples/a",
2703 "examples/b",
2704 "examples/c",
2705 "examples/d",
2706 "examples/e",
2707 "examples/f",
2708 ]
2709 .into_iter()
2710 .map(|path| diag(&root, path))
2711 .collect::<Vec<_>>();
2712
2713 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2714 .expect("warning should be rendered");
2715
2716 assert_eq!(
2717 warning,
2718 "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."
2719 );
2720 }
2721
2722 #[test]
2723 fn collect_config_search_roots_includes_file_ancestors_once() {
2724 let root = PathBuf::from("/repo");
2725 let search_roots = collect_config_search_roots(
2726 &root,
2727 &[
2728 root.join("apps/query/src/main.ts"),
2729 root.join("packages/shared/lib/index.ts"),
2730 ],
2731 );
2732
2733 assert_eq!(
2734 search_roots,
2735 vec![
2736 root.clone(),
2737 root.join("apps"),
2738 root.join("apps/query"),
2739 root.join("apps/query/src"),
2740 root.join("packages"),
2741 root.join("packages/shared"),
2742 root.join("packages/shared/lib"),
2743 ]
2744 );
2745 }
2746
2747 #[test]
2748 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2749 let root = PathBuf::from("/repo");
2750 let ui = fallow_config::WorkspaceInfo {
2751 root: root.join("apps/ui"),
2752 name: "ui".to_string(),
2753 is_internal_dependency: false,
2754 };
2755 let api = fallow_config::WorkspaceInfo {
2756 root: root.join("apps/api"),
2757 name: "api".to_string(),
2758 is_internal_dependency: false,
2759 };
2760 let workspace_pkgs = vec![
2761 (
2762 &ui,
2763 fallow_config::PackageJson {
2764 name: Some("ui".to_string()),
2765 ..Default::default()
2766 },
2767 ),
2768 (
2769 &api,
2770 fallow_config::PackageJson {
2771 name: Some("api".to_string()),
2772 ..Default::default()
2773 },
2774 ),
2775 ];
2776 let files = vec![
2777 root.join("apps/ui/vite.config.ts"),
2778 root.join("apps/ui/src/main.ts"),
2779 root.join("apps/api/src/server.ts"),
2780 root.join("tools/build.ts"),
2781 ];
2782
2783 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2784
2785 assert_eq!(
2786 buckets[0],
2787 vec![
2788 (
2789 root.join("apps/ui/vite.config.ts"),
2790 "vite.config.ts".to_string()
2791 ),
2792 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2793 ]
2794 );
2795 assert_eq!(
2796 buckets[1],
2797 vec![(
2798 root.join("apps/api/src/server.ts"),
2799 "src/server.ts".to_string()
2800 )]
2801 );
2802 }
2803
2804 #[test]
2805 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
2806 let dir = tempfile::tempdir().expect("create temp dir");
2807 let pkg_good = dir.path().join("packages").join("good");
2808 let pkg_bad = dir.path().join("packages").join("bad");
2809 std::fs::create_dir_all(&pkg_good).unwrap();
2810 std::fs::create_dir_all(&pkg_bad).unwrap();
2811 std::fs::write(
2812 dir.path().join("package.json"),
2813 r#"{"workspaces": ["packages/*"]}"#,
2814 )
2815 .unwrap();
2816 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2817 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2818
2819 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
2820 dir.path(),
2821 &globset::GlobSet::empty(),
2822 )
2823 .expect("root package.json is valid");
2824 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
2825 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
2826
2827 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
2828
2829 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2830 let mut malformed = 0;
2831 let mut undeclared_for_bad = 0;
2832 for diag in &diagnostics {
2833 if matches!(
2834 diag.kind,
2835 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
2836 ) && diag.path.ends_with("bad")
2837 {
2838 malformed += 1;
2839 }
2840 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
2841 && diag.path.ends_with("bad")
2842 {
2843 undeclared_for_bad += 1;
2844 }
2845 }
2846 assert_eq!(
2847 malformed, 1,
2848 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
2849 );
2850 assert_eq!(
2851 undeclared_for_bad, 0,
2852 "warn_undeclared_workspaces must NOT re-flag a path that already \
2853 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
2854 );
2855 }
2856}