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