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