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