1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14#![cfg_attr(
15 test,
16 allow(
17 clippy::unwrap_used,
18 clippy::expect_used,
19 reason = "tests use unwrap and expect to keep fixture setup concise"
20 )
21)]
22
23pub mod analyze;
24pub mod cache;
25pub mod changed_files;
26pub mod churn;
27pub mod cross_reference;
28pub mod discover;
29pub mod duplicates;
30pub(crate) mod errors;
31mod external_style_usage;
32pub mod extract;
33pub mod git_env;
34mod package_assets;
35pub mod plugins;
36pub(crate) mod progress;
37pub mod results;
38pub(crate) mod scripts;
39pub(crate) mod spawn;
40pub mod suppress;
41pub mod trace;
42pub mod trace_chain;
43
44pub use fallow_graph::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) {
177 for module in modules {
178 if let Some(file) = files.get(module.file_id.0 as usize) {
179 let (mt, sz) = file_mtime_and_size(&file.path);
180 if let Some(cached) = store.get_by_path_only(&file.path)
181 && cached.content_hash == module.content_hash
182 {
183 if cached.mtime_secs != mt || cached.file_size != sz {
184 let preserved_last_access = cached.last_access_secs;
185 let mut refreshed = cache::module_to_cached(module, mt, sz);
186 refreshed.last_access_secs = preserved_last_access;
187 store.insert(&file.path, refreshed);
188 }
189 continue;
190 }
191 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
192 }
193 }
194 store.retain_paths(files);
195}
196
197#[must_use]
205pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
206 config
207 .cache_max_size_mb
208 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
209 (mb as usize).saturating_mul(1024 * 1024)
210 })
211}
212
213fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
215 std::fs::metadata(path).map_or((0, 0), |m| {
216 let mt = m
217 .modified()
218 .ok()
219 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
220 .map_or(0, |d| d.as_secs());
221 (mt, m.len())
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_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. 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_cli::programmatic::detect_dead_code instead. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
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
341#[deprecated(
352 since = "2.90.0",
353 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code and `compute_complexity` instead. NOTE: this combined LSP-only typed surface is not exposed externally. See docs/fallow-core-migration.md and ADR-008."
354)]
355pub fn analyze_with_usages_and_complexity(
356 config: &ResolvedConfig,
357) -> Result<AnalysisOutput, FallowError> {
358 analyze_full(config, false, true, true, true)
359}
360
361#[deprecated(
367 since = "2.76.0",
368 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow dead-code --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
369)]
370pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
371 analyze_full(config, true, false, false, false)
372}
373
374#[deprecated(
383 since = "2.76.0",
384 note = "fallow_core is internal; the CLI fix command uses this via the workspace path dependency. External embedders should use fallow_cli::programmatic::detect_dead_code. See docs/fallow-core-migration.md and ADR-008."
385)]
386pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
387 analyze_full(config, false, false, false, false)
388}
389
390#[deprecated(
400 since = "2.76.0",
401 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
402)]
403pub fn analyze_retaining_modules(
404 config: &ResolvedConfig,
405 need_complexity: bool,
406 retain_graph: bool,
407) -> Result<AnalysisOutput, FallowError> {
408 analyze_full(config, retain_graph, false, need_complexity, true)
409}
410
411fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
412 let show_progress = !config.quiet
413 && std::io::IsTerminal::is_terminal(&std::io::stderr())
414 && matches!(
415 config.output,
416 fallow_config::OutputFormat::Human
417 | fallow_config::OutputFormat::Compact
418 | fallow_config::OutputFormat::Markdown
419 );
420 progress::AnalysisProgress::new(show_progress)
421}
422
423fn warn_missing_node_modules(config: &ResolvedConfig) {
424 if config.root.join("node_modules").is_dir() {
425 return;
426 }
427
428 tracing::warn!(
429 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
430 );
431}
432
433fn discover_analysis_workspaces(
434 config: &ResolvedConfig,
435) -> (Vec<fallow_config::WorkspaceInfo>, f64) {
436 let t = Instant::now();
437 let workspaces = discover_workspaces(&config.root);
438 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
439 if !workspaces.is_empty() {
440 tracing::info!(count = workspaces.len(), "workspaces discovered");
441 }
442
443 warn_undeclared_workspaces(
444 &config.root,
445 &workspaces,
446 &config.ignore_patterns,
447 config.quiet,
448 );
449
450 (workspaces, workspaces_ms)
451}
452
453struct AnalysisSetup {
457 progress: progress::AnalysisProgress,
458 project: project::ProjectState,
459 root_pkg: Option<PackageJson>,
460 config_candidates: Vec<std::path::PathBuf>,
465 discover_ms: f64,
466 workspaces_ms: f64,
467}
468
469fn run_analysis_setup(config: &ResolvedConfig) -> AnalysisSetup {
472 let progress = new_analysis_progress(config);
473 warn_missing_node_modules(config);
474
475 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config);
476 let root_pkg = load_root_package_json(config);
477 let discovery_hidden_dir_scopes =
478 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
479
480 let t = Instant::now();
481 progress.set_stage("discovering files...");
482 let (discovered_files, config_candidates) =
483 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
484 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
485
486 let project = project::ProjectState::new(discovered_files, workspaces_vec);
487
488 AnalysisSetup {
489 progress,
490 project,
491 root_pkg,
492 config_candidates,
493 discover_ms,
494 workspaces_ms,
495 }
496}
497
498struct PluginScriptInput<'a> {
500 config: &'a ResolvedConfig,
501 progress: &'a progress::AnalysisProgress,
502 files: &'a [discover::DiscoveredFile],
503 workspaces: &'a [fallow_config::WorkspaceInfo],
504 root_pkg: Option<&'a PackageJson>,
505 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
506 config_candidates: &'a [std::path::PathBuf],
507}
508
509fn run_plugins_and_scripts(
512 input: &PluginScriptInput<'_>,
513) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
514 let t = Instant::now();
515 input.progress.set_stage("detecting plugins...");
516 let mut plugin_result = run_plugins(
517 input.config,
518 input.files,
519 input.workspaces,
520 input.root_pkg,
521 input.workspace_pkgs,
522 input.config_candidates,
523 )?;
524 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
525
526 let t = Instant::now();
527 analyze_all_scripts(
528 input.config,
529 input.workspaces,
530 input.root_pkg,
531 input.workspace_pkgs,
532 &mut plugin_result,
533 );
534 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
535
536 Ok((plugin_result, plugins_ms, scripts_ms))
537}
538
539#[deprecated(
551 since = "2.76.0",
552 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: pre-parsed module reuse is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
553)]
554pub fn analyze_with_parse_result(
555 config: &ResolvedConfig,
556 modules: &[extract::ModuleInfo],
557) -> Result<AnalysisOutput, FallowError> {
558 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
559 let pipeline_start = Instant::now();
560
561 let AnalysisSetup {
562 progress,
563 project,
564 root_pkg,
565 config_candidates,
566 discover_ms,
567 workspaces_ms,
568 } = run_analysis_setup(config);
569 let files = project.files();
570 let workspaces = project.workspaces();
571 let workspace_pkgs = load_workspace_packages(workspaces);
572
573 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
574 config,
575 progress: &progress,
576 files,
577 workspaces,
578 root_pkg: root_pkg.as_ref(),
579 workspace_pkgs: &workspace_pkgs,
580 config_candidates: &config_candidates,
581 })?;
582
583 let core = run_reused_analysis_core(&ReusedAnalysisCoreInput {
584 config,
585 progress: &progress,
586 files,
587 workspaces,
588 root_pkg: root_pkg.as_ref(),
589 workspace_pkgs: &workspace_pkgs,
590 plugin_result: &plugin_result,
591 modules,
592 });
593 progress.finish();
594
595 let timings = PreludeTimings {
596 discover_ms,
597 workspaces_ms,
598 plugins_ms,
599 scripts_ms,
600 };
601 let prelude = prelude_metrics(&timings, pipeline_start, files, workspaces, modules.len());
602 let profile = reused_pipeline_profile(&prelude, &core);
603 trace_reused_pipeline_profile(&profile);
604
605 Ok(AnalysisOutput {
606 results: core.result,
607 timings: retained_pipeline_timings(true, &profile),
608 graph: Some(core.graph),
609 modules: None,
610 files: None,
611 script_used_packages: plugin_result.script_used_packages,
612 file_hashes: collect_file_hashes(modules, files),
613 })
614}
615
616struct PreludeMetrics {
619 discover_ms: f64,
620 workspaces_ms: f64,
621 plugins_ms: f64,
622 scripts_ms: f64,
623 total_ms: f64,
624 file_count: usize,
625 workspace_count: usize,
626 module_count: usize,
627}
628
629#[expect(
631 clippy::struct_field_names,
632 reason = "timings are all milliseconds; the _ms suffix is the unit"
633)]
634struct PreludeTimings {
635 discover_ms: f64,
636 workspaces_ms: f64,
637 plugins_ms: f64,
638 scripts_ms: f64,
639}
640
641fn prelude_metrics(
644 timings: &PreludeTimings,
645 pipeline_start: Instant,
646 files: &[discover::DiscoveredFile],
647 workspaces: &[fallow_config::WorkspaceInfo],
648 module_count: usize,
649) -> PreludeMetrics {
650 PreludeMetrics {
651 discover_ms: timings.discover_ms,
652 workspaces_ms: timings.workspaces_ms,
653 plugins_ms: timings.plugins_ms,
654 scripts_ms: timings.scripts_ms,
655 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
656 file_count: files.len(),
657 workspace_count: workspaces.len(),
658 module_count,
659 }
660}
661
662fn reused_pipeline_profile(prelude: &PreludeMetrics, core: &ReusedAnalysisCore) -> PipelineProfile {
665 PipelineProfile {
666 discover_ms: prelude.discover_ms,
667 workspaces_ms: prelude.workspaces_ms,
668 plugins_ms: prelude.plugins_ms,
669 scripts_ms: prelude.scripts_ms,
670 parse_ms: 0.0,
671 cache_ms: 0.0,
672 entry_points_ms: core.entry_points_ms,
673 resolve_ms: core.resolve_ms,
674 graph_ms: core.graph_ms,
675 analyze_ms: core.analyze_ms,
676 total_ms: prelude.total_ms,
677 file_count: prelude.file_count,
678 workspace_count: prelude.workspace_count,
679 module_count: prelude.module_count,
680 entry_point_count: core.entry_point_count,
681 cache_hits: 0,
682 cache_misses: 0,
683 parse_cpu_ms: 0.0,
684 }
685}
686
687struct ReusedAnalysisCoreInput<'a> {
689 config: &'a ResolvedConfig,
690 progress: &'a progress::AnalysisProgress,
691 files: &'a [discover::DiscoveredFile],
692 workspaces: &'a [fallow_config::WorkspaceInfo],
693 root_pkg: Option<&'a PackageJson>,
694 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
695 plugin_result: &'a plugins::AggregatedPluginResult,
696 modules: &'a [extract::ModuleInfo],
697}
698
699struct ReusedAnalysisCore {
702 result: AnalysisResults,
703 graph: graph::ModuleGraph,
704 entry_point_count: usize,
705 entry_points_ms: f64,
706 resolve_ms: f64,
707 graph_ms: f64,
708 analyze_ms: f64,
709}
710
711struct AnalysisCoreSharedInput<'a> {
712 config: &'a ResolvedConfig,
713 progress: &'a progress::AnalysisProgress,
714 files: &'a [discover::DiscoveredFile],
715 workspaces: &'a [fallow_config::WorkspaceInfo],
716 root_pkg: Option<&'a PackageJson>,
717 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
718 plugin_result: &'a plugins::AggregatedPluginResult,
719}
720
721struct TimedEntryPoints {
722 entry_points: discover::CategorizedEntryPoints,
723 summary: results::EntryPointSummary,
724 count: usize,
725 elapsed_ms: f64,
726}
727
728struct TimedResolvedModules {
729 resolved: Vec<resolve::ResolvedModule>,
730 elapsed_ms: f64,
731}
732
733struct TimedGraph {
734 graph: graph::ModuleGraph,
735 elapsed_ms: f64,
736}
737
738struct TimedAnalysis {
739 result: AnalysisResults,
740 elapsed_ms: f64,
741}
742
743fn run_reused_analysis_core(input: &ReusedAnalysisCoreInput<'_>) -> ReusedAnalysisCore {
746 let &ReusedAnalysisCoreInput {
747 config,
748 progress,
749 files,
750 workspaces,
751 root_pkg,
752 workspace_pkgs,
753 plugin_result,
754 modules,
755 } = input;
756 let shared = AnalysisCoreSharedInput {
757 config,
758 progress,
759 files,
760 workspaces,
761 root_pkg,
762 workspace_pkgs,
763 plugin_result,
764 };
765
766 let entry_points = discover_analysis_entry_points(&shared);
767 let resolved = resolve_analysis_imports_timed(&shared, modules);
768 let graph = build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, modules);
769
770 let mut analysis_modules = modules.to_vec();
771 release_resolution_payloads(&mut analysis_modules);
772 let analysis = analyze_dead_code_timed(
773 &shared,
774 &graph.graph,
775 &resolved.resolved,
776 &analysis_modules,
777 false,
778 entry_points.summary,
779 );
780
781 ReusedAnalysisCore {
782 result: analysis.result,
783 graph: graph.graph,
784 entry_point_count: entry_points.count,
785 entry_points_ms: entry_points.elapsed_ms,
786 resolve_ms: resolved.elapsed_ms,
787 graph_ms: graph.elapsed_ms,
788 analyze_ms: analysis.elapsed_ms,
789 }
790}
791
792fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
793 let t = Instant::now();
794 let entry_points = discover_all_entry_points(
795 input.config,
796 input.files,
797 input.workspaces,
798 input.root_pkg,
799 input.workspace_pkgs,
800 input.plugin_result,
801 );
802 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
803 let summary = summarize_entry_points(&entry_points.all);
804 let count = entry_points.all.len();
805
806 TimedEntryPoints {
807 entry_points,
808 summary,
809 count,
810 elapsed_ms,
811 }
812}
813
814fn resolve_analysis_imports_timed(
815 input: &AnalysisCoreSharedInput<'_>,
816 modules: &[extract::ModuleInfo],
817) -> TimedResolvedModules {
818 let t = Instant::now();
819 input.progress.set_stage("resolving imports...");
820 let resolved = resolve_analysis_imports(
821 modules,
822 input.files,
823 input.workspaces,
824 input.plugin_result,
825 input.config,
826 );
827 TimedResolvedModules {
828 resolved,
829 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
830 }
831}
832
833fn build_analysis_graph_timed(
834 input: &AnalysisCoreSharedInput<'_>,
835 resolved: &[resolve::ResolvedModule],
836 entry_points: &TimedEntryPoints,
837 modules: &[extract::ModuleInfo],
838) -> TimedGraph {
839 let t = Instant::now();
840 input.progress.set_stage("building module graph...");
841 let graph = build_analysis_graph(
842 resolved,
843 &entry_points.entry_points,
844 input.files,
845 modules,
846 input.workspaces,
847 );
848 TimedGraph {
849 graph,
850 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
851 }
852}
853
854fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
855 for module in modules {
856 module.release_resolution_payload();
857 }
858}
859
860fn analyze_dead_code_timed(
861 input: &AnalysisCoreSharedInput<'_>,
862 graph: &graph::ModuleGraph,
863 resolved: &[resolve::ResolvedModule],
864 modules: &[extract::ModuleInfo],
865 collect_usages: bool,
866 entry_point_summary: results::EntryPointSummary,
867) -> TimedAnalysis {
868 let t = Instant::now();
869 input.progress.set_stage("analyzing...");
870 #[expect(
871 deprecated,
872 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
873 )]
874 let mut result = analyze::find_dead_code_full(
875 graph,
876 input.config,
877 resolved,
878 Some(input.plugin_result),
879 input.workspaces,
880 modules,
881 collect_usages,
882 );
883 result.entry_point_summary = Some(entry_point_summary);
884 TimedAnalysis {
885 result,
886 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
887 }
888}
889
890fn analyze_full(
891 config: &ResolvedConfig,
892 retain: bool,
893 collect_usages: bool,
894 need_complexity: bool,
895 retain_modules: bool,
896) -> Result<AnalysisOutput, FallowError> {
897 let _span = tracing::info_span!("fallow_analyze").entered();
898 let pipeline_start = Instant::now();
899
900 let AnalysisSetup {
901 progress,
902 project,
903 root_pkg,
904 config_candidates,
905 discover_ms,
906 workspaces_ms,
907 } = run_analysis_setup(config);
908 let files = project.files();
909 let workspaces = project.workspaces();
910 let workspace_pkgs = load_workspace_packages(workspaces);
911
912 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
913 config,
914 progress: &progress,
915 files,
916 workspaces,
917 root_pkg: root_pkg.as_ref(),
918 workspace_pkgs: &workspace_pkgs,
919 config_candidates: &config_candidates,
920 })?;
921
922 let FullAnalysisCoreOutput { core, metrics } = run_full_analysis_core(&FullAnalysisCoreInput {
923 config,
924 progress: &progress,
925 files,
926 workspaces,
927 root_pkg: root_pkg.as_ref(),
928 workspace_pkgs: &workspace_pkgs,
929 plugin_result: &plugin_result,
930 need_complexity,
931 collect_usages,
932 });
933 progress.finish();
934
935 let profile = full_analysis_pipeline_profile(
936 &PreludeTimings {
937 discover_ms,
938 workspaces_ms,
939 plugins_ms,
940 scripts_ms,
941 },
942 pipeline_start,
943 files,
944 workspaces,
945 &core,
946 &metrics,
947 );
948 trace_pipeline_profile(&profile);
949
950 Ok(assemble_full_output(
951 core,
952 plugin_result,
953 &profile,
954 files,
955 retain,
956 retain_modules,
957 ))
958}
959
960struct FullAnalysisCoreInput<'a> {
961 config: &'a ResolvedConfig,
962 progress: &'a progress::AnalysisProgress,
963 files: &'a [discover::DiscoveredFile],
964 workspaces: &'a [fallow_config::WorkspaceInfo],
965 root_pkg: Option<&'a PackageJson>,
966 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
967 plugin_result: &'a plugins::AggregatedPluginResult,
968 need_complexity: bool,
969 collect_usages: bool,
970}
971
972struct FullAnalysisCoreOutput {
973 core: OwnedAnalysisCore,
974 metrics: ParseMetrics,
975}
976
977fn run_full_analysis_core(input: &FullAnalysisCoreInput<'_>) -> FullAnalysisCoreOutput {
978 let &FullAnalysisCoreInput {
979 config,
980 progress,
981 files,
982 workspaces,
983 root_pkg,
984 workspace_pkgs,
985 plugin_result,
986 need_complexity,
987 collect_usages,
988 } = input;
989
990 let t = Instant::now();
991 progress.set_stage(&format!("parsing {} files...", files.len()));
992 let AnalysisParseOutput { modules, metrics } =
993 parse_analysis_modules(config, files, need_complexity, t);
994
995 let core = run_owned_analysis_core(OwnedAnalysisCoreInput {
996 config,
997 progress,
998 files,
999 workspaces,
1000 root_pkg,
1001 workspace_pkgs,
1002 plugin_result,
1003 modules,
1004 collect_usages,
1005 });
1006
1007 FullAnalysisCoreOutput { core, metrics }
1008}
1009
1010fn full_analysis_pipeline_profile(
1011 timings: &PreludeTimings,
1012 pipeline_start: Instant,
1013 files: &[discover::DiscoveredFile],
1014 workspaces: &[fallow_config::WorkspaceInfo],
1015 core: &OwnedAnalysisCore,
1016 metrics: &ParseMetrics,
1017) -> PipelineProfile {
1018 let prelude = prelude_metrics(
1019 timings,
1020 pipeline_start,
1021 files,
1022 workspaces,
1023 core.modules.len(),
1024 );
1025 full_pipeline_profile(&prelude, core, metrics)
1026}
1027
1028fn assemble_full_output(
1031 core: OwnedAnalysisCore,
1032 plugin_result: plugins::AggregatedPluginResult,
1033 profile: &PipelineProfile,
1034 files: &[discover::DiscoveredFile],
1035 retain: bool,
1036 retain_modules: bool,
1037) -> AnalysisOutput {
1038 let file_hashes = collect_file_hashes(&core.modules, files);
1039 AnalysisOutput {
1040 results: core.result,
1041 timings: retained_pipeline_timings(retain, profile),
1042 graph: if retain { Some(core.graph) } else { None },
1043 modules: if retain_modules {
1044 Some(core.modules)
1045 } else {
1046 None
1047 },
1048 files: if retain_modules {
1049 Some(files.to_vec())
1050 } else {
1051 None
1052 },
1053 script_used_packages: plugin_result.script_used_packages,
1054 file_hashes,
1055 }
1056}
1057
1058struct OwnedAnalysisCoreInput<'a> {
1060 config: &'a ResolvedConfig,
1061 progress: &'a progress::AnalysisProgress,
1062 files: &'a [discover::DiscoveredFile],
1063 workspaces: &'a [fallow_config::WorkspaceInfo],
1064 root_pkg: Option<&'a PackageJson>,
1065 workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
1066 plugin_result: &'a plugins::AggregatedPluginResult,
1067 modules: Vec<extract::ModuleInfo>,
1068 collect_usages: bool,
1069}
1070
1071struct OwnedAnalysisCore {
1074 result: AnalysisResults,
1075 graph: graph::ModuleGraph,
1076 modules: Vec<extract::ModuleInfo>,
1077 entry_point_count: usize,
1078 entry_points_ms: f64,
1079 resolve_ms: f64,
1080 graph_ms: f64,
1081 analyze_ms: f64,
1082}
1083
1084fn run_owned_analysis_core(input: OwnedAnalysisCoreInput<'_>) -> OwnedAnalysisCore {
1088 let OwnedAnalysisCoreInput {
1089 config,
1090 progress,
1091 files,
1092 workspaces,
1093 root_pkg,
1094 workspace_pkgs,
1095 plugin_result,
1096 mut modules,
1097 collect_usages,
1098 } = input;
1099 let shared = AnalysisCoreSharedInput {
1100 config,
1101 progress,
1102 files,
1103 workspaces,
1104 root_pkg,
1105 workspace_pkgs,
1106 plugin_result,
1107 };
1108
1109 let entry_points = discover_analysis_entry_points(&shared);
1110 let resolved = resolve_analysis_imports_timed(&shared, &modules);
1111 let graph = build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, &modules);
1112 release_resolution_payloads(&mut modules);
1113 let analysis = analyze_dead_code_timed(
1114 &shared,
1115 &graph.graph,
1116 &resolved.resolved,
1117 &modules,
1118 collect_usages,
1119 entry_points.summary,
1120 );
1121
1122 OwnedAnalysisCore {
1123 result: analysis.result,
1124 graph: graph.graph,
1125 modules,
1126 entry_point_count: entry_points.count,
1127 entry_points_ms: entry_points.elapsed_ms,
1128 resolve_ms: resolved.elapsed_ms,
1129 graph_ms: graph.elapsed_ms,
1130 analyze_ms: analysis.elapsed_ms,
1131 }
1132}
1133
1134fn full_pipeline_profile(
1136 prelude: &PreludeMetrics,
1137 core: &OwnedAnalysisCore,
1138 parse: &ParseMetrics,
1139) -> PipelineProfile {
1140 PipelineProfile {
1141 discover_ms: prelude.discover_ms,
1142 workspaces_ms: prelude.workspaces_ms,
1143 plugins_ms: prelude.plugins_ms,
1144 scripts_ms: prelude.scripts_ms,
1145 parse_ms: parse.parse_ms,
1146 cache_ms: parse.cache_ms,
1147 entry_points_ms: core.entry_points_ms,
1148 resolve_ms: core.resolve_ms,
1149 graph_ms: core.graph_ms,
1150 analyze_ms: core.analyze_ms,
1151 total_ms: prelude.total_ms,
1152 file_count: prelude.file_count,
1153 workspace_count: prelude.workspace_count,
1154 module_count: prelude.module_count,
1155 entry_point_count: core.entry_point_count,
1156 cache_hits: parse.cache_hits,
1157 cache_misses: parse.cache_misses,
1158 parse_cpu_ms: parse.parse_cpu_ms,
1159 }
1160}
1161
1162#[derive(Clone, Copy)]
1163struct PipelineProfile {
1164 discover_ms: f64,
1165 workspaces_ms: f64,
1166 plugins_ms: f64,
1167 scripts_ms: f64,
1168 parse_ms: f64,
1169 cache_ms: f64,
1170 entry_points_ms: f64,
1171 resolve_ms: f64,
1172 graph_ms: f64,
1173 analyze_ms: f64,
1174 total_ms: f64,
1175 file_count: usize,
1176 workspace_count: usize,
1177 module_count: usize,
1178 entry_point_count: usize,
1179 cache_hits: usize,
1180 cache_misses: usize,
1181 parse_cpu_ms: f64,
1182}
1183
1184struct AnalysisParseOutput {
1185 modules: Vec<extract::ModuleInfo>,
1186 metrics: ParseMetrics,
1187}
1188
1189struct ParseMetrics {
1191 parse_ms: f64,
1192 cache_ms: f64,
1193 cache_hits: usize,
1194 cache_misses: usize,
1195 parse_cpu_ms: f64,
1196}
1197
1198fn parse_analysis_modules(
1199 config: &ResolvedConfig,
1200 files: &[discover::DiscoveredFile],
1201 need_complexity: bool,
1202 start: Instant,
1203) -> AnalysisParseOutput {
1204 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1205 let mut cache_store = if config.no_cache {
1206 None
1207 } else {
1208 cache::CacheStore::load(
1209 &config.cache_dir,
1210 config.cache_config_hash,
1211 cache_max_size_bytes,
1212 )
1213 };
1214
1215 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1216 let modules = parse_result.modules;
1217 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1218 let cache_ms = update_parse_cache_if_enabled(
1219 config,
1220 &mut cache_store,
1221 &modules,
1222 files,
1223 cache_max_size_bytes,
1224 );
1225
1226 AnalysisParseOutput {
1227 modules,
1228 metrics: ParseMetrics {
1229 parse_ms,
1230 cache_ms,
1231 cache_hits: parse_result.cache_hits,
1232 cache_misses: parse_result.cache_misses,
1233 parse_cpu_ms: parse_result.parse_cpu_ms,
1234 },
1235 }
1236}
1237
1238fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1239 retain.then_some(PipelineTimings {
1240 discover_files_ms: profile.discover_ms,
1241 file_count: profile.file_count,
1242 workspaces_ms: profile.workspaces_ms,
1243 workspace_count: profile.workspace_count,
1244 plugins_ms: profile.plugins_ms,
1245 script_analysis_ms: profile.scripts_ms,
1246 parse_extract_ms: profile.parse_ms,
1247 parse_cpu_ms: profile.parse_cpu_ms,
1248 module_count: profile.module_count,
1249 cache_hits: profile.cache_hits,
1250 cache_misses: profile.cache_misses,
1251 cache_update_ms: profile.cache_ms,
1252 entry_points_ms: profile.entry_points_ms,
1253 entry_point_count: profile.entry_point_count,
1254 resolve_imports_ms: profile.resolve_ms,
1255 build_graph_ms: profile.graph_ms,
1256 analyze_ms: profile.analyze_ms,
1257 duplication_ms: None,
1258 total_ms: profile.total_ms,
1259 })
1260}
1261
1262fn trace_reused_pipeline_profile(profile: &PipelineProfile) {
1263 tracing::debug!(
1264 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
1265 │ discover files: {:>8.1}ms ({} files)\n\
1266 │ workspaces: {:>8.1}ms\n\
1267 │ plugins: {:>8.1}ms\n\
1268 │ script analysis: {:>8.1}ms\n\
1269 │ parse/extract: SKIPPED (reused {} modules)\n\
1270 │ entry points: {:>8.1}ms ({} entries)\n\
1271 │ resolve imports: {:>8.1}ms\n\
1272 │ build graph: {:>8.1}ms\n\
1273 │ analyze: {:>8.1}ms\n\
1274 │ ────────────────────────────────────────────\n\
1275 │ TOTAL: {:>8.1}ms\n\
1276 └─────────────────────────────────────────────────",
1277 profile.discover_ms,
1278 profile.file_count,
1279 profile.workspaces_ms,
1280 profile.plugins_ms,
1281 profile.scripts_ms,
1282 profile.module_count,
1283 profile.entry_points_ms,
1284 profile.entry_point_count,
1285 profile.resolve_ms,
1286 profile.graph_ms,
1287 profile.analyze_ms,
1288 profile.total_ms,
1289 );
1290}
1291
1292fn update_parse_cache_if_enabled(
1293 config: &ResolvedConfig,
1294 cache_store: &mut Option<cache::CacheStore>,
1295 modules: &[extract::ModuleInfo],
1296 files: &[discover::DiscoveredFile],
1297 cache_max_size_bytes: usize,
1298) -> f64 {
1299 let t = Instant::now();
1300 if !config.no_cache {
1301 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1302 update_cache(store, modules, files);
1303 if let Err(error) = store.save(
1304 &config.cache_dir,
1305 config.cache_config_hash,
1306 cache_max_size_bytes,
1307 ) {
1308 tracing::warn!("Failed to save cache: {error}");
1309 }
1310 }
1311 t.elapsed().as_secs_f64() * 1000.0
1312}
1313
1314fn resolve_analysis_imports(
1315 modules: &[extract::ModuleInfo],
1316 files: &[discover::DiscoveredFile],
1317 workspaces: &[fallow_config::WorkspaceInfo],
1318 plugin_result: &plugins::AggregatedPluginResult,
1319 config: &ResolvedConfig,
1320) -> Vec<resolve::ResolvedModule> {
1321 let mut resolved = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1322 modules,
1323 files,
1324 workspaces,
1325 active_plugins: &plugin_result.active_plugins,
1326 path_aliases: &plugin_result.path_aliases,
1327 auto_imports: &plugin_result.auto_imports,
1328 scss_include_paths: &plugin_result.scss_include_paths,
1329 static_dir_mappings: &plugin_result.static_dir_mappings,
1330 root: &config.root,
1331 extra_conditions: &config.resolve.conditions,
1332 });
1333 external_style_usage::augment_external_style_package_usage(
1334 &mut resolved,
1335 config,
1336 workspaces,
1337 plugin_result,
1338 );
1339 resolved
1340}
1341
1342fn build_analysis_graph(
1343 resolved: &[resolve::ResolvedModule],
1344 entry_points: &discover::CategorizedEntryPoints,
1345 files: &[discover::DiscoveredFile],
1346 modules: &[extract::ModuleInfo],
1347 workspaces: &[fallow_config::WorkspaceInfo],
1348) -> graph::ModuleGraph {
1349 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
1350 resolved,
1351 &entry_points.all,
1352 &entry_points.runtime,
1353 &entry_points.test,
1354 files,
1355 );
1356 credit_package_path_references(&mut graph, modules);
1357 credit_workspace_package_usage(&mut graph, resolved, workspaces);
1358 graph
1359}
1360
1361fn collect_file_hashes(
1362 modules: &[extract::ModuleInfo],
1363 files: &[discover::DiscoveredFile],
1364) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1365 modules
1366 .iter()
1367 .filter_map(|module| {
1368 files
1369 .get(module.file_id.0 as usize)
1370 .map(|file| (file.path.clone(), module.content_hash))
1371 })
1372 .collect()
1373}
1374
1375fn trace_pipeline_profile(profile: &PipelineProfile) {
1376 let PipelineProfile {
1377 discover_ms,
1378 workspaces_ms,
1379 plugins_ms,
1380 scripts_ms,
1381 parse_ms,
1382 cache_ms,
1383 entry_points_ms,
1384 resolve_ms,
1385 graph_ms,
1386 analyze_ms,
1387 total_ms,
1388 file_count,
1389 module_count,
1390 entry_point_count,
1391 cache_hits,
1392 cache_misses,
1393 ..
1394 } = *profile;
1395 let cache_summary = if cache_hits > 0 {
1396 format!(" ({cache_hits} cached, {cache_misses} parsed)")
1397 } else {
1398 String::new()
1399 };
1400
1401 tracing::debug!(
1402 "\n┌─ Pipeline Profile ─────────────────────────────\n\
1403 │ discover files: {:>8.1}ms ({} files)\n\
1404 │ workspaces: {:>8.1}ms\n\
1405 │ plugins: {:>8.1}ms\n\
1406 │ script analysis: {:>8.1}ms\n\
1407 │ parse/extract: {:>8.1}ms ({} modules{})\n\
1408 │ cache update: {:>8.1}ms\n\
1409 │ entry points: {:>8.1}ms ({} entries)\n\
1410 │ resolve imports: {:>8.1}ms\n\
1411 │ build graph: {:>8.1}ms\n\
1412 │ analyze: {:>8.1}ms\n\
1413 │ ────────────────────────────────────────────\n\
1414 │ TOTAL: {:>8.1}ms\n\
1415 └─────────────────────────────────────────────────",
1416 discover_ms,
1417 file_count,
1418 workspaces_ms,
1419 plugins_ms,
1420 scripts_ms,
1421 parse_ms,
1422 module_count,
1423 cache_summary,
1424 cache_ms,
1425 entry_points_ms,
1426 entry_point_count,
1427 resolve_ms,
1428 graph_ms,
1429 analyze_ms,
1430 total_ms,
1431 );
1432}
1433
1434fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1439 PackageJson::load(&config.root.join("package.json")).ok()
1440}
1441
1442fn load_workspace_packages(
1443 workspaces: &[fallow_config::WorkspaceInfo],
1444) -> Vec<LoadedWorkspacePackage<'_>> {
1445 workspaces
1446 .iter()
1447 .filter_map(|ws| {
1448 PackageJson::load(&ws.root.join("package.json"))
1449 .ok()
1450 .map(|pkg| (ws, pkg))
1451 })
1452 .collect()
1453}
1454
1455fn analyze_all_scripts(
1456 config: &ResolvedConfig,
1457 workspaces: &[fallow_config::WorkspaceInfo],
1458 root_pkg: Option<&PackageJson>,
1459 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1460 plugin_result: &mut plugins::AggregatedPluginResult,
1461) {
1462 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1463 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1464 let all_script_names = collect_all_script_names(root_pkg, workspace_pkgs);
1465
1466 let nm_roots = collect_node_modules_roots(config, workspaces);
1467 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1468
1469 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1470 analyze_workspace_scripts(
1471 config,
1472 workspace_pkgs,
1473 &bin_map,
1474 &all_dep_set,
1475 plugin_result,
1476 );
1477 analyze_ci_scripts(
1478 config,
1479 &bin_map,
1480 &all_dep_set,
1481 &all_script_names,
1482 plugin_result,
1483 );
1484
1485 plugin_result
1486 .entry_point_roles
1487 .entry("scripts".to_string())
1488 .or_insert(EntryPointRole::Support);
1489}
1490
1491fn collect_all_dependency_names(
1493 root_pkg: Option<&PackageJson>,
1494 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1495) -> Vec<String> {
1496 let mut all_dep_names: Vec<String> = Vec::new();
1497 if let Some(pkg) = root_pkg {
1498 all_dep_names.extend(pkg.all_dependency_names());
1499 }
1500 for (_, ws_pkg) in workspace_pkgs {
1501 all_dep_names.extend(ws_pkg.all_dependency_names());
1502 }
1503 all_dep_names.sort_unstable();
1504 all_dep_names.dedup();
1505 all_dep_names
1506}
1507
1508fn collect_all_script_names(
1510 root_pkg: Option<&PackageJson>,
1511 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1512) -> FxHashSet<String> {
1513 let mut all_script_names: FxHashSet<String> = FxHashSet::default();
1514 if let Some(pkg) = root_pkg
1515 && let Some(ref pkg_scripts) = pkg.scripts
1516 {
1517 all_script_names.extend(pkg_scripts.keys().cloned());
1518 }
1519 for (_, ws_pkg) in workspace_pkgs {
1520 if let Some(ref ws_scripts) = ws_pkg.scripts {
1521 all_script_names.extend(ws_scripts.keys().cloned());
1522 }
1523 }
1524 all_script_names
1525}
1526
1527fn collect_node_modules_roots<'a>(
1529 config: &'a ResolvedConfig,
1530 workspaces: &'a [fallow_config::WorkspaceInfo],
1531) -> Vec<&'a std::path::Path> {
1532 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1533 if config.root.join("node_modules").is_dir() {
1534 nm_roots.push(&config.root);
1535 }
1536 for ws in workspaces {
1537 if ws.root.join("node_modules").is_dir() {
1538 nm_roots.push(&ws.root);
1539 }
1540 }
1541 nm_roots
1542}
1543
1544fn analyze_root_scripts(
1546 config: &ResolvedConfig,
1547 root_pkg: Option<&PackageJson>,
1548 bin_map: &rustc_hash::FxHashMap<String, String>,
1549 all_dep_set: &FxHashSet<String>,
1550 plugin_result: &mut plugins::AggregatedPluginResult,
1551) {
1552 let Some(pkg) = root_pkg else {
1553 return;
1554 };
1555 let Some(ref pkg_scripts) = pkg.scripts else {
1556 return;
1557 };
1558 let scripts_to_analyze = if config.production {
1559 scripts::filter_production_scripts(pkg_scripts)
1560 } else {
1561 pkg_scripts.clone()
1562 };
1563 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
1564 let script_analysis = scripts::analyze_scripts_with_dependency_context(
1565 &scripts_to_analyze,
1566 &config.root,
1567 bin_map,
1568 all_dep_set,
1569 &script_names,
1570 );
1571 plugin_result.script_used_packages = script_analysis.used_packages;
1572
1573 for config_file in &script_analysis.config_files {
1574 plugin_result
1575 .discovered_always_used
1576 .push((config_file.clone(), "scripts".to_string()));
1577 }
1578 for entry in &script_analysis.entry_files {
1579 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1580 plugin_result
1581 .entry_patterns
1582 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1583 }
1584 }
1585}
1586
1587type WsScriptOut = (
1589 Vec<String>,
1590 Vec<(String, String)>,
1591 Vec<(plugins::PathRule, String)>,
1592);
1593
1594fn analyze_workspace_scripts(
1595 config: &ResolvedConfig,
1596 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1597 bin_map: &rustc_hash::FxHashMap<String, String>,
1598 all_dep_set: &FxHashSet<String>,
1599 plugin_result: &mut plugins::AggregatedPluginResult,
1600) {
1601 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1602 .par_iter()
1603 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1604 .collect();
1605 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1606 plugin_result.script_used_packages.extend(used_packages);
1607 plugin_result
1608 .discovered_always_used
1609 .extend(discovered_always_used);
1610 plugin_result.entry_patterns.extend(entry_patterns);
1611 }
1612}
1613
1614fn analyze_one_workspace_scripts(
1617 config: &ResolvedConfig,
1618 ws: &fallow_config::WorkspaceInfo,
1619 ws_pkg: &PackageJson,
1620 bin_map: &rustc_hash::FxHashMap<String, String>,
1621 all_dep_set: &FxHashSet<String>,
1622) -> WsScriptOut {
1623 let mut used_packages = Vec::new();
1624 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1625 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1626 let Some(ref ws_scripts) = ws_pkg.scripts else {
1627 return (used_packages, discovered_always_used, entry_patterns);
1628 };
1629 let scripts_to_analyze = if config.production {
1630 scripts::filter_production_scripts(ws_scripts)
1631 } else {
1632 ws_scripts.clone()
1633 };
1634 let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1635 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1636 &scripts_to_analyze,
1637 &ws.root,
1638 bin_map,
1639 all_dep_set,
1640 &script_names,
1641 );
1642 used_packages.extend(ws_analysis.used_packages);
1643
1644 let ws_prefix = ws
1645 .root
1646 .strip_prefix(&config.root)
1647 .unwrap_or(&ws.root)
1648 .to_string_lossy();
1649 for config_file in &ws_analysis.config_files {
1650 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1651 }
1652 for entry in &ws_analysis.entry_files {
1653 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1654 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1655 }
1656 }
1657 (used_packages, discovered_always_used, entry_patterns)
1658}
1659
1660fn analyze_ci_scripts(
1662 config: &ResolvedConfig,
1663 bin_map: &rustc_hash::FxHashMap<String, String>,
1664 all_dep_set: &FxHashSet<String>,
1665 all_script_names: &FxHashSet<String>,
1666 plugin_result: &mut plugins::AggregatedPluginResult,
1667) {
1668 let ci_analysis =
1669 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_script_names);
1670 plugin_result
1671 .script_used_packages
1672 .extend(ci_analysis.used_packages);
1673 for entry in &ci_analysis.entry_files {
1674 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1675 plugin_result
1676 .entry_patterns
1677 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1678 }
1679 }
1680}
1681
1682fn discover_all_entry_points(
1684 config: &ResolvedConfig,
1685 files: &[discover::DiscoveredFile],
1686 workspaces: &[fallow_config::WorkspaceInfo],
1687 root_pkg: Option<&PackageJson>,
1688 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1689 plugin_result: &plugins::AggregatedPluginResult,
1690) -> discover::CategorizedEntryPoints {
1691 let mut entry_points = discover::CategorizedEntryPoints::default();
1692 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1693 config,
1694 files,
1695 root_pkg,
1696 workspaces.is_empty(),
1697 );
1698
1699 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1700 workspace_pkgs
1701 .iter()
1702 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1703 .collect();
1704
1705 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1706 .par_iter()
1707 .map(|ws| {
1708 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1709 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1710 })
1711 .collect();
1712 let mut skipped_entries = rustc_hash::FxHashMap::default();
1713 entry_points.extend_runtime(root_discovery.entries);
1714 for (path, count) in root_discovery.skipped_entries {
1715 *skipped_entries.entry(path).or_insert(0) += count;
1716 }
1717 let mut ws_entries = Vec::new();
1718 for workspace in workspace_discovery {
1719 ws_entries.extend(workspace.entries);
1720 for (path, count) in workspace.skipped_entries {
1721 *skipped_entries.entry(path).or_insert(0) += count;
1722 }
1723 }
1724 discover::warn_skipped_entry_summary(&skipped_entries);
1725 entry_points.extend_runtime(ws_entries);
1726
1727 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1728 entry_points.extend(plugin_entries);
1729
1730 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1731 entry_points.extend_runtime(infra_entries);
1732
1733 if !config.dynamically_loaded.is_empty() {
1734 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1735 entry_points.extend_runtime(dynamic_entries);
1736 }
1737
1738 entry_points.dedup()
1739}
1740
1741fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1743 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1744 for ep in entry_points {
1745 let category = match &ep.source {
1746 discover::EntryPointSource::PackageJsonMain
1747 | discover::EntryPointSource::PackageJsonModule
1748 | discover::EntryPointSource::PackageJsonExports
1749 | discover::EntryPointSource::PackageJsonBin
1750 | discover::EntryPointSource::PackageJsonScript => "package.json",
1751 discover::EntryPointSource::Plugin { .. } => "plugin",
1752 discover::EntryPointSource::TestFile => "test file",
1753 discover::EntryPointSource::DefaultIndex => "default index",
1754 discover::EntryPointSource::ManualEntry => "manual entry",
1755 discover::EntryPointSource::InfrastructureConfig => "config",
1756 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1757 };
1758 *counts.entry(category.to_string()).or_insert(0) += 1;
1759 }
1760 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1761 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1762 results::EntryPointSummary {
1763 total: entry_points.len(),
1764 by_source,
1765 }
1766}
1767
1768fn append_package_file_asset_patterns(
1769 result: &mut plugins::AggregatedPluginResult,
1770 prefix: &str,
1771 pkg: &PackageJson,
1772) {
1773 let prefix = prefix.trim_matches('/');
1774 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1775 let pattern = if prefix.is_empty() {
1776 pattern
1777 } else {
1778 format!("{prefix}/{pattern}")
1779 };
1780 result
1781 .discovered_always_used
1782 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1783 }
1784}
1785
1786fn append_workspace_package_file_asset_patterns(
1787 result: &mut plugins::AggregatedPluginResult,
1788 config: &ResolvedConfig,
1789 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1790) {
1791 for (ws, ws_pkg) in workspace_pkgs {
1792 let ws_prefix = ws
1793 .root
1794 .strip_prefix(&config.root)
1795 .unwrap_or(&ws.root)
1796 .to_string_lossy()
1797 .replace('\\', "/");
1798 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1799 }
1800}
1801
1802fn run_plugins(
1804 config: &ResolvedConfig,
1805 files: &[discover::DiscoveredFile],
1806 workspaces: &[fallow_config::WorkspaceInfo],
1807 root_pkg: Option<&PackageJson>,
1808 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1809 config_candidates: &[std::path::PathBuf],
1810) -> Result<plugins::AggregatedPluginResult, FallowError> {
1811 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1812 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1813
1814 let candidate_index = (!config.production).then(|| {
1819 plugins::registry::ConfigCandidateIndex::build(
1820 file_paths
1821 .iter()
1822 .map(std::path::PathBuf::as_path)
1823 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
1824 )
1825 });
1826
1827 let mut result = run_root_plugins(
1828 ®istry,
1829 config,
1830 root_pkg,
1831 &file_paths,
1832 candidate_index.as_ref(),
1833 )?;
1834
1835 if workspaces.is_empty() {
1836 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1837 return Ok(result);
1838 }
1839
1840 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1841
1842 let ws_results = run_workspace_plugins(
1843 ®istry,
1844 config,
1845 workspace_pkgs,
1846 &file_paths,
1847 &result.active_plugins,
1848 candidate_index.as_ref(),
1849 );
1850 merge_workspace_plugin_results(&mut result, ws_results)?;
1851
1852 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1853
1854 Ok(result)
1855}
1856
1857type WorkspacePluginResult = Result<
1858 (plugins::AggregatedPluginResult, String),
1859 Vec<plugins::registry::PluginRegexValidationError>,
1860>;
1861
1862fn run_root_plugins(
1864 registry: &plugins::PluginRegistry,
1865 config: &ResolvedConfig,
1866 root_pkg: Option<&PackageJson>,
1867 file_paths: &[std::path::PathBuf],
1868 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
1869) -> Result<plugins::AggregatedPluginResult, FallowError> {
1870 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
1871 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1872 .iter()
1873 .map(std::path::PathBuf::as_path)
1874 .collect();
1875
1876 let mut result = if let Some(pkg) = root_pkg {
1877 registry
1878 .try_run_with_search_roots(
1879 pkg,
1880 &config.root,
1881 file_paths,
1882 &root_config_search_root_refs,
1883 config.production,
1884 candidate_index,
1885 )
1886 .map_err(|errors| {
1887 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
1888 })?
1889 } else {
1890 plugins::AggregatedPluginResult::default()
1891 };
1892 if let Some(pkg) = root_pkg {
1893 append_package_file_asset_patterns(&mut result, "", pkg);
1894 }
1895 Ok(result)
1896}
1897
1898fn run_workspace_plugins(
1901 registry: &plugins::PluginRegistry,
1902 config: &ResolvedConfig,
1903 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1904 file_paths: &[std::path::PathBuf],
1905 root_active_plugins: &[String],
1906 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
1907) -> Vec<WorkspacePluginResult> {
1908 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1909 root_active_plugins.iter().map(String::as_str).collect();
1910
1911 let precompiled_matchers = registry.precompile_config_matchers();
1912 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
1913
1914 workspace_pkgs
1915 .par_iter()
1916 .zip(workspace_relative_files.par_iter())
1917 .filter_map(|((ws, ws_pkg), relative_files)| {
1918 let ws_result =
1919 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
1920 pkg: ws_pkg,
1921 root: &ws.root,
1922 project_root: &config.root,
1923 precompiled_config_matchers: &precompiled_matchers,
1924 relative_files,
1925 skip_config_plugins: &root_active_plugins,
1926 production_mode: config.production,
1927 candidate_index,
1928 }) {
1929 Ok(result) => result,
1930 Err(errors) => return Some(Err(errors)),
1931 };
1932 if ws_result.active_plugins.is_empty() {
1933 return None;
1934 }
1935 let ws_prefix = ws
1936 .root
1937 .strip_prefix(&config.root)
1938 .unwrap_or(&ws.root)
1939 .to_string_lossy()
1940 .into_owned();
1941 Some(Ok((ws_result, ws_prefix)))
1942 })
1943 .collect::<Vec<_>>()
1944}
1945
1946fn merge_workspace_plugin_results(
1949 result: &mut plugins::AggregatedPluginResult,
1950 ws_results: Vec<WorkspacePluginResult>,
1951) -> Result<(), FallowError> {
1952 let mut regex_errors = Vec::new();
1953 for ws_result in ws_results {
1954 match ws_result {
1955 Ok((mut ws_result, ws_prefix)) => {
1956 ws_result.apply_workspace_prefix(&ws_prefix);
1957 ws_result.config_patterns.clear();
1958 ws_result.script_used_packages.clear();
1959 result.merge_into(ws_result);
1960 }
1961 Err(mut errors) => regex_errors.append(&mut errors),
1962 }
1963 }
1964 if !regex_errors.is_empty() {
1965 return Err(FallowError::config(
1966 plugins::registry::format_plugin_regex_errors(®ex_errors),
1967 ));
1968 }
1969 Ok(())
1970}
1971
1972fn gate_auto_import_entry_patterns(
1978 result: &mut plugins::AggregatedPluginResult,
1979 config: &ResolvedConfig,
1980 workspaces: &[fallow_config::WorkspaceInfo],
1981) {
1982 if !config.auto_imports {
1983 return;
1984 }
1985 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1986 return;
1987 }
1988 let components_custom = plugins::nuxt::config_declares_components(&config.root)
1989 || workspaces
1990 .iter()
1991 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1992 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1993 || workspaces
1994 .iter()
1995 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1996 result.entry_patterns.retain(|(rule, plugin)| {
1997 if plugin != "nuxt" {
1998 return true;
1999 }
2000 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2001 return false;
2002 }
2003 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2004 return false;
2005 }
2006 true
2007 });
2008}
2009
2010fn bucket_files_by_workspace(
2011 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
2012 file_paths: &[std::path::PathBuf],
2013) -> Vec<Vec<(std::path::PathBuf, String)>> {
2014 use rayon::prelude::*;
2015
2016 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2025 .par_iter()
2026 .map(|file_path| {
2027 workspace_pkgs
2028 .iter()
2029 .enumerate()
2030 .find_map(|(idx, (ws, _))| {
2031 file_path.strip_prefix(&ws.root).ok().map(|relative| {
2032 (
2033 idx,
2034 file_path.clone(),
2035 relative.to_string_lossy().into_owned(),
2036 )
2037 })
2038 })
2039 })
2040 .collect();
2041
2042 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
2043 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2044 buckets[idx].push((file_path, relative));
2045 }
2046
2047 buckets
2048}
2049
2050fn collect_config_search_roots(
2051 root: &Path,
2052 file_paths: &[std::path::PathBuf],
2053) -> Vec<std::path::PathBuf> {
2054 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2055 roots.insert(root.to_path_buf());
2056
2057 for file_path in file_paths {
2058 let mut current = file_path.parent();
2059 while let Some(dir) = current {
2060 if !dir.starts_with(root) {
2061 break;
2062 }
2063 roots.insert(dir.to_path_buf());
2064 if dir == root {
2065 break;
2066 }
2067 current = dir.parent();
2068 }
2069 }
2070
2071 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2072 roots_vec.sort();
2073 roots_vec
2074}
2075
2076#[deprecated(
2082 since = "2.76.0",
2083 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead (build a `DeadCodeOptions { analysis: AnalysisOptions { root, ..default() }, ..default() }`). See docs/fallow-core-migration.md and ADR-008."
2084)]
2085pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
2086 let config = default_config(root);
2087 #[expect(
2088 deprecated,
2089 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
2090 )]
2091 analyze_with_usages(&config)
2092}
2093
2094pub fn config_for_project(
2102 root: &Path,
2103 config_path: Option<&Path>,
2104) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2105 let user_config = if let Some(path) = config_path {
2106 Some((
2107 fallow_config::FallowConfig::load(path)
2108 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2109 path.to_path_buf(),
2110 ))
2111 } else {
2112 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2113 };
2114
2115 let config = match user_config {
2116 Some((config, path)) => resolve_user_config(config, path, root)?,
2117 None => (
2118 fallow_config::FallowConfig::default().resolve(
2119 root.to_path_buf(),
2120 fallow_config::OutputFormat::Human,
2121 num_cpus(),
2122 false,
2123 true,
2124 None,
2125 ),
2126 None,
2127 ),
2128 };
2129
2130 Ok(config)
2131}
2132
2133fn resolve_user_config(
2136 mut config: fallow_config::FallowConfig,
2137 path: std::path::PathBuf,
2138 root: &Path,
2139) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2140 let dead_code_production = config
2141 .production
2142 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2143 config.production = dead_code_production.into();
2144 config
2145 .validate_resolved_boundaries(root)
2146 .map_err(|errors| {
2147 let joined = errors
2148 .iter()
2149 .map(ToString::to_string)
2150 .collect::<Vec<_>>()
2151 .join("\n - ");
2152 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
2153 })?;
2154 fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2155 let joined = errors
2156 .iter()
2157 .map(ToString::to_string)
2158 .collect::<Vec<_>>()
2159 .join("\n - ");
2160 FallowError::config(format!("invalid rule pack:\n - {joined}"))
2161 })?;
2162 Ok((
2163 config.resolve(
2164 root.to_path_buf(),
2165 fallow_config::OutputFormat::Human,
2166 num_cpus(),
2167 false,
2168 true, None, ),
2171 Some(path),
2172 ))
2173}
2174
2175pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2186 config_for_project(root, None).map_or_else(
2187 |_| {
2188 fallow_config::FallowConfig::default().resolve(
2189 root.to_path_buf(),
2190 fallow_config::OutputFormat::Human,
2191 num_cpus(),
2192 false,
2193 true,
2194 None,
2195 )
2196 },
2197 |(config, _)| config,
2198 )
2199}
2200
2201fn num_cpus() -> usize {
2202 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2203}
2204
2205#[cfg(test)]
2206mod tests {
2207 use super::{
2208 bucket_files_by_workspace, collect_config_search_roots,
2209 format_undeclared_workspace_warning, warn_undeclared_workspaces,
2210 };
2211 use std::path::{Path, PathBuf};
2212
2213 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
2214
2215 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2216 WorkspaceDiagnostic::new(
2217 root,
2218 root.join(relative),
2219 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2220 )
2221 }
2222
2223 #[test]
2224 fn undeclared_workspace_warning_is_singular_for_one_path() {
2225 let root = Path::new("/repo");
2226 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2227 .expect("warning should be rendered");
2228
2229 assert_eq!(
2230 warning,
2231 "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."
2232 );
2233 }
2234
2235 #[test]
2236 fn undeclared_workspace_warning_summarizes_many_paths() {
2237 let root = PathBuf::from("/repo");
2238 let diagnostics = [
2239 "examples/a",
2240 "examples/b",
2241 "examples/c",
2242 "examples/d",
2243 "examples/e",
2244 "examples/f",
2245 ]
2246 .into_iter()
2247 .map(|path| diag(&root, path))
2248 .collect::<Vec<_>>();
2249
2250 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2251 .expect("warning should be rendered");
2252
2253 assert_eq!(
2254 warning,
2255 "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."
2256 );
2257 }
2258
2259 #[test]
2260 fn collect_config_search_roots_includes_file_ancestors_once() {
2261 let root = PathBuf::from("/repo");
2262 let search_roots = collect_config_search_roots(
2263 &root,
2264 &[
2265 root.join("apps/query/src/main.ts"),
2266 root.join("packages/shared/lib/index.ts"),
2267 ],
2268 );
2269
2270 assert_eq!(
2271 search_roots,
2272 vec![
2273 root.clone(),
2274 root.join("apps"),
2275 root.join("apps/query"),
2276 root.join("apps/query/src"),
2277 root.join("packages"),
2278 root.join("packages/shared"),
2279 root.join("packages/shared/lib"),
2280 ]
2281 );
2282 }
2283
2284 #[test]
2285 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2286 let root = PathBuf::from("/repo");
2287 let ui = fallow_config::WorkspaceInfo {
2288 root: root.join("apps/ui"),
2289 name: "ui".to_string(),
2290 is_internal_dependency: false,
2291 };
2292 let api = fallow_config::WorkspaceInfo {
2293 root: root.join("apps/api"),
2294 name: "api".to_string(),
2295 is_internal_dependency: false,
2296 };
2297 let workspace_pkgs = vec![
2298 (
2299 &ui,
2300 fallow_config::PackageJson {
2301 name: Some("ui".to_string()),
2302 ..Default::default()
2303 },
2304 ),
2305 (
2306 &api,
2307 fallow_config::PackageJson {
2308 name: Some("api".to_string()),
2309 ..Default::default()
2310 },
2311 ),
2312 ];
2313 let files = vec![
2314 root.join("apps/ui/vite.config.ts"),
2315 root.join("apps/ui/src/main.ts"),
2316 root.join("apps/api/src/server.ts"),
2317 root.join("tools/build.ts"),
2318 ];
2319
2320 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2321
2322 assert_eq!(
2323 buckets[0],
2324 vec![
2325 (
2326 root.join("apps/ui/vite.config.ts"),
2327 "vite.config.ts".to_string()
2328 ),
2329 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2330 ]
2331 );
2332 assert_eq!(
2333 buckets[1],
2334 vec![(
2335 root.join("apps/api/src/server.ts"),
2336 "src/server.ts".to_string()
2337 )]
2338 );
2339 }
2340
2341 #[test]
2342 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
2343 let dir = tempfile::tempdir().expect("create temp dir");
2344 let pkg_good = dir.path().join("packages").join("good");
2345 let pkg_bad = dir.path().join("packages").join("bad");
2346 std::fs::create_dir_all(&pkg_good).unwrap();
2347 std::fs::create_dir_all(&pkg_bad).unwrap();
2348 std::fs::write(
2349 dir.path().join("package.json"),
2350 r#"{"workspaces": ["packages/*"]}"#,
2351 )
2352 .unwrap();
2353 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2354 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2355
2356 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
2357 dir.path(),
2358 &globset::GlobSet::empty(),
2359 )
2360 .expect("root package.json is valid");
2361 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
2362 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
2363
2364 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
2365
2366 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
2367 let mut malformed = 0;
2368 let mut undeclared_for_bad = 0;
2369 for diag in &diagnostics {
2370 if matches!(
2371 diag.kind,
2372 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
2373 ) && diag.path.ends_with("bad")
2374 {
2375 malformed += 1;
2376 }
2377 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
2378 && diag.path.ends_with("bad")
2379 {
2380 undeclared_for_bad += 1;
2381 }
2382 }
2383 assert_eq!(
2384 malformed, 1,
2385 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
2386 );
2387 assert_eq!(
2388 undeclared_for_bad, 0,
2389 "warn_undeclared_workspaces must NOT re-flag a path that already \
2390 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
2391 );
2392 }
2393}