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
410#[allow(
422 clippy::too_many_lines,
423 reason = "pipeline orchestration stays easier to audit in one place"
424)]
425#[deprecated(
426 since = "2.76.0",
427 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."
428)]
429pub fn analyze_with_parse_result(
430 config: &ResolvedConfig,
431 modules: &[extract::ModuleInfo],
432) -> Result<AnalysisOutput, FallowError> {
433 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
434 let pipeline_start = Instant::now();
435
436 let show_progress = !config.quiet
437 && std::io::IsTerminal::is_terminal(&std::io::stderr())
438 && matches!(
439 config.output,
440 fallow_config::OutputFormat::Human
441 | fallow_config::OutputFormat::Compact
442 | fallow_config::OutputFormat::Markdown
443 );
444 let progress = progress::AnalysisProgress::new(show_progress);
445
446 if !config.root.join("node_modules").is_dir() {
447 tracing::warn!(
448 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
449 );
450 }
451
452 let t = Instant::now();
453 let workspaces_vec = discover_workspaces(&config.root);
454 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
455 if !workspaces_vec.is_empty() {
456 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
457 }
458
459 warn_undeclared_workspaces(
460 &config.root,
461 &workspaces_vec,
462 &config.ignore_patterns,
463 config.quiet,
464 );
465 let root_pkg = load_root_package_json(config);
466 let discovery_hidden_dir_scopes =
467 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
468
469 let t = Instant::now();
470 progress.set_stage("discovering files...");
471 let discovered_files =
472 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
473 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
474
475 let project = project::ProjectState::new(discovered_files, workspaces_vec);
476 let files = project.files();
477 let workspaces = project.workspaces();
478 let workspace_pkgs = load_workspace_packages(workspaces);
479
480 let t = Instant::now();
481 progress.set_stage("detecting plugins...");
482 let mut plugin_result = run_plugins(
483 config,
484 files,
485 workspaces,
486 root_pkg.as_ref(),
487 &workspace_pkgs,
488 )?;
489 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
490
491 let t = Instant::now();
492 analyze_all_scripts(
493 config,
494 workspaces,
495 root_pkg.as_ref(),
496 &workspace_pkgs,
497 &mut plugin_result,
498 );
499 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
500
501 let t = Instant::now();
502 let entry_points = discover_all_entry_points(
503 config,
504 files,
505 workspaces,
506 root_pkg.as_ref(),
507 &workspace_pkgs,
508 &plugin_result,
509 );
510 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
511
512 let ep_summary = summarize_entry_points(&entry_points.all);
513
514 let t = Instant::now();
515 progress.set_stage("resolving imports...");
516 let mut resolved = resolve::resolve_all_imports(
517 modules,
518 files,
519 workspaces,
520 &plugin_result.active_plugins,
521 &plugin_result.path_aliases,
522 &plugin_result.auto_imports,
523 &plugin_result.scss_include_paths,
524 &plugin_result.static_dir_mappings,
525 &config.root,
526 &config.resolve.conditions,
527 );
528 external_style_usage::augment_external_style_package_usage(
529 &mut resolved,
530 config,
531 workspaces,
532 &plugin_result,
533 );
534 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
535
536 let t = Instant::now();
537 progress.set_stage("building module graph...");
538 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
539 &resolved,
540 &entry_points.all,
541 &entry_points.runtime,
542 &entry_points.test,
543 files,
544 );
545 credit_package_path_references(&mut graph, modules);
546 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
547 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
548
549 let t = Instant::now();
550 progress.set_stage("analyzing...");
551 #[expect(
552 deprecated,
553 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
554 )]
555 let mut result = analyze::find_dead_code_full(
556 &graph,
557 config,
558 &resolved,
559 Some(&plugin_result),
560 workspaces,
561 modules,
562 false,
563 );
564 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
565 progress.finish();
566
567 result.entry_point_summary = Some(ep_summary);
568
569 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
570
571 tracing::debug!(
572 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
573 │ discover files: {:>8.1}ms ({} files)\n\
574 │ workspaces: {:>8.1}ms\n\
575 │ plugins: {:>8.1}ms\n\
576 │ script analysis: {:>8.1}ms\n\
577 │ parse/extract: SKIPPED (reused {} modules)\n\
578 │ entry points: {:>8.1}ms ({} entries)\n\
579 │ resolve imports: {:>8.1}ms\n\
580 │ build graph: {:>8.1}ms\n\
581 │ analyze: {:>8.1}ms\n\
582 │ ────────────────────────────────────────────\n\
583 │ TOTAL: {:>8.1}ms\n\
584 └─────────────────────────────────────────────────",
585 discover_ms,
586 files.len(),
587 workspaces_ms,
588 plugins_ms,
589 scripts_ms,
590 modules.len(),
591 entry_points_ms,
592 entry_points.all.len(),
593 resolve_ms,
594 graph_ms,
595 analyze_ms,
596 total_ms,
597 );
598
599 let timings = Some(PipelineTimings {
600 discover_files_ms: discover_ms,
601 file_count: files.len(),
602 workspaces_ms,
603 workspace_count: workspaces.len(),
604 plugins_ms,
605 script_analysis_ms: scripts_ms,
606 parse_extract_ms: 0.0, parse_cpu_ms: 0.0, module_count: modules.len(),
609 cache_hits: 0,
610 cache_misses: 0,
611 cache_update_ms: 0.0,
612 entry_points_ms,
613 entry_point_count: entry_points.all.len(),
614 resolve_imports_ms: resolve_ms,
615 build_graph_ms: graph_ms,
616 analyze_ms,
617 duplication_ms: None,
618 total_ms,
619 });
620
621 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
622 .iter()
623 .filter_map(|module| {
624 files
625 .get(module.file_id.0 as usize)
626 .map(|file| (file.path.clone(), module.content_hash))
627 })
628 .collect();
629
630 Ok(AnalysisOutput {
631 results: result,
632 timings,
633 graph: Some(graph),
634 modules: None,
635 files: None,
636 script_used_packages: plugin_result.script_used_packages.clone(),
637 file_hashes,
638 })
639}
640
641#[expect(
642 clippy::too_many_lines,
643 reason = "main pipeline function; sequential phases are held together for clarity"
644)]
645fn analyze_full(
646 config: &ResolvedConfig,
647 retain: bool,
648 collect_usages: bool,
649 need_complexity: bool,
650 retain_modules: bool,
651) -> Result<AnalysisOutput, FallowError> {
652 let _span = tracing::info_span!("fallow_analyze").entered();
653 let pipeline_start = Instant::now();
654
655 let show_progress = !config.quiet
656 && std::io::IsTerminal::is_terminal(&std::io::stderr())
657 && matches!(
658 config.output,
659 fallow_config::OutputFormat::Human
660 | fallow_config::OutputFormat::Compact
661 | fallow_config::OutputFormat::Markdown
662 );
663 let progress = progress::AnalysisProgress::new(show_progress);
664
665 if !config.root.join("node_modules").is_dir() {
666 tracing::warn!(
667 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
668 );
669 }
670
671 let t = Instant::now();
672 let workspaces_vec = discover_workspaces(&config.root);
673 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
674 if !workspaces_vec.is_empty() {
675 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
676 }
677
678 warn_undeclared_workspaces(
679 &config.root,
680 &workspaces_vec,
681 &config.ignore_patterns,
682 config.quiet,
683 );
684 let root_pkg = load_root_package_json(config);
685 let discovery_hidden_dir_scopes =
686 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
687
688 let t = Instant::now();
689 progress.set_stage("discovering files...");
690 let discovered_files =
691 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
692 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
693
694 let project = project::ProjectState::new(discovered_files, workspaces_vec);
695 let files = project.files();
696 let workspaces = project.workspaces();
697 let workspace_pkgs = load_workspace_packages(workspaces);
698
699 let t = Instant::now();
700 progress.set_stage("detecting plugins...");
701 let mut plugin_result = run_plugins(
702 config,
703 files,
704 workspaces,
705 root_pkg.as_ref(),
706 &workspace_pkgs,
707 )?;
708 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
709
710 let t = Instant::now();
711 analyze_all_scripts(
712 config,
713 workspaces,
714 root_pkg.as_ref(),
715 &workspace_pkgs,
716 &mut plugin_result,
717 );
718 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
719
720 let t = Instant::now();
721 progress.set_stage(&format!("parsing {} files...", files.len()));
722 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
723 let mut cache_store = if config.no_cache {
724 None
725 } else {
726 cache::CacheStore::load(
727 &config.cache_dir,
728 config.cache_config_hash,
729 cache_max_size_bytes,
730 )
731 };
732
733 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
734 let mut modules = parse_result.modules;
735 let cache_hits = parse_result.cache_hits;
736 let cache_misses = parse_result.cache_misses;
737 let parse_cpu_ms = parse_result.parse_cpu_ms;
738 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
739
740 let t = Instant::now();
741 if !config.no_cache {
742 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
743 update_cache(store, &modules, files);
744 if let Err(e) = store.save(
745 &config.cache_dir,
746 config.cache_config_hash,
747 cache_max_size_bytes,
748 ) {
749 tracing::warn!("Failed to save cache: {e}");
750 }
751 }
752 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
753
754 let t = Instant::now();
755 let entry_points = discover_all_entry_points(
756 config,
757 files,
758 workspaces,
759 root_pkg.as_ref(),
760 &workspace_pkgs,
761 &plugin_result,
762 );
763 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
764
765 let t = Instant::now();
766 progress.set_stage("resolving imports...");
767 let mut resolved = resolve::resolve_all_imports(
768 &modules,
769 files,
770 workspaces,
771 &plugin_result.active_plugins,
772 &plugin_result.path_aliases,
773 &plugin_result.auto_imports,
774 &plugin_result.scss_include_paths,
775 &plugin_result.static_dir_mappings,
776 &config.root,
777 &config.resolve.conditions,
778 );
779 external_style_usage::augment_external_style_package_usage(
780 &mut resolved,
781 config,
782 workspaces,
783 &plugin_result,
784 );
785 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
786
787 let t = Instant::now();
788 progress.set_stage("building module graph...");
789 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
790 &resolved,
791 &entry_points.all,
792 &entry_points.runtime,
793 &entry_points.test,
794 files,
795 );
796 credit_package_path_references(&mut graph, &modules);
797 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
798 for module in &mut modules {
799 module.release_resolution_payload();
800 }
801 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
802
803 let ep_summary = summarize_entry_points(&entry_points.all);
804
805 let t = Instant::now();
806 progress.set_stage("analyzing...");
807 #[expect(
808 deprecated,
809 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
810 )]
811 let mut result = analyze::find_dead_code_full(
812 &graph,
813 config,
814 &resolved,
815 Some(&plugin_result),
816 workspaces,
817 &modules,
818 collect_usages,
819 );
820 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
821 progress.finish();
822
823 result.entry_point_summary = Some(ep_summary);
824
825 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
826
827 let cache_summary = if cache_hits > 0 {
828 format!(" ({cache_hits} cached, {cache_misses} parsed)")
829 } else {
830 String::new()
831 };
832
833 tracing::debug!(
834 "\n┌─ Pipeline Profile ─────────────────────────────\n\
835 │ discover files: {:>8.1}ms ({} files)\n\
836 │ workspaces: {:>8.1}ms\n\
837 │ plugins: {:>8.1}ms\n\
838 │ script analysis: {:>8.1}ms\n\
839 │ parse/extract: {:>8.1}ms ({} modules{})\n\
840 │ cache update: {:>8.1}ms\n\
841 │ entry points: {:>8.1}ms ({} entries)\n\
842 │ resolve imports: {:>8.1}ms\n\
843 │ build graph: {:>8.1}ms\n\
844 │ analyze: {:>8.1}ms\n\
845 │ ────────────────────────────────────────────\n\
846 │ TOTAL: {:>8.1}ms\n\
847 └─────────────────────────────────────────────────",
848 discover_ms,
849 files.len(),
850 workspaces_ms,
851 plugins_ms,
852 scripts_ms,
853 parse_ms,
854 modules.len(),
855 cache_summary,
856 cache_ms,
857 entry_points_ms,
858 entry_points.all.len(),
859 resolve_ms,
860 graph_ms,
861 analyze_ms,
862 total_ms,
863 );
864
865 let timings = if retain {
866 Some(PipelineTimings {
867 discover_files_ms: discover_ms,
868 file_count: files.len(),
869 workspaces_ms,
870 workspace_count: workspaces.len(),
871 plugins_ms,
872 script_analysis_ms: scripts_ms,
873 parse_extract_ms: parse_ms,
874 parse_cpu_ms,
875 module_count: modules.len(),
876 cache_hits,
877 cache_misses,
878 cache_update_ms: cache_ms,
879 entry_points_ms,
880 entry_point_count: entry_points.all.len(),
881 resolve_imports_ms: resolve_ms,
882 build_graph_ms: graph_ms,
883 analyze_ms,
884 duplication_ms: None,
885 total_ms,
886 })
887 } else {
888 None
889 };
890
891 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
892 .iter()
893 .filter_map(|module| {
894 files
895 .get(module.file_id.0 as usize)
896 .map(|file| (file.path.clone(), module.content_hash))
897 })
898 .collect();
899
900 Ok(AnalysisOutput {
901 results: result,
902 timings,
903 graph: if retain { Some(graph) } else { None },
904 modules: if retain_modules { Some(modules) } else { None },
905 files: if retain_modules {
906 Some(files.to_vec())
907 } else {
908 None
909 },
910 script_used_packages: plugin_result.script_used_packages,
911 file_hashes,
912 })
913}
914
915fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
920 PackageJson::load(&config.root.join("package.json")).ok()
921}
922
923fn load_workspace_packages(
924 workspaces: &[fallow_config::WorkspaceInfo],
925) -> Vec<LoadedWorkspacePackage<'_>> {
926 workspaces
927 .iter()
928 .filter_map(|ws| {
929 PackageJson::load(&ws.root.join("package.json"))
930 .ok()
931 .map(|pkg| (ws, pkg))
932 })
933 .collect()
934}
935
936fn analyze_all_scripts(
937 config: &ResolvedConfig,
938 workspaces: &[fallow_config::WorkspaceInfo],
939 root_pkg: Option<&PackageJson>,
940 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
941 plugin_result: &mut plugins::AggregatedPluginResult,
942) {
943 let mut all_dep_names: Vec<String> = Vec::new();
944 if let Some(pkg) = root_pkg {
945 all_dep_names.extend(pkg.all_dependency_names());
946 }
947 for (_, ws_pkg) in workspace_pkgs {
948 all_dep_names.extend(ws_pkg.all_dependency_names());
949 }
950 all_dep_names.sort_unstable();
951 all_dep_names.dedup();
952 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
953 let mut all_script_names: FxHashSet<String> = FxHashSet::default();
954 if let Some(pkg) = root_pkg
955 && let Some(ref pkg_scripts) = pkg.scripts
956 {
957 all_script_names.extend(pkg_scripts.keys().cloned());
958 }
959 for (_, ws_pkg) in workspace_pkgs {
960 if let Some(ref ws_scripts) = ws_pkg.scripts {
961 all_script_names.extend(ws_scripts.keys().cloned());
962 }
963 }
964
965 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
966 if config.root.join("node_modules").is_dir() {
967 nm_roots.push(&config.root);
968 }
969 for ws in workspaces {
970 if ws.root.join("node_modules").is_dir() {
971 nm_roots.push(&ws.root);
972 }
973 }
974 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
975
976 if let Some(pkg) = root_pkg
977 && let Some(ref pkg_scripts) = pkg.scripts
978 {
979 let scripts_to_analyze = if config.production {
980 scripts::filter_production_scripts(pkg_scripts)
981 } else {
982 pkg_scripts.clone()
983 };
984 let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
985 let script_analysis = scripts::analyze_scripts_with_dependency_context(
986 &scripts_to_analyze,
987 &config.root,
988 &bin_map,
989 &all_dep_set,
990 &script_names,
991 );
992 plugin_result.script_used_packages = script_analysis.used_packages;
993
994 for config_file in &script_analysis.config_files {
995 plugin_result
996 .discovered_always_used
997 .push((config_file.clone(), "scripts".to_string()));
998 }
999 for entry in &script_analysis.entry_files {
1000 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1001 plugin_result
1002 .entry_patterns
1003 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1004 }
1005 }
1006 }
1007 use rayon::prelude::*;
1008 type WsScriptOut = (
1009 Vec<String>,
1010 Vec<(String, String)>,
1011 Vec<(plugins::PathRule, String)>,
1012 );
1013 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1014 .par_iter()
1015 .map(|(ws, ws_pkg)| {
1016 let mut used_packages = Vec::new();
1017 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1018 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1019 if let Some(ref ws_scripts) = ws_pkg.scripts {
1020 let scripts_to_analyze = if config.production {
1021 scripts::filter_production_scripts(ws_scripts)
1022 } else {
1023 ws_scripts.clone()
1024 };
1025 let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
1026 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1027 &scripts_to_analyze,
1028 &ws.root,
1029 &bin_map,
1030 &all_dep_set,
1031 &script_names,
1032 );
1033 used_packages.extend(ws_analysis.used_packages);
1034
1035 let ws_prefix = ws
1036 .root
1037 .strip_prefix(&config.root)
1038 .unwrap_or(&ws.root)
1039 .to_string_lossy();
1040 for config_file in &ws_analysis.config_files {
1041 discovered_always_used
1042 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1043 }
1044 for entry in &ws_analysis.entry_files {
1045 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1046 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1047 }
1048 }
1049 }
1050 (used_packages, discovered_always_used, entry_patterns)
1051 })
1052 .collect();
1053 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1054 plugin_result.script_used_packages.extend(used_packages);
1055 plugin_result
1056 .discovered_always_used
1057 .extend(discovered_always_used);
1058 plugin_result.entry_patterns.extend(entry_patterns);
1059 }
1060
1061 let ci_analysis =
1062 scripts::ci::analyze_ci_files(&config.root, &bin_map, &all_dep_set, &all_script_names);
1063 plugin_result
1064 .script_used_packages
1065 .extend(ci_analysis.used_packages);
1066 for entry in &ci_analysis.entry_files {
1067 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1068 plugin_result
1069 .entry_patterns
1070 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1071 }
1072 }
1073 plugin_result
1074 .entry_point_roles
1075 .entry("scripts".to_string())
1076 .or_insert(EntryPointRole::Support);
1077}
1078
1079fn discover_all_entry_points(
1081 config: &ResolvedConfig,
1082 files: &[discover::DiscoveredFile],
1083 workspaces: &[fallow_config::WorkspaceInfo],
1084 root_pkg: Option<&PackageJson>,
1085 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1086 plugin_result: &plugins::AggregatedPluginResult,
1087) -> discover::CategorizedEntryPoints {
1088 let mut entry_points = discover::CategorizedEntryPoints::default();
1089 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1090 config,
1091 files,
1092 root_pkg,
1093 workspaces.is_empty(),
1094 );
1095
1096 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1097 workspace_pkgs
1098 .iter()
1099 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1100 .collect();
1101
1102 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1103 .par_iter()
1104 .map(|ws| {
1105 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1106 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1107 })
1108 .collect();
1109 let mut skipped_entries = rustc_hash::FxHashMap::default();
1110 entry_points.extend_runtime(root_discovery.entries);
1111 for (path, count) in root_discovery.skipped_entries {
1112 *skipped_entries.entry(path).or_insert(0) += count;
1113 }
1114 let mut ws_entries = Vec::new();
1115 for workspace in workspace_discovery {
1116 ws_entries.extend(workspace.entries);
1117 for (path, count) in workspace.skipped_entries {
1118 *skipped_entries.entry(path).or_insert(0) += count;
1119 }
1120 }
1121 discover::warn_skipped_entry_summary(&skipped_entries);
1122 entry_points.extend_runtime(ws_entries);
1123
1124 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1125 entry_points.extend(plugin_entries);
1126
1127 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1128 entry_points.extend_runtime(infra_entries);
1129
1130 if !config.dynamically_loaded.is_empty() {
1131 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1132 entry_points.extend_runtime(dynamic_entries);
1133 }
1134
1135 entry_points.dedup()
1136}
1137
1138fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1140 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1141 for ep in entry_points {
1142 let category = match &ep.source {
1143 discover::EntryPointSource::PackageJsonMain
1144 | discover::EntryPointSource::PackageJsonModule
1145 | discover::EntryPointSource::PackageJsonExports
1146 | discover::EntryPointSource::PackageJsonBin
1147 | discover::EntryPointSource::PackageJsonScript => "package.json",
1148 discover::EntryPointSource::Plugin { .. } => "plugin",
1149 discover::EntryPointSource::TestFile => "test file",
1150 discover::EntryPointSource::DefaultIndex => "default index",
1151 discover::EntryPointSource::ManualEntry => "manual entry",
1152 discover::EntryPointSource::InfrastructureConfig => "config",
1153 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1154 };
1155 *counts.entry(category.to_string()).or_insert(0) += 1;
1156 }
1157 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1158 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1159 results::EntryPointSummary {
1160 total: entry_points.len(),
1161 by_source,
1162 }
1163}
1164
1165fn append_package_file_asset_patterns(
1166 result: &mut plugins::AggregatedPluginResult,
1167 prefix: &str,
1168 pkg: &PackageJson,
1169) {
1170 let prefix = prefix.trim_matches('/');
1171 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1172 let pattern = if prefix.is_empty() {
1173 pattern
1174 } else {
1175 format!("{prefix}/{pattern}")
1176 };
1177 result
1178 .discovered_always_used
1179 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1180 }
1181}
1182
1183fn append_workspace_package_file_asset_patterns(
1184 result: &mut plugins::AggregatedPluginResult,
1185 config: &ResolvedConfig,
1186 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1187) {
1188 for (ws, ws_pkg) in workspace_pkgs {
1189 let ws_prefix = ws
1190 .root
1191 .strip_prefix(&config.root)
1192 .unwrap_or(&ws.root)
1193 .to_string_lossy()
1194 .replace('\\', "/");
1195 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1196 }
1197}
1198
1199fn run_plugins(
1201 config: &ResolvedConfig,
1202 files: &[discover::DiscoveredFile],
1203 workspaces: &[fallow_config::WorkspaceInfo],
1204 root_pkg: Option<&PackageJson>,
1205 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1206) -> Result<plugins::AggregatedPluginResult, FallowError> {
1207 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1208 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1209 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1210 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1211 .iter()
1212 .map(std::path::PathBuf::as_path)
1213 .collect();
1214
1215 let mut result = if let Some(pkg) = root_pkg {
1216 registry
1217 .try_run_with_search_roots(
1218 pkg,
1219 &config.root,
1220 &file_paths,
1221 &root_config_search_root_refs,
1222 config.production,
1223 )
1224 .map_err(|errors| {
1225 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
1226 })?
1227 } else {
1228 plugins::AggregatedPluginResult::default()
1229 };
1230 if let Some(pkg) = root_pkg {
1231 append_package_file_asset_patterns(&mut result, "", pkg);
1232 }
1233
1234 if workspaces.is_empty() {
1235 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1236 return Ok(result);
1237 }
1238
1239 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1240
1241 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1242 result.active_plugins.iter().map(String::as_str).collect();
1243
1244 let precompiled_matchers = registry.precompile_config_matchers();
1245 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1246
1247 let ws_results: Vec<_> = workspace_pkgs
1248 .par_iter()
1249 .zip(workspace_relative_files.par_iter())
1250 .filter_map(|((ws, ws_pkg), relative_files)| {
1251 let ws_result = match registry.try_run_workspace_fast(
1252 ws_pkg,
1253 &ws.root,
1254 &config.root,
1255 &precompiled_matchers,
1256 relative_files,
1257 &root_active_plugins,
1258 config.production,
1259 ) {
1260 Ok(result) => result,
1261 Err(errors) => return Some(Err(errors)),
1262 };
1263 if ws_result.active_plugins.is_empty() {
1264 return None;
1265 }
1266 let ws_prefix = ws
1267 .root
1268 .strip_prefix(&config.root)
1269 .unwrap_or(&ws.root)
1270 .to_string_lossy()
1271 .into_owned();
1272 Some(Ok((ws_result, ws_prefix)))
1273 })
1274 .collect::<Vec<_>>();
1275
1276 let mut regex_errors = Vec::new();
1277 for ws_result in ws_results {
1278 match ws_result {
1279 Ok((mut ws_result, ws_prefix)) => {
1280 ws_result.apply_workspace_prefix(&ws_prefix);
1281 ws_result.config_patterns.clear();
1282 ws_result.script_used_packages.clear();
1283 result.merge_into(ws_result);
1284 }
1285 Err(mut errors) => regex_errors.append(&mut errors),
1286 }
1287 }
1288 if !regex_errors.is_empty() {
1289 return Err(FallowError::config(
1290 plugins::registry::format_plugin_regex_errors(®ex_errors),
1291 ));
1292 }
1293
1294 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1295
1296 Ok(result)
1297}
1298
1299fn gate_auto_import_entry_patterns(
1305 result: &mut plugins::AggregatedPluginResult,
1306 config: &ResolvedConfig,
1307 workspaces: &[fallow_config::WorkspaceInfo],
1308) {
1309 if !config.auto_imports {
1310 return;
1311 }
1312 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1313 return;
1314 }
1315 let components_custom = plugins::nuxt::config_declares_components(&config.root)
1316 || workspaces
1317 .iter()
1318 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
1319 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
1320 || workspaces
1321 .iter()
1322 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
1323 result.entry_patterns.retain(|(rule, plugin)| {
1324 if plugin != "nuxt" {
1325 return true;
1326 }
1327 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
1328 return false;
1329 }
1330 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
1331 return false;
1332 }
1333 true
1334 });
1335}
1336
1337fn bucket_files_by_workspace(
1338 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1339 file_paths: &[std::path::PathBuf],
1340) -> Vec<Vec<(std::path::PathBuf, String)>> {
1341 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1342
1343 for file_path in file_paths {
1344 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1345 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1346 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1347 break;
1348 }
1349 }
1350 }
1351
1352 buckets
1353}
1354
1355fn collect_config_search_roots(
1356 root: &Path,
1357 file_paths: &[std::path::PathBuf],
1358) -> Vec<std::path::PathBuf> {
1359 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1360 roots.insert(root.to_path_buf());
1361
1362 for file_path in file_paths {
1363 let mut current = file_path.parent();
1364 while let Some(dir) = current {
1365 if !dir.starts_with(root) {
1366 break;
1367 }
1368 roots.insert(dir.to_path_buf());
1369 if dir == root {
1370 break;
1371 }
1372 current = dir.parent();
1373 }
1374 }
1375
1376 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1377 roots_vec.sort();
1378 roots_vec
1379}
1380
1381#[deprecated(
1387 since = "2.76.0",
1388 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."
1389)]
1390pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1391 let config = default_config(root);
1392 #[expect(
1393 deprecated,
1394 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1395 )]
1396 analyze_with_usages(&config)
1397}
1398
1399pub fn config_for_project(
1407 root: &Path,
1408 config_path: Option<&Path>,
1409) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1410 let user_config = if let Some(path) = config_path {
1411 Some((
1412 fallow_config::FallowConfig::load(path)
1413 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1414 path.to_path_buf(),
1415 ))
1416 } else {
1417 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1418 };
1419
1420 let config = match user_config {
1421 Some((mut config, path)) => {
1422 let dead_code_production = config
1423 .production
1424 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1425 config.production = dead_code_production.into();
1426 config
1427 .validate_resolved_boundaries(root)
1428 .map_err(|errors| {
1429 let joined = errors
1430 .iter()
1431 .map(ToString::to_string)
1432 .collect::<Vec<_>>()
1433 .join("\n - ");
1434 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
1435 })?;
1436 fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
1437 let joined = errors
1438 .iter()
1439 .map(ToString::to_string)
1440 .collect::<Vec<_>>()
1441 .join("\n - ");
1442 FallowError::config(format!("invalid rule pack:\n - {joined}"))
1443 })?;
1444 (
1445 config.resolve(
1446 root.to_path_buf(),
1447 fallow_config::OutputFormat::Human,
1448 num_cpus(),
1449 false,
1450 true, None, ),
1453 Some(path),
1454 )
1455 }
1456 None => (
1457 fallow_config::FallowConfig::default().resolve(
1458 root.to_path_buf(),
1459 fallow_config::OutputFormat::Human,
1460 num_cpus(),
1461 false,
1462 true,
1463 None,
1464 ),
1465 None,
1466 ),
1467 };
1468
1469 Ok(config)
1470}
1471
1472pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1483 config_for_project(root, None).map_or_else(
1484 |_| {
1485 fallow_config::FallowConfig::default().resolve(
1486 root.to_path_buf(),
1487 fallow_config::OutputFormat::Human,
1488 num_cpus(),
1489 false,
1490 true,
1491 None,
1492 )
1493 },
1494 |(config, _)| config,
1495 )
1496}
1497
1498fn num_cpus() -> usize {
1499 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504 use super::{
1505 bucket_files_by_workspace, collect_config_search_roots,
1506 format_undeclared_workspace_warning, warn_undeclared_workspaces,
1507 };
1508 use std::path::{Path, PathBuf};
1509
1510 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1511
1512 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1513 WorkspaceDiagnostic::new(
1514 root,
1515 root.join(relative),
1516 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1517 )
1518 }
1519
1520 #[test]
1521 fn undeclared_workspace_warning_is_singular_for_one_path() {
1522 let root = Path::new("/repo");
1523 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1524 .expect("warning should be rendered");
1525
1526 assert_eq!(
1527 warning,
1528 "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."
1529 );
1530 }
1531
1532 #[test]
1533 fn undeclared_workspace_warning_summarizes_many_paths() {
1534 let root = PathBuf::from("/repo");
1535 let diagnostics = [
1536 "examples/a",
1537 "examples/b",
1538 "examples/c",
1539 "examples/d",
1540 "examples/e",
1541 "examples/f",
1542 ]
1543 .into_iter()
1544 .map(|path| diag(&root, path))
1545 .collect::<Vec<_>>();
1546
1547 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1548 .expect("warning should be rendered");
1549
1550 assert_eq!(
1551 warning,
1552 "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."
1553 );
1554 }
1555
1556 #[test]
1557 fn collect_config_search_roots_includes_file_ancestors_once() {
1558 let root = PathBuf::from("/repo");
1559 let search_roots = collect_config_search_roots(
1560 &root,
1561 &[
1562 root.join("apps/query/src/main.ts"),
1563 root.join("packages/shared/lib/index.ts"),
1564 ],
1565 );
1566
1567 assert_eq!(
1568 search_roots,
1569 vec![
1570 root.clone(),
1571 root.join("apps"),
1572 root.join("apps/query"),
1573 root.join("apps/query/src"),
1574 root.join("packages"),
1575 root.join("packages/shared"),
1576 root.join("packages/shared/lib"),
1577 ]
1578 );
1579 }
1580
1581 #[test]
1582 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1583 let root = PathBuf::from("/repo");
1584 let ui = fallow_config::WorkspaceInfo {
1585 root: root.join("apps/ui"),
1586 name: "ui".to_string(),
1587 is_internal_dependency: false,
1588 };
1589 let api = fallow_config::WorkspaceInfo {
1590 root: root.join("apps/api"),
1591 name: "api".to_string(),
1592 is_internal_dependency: false,
1593 };
1594 let workspace_pkgs = vec![
1595 (
1596 &ui,
1597 fallow_config::PackageJson {
1598 name: Some("ui".to_string()),
1599 ..Default::default()
1600 },
1601 ),
1602 (
1603 &api,
1604 fallow_config::PackageJson {
1605 name: Some("api".to_string()),
1606 ..Default::default()
1607 },
1608 ),
1609 ];
1610 let files = vec![
1611 root.join("apps/ui/vite.config.ts"),
1612 root.join("apps/ui/src/main.ts"),
1613 root.join("apps/api/src/server.ts"),
1614 root.join("tools/build.ts"),
1615 ];
1616
1617 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1618
1619 assert_eq!(
1620 buckets[0],
1621 vec![
1622 (
1623 root.join("apps/ui/vite.config.ts"),
1624 "vite.config.ts".to_string()
1625 ),
1626 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1627 ]
1628 );
1629 assert_eq!(
1630 buckets[1],
1631 vec![(
1632 root.join("apps/api/src/server.ts"),
1633 "src/server.ts".to_string()
1634 )]
1635 );
1636 }
1637
1638 #[test]
1639 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1640 let dir = tempfile::tempdir().expect("create temp dir");
1641 let pkg_good = dir.path().join("packages").join("good");
1642 let pkg_bad = dir.path().join("packages").join("bad");
1643 std::fs::create_dir_all(&pkg_good).unwrap();
1644 std::fs::create_dir_all(&pkg_bad).unwrap();
1645 std::fs::write(
1646 dir.path().join("package.json"),
1647 r#"{"workspaces": ["packages/*"]}"#,
1648 )
1649 .unwrap();
1650 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1651 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1652
1653 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1654 dir.path(),
1655 &globset::GlobSet::empty(),
1656 )
1657 .expect("root package.json is valid");
1658 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1659 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1660
1661 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1662
1663 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1664 let mut malformed = 0;
1665 let mut undeclared_for_bad = 0;
1666 for diag in &diagnostics {
1667 if matches!(
1668 diag.kind,
1669 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1670 ) && diag.path.ends_with("bad")
1671 {
1672 malformed += 1;
1673 }
1674 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1675 && diag.path.ends_with("bad")
1676 {
1677 undeclared_for_bad += 1;
1678 }
1679 }
1680 assert_eq!(
1681 malformed, 1,
1682 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1683 );
1684 assert_eq!(
1685 undeclared_for_bad, 0,
1686 "warn_undeclared_workspaces must NOT re-flag a path that already \
1687 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1688 );
1689 }
1690}