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