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