1pub mod analyze;
14pub mod cache;
15pub mod changed_files;
16pub mod churn;
17pub mod cross_reference;
18pub mod discover;
19pub mod duplicates;
20pub(crate) mod errors;
21mod external_style_usage;
22pub mod extract;
23pub mod git_env;
24pub mod plugins;
25pub(crate) mod progress;
26pub mod results;
27pub(crate) mod scripts;
28pub mod suppress;
29pub mod trace;
30
31pub use fallow_graph::graph;
33pub use fallow_graph::project;
34pub use fallow_graph::resolve;
35
36use std::path::{Path, PathBuf};
37use std::time::Instant;
38
39use errors::FallowError;
40use fallow_config::{
41 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
42 find_undeclared_workspaces_with_ignores,
43};
44use rayon::prelude::*;
45use results::AnalysisResults;
46use rustc_hash::FxHashSet;
47use trace::PipelineTimings;
48
49const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
50type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
51
52fn record_graph_package_usage(
53 graph: &mut graph::ModuleGraph,
54 package_name: &str,
55 file_id: discover::FileId,
56 is_type_only: bool,
57) {
58 graph
59 .package_usage
60 .entry(package_name.to_owned())
61 .or_default()
62 .push(file_id);
63 if is_type_only {
64 graph
65 .type_only_package_usage
66 .entry(package_name.to_owned())
67 .or_default()
68 .push(file_id);
69 }
70}
71
72fn workspace_package_name<'a>(
73 source: &str,
74 workspace_names: &'a FxHashSet<&str>,
75) -> Option<&'a str> {
76 if !resolve::is_bare_specifier(source) {
77 return None;
78 }
79 let package_name = resolve::extract_package_name(source);
80 workspace_names.get(package_name.as_str()).copied()
81}
82
83fn credit_workspace_package_usage(
84 graph: &mut graph::ModuleGraph,
85 resolved: &[resolve::ResolvedModule],
86 workspaces: &[fallow_config::WorkspaceInfo],
87) {
88 if workspaces.is_empty() {
89 return;
90 }
91
92 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
93 for module in resolved {
94 for import in module.all_resolved_imports() {
95 if matches!(import.target, resolve::ResolveResult::InternalModule(_))
96 && let Some(package_name) =
97 workspace_package_name(&import.info.source, &workspace_names)
98 {
99 record_graph_package_usage(
100 graph,
101 package_name,
102 module.file_id,
103 import.info.is_type_only,
104 );
105 }
106 }
107
108 for re_export in &module.re_exports {
109 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
110 && let Some(package_name) =
111 workspace_package_name(&re_export.info.source, &workspace_names)
112 {
113 record_graph_package_usage(
114 graph,
115 package_name,
116 module.file_id,
117 re_export.info.is_type_only,
118 );
119 }
120 }
121 }
122}
123
124pub struct AnalysisOutput {
126 pub results: AnalysisResults,
127 pub timings: Option<PipelineTimings>,
128 pub graph: Option<graph::ModuleGraph>,
129 pub modules: Option<Vec<extract::ModuleInfo>>,
132 pub files: Option<Vec<discover::DiscoveredFile>>,
134 pub script_used_packages: rustc_hash::FxHashSet<String>,
139 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
148}
149
150fn update_cache(
152 store: &mut cache::CacheStore,
153 modules: &[extract::ModuleInfo],
154 files: &[discover::DiscoveredFile],
155) {
156 for module in modules {
157 if let Some(file) = files.get(module.file_id.0 as usize) {
158 let (mt, sz) = file_mtime_and_size(&file.path);
159 if let Some(cached) = store.get_by_path_only(&file.path)
166 && cached.content_hash == module.content_hash
167 {
168 if cached.mtime_secs != mt || cached.file_size != sz {
169 let preserved_last_access = cached.last_access_secs;
170 let mut refreshed = cache::module_to_cached(module, mt, sz);
171 refreshed.last_access_secs = preserved_last_access;
172 store.insert(&file.path, refreshed);
173 }
174 continue;
175 }
176 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
177 }
178 }
179 store.retain_paths(files);
180}
181
182#[must_use]
190pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
191 config
192 .cache_max_size_mb
193 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
194 (mb as usize).saturating_mul(1024 * 1024)
195 })
196}
197
198fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
200 std::fs::metadata(path).map_or((0, 0), |m| {
201 let mt = m
202 .modified()
203 .ok()
204 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
205 .map_or(0, |d| d.as_secs());
206 (mt, m.len())
207 })
208}
209
210fn format_undeclared_workspace_warning(
211 root: &Path,
212 undeclared: &[fallow_config::WorkspaceDiagnostic],
213) -> Option<String> {
214 if undeclared.is_empty() {
215 return None;
216 }
217
218 let preview = undeclared
219 .iter()
220 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
221 .map(|diag| {
222 diag.path
223 .strip_prefix(root)
224 .unwrap_or(&diag.path)
225 .display()
226 .to_string()
227 .replace('\\', "/")
228 })
229 .collect::<Vec<_>>();
230 let remaining = undeclared
231 .len()
232 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
233 let tail = if remaining > 0 {
234 format!(" (and {remaining} more)")
235 } else {
236 String::new()
237 };
238 let noun = if undeclared.len() == 1 {
239 "directory with package.json is"
240 } else {
241 "directories with package.json are"
242 };
243 let guidance = if undeclared.len() == 1 {
244 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
245 } else {
246 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
247 };
248
249 Some(format!(
250 "{} {} not declared as {}: {}{}. {}",
251 undeclared.len(),
252 noun,
253 if undeclared.len() == 1 {
254 "a workspace"
255 } else {
256 "workspaces"
257 },
258 preview.join(", "),
259 tail,
260 guidance
261 ))
262}
263
264fn warn_undeclared_workspaces(
265 root: &Path,
266 workspaces_vec: &[fallow_config::WorkspaceInfo],
267 ignore_patterns: &globset::GlobSet,
268 quiet: bool,
269) {
270 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
271 if undeclared.is_empty() {
272 return;
273 }
274
275 let existing = fallow_config::workspace_diagnostics_for(root);
283 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
284 .iter()
285 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
286 .collect();
287 let undeclared: Vec<_> = undeclared
288 .into_iter()
289 .filter(|diag| {
290 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
291 !already_flagged.contains(&canonical)
292 })
293 .collect();
294 if undeclared.is_empty() {
295 return;
296 }
297
298 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
303
304 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
305 tracing::warn!("{message}");
306 }
307}
308
309#[deprecated(
315 since = "2.76.0",
316 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."
317)]
318pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
319 let output = analyze_full(config, false, false, false, false)?;
320 Ok(output.results)
321}
322
323#[deprecated(
329 since = "2.76.0",
330 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."
331)]
332pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
333 let output = analyze_full(config, false, true, false, false)?;
334 Ok(output.results)
335}
336
337#[deprecated(
343 since = "2.76.0",
344 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 check --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
345)]
346pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
347 analyze_full(config, true, false, false, false)
348}
349
350#[deprecated(
359 since = "2.76.0",
360 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."
361)]
362pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
363 analyze_full(config, false, false, false, false)
364}
365
366#[deprecated(
376 since = "2.76.0",
377 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."
378)]
379pub fn analyze_retaining_modules(
380 config: &ResolvedConfig,
381 need_complexity: bool,
382 retain_graph: bool,
383) -> Result<AnalysisOutput, FallowError> {
384 analyze_full(config, retain_graph, false, need_complexity, true)
385}
386
387#[allow(
398 clippy::too_many_lines,
399 reason = "pipeline orchestration stays easier to audit in one place"
400)]
401#[deprecated(
402 since = "2.76.0",
403 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."
404)]
405pub fn analyze_with_parse_result(
406 config: &ResolvedConfig,
407 modules: &[extract::ModuleInfo],
408) -> Result<AnalysisOutput, FallowError> {
409 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
410 let pipeline_start = Instant::now();
411
412 let show_progress = !config.quiet
413 && std::io::IsTerminal::is_terminal(&std::io::stderr())
414 && matches!(
415 config.output,
416 fallow_config::OutputFormat::Human
417 | fallow_config::OutputFormat::Compact
418 | fallow_config::OutputFormat::Markdown
419 );
420 let progress = progress::AnalysisProgress::new(show_progress);
421
422 if !config.root.join("node_modules").is_dir() {
423 tracing::warn!(
424 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
425 );
426 }
427
428 let t = Instant::now();
430 let workspaces_vec = discover_workspaces(&config.root);
431 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
432 if !workspaces_vec.is_empty() {
433 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
434 }
435
436 warn_undeclared_workspaces(
438 &config.root,
439 &workspaces_vec,
440 &config.ignore_patterns,
441 config.quiet,
442 );
443 let root_pkg = load_root_package_json(config);
444 let discovery_hidden_dir_scopes =
445 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
446
447 let t = Instant::now();
449 progress.set_stage("discovering files...");
450 let discovered_files =
451 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
452 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
453
454 let project = project::ProjectState::new(discovered_files, workspaces_vec);
455 let files = project.files();
456 let workspaces = project.workspaces();
457 let workspace_pkgs = load_workspace_packages(workspaces);
458
459 let t = Instant::now();
461 progress.set_stage("detecting plugins...");
462 let mut plugin_result = run_plugins(
463 config,
464 files,
465 workspaces,
466 root_pkg.as_ref(),
467 &workspace_pkgs,
468 );
469 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
470
471 let t = Instant::now();
473 analyze_all_scripts(
474 config,
475 workspaces,
476 root_pkg.as_ref(),
477 &workspace_pkgs,
478 &mut plugin_result,
479 );
480 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
481
482 let t = Instant::now();
486 let entry_points = discover_all_entry_points(
487 config,
488 files,
489 workspaces,
490 root_pkg.as_ref(),
491 &workspace_pkgs,
492 &plugin_result,
493 );
494 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
495
496 let ep_summary = summarize_entry_points(&entry_points.all);
498
499 let t = Instant::now();
501 progress.set_stage("resolving imports...");
502 let mut resolved = resolve::resolve_all_imports(
503 modules,
504 files,
505 workspaces,
506 &plugin_result.active_plugins,
507 &plugin_result.path_aliases,
508 &plugin_result.scss_include_paths,
509 &config.root,
510 &config.resolve.conditions,
511 );
512 external_style_usage::augment_external_style_package_usage(
513 &mut resolved,
514 config,
515 workspaces,
516 &plugin_result,
517 );
518 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
519
520 let t = Instant::now();
522 progress.set_stage("building module graph...");
523 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
524 &resolved,
525 &entry_points.all,
526 &entry_points.runtime,
527 &entry_points.test,
528 files,
529 );
530 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
531 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
532
533 let t = Instant::now();
535 progress.set_stage("analyzing...");
536 #[expect(
537 deprecated,
538 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
539 )]
540 let mut result = analyze::find_dead_code_full(
541 &graph,
542 config,
543 &resolved,
544 Some(&plugin_result),
545 workspaces,
546 modules,
547 false,
548 );
549 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
550 progress.finish();
551
552 result.entry_point_summary = Some(ep_summary);
553
554 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
555
556 tracing::debug!(
557 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
558 │ discover files: {:>8.1}ms ({} files)\n\
559 │ workspaces: {:>8.1}ms\n\
560 │ plugins: {:>8.1}ms\n\
561 │ script analysis: {:>8.1}ms\n\
562 │ parse/extract: SKIPPED (reused {} modules)\n\
563 │ entry points: {:>8.1}ms ({} entries)\n\
564 │ resolve imports: {:>8.1}ms\n\
565 │ build graph: {:>8.1}ms\n\
566 │ analyze: {:>8.1}ms\n\
567 │ ────────────────────────────────────────────\n\
568 │ TOTAL: {:>8.1}ms\n\
569 └─────────────────────────────────────────────────",
570 discover_ms,
571 files.len(),
572 workspaces_ms,
573 plugins_ms,
574 scripts_ms,
575 modules.len(),
576 entry_points_ms,
577 entry_points.all.len(),
578 resolve_ms,
579 graph_ms,
580 analyze_ms,
581 total_ms,
582 );
583
584 let timings = Some(PipelineTimings {
585 discover_files_ms: discover_ms,
586 file_count: files.len(),
587 workspaces_ms,
588 workspace_count: workspaces.len(),
589 plugins_ms,
590 script_analysis_ms: scripts_ms,
591 parse_extract_ms: 0.0, module_count: modules.len(),
593 cache_hits: 0,
594 cache_misses: 0,
595 cache_update_ms: 0.0,
596 entry_points_ms,
597 entry_point_count: entry_points.all.len(),
598 resolve_imports_ms: resolve_ms,
599 build_graph_ms: graph_ms,
600 analyze_ms,
601 duplication_ms: None,
602 total_ms,
603 });
604
605 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
606 .iter()
607 .filter_map(|module| {
608 files
609 .get(module.file_id.0 as usize)
610 .map(|file| (file.path.clone(), module.content_hash))
611 })
612 .collect();
613
614 Ok(AnalysisOutput {
615 results: result,
616 timings,
617 graph: Some(graph),
618 modules: None,
619 files: None,
620 script_used_packages: plugin_result.script_used_packages.clone(),
621 file_hashes,
622 })
623}
624
625#[expect(
626 clippy::unnecessary_wraps,
627 reason = "Result kept for future error handling"
628)]
629#[expect(
630 clippy::too_many_lines,
631 reason = "main pipeline function; sequential phases are held together for clarity"
632)]
633fn analyze_full(
634 config: &ResolvedConfig,
635 retain: bool,
636 collect_usages: bool,
637 need_complexity: bool,
638 retain_modules: bool,
639) -> Result<AnalysisOutput, FallowError> {
640 let _span = tracing::info_span!("fallow_analyze").entered();
641 let pipeline_start = Instant::now();
642
643 let show_progress = !config.quiet
647 && std::io::IsTerminal::is_terminal(&std::io::stderr())
648 && matches!(
649 config.output,
650 fallow_config::OutputFormat::Human
651 | fallow_config::OutputFormat::Compact
652 | fallow_config::OutputFormat::Markdown
653 );
654 let progress = progress::AnalysisProgress::new(show_progress);
655
656 if !config.root.join("node_modules").is_dir() {
658 tracing::warn!(
659 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
660 );
661 }
662
663 let t = Instant::now();
665 let workspaces_vec = discover_workspaces(&config.root);
666 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
667 if !workspaces_vec.is_empty() {
668 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
669 }
670
671 warn_undeclared_workspaces(
673 &config.root,
674 &workspaces_vec,
675 &config.ignore_patterns,
676 config.quiet,
677 );
678 let root_pkg = load_root_package_json(config);
679 let discovery_hidden_dir_scopes =
680 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
681
682 let t = Instant::now();
684 progress.set_stage("discovering files...");
685 let discovered_files =
686 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
687 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
688
689 let project = project::ProjectState::new(discovered_files, workspaces_vec);
692 let files = project.files();
693 let workspaces = project.workspaces();
694 let workspace_pkgs = load_workspace_packages(workspaces);
695
696 let t = Instant::now();
698 progress.set_stage("detecting plugins...");
699 let mut plugin_result = run_plugins(
700 config,
701 files,
702 workspaces,
703 root_pkg.as_ref(),
704 &workspace_pkgs,
705 );
706 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
707
708 let t = Instant::now();
710 analyze_all_scripts(
711 config,
712 workspaces,
713 root_pkg.as_ref(),
714 &workspace_pkgs,
715 &mut plugin_result,
716 );
717 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
718
719 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 modules = parse_result.modules;
735 let cache_hits = parse_result.cache_hits;
736 let cache_misses = parse_result.cache_misses;
737 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
738
739 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();
756 let entry_points = discover_all_entry_points(
757 config,
758 files,
759 workspaces,
760 root_pkg.as_ref(),
761 &workspace_pkgs,
762 &plugin_result,
763 );
764 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
765
766 let t = Instant::now();
768 progress.set_stage("resolving imports...");
769 let mut resolved = resolve::resolve_all_imports(
770 &modules,
771 files,
772 workspaces,
773 &plugin_result.active_plugins,
774 &plugin_result.path_aliases,
775 &plugin_result.scss_include_paths,
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();
789 progress.set_stage("building module graph...");
790 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
791 &resolved,
792 &entry_points.all,
793 &entry_points.runtime,
794 &entry_points.test,
795 files,
796 );
797 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
798 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
799
800 let ep_summary = summarize_entry_points(&entry_points.all);
802
803 let t = Instant::now();
805 progress.set_stage("analyzing...");
806 #[expect(
807 deprecated,
808 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
809 )]
810 let mut result = analyze::find_dead_code_full(
811 &graph,
812 config,
813 &resolved,
814 Some(&plugin_result),
815 workspaces,
816 &modules,
817 collect_usages,
818 );
819 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
820 progress.finish();
821
822 result.entry_point_summary = Some(ep_summary);
823
824 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
825
826 let cache_summary = if cache_hits > 0 {
827 format!(" ({cache_hits} cached, {cache_misses} parsed)")
828 } else {
829 String::new()
830 };
831
832 tracing::debug!(
833 "\n┌─ Pipeline Profile ─────────────────────────────\n\
834 │ discover files: {:>8.1}ms ({} files)\n\
835 │ workspaces: {:>8.1}ms\n\
836 │ plugins: {:>8.1}ms\n\
837 │ script analysis: {:>8.1}ms\n\
838 │ parse/extract: {:>8.1}ms ({} modules{})\n\
839 │ cache update: {:>8.1}ms\n\
840 │ entry points: {:>8.1}ms ({} entries)\n\
841 │ resolve imports: {:>8.1}ms\n\
842 │ build graph: {:>8.1}ms\n\
843 │ analyze: {:>8.1}ms\n\
844 │ ────────────────────────────────────────────\n\
845 │ TOTAL: {:>8.1}ms\n\
846 └─────────────────────────────────────────────────",
847 discover_ms,
848 files.len(),
849 workspaces_ms,
850 plugins_ms,
851 scripts_ms,
852 parse_ms,
853 modules.len(),
854 cache_summary,
855 cache_ms,
856 entry_points_ms,
857 entry_points.all.len(),
858 resolve_ms,
859 graph_ms,
860 analyze_ms,
861 total_ms,
862 );
863
864 let timings = if retain {
865 Some(PipelineTimings {
866 discover_files_ms: discover_ms,
867 file_count: files.len(),
868 workspaces_ms,
869 workspace_count: workspaces.len(),
870 plugins_ms,
871 script_analysis_ms: scripts_ms,
872 parse_extract_ms: parse_ms,
873 module_count: modules.len(),
874 cache_hits,
875 cache_misses,
876 cache_update_ms: cache_ms,
877 entry_points_ms,
878 entry_point_count: entry_points.all.len(),
879 resolve_imports_ms: resolve_ms,
880 build_graph_ms: graph_ms,
881 analyze_ms,
882 duplication_ms: None,
883 total_ms,
884 })
885 } else {
886 None
887 };
888
889 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
890 .iter()
891 .filter_map(|module| {
892 files
893 .get(module.file_id.0 as usize)
894 .map(|file| (file.path.clone(), module.content_hash))
895 })
896 .collect();
897
898 Ok(AnalysisOutput {
899 results: result,
900 timings,
901 graph: if retain { Some(graph) } else { None },
902 modules: if retain_modules { Some(modules) } else { None },
903 files: if retain_modules {
904 Some(files.to_vec())
905 } else {
906 None
907 },
908 script_used_packages: plugin_result.script_used_packages,
909 file_hashes,
910 })
911}
912
913fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
918 PackageJson::load(&config.root.join("package.json")).ok()
919}
920
921fn load_workspace_packages(
922 workspaces: &[fallow_config::WorkspaceInfo],
923) -> Vec<LoadedWorkspacePackage<'_>> {
924 workspaces
925 .iter()
926 .filter_map(|ws| {
927 PackageJson::load(&ws.root.join("package.json"))
928 .ok()
929 .map(|pkg| (ws, pkg))
930 })
931 .collect()
932}
933
934fn analyze_all_scripts(
935 config: &ResolvedConfig,
936 workspaces: &[fallow_config::WorkspaceInfo],
937 root_pkg: Option<&PackageJson>,
938 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
939 plugin_result: &mut plugins::AggregatedPluginResult,
940) {
941 let mut all_dep_names: Vec<String> = Vec::new();
945 if let Some(pkg) = root_pkg {
946 all_dep_names.extend(pkg.all_dependency_names());
947 }
948 for (_, ws_pkg) in workspace_pkgs {
949 all_dep_names.extend(ws_pkg.all_dependency_names());
950 }
951 all_dep_names.sort_unstable();
952 all_dep_names.dedup();
953
954 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
957 if config.root.join("node_modules").is_dir() {
958 nm_roots.push(&config.root);
959 }
960 for ws in workspaces {
961 if ws.root.join("node_modules").is_dir() {
962 nm_roots.push(&ws.root);
963 }
964 }
965 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
966
967 if let Some(pkg) = root_pkg
968 && let Some(ref pkg_scripts) = pkg.scripts
969 {
970 let scripts_to_analyze = if config.production {
971 scripts::filter_production_scripts(pkg_scripts)
972 } else {
973 pkg_scripts.clone()
974 };
975 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root, &bin_map);
976 plugin_result.script_used_packages = script_analysis.used_packages;
977
978 for config_file in &script_analysis.config_files {
979 plugin_result
980 .discovered_always_used
981 .push((config_file.clone(), "scripts".to_string()));
982 }
983 for entry in &script_analysis.entry_files {
984 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
985 plugin_result
986 .entry_patterns
987 .push((plugins::PathRule::new(pat), "scripts".to_string()));
988 }
989 }
990 }
991 use rayon::prelude::*;
992 type WsScriptOut = (
993 Vec<String>,
994 Vec<(String, String)>,
995 Vec<(plugins::PathRule, String)>,
996 );
997 let ws_results: Vec<WsScriptOut> = workspace_pkgs
998 .par_iter()
999 .map(|(ws, ws_pkg)| {
1000 let mut used_packages = Vec::new();
1001 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1002 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1003 if let Some(ref ws_scripts) = ws_pkg.scripts {
1004 let scripts_to_analyze = if config.production {
1005 scripts::filter_production_scripts(ws_scripts)
1006 } else {
1007 ws_scripts.clone()
1008 };
1009 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root, &bin_map);
1010 used_packages.extend(ws_analysis.used_packages);
1011
1012 let ws_prefix = ws
1013 .root
1014 .strip_prefix(&config.root)
1015 .unwrap_or(&ws.root)
1016 .to_string_lossy();
1017 for config_file in &ws_analysis.config_files {
1018 discovered_always_used
1019 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1020 }
1021 for entry in &ws_analysis.entry_files {
1022 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1023 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1024 }
1025 }
1026 }
1027 (used_packages, discovered_always_used, entry_patterns)
1028 })
1029 .collect();
1030 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1031 plugin_result.script_used_packages.extend(used_packages);
1032 plugin_result
1033 .discovered_always_used
1034 .extend(discovered_always_used);
1035 plugin_result.entry_patterns.extend(entry_patterns);
1036 }
1037
1038 let ci_analysis = scripts::ci::analyze_ci_files(&config.root, &bin_map);
1045 plugin_result
1046 .script_used_packages
1047 .extend(ci_analysis.used_packages);
1048 for entry in &ci_analysis.entry_files {
1049 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1050 plugin_result
1051 .entry_patterns
1052 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1053 }
1054 }
1055 plugin_result
1056 .entry_point_roles
1057 .entry("scripts".to_string())
1058 .or_insert(EntryPointRole::Support);
1059}
1060
1061fn discover_all_entry_points(
1063 config: &ResolvedConfig,
1064 files: &[discover::DiscoveredFile],
1065 workspaces: &[fallow_config::WorkspaceInfo],
1066 root_pkg: Option<&PackageJson>,
1067 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1068 plugin_result: &plugins::AggregatedPluginResult,
1069) -> discover::CategorizedEntryPoints {
1070 let mut entry_points = discover::CategorizedEntryPoints::default();
1071 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1072 config,
1073 files,
1074 root_pkg,
1075 workspaces.is_empty(),
1076 );
1077
1078 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1079 workspace_pkgs
1080 .iter()
1081 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1082 .collect();
1083
1084 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1085 .par_iter()
1086 .map(|ws| {
1087 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1088 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1089 })
1090 .collect();
1091 let mut skipped_entries = rustc_hash::FxHashMap::default();
1092 entry_points.extend_runtime(root_discovery.entries);
1093 for (path, count) in root_discovery.skipped_entries {
1094 *skipped_entries.entry(path).or_insert(0) += count;
1095 }
1096 let mut ws_entries = Vec::new();
1097 for workspace in workspace_discovery {
1098 ws_entries.extend(workspace.entries);
1099 for (path, count) in workspace.skipped_entries {
1100 *skipped_entries.entry(path).or_insert(0) += count;
1101 }
1102 }
1103 discover::warn_skipped_entry_summary(&skipped_entries);
1104 entry_points.extend_runtime(ws_entries);
1105
1106 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1107 entry_points.extend(plugin_entries);
1108
1109 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1110 entry_points.extend_runtime(infra_entries);
1111
1112 if !config.dynamically_loaded.is_empty() {
1114 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1115 entry_points.extend_runtime(dynamic_entries);
1116 }
1117
1118 entry_points.dedup()
1119}
1120
1121fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1123 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1124 for ep in entry_points {
1125 let category = match &ep.source {
1126 discover::EntryPointSource::PackageJsonMain
1127 | discover::EntryPointSource::PackageJsonModule
1128 | discover::EntryPointSource::PackageJsonExports
1129 | discover::EntryPointSource::PackageJsonBin
1130 | discover::EntryPointSource::PackageJsonScript => "package.json",
1131 discover::EntryPointSource::Plugin { .. } => "plugin",
1132 discover::EntryPointSource::TestFile => "test file",
1133 discover::EntryPointSource::DefaultIndex => "default index",
1134 discover::EntryPointSource::ManualEntry => "manual entry",
1135 discover::EntryPointSource::InfrastructureConfig => "config",
1136 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1137 };
1138 *counts.entry(category.to_string()).or_insert(0) += 1;
1139 }
1140 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1141 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1142 results::EntryPointSummary {
1143 total: entry_points.len(),
1144 by_source,
1145 }
1146}
1147
1148fn run_plugins(
1150 config: &ResolvedConfig,
1151 files: &[discover::DiscoveredFile],
1152 workspaces: &[fallow_config::WorkspaceInfo],
1153 root_pkg: Option<&PackageJson>,
1154 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1155) -> plugins::AggregatedPluginResult {
1156 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1157 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1158 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1159 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1160 .iter()
1161 .map(std::path::PathBuf::as_path)
1162 .collect();
1163
1164 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1166 registry.run_with_search_roots(
1167 pkg,
1168 &config.root,
1169 &file_paths,
1170 &root_config_search_root_refs,
1171 config.production,
1172 )
1173 });
1174
1175 if workspaces.is_empty() {
1176 return result;
1177 }
1178
1179 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1180 result.active_plugins.iter().map(String::as_str).collect();
1181
1182 let precompiled_matchers = registry.precompile_config_matchers();
1186 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1187
1188 let ws_results: Vec<_> = workspace_pkgs
1190 .par_iter()
1191 .zip(workspace_relative_files.par_iter())
1192 .filter_map(|((ws, ws_pkg), relative_files)| {
1193 let ws_result = registry.run_workspace_fast(
1194 ws_pkg,
1195 &ws.root,
1196 &config.root,
1197 &precompiled_matchers,
1198 relative_files,
1199 &root_active_plugins,
1200 config.production,
1201 );
1202 if ws_result.active_plugins.is_empty() {
1203 return None;
1204 }
1205 let ws_prefix = ws
1206 .root
1207 .strip_prefix(&config.root)
1208 .unwrap_or(&ws.root)
1209 .to_string_lossy()
1210 .into_owned();
1211 Some((ws_result, ws_prefix))
1212 })
1213 .collect();
1214
1215 let mut seen_plugins: rustc_hash::FxHashSet<String> =
1218 result.active_plugins.iter().cloned().collect();
1219 let mut seen_prefixes: rustc_hash::FxHashSet<String> =
1220 result.virtual_module_prefixes.iter().cloned().collect();
1221 let mut seen_generated: rustc_hash::FxHashSet<String> =
1222 result.generated_import_patterns.iter().cloned().collect();
1223 let mut seen_suffixes: rustc_hash::FxHashSet<String> =
1224 result.virtual_package_suffixes.iter().cloned().collect();
1225
1226 fn extend_unique(
1227 target: &mut Vec<String>,
1228 seen: &mut rustc_hash::FxHashSet<String>,
1229 items: Vec<String>,
1230 ) {
1231 for item in items {
1232 if seen.insert(item.clone()) {
1233 target.push(item);
1234 }
1235 }
1236 }
1237 for (ws_result, ws_prefix) in ws_results {
1238 let prefix_if_needed = |pat: &str| -> String {
1243 if pat.starts_with(ws_prefix.as_str()) || pat.starts_with('/') {
1244 pat.to_string()
1245 } else {
1246 format!("{ws_prefix}/{pat}")
1247 }
1248 };
1249
1250 for (rule, pname) in &ws_result.entry_patterns {
1251 result
1252 .entry_patterns
1253 .push((rule.prefixed(&ws_prefix), pname.clone()));
1254 }
1255 for (plugin_name, role) in ws_result.entry_point_roles {
1256 result.entry_point_roles.entry(plugin_name).or_insert(role);
1257 }
1258 for (pat, pname) in &ws_result.always_used {
1259 result
1260 .always_used
1261 .push((prefix_if_needed(pat), pname.clone()));
1262 }
1263 for (pat, pname) in &ws_result.discovered_always_used {
1264 result
1265 .discovered_always_used
1266 .push((prefix_if_needed(pat), pname.clone()));
1267 }
1268 for (pat, pname) in &ws_result.fixture_patterns {
1269 result
1270 .fixture_patterns
1271 .push((prefix_if_needed(pat), pname.clone()));
1272 }
1273 for rule in &ws_result.used_exports {
1274 result.used_exports.push(rule.prefixed(&ws_prefix));
1275 }
1276 for plugin_name in ws_result.active_plugins {
1278 if !seen_plugins.contains(&plugin_name) {
1279 seen_plugins.insert(plugin_name.clone());
1280 result.active_plugins.push(plugin_name);
1281 }
1282 }
1283 result
1285 .referenced_dependencies
1286 .extend(ws_result.referenced_dependencies);
1287 result.setup_files.extend(ws_result.setup_files);
1288 result
1289 .tooling_dependencies
1290 .extend(ws_result.tooling_dependencies);
1291 extend_unique(
1296 &mut result.virtual_module_prefixes,
1297 &mut seen_prefixes,
1298 ws_result.virtual_module_prefixes,
1299 );
1300 extend_unique(
1301 &mut result.generated_import_patterns,
1302 &mut seen_generated,
1303 ws_result.generated_import_patterns,
1304 );
1305 extend_unique(
1306 &mut result.virtual_package_suffixes,
1307 &mut seen_suffixes,
1308 ws_result.virtual_package_suffixes,
1309 );
1310 for (prefix, replacement) in ws_result.path_aliases {
1313 result
1314 .path_aliases
1315 .push((prefix, format!("{ws_prefix}/{replacement}")));
1316 }
1317 }
1318
1319 result
1320}
1321
1322fn bucket_files_by_workspace(
1323 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1324 file_paths: &[std::path::PathBuf],
1325) -> Vec<Vec<(std::path::PathBuf, String)>> {
1326 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1327
1328 for file_path in file_paths {
1329 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1330 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1331 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1332 break;
1333 }
1334 }
1335 }
1336
1337 buckets
1338}
1339
1340fn collect_config_search_roots(
1341 root: &Path,
1342 file_paths: &[std::path::PathBuf],
1343) -> Vec<std::path::PathBuf> {
1344 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1345 roots.insert(root.to_path_buf());
1346
1347 for file_path in file_paths {
1348 let mut current = file_path.parent();
1349 while let Some(dir) = current {
1350 if !dir.starts_with(root) {
1351 break;
1352 }
1353 roots.insert(dir.to_path_buf());
1354 if dir == root {
1355 break;
1356 }
1357 current = dir.parent();
1358 }
1359 }
1360
1361 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1362 roots_vec.sort();
1363 roots_vec
1364}
1365
1366#[deprecated(
1372 since = "2.76.0",
1373 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."
1374)]
1375pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1376 let config = default_config(root);
1377 #[expect(
1378 deprecated,
1379 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1380 )]
1381 analyze_with_usages(&config)
1382}
1383
1384pub fn config_for_project(
1392 root: &Path,
1393 config_path: Option<&Path>,
1394) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1395 let user_config = if let Some(path) = config_path {
1396 Some((
1397 fallow_config::FallowConfig::load(path)
1398 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1399 path.to_path_buf(),
1400 ))
1401 } else {
1402 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1403 };
1404
1405 let config = match user_config {
1406 Some((mut config, path)) => {
1407 let dead_code_production = config
1408 .production
1409 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1410 config.production = dead_code_production.into();
1411 config
1417 .validate_resolved_boundaries(root)
1418 .map_err(|errors| {
1419 let joined = errors
1420 .iter()
1421 .map(ToString::to_string)
1422 .collect::<Vec<_>>()
1423 .join("\n - ");
1424 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
1425 })?;
1426 (
1427 config.resolve(
1428 root.to_path_buf(),
1429 fallow_config::OutputFormat::Human,
1430 num_cpus(),
1431 false,
1432 true, None, ),
1435 Some(path),
1436 )
1437 }
1438 None => (
1439 fallow_config::FallowConfig::default().resolve(
1440 root.to_path_buf(),
1441 fallow_config::OutputFormat::Human,
1442 num_cpus(),
1443 false,
1444 true,
1445 None,
1446 ),
1447 None,
1448 ),
1449 };
1450
1451 Ok(config)
1452}
1453
1454pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
1465 config_for_project(root, None).map_or_else(
1466 |_| {
1467 fallow_config::FallowConfig::default().resolve(
1468 root.to_path_buf(),
1469 fallow_config::OutputFormat::Human,
1470 num_cpus(),
1471 false,
1472 true,
1473 None,
1474 )
1475 },
1476 |(config, _)| config,
1477 )
1478}
1479
1480fn num_cpus() -> usize {
1481 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use super::{
1487 bucket_files_by_workspace, collect_config_search_roots,
1488 format_undeclared_workspace_warning, warn_undeclared_workspaces,
1489 };
1490 use std::path::{Path, PathBuf};
1491
1492 use fallow_config::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
1493
1494 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
1495 WorkspaceDiagnostic::new(
1496 root,
1497 root.join(relative),
1498 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1499 )
1500 }
1501
1502 #[test]
1503 fn undeclared_workspace_warning_is_singular_for_one_path() {
1504 let root = Path::new("/repo");
1505 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
1506 .expect("warning should be rendered");
1507
1508 assert_eq!(
1509 warning,
1510 "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."
1511 );
1512 }
1513
1514 #[test]
1515 fn undeclared_workspace_warning_summarizes_many_paths() {
1516 let root = PathBuf::from("/repo");
1517 let diagnostics = [
1518 "examples/a",
1519 "examples/b",
1520 "examples/c",
1521 "examples/d",
1522 "examples/e",
1523 "examples/f",
1524 ]
1525 .into_iter()
1526 .map(|path| diag(&root, path))
1527 .collect::<Vec<_>>();
1528
1529 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
1530 .expect("warning should be rendered");
1531
1532 assert_eq!(
1533 warning,
1534 "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."
1535 );
1536 }
1537
1538 #[test]
1539 fn collect_config_search_roots_includes_file_ancestors_once() {
1540 let root = PathBuf::from("/repo");
1541 let search_roots = collect_config_search_roots(
1542 &root,
1543 &[
1544 root.join("apps/query/src/main.ts"),
1545 root.join("packages/shared/lib/index.ts"),
1546 ],
1547 );
1548
1549 assert_eq!(
1550 search_roots,
1551 vec![
1552 root.clone(),
1553 root.join("apps"),
1554 root.join("apps/query"),
1555 root.join("apps/query/src"),
1556 root.join("packages"),
1557 root.join("packages/shared"),
1558 root.join("packages/shared/lib"),
1559 ]
1560 );
1561 }
1562
1563 #[test]
1564 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
1565 let root = PathBuf::from("/repo");
1566 let ui = fallow_config::WorkspaceInfo {
1567 root: root.join("apps/ui"),
1568 name: "ui".to_string(),
1569 is_internal_dependency: false,
1570 };
1571 let api = fallow_config::WorkspaceInfo {
1572 root: root.join("apps/api"),
1573 name: "api".to_string(),
1574 is_internal_dependency: false,
1575 };
1576 let workspace_pkgs = vec![
1577 (
1578 &ui,
1579 fallow_config::PackageJson {
1580 name: Some("ui".to_string()),
1581 ..Default::default()
1582 },
1583 ),
1584 (
1585 &api,
1586 fallow_config::PackageJson {
1587 name: Some("api".to_string()),
1588 ..Default::default()
1589 },
1590 ),
1591 ];
1592 let files = vec![
1593 root.join("apps/ui/vite.config.ts"),
1594 root.join("apps/ui/src/main.ts"),
1595 root.join("apps/api/src/server.ts"),
1596 root.join("tools/build.ts"),
1597 ];
1598
1599 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
1600
1601 assert_eq!(
1602 buckets[0],
1603 vec![
1604 (
1605 root.join("apps/ui/vite.config.ts"),
1606 "vite.config.ts".to_string()
1607 ),
1608 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
1609 ]
1610 );
1611 assert_eq!(
1612 buckets[1],
1613 vec![(
1614 root.join("apps/api/src/server.ts"),
1615 "src/server.ts".to_string()
1616 )]
1617 );
1618 }
1619
1620 #[test]
1621 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
1622 let dir = tempfile::tempdir().expect("create temp dir");
1633 let pkg_good = dir.path().join("packages").join("good");
1634 let pkg_bad = dir.path().join("packages").join("bad");
1635 std::fs::create_dir_all(&pkg_good).unwrap();
1636 std::fs::create_dir_all(&pkg_bad).unwrap();
1637 std::fs::write(
1638 dir.path().join("package.json"),
1639 r#"{"workspaces": ["packages/*"]}"#,
1640 )
1641 .unwrap();
1642 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1643 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1644
1645 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1649 dir.path(),
1650 &globset::GlobSet::empty(),
1651 )
1652 .expect("root package.json is valid");
1653 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1654 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1655
1656 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1660
1661 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1662 let mut malformed = 0;
1663 let mut undeclared_for_bad = 0;
1664 for diag in &diagnostics {
1665 if matches!(
1666 diag.kind,
1667 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1668 ) && diag.path.ends_with("bad")
1669 {
1670 malformed += 1;
1671 }
1672 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1673 && diag.path.ends_with("bad")
1674 {
1675 undeclared_for_bad += 1;
1676 }
1677 }
1678 assert_eq!(
1679 malformed, 1,
1680 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1681 );
1682 assert_eq!(
1683 undeclared_for_bad, 0,
1684 "warn_undeclared_workspaces must NOT re-flag a path that already \
1685 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1686 );
1687 }
1688}