1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
19
20pub mod analyze;
21pub mod cache;
22pub mod changed_files;
23pub mod churn;
24pub mod cross_reference;
25pub mod discover;
26pub mod duplicates;
27pub(crate) mod errors;
28mod external_style_usage;
29pub mod extract;
30pub mod git_env;
31mod package_assets;
32pub mod plugins;
33pub(crate) mod progress;
34pub mod results;
35pub(crate) mod scripts;
36pub(crate) mod spawn;
37pub mod suppress;
38pub mod trace;
39
40pub use fallow_graph::graph;
42pub use fallow_graph::project;
43pub use fallow_graph::resolve;
44
45use std::path::{Path, PathBuf};
46use std::time::Instant;
47
48use errors::FallowError;
49use fallow_config::{
50 EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
51 find_undeclared_workspaces_with_ignores,
52};
53use rayon::prelude::*;
54use results::AnalysisResults;
55use rustc_hash::FxHashSet;
56use trace::PipelineTimings;
57
58const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
59type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
60
61fn record_graph_package_usage(
62 graph: &mut graph::ModuleGraph,
63 package_name: &str,
64 file_id: discover::FileId,
65 is_type_only: bool,
66) {
67 graph
68 .package_usage
69 .entry(package_name.to_owned())
70 .or_default()
71 .push(file_id);
72 if is_type_only {
73 graph
74 .type_only_package_usage
75 .entry(package_name.to_owned())
76 .or_default()
77 .push(file_id);
78 }
79}
80
81fn workspace_package_name<'a>(
82 source: &str,
83 workspace_names: &'a FxHashSet<&str>,
84) -> Option<&'a str> {
85 if !resolve::is_bare_specifier(source) {
86 return None;
87 }
88 let package_name = resolve::extract_package_name(source);
89 workspace_names.get(package_name.as_str()).copied()
90}
91
92fn credit_workspace_package_usage(
93 graph: &mut graph::ModuleGraph,
94 resolved: &[resolve::ResolvedModule],
95 workspaces: &[fallow_config::WorkspaceInfo],
96) {
97 if workspaces.is_empty() {
98 return;
99 }
100
101 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
102 for module in resolved {
103 for import in module.all_resolved_imports() {
104 if matches!(import.target, resolve::ResolveResult::InternalModule(_))
105 && let Some(package_name) =
106 workspace_package_name(&import.info.source, &workspace_names)
107 {
108 record_graph_package_usage(
109 graph,
110 package_name,
111 module.file_id,
112 import.info.is_type_only,
113 );
114 }
115 }
116
117 for re_export in &module.re_exports {
118 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
119 && let Some(package_name) =
120 workspace_package_name(&re_export.info.source, &workspace_names)
121 {
122 record_graph_package_usage(
123 graph,
124 package_name,
125 module.file_id,
126 re_export.info.is_type_only,
127 );
128 }
129 }
130 }
131}
132
133pub struct AnalysisOutput {
135 pub results: AnalysisResults,
136 pub timings: Option<PipelineTimings>,
137 pub graph: Option<graph::ModuleGraph>,
138 pub modules: Option<Vec<extract::ModuleInfo>>,
141 pub files: Option<Vec<discover::DiscoveredFile>>,
143 pub script_used_packages: rustc_hash::FxHashSet<String>,
148 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
157}
158
159fn update_cache(
161 store: &mut cache::CacheStore,
162 modules: &[extract::ModuleInfo],
163 files: &[discover::DiscoveredFile],
164) {
165 for module in modules {
166 if let Some(file) = files.get(module.file_id.0 as usize) {
167 let (mt, sz) = file_mtime_and_size(&file.path);
168 if let Some(cached) = store.get_by_path_only(&file.path)
175 && cached.content_hash == module.content_hash
176 {
177 if cached.mtime_secs != mt || cached.file_size != sz {
178 let preserved_last_access = cached.last_access_secs;
179 let mut refreshed = cache::module_to_cached(module, mt, sz);
180 refreshed.last_access_secs = preserved_last_access;
181 store.insert(&file.path, refreshed);
182 }
183 continue;
184 }
185 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
186 }
187 }
188 store.retain_paths(files);
189}
190
191#[must_use]
199pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
200 config
201 .cache_max_size_mb
202 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
203 (mb as usize).saturating_mul(1024 * 1024)
204 })
205}
206
207fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
209 std::fs::metadata(path).map_or((0, 0), |m| {
210 let mt = m
211 .modified()
212 .ok()
213 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
214 .map_or(0, |d| d.as_secs());
215 (mt, m.len())
216 })
217}
218
219fn format_undeclared_workspace_warning(
220 root: &Path,
221 undeclared: &[fallow_config::WorkspaceDiagnostic],
222) -> Option<String> {
223 if undeclared.is_empty() {
224 return None;
225 }
226
227 let preview = undeclared
228 .iter()
229 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
230 .map(|diag| {
231 diag.path
232 .strip_prefix(root)
233 .unwrap_or(&diag.path)
234 .display()
235 .to_string()
236 .replace('\\', "/")
237 })
238 .collect::<Vec<_>>();
239 let remaining = undeclared
240 .len()
241 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
242 let tail = if remaining > 0 {
243 format!(" (and {remaining} more)")
244 } else {
245 String::new()
246 };
247 let noun = if undeclared.len() == 1 {
248 "directory with package.json is"
249 } else {
250 "directories with package.json are"
251 };
252 let guidance = if undeclared.len() == 1 {
253 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
254 } else {
255 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
256 };
257
258 Some(format!(
259 "{} {} not declared as {}: {}{}. {}",
260 undeclared.len(),
261 noun,
262 if undeclared.len() == 1 {
263 "a workspace"
264 } else {
265 "workspaces"
266 },
267 preview.join(", "),
268 tail,
269 guidance
270 ))
271}
272
273fn warn_undeclared_workspaces(
274 root: &Path,
275 workspaces_vec: &[fallow_config::WorkspaceInfo],
276 ignore_patterns: &globset::GlobSet,
277 quiet: bool,
278) {
279 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
280 if undeclared.is_empty() {
281 return;
282 }
283
284 let existing = fallow_config::workspace_diagnostics_for(root);
292 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
293 .iter()
294 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
295 .collect();
296 let undeclared: Vec<_> = undeclared
297 .into_iter()
298 .filter(|diag| {
299 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
300 !already_flagged.contains(&canonical)
301 })
302 .collect();
303 if undeclared.is_empty() {
304 return;
305 }
306
307 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
312
313 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
314 tracing::warn!("{message}");
315 }
316}
317
318#[deprecated(
324 since = "2.76.0",
325 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."
326)]
327pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
328 let output = analyze_full(config, false, false, false, false)?;
329 Ok(output.results)
330}
331
332#[deprecated(
338 since = "2.76.0",
339 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."
340)]
341pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
342 let output = analyze_full(config, false, true, false, false)?;
343 Ok(output.results)
344}
345
346#[deprecated(
352 since = "2.76.0",
353 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."
354)]
355pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
356 analyze_full(config, true, false, false, false)
357}
358
359#[deprecated(
368 since = "2.76.0",
369 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."
370)]
371pub fn analyze_with_file_hashes(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
372 analyze_full(config, false, false, false, false)
373}
374
375#[deprecated(
385 since = "2.76.0",
386 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."
387)]
388pub fn analyze_retaining_modules(
389 config: &ResolvedConfig,
390 need_complexity: bool,
391 retain_graph: bool,
392) -> Result<AnalysisOutput, FallowError> {
393 analyze_full(config, retain_graph, false, need_complexity, true)
394}
395
396#[allow(
407 clippy::too_many_lines,
408 reason = "pipeline orchestration stays easier to audit in one place"
409)]
410#[deprecated(
411 since = "2.76.0",
412 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."
413)]
414pub fn analyze_with_parse_result(
415 config: &ResolvedConfig,
416 modules: &[extract::ModuleInfo],
417) -> Result<AnalysisOutput, FallowError> {
418 let _span = tracing::info_span!("fallow_analyze_with_parse_result").entered();
419 let pipeline_start = Instant::now();
420
421 let show_progress = !config.quiet
422 && std::io::IsTerminal::is_terminal(&std::io::stderr())
423 && matches!(
424 config.output,
425 fallow_config::OutputFormat::Human
426 | fallow_config::OutputFormat::Compact
427 | fallow_config::OutputFormat::Markdown
428 );
429 let progress = progress::AnalysisProgress::new(show_progress);
430
431 if !config.root.join("node_modules").is_dir() {
432 tracing::warn!(
433 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
434 );
435 }
436
437 let t = Instant::now();
439 let workspaces_vec = discover_workspaces(&config.root);
440 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
441 if !workspaces_vec.is_empty() {
442 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
443 }
444
445 warn_undeclared_workspaces(
447 &config.root,
448 &workspaces_vec,
449 &config.ignore_patterns,
450 config.quiet,
451 );
452 let root_pkg = load_root_package_json(config);
453 let discovery_hidden_dir_scopes =
454 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
455
456 let t = Instant::now();
458 progress.set_stage("discovering files...");
459 let discovered_files =
460 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
461 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
462
463 let project = project::ProjectState::new(discovered_files, workspaces_vec);
464 let files = project.files();
465 let workspaces = project.workspaces();
466 let workspace_pkgs = load_workspace_packages(workspaces);
467
468 let t = Instant::now();
470 progress.set_stage("detecting plugins...");
471 let mut plugin_result = run_plugins(
472 config,
473 files,
474 workspaces,
475 root_pkg.as_ref(),
476 &workspace_pkgs,
477 );
478 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
479
480 let t = Instant::now();
482 analyze_all_scripts(
483 config,
484 workspaces,
485 root_pkg.as_ref(),
486 &workspace_pkgs,
487 &mut plugin_result,
488 );
489 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
490
491 let t = Instant::now();
495 let entry_points = discover_all_entry_points(
496 config,
497 files,
498 workspaces,
499 root_pkg.as_ref(),
500 &workspace_pkgs,
501 &plugin_result,
502 );
503 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
504
505 let ep_summary = summarize_entry_points(&entry_points.all);
507
508 let t = Instant::now();
510 progress.set_stage("resolving imports...");
511 let mut resolved = resolve::resolve_all_imports(
512 modules,
513 files,
514 workspaces,
515 &plugin_result.active_plugins,
516 &plugin_result.path_aliases,
517 &plugin_result.auto_imports,
518 &plugin_result.scss_include_paths,
519 &plugin_result.static_dir_mappings,
520 &config.root,
521 &config.resolve.conditions,
522 );
523 external_style_usage::augment_external_style_package_usage(
524 &mut resolved,
525 config,
526 workspaces,
527 &plugin_result,
528 );
529 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
530
531 let t = Instant::now();
533 progress.set_stage("building module graph...");
534 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
535 &resolved,
536 &entry_points.all,
537 &entry_points.runtime,
538 &entry_points.test,
539 files,
540 );
541 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
542 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
543
544 let t = Instant::now();
546 progress.set_stage("analyzing...");
547 #[expect(
548 deprecated,
549 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
550 )]
551 let mut result = analyze::find_dead_code_full(
552 &graph,
553 config,
554 &resolved,
555 Some(&plugin_result),
556 workspaces,
557 modules,
558 false,
559 );
560 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
561 progress.finish();
562
563 result.entry_point_summary = Some(ep_summary);
564
565 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
566
567 tracing::debug!(
568 "\n┌─ Pipeline Profile (reuse) ─────────────────────\n\
569 │ discover files: {:>8.1}ms ({} files)\n\
570 │ workspaces: {:>8.1}ms\n\
571 │ plugins: {:>8.1}ms\n\
572 │ script analysis: {:>8.1}ms\n\
573 │ parse/extract: SKIPPED (reused {} modules)\n\
574 │ entry points: {:>8.1}ms ({} entries)\n\
575 │ resolve imports: {:>8.1}ms\n\
576 │ build graph: {:>8.1}ms\n\
577 │ analyze: {:>8.1}ms\n\
578 │ ────────────────────────────────────────────\n\
579 │ TOTAL: {:>8.1}ms\n\
580 └─────────────────────────────────────────────────",
581 discover_ms,
582 files.len(),
583 workspaces_ms,
584 plugins_ms,
585 scripts_ms,
586 modules.len(),
587 entry_points_ms,
588 entry_points.all.len(),
589 resolve_ms,
590 graph_ms,
591 analyze_ms,
592 total_ms,
593 );
594
595 let timings = Some(PipelineTimings {
596 discover_files_ms: discover_ms,
597 file_count: files.len(),
598 workspaces_ms,
599 workspace_count: workspaces.len(),
600 plugins_ms,
601 script_analysis_ms: scripts_ms,
602 parse_extract_ms: 0.0, parse_cpu_ms: 0.0, module_count: modules.len(),
605 cache_hits: 0,
606 cache_misses: 0,
607 cache_update_ms: 0.0,
608 entry_points_ms,
609 entry_point_count: entry_points.all.len(),
610 resolve_imports_ms: resolve_ms,
611 build_graph_ms: graph_ms,
612 analyze_ms,
613 duplication_ms: None,
614 total_ms,
615 });
616
617 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
618 .iter()
619 .filter_map(|module| {
620 files
621 .get(module.file_id.0 as usize)
622 .map(|file| (file.path.clone(), module.content_hash))
623 })
624 .collect();
625
626 Ok(AnalysisOutput {
627 results: result,
628 timings,
629 graph: Some(graph),
630 modules: None,
631 files: None,
632 script_used_packages: plugin_result.script_used_packages.clone(),
633 file_hashes,
634 })
635}
636
637#[expect(
638 clippy::unnecessary_wraps,
639 reason = "Result kept for future error handling"
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
659 && std::io::IsTerminal::is_terminal(&std::io::stderr())
660 && matches!(
661 config.output,
662 fallow_config::OutputFormat::Human
663 | fallow_config::OutputFormat::Compact
664 | fallow_config::OutputFormat::Markdown
665 );
666 let progress = progress::AnalysisProgress::new(show_progress);
667
668 if !config.root.join("node_modules").is_dir() {
670 tracing::warn!(
671 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
672 );
673 }
674
675 let t = Instant::now();
677 let workspaces_vec = discover_workspaces(&config.root);
678 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
679 if !workspaces_vec.is_empty() {
680 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
681 }
682
683 warn_undeclared_workspaces(
685 &config.root,
686 &workspaces_vec,
687 &config.ignore_patterns,
688 config.quiet,
689 );
690 let root_pkg = load_root_package_json(config);
691 let discovery_hidden_dir_scopes =
692 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
693
694 let t = Instant::now();
696 progress.set_stage("discovering files...");
697 let discovered_files =
698 discover::discover_files_with_additional_hidden_dirs(config, &discovery_hidden_dir_scopes);
699 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
700
701 let project = project::ProjectState::new(discovered_files, workspaces_vec);
704 let files = project.files();
705 let workspaces = project.workspaces();
706 let workspace_pkgs = load_workspace_packages(workspaces);
707
708 let t = Instant::now();
710 progress.set_stage("detecting plugins...");
711 let mut plugin_result = run_plugins(
712 config,
713 files,
714 workspaces,
715 root_pkg.as_ref(),
716 &workspace_pkgs,
717 );
718 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
719
720 let t = Instant::now();
722 analyze_all_scripts(
723 config,
724 workspaces,
725 root_pkg.as_ref(),
726 &workspace_pkgs,
727 &mut plugin_result,
728 );
729 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
730
731 let t = Instant::now();
733 progress.set_stage(&format!("parsing {} files...", files.len()));
734 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
735 let mut cache_store = if config.no_cache {
736 None
737 } else {
738 cache::CacheStore::load(
739 &config.cache_dir,
740 config.cache_config_hash,
741 cache_max_size_bytes,
742 )
743 };
744
745 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
746 let modules = parse_result.modules;
747 let cache_hits = parse_result.cache_hits;
748 let cache_misses = parse_result.cache_misses;
749 let parse_cpu_ms = parse_result.parse_cpu_ms;
750 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
751
752 let t = Instant::now();
754 if !config.no_cache {
755 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
756 update_cache(store, &modules, files);
757 if let Err(e) = store.save(
758 &config.cache_dir,
759 config.cache_config_hash,
760 cache_max_size_bytes,
761 ) {
762 tracing::warn!("Failed to save cache: {e}");
763 }
764 }
765 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
766
767 let t = Instant::now();
769 let entry_points = discover_all_entry_points(
770 config,
771 files,
772 workspaces,
773 root_pkg.as_ref(),
774 &workspace_pkgs,
775 &plugin_result,
776 );
777 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
778
779 let t = Instant::now();
781 progress.set_stage("resolving imports...");
782 let mut resolved = resolve::resolve_all_imports(
783 &modules,
784 files,
785 workspaces,
786 &plugin_result.active_plugins,
787 &plugin_result.path_aliases,
788 &plugin_result.auto_imports,
789 &plugin_result.scss_include_paths,
790 &plugin_result.static_dir_mappings,
791 &config.root,
792 &config.resolve.conditions,
793 );
794 external_style_usage::augment_external_style_package_usage(
795 &mut resolved,
796 config,
797 workspaces,
798 &plugin_result,
799 );
800 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
801
802 let t = Instant::now();
804 progress.set_stage("building module graph...");
805 let mut graph = graph::ModuleGraph::build_with_reachability_roots(
806 &resolved,
807 &entry_points.all,
808 &entry_points.runtime,
809 &entry_points.test,
810 files,
811 );
812 credit_workspace_package_usage(&mut graph, &resolved, workspaces);
813 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
814
815 let ep_summary = summarize_entry_points(&entry_points.all);
817
818 let t = Instant::now();
820 progress.set_stage("analyzing...");
821 #[expect(
822 deprecated,
823 reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
824 )]
825 let mut result = analyze::find_dead_code_full(
826 &graph,
827 config,
828 &resolved,
829 Some(&plugin_result),
830 workspaces,
831 &modules,
832 collect_usages,
833 );
834 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
835 progress.finish();
836
837 result.entry_point_summary = Some(ep_summary);
838
839 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
840
841 let cache_summary = if cache_hits > 0 {
842 format!(" ({cache_hits} cached, {cache_misses} parsed)")
843 } else {
844 String::new()
845 };
846
847 tracing::debug!(
848 "\n┌─ Pipeline Profile ─────────────────────────────\n\
849 │ discover files: {:>8.1}ms ({} files)\n\
850 │ workspaces: {:>8.1}ms\n\
851 │ plugins: {:>8.1}ms\n\
852 │ script analysis: {:>8.1}ms\n\
853 │ parse/extract: {:>8.1}ms ({} modules{})\n\
854 │ cache update: {:>8.1}ms\n\
855 │ entry points: {:>8.1}ms ({} entries)\n\
856 │ resolve imports: {:>8.1}ms\n\
857 │ build graph: {:>8.1}ms\n\
858 │ analyze: {:>8.1}ms\n\
859 │ ────────────────────────────────────────────\n\
860 │ TOTAL: {:>8.1}ms\n\
861 └─────────────────────────────────────────────────",
862 discover_ms,
863 files.len(),
864 workspaces_ms,
865 plugins_ms,
866 scripts_ms,
867 parse_ms,
868 modules.len(),
869 cache_summary,
870 cache_ms,
871 entry_points_ms,
872 entry_points.all.len(),
873 resolve_ms,
874 graph_ms,
875 analyze_ms,
876 total_ms,
877 );
878
879 let timings = if retain {
880 Some(PipelineTimings {
881 discover_files_ms: discover_ms,
882 file_count: files.len(),
883 workspaces_ms,
884 workspace_count: workspaces.len(),
885 plugins_ms,
886 script_analysis_ms: scripts_ms,
887 parse_extract_ms: parse_ms,
888 parse_cpu_ms,
889 module_count: modules.len(),
890 cache_hits,
891 cache_misses,
892 cache_update_ms: cache_ms,
893 entry_points_ms,
894 entry_point_count: entry_points.all.len(),
895 resolve_imports_ms: resolve_ms,
896 build_graph_ms: graph_ms,
897 analyze_ms,
898 duplication_ms: None,
899 total_ms,
900 })
901 } else {
902 None
903 };
904
905 let file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64> = modules
906 .iter()
907 .filter_map(|module| {
908 files
909 .get(module.file_id.0 as usize)
910 .map(|file| (file.path.clone(), module.content_hash))
911 })
912 .collect();
913
914 Ok(AnalysisOutput {
915 results: result,
916 timings,
917 graph: if retain { Some(graph) } else { None },
918 modules: if retain_modules { Some(modules) } else { None },
919 files: if retain_modules {
920 Some(files.to_vec())
921 } else {
922 None
923 },
924 script_used_packages: plugin_result.script_used_packages,
925 file_hashes,
926 })
927}
928
929fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
934 PackageJson::load(&config.root.join("package.json")).ok()
935}
936
937fn load_workspace_packages(
938 workspaces: &[fallow_config::WorkspaceInfo],
939) -> Vec<LoadedWorkspacePackage<'_>> {
940 workspaces
941 .iter()
942 .filter_map(|ws| {
943 PackageJson::load(&ws.root.join("package.json"))
944 .ok()
945 .map(|pkg| (ws, pkg))
946 })
947 .collect()
948}
949
950fn analyze_all_scripts(
951 config: &ResolvedConfig,
952 workspaces: &[fallow_config::WorkspaceInfo],
953 root_pkg: Option<&PackageJson>,
954 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
955 plugin_result: &mut plugins::AggregatedPluginResult,
956) {
957 let mut all_dep_names: Vec<String> = Vec::new();
961 if let Some(pkg) = root_pkg {
962 all_dep_names.extend(pkg.all_dependency_names());
963 }
964 for (_, ws_pkg) in workspace_pkgs {
965 all_dep_names.extend(ws_pkg.all_dependency_names());
966 }
967 all_dep_names.sort_unstable();
968 all_dep_names.dedup();
969
970 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
973 if config.root.join("node_modules").is_dir() {
974 nm_roots.push(&config.root);
975 }
976 for ws in workspaces {
977 if ws.root.join("node_modules").is_dir() {
978 nm_roots.push(&ws.root);
979 }
980 }
981 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
982
983 if let Some(pkg) = root_pkg
984 && let Some(ref pkg_scripts) = pkg.scripts
985 {
986 let scripts_to_analyze = if config.production {
987 scripts::filter_production_scripts(pkg_scripts)
988 } else {
989 pkg_scripts.clone()
990 };
991 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root, &bin_map);
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 ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root, &bin_map);
1026 used_packages.extend(ws_analysis.used_packages);
1027
1028 let ws_prefix = ws
1029 .root
1030 .strip_prefix(&config.root)
1031 .unwrap_or(&ws.root)
1032 .to_string_lossy();
1033 for config_file in &ws_analysis.config_files {
1034 discovered_always_used
1035 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1036 }
1037 for entry in &ws_analysis.entry_files {
1038 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1039 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1040 }
1041 }
1042 }
1043 (used_packages, discovered_always_used, entry_patterns)
1044 })
1045 .collect();
1046 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1047 plugin_result.script_used_packages.extend(used_packages);
1048 plugin_result
1049 .discovered_always_used
1050 .extend(discovered_always_used);
1051 plugin_result.entry_patterns.extend(entry_patterns);
1052 }
1053
1054 let ci_analysis = scripts::ci::analyze_ci_files(&config.root, &bin_map);
1061 plugin_result
1062 .script_used_packages
1063 .extend(ci_analysis.used_packages);
1064 for entry in &ci_analysis.entry_files {
1065 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1066 plugin_result
1067 .entry_patterns
1068 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1069 }
1070 }
1071 plugin_result
1072 .entry_point_roles
1073 .entry("scripts".to_string())
1074 .or_insert(EntryPointRole::Support);
1075}
1076
1077fn discover_all_entry_points(
1079 config: &ResolvedConfig,
1080 files: &[discover::DiscoveredFile],
1081 workspaces: &[fallow_config::WorkspaceInfo],
1082 root_pkg: Option<&PackageJson>,
1083 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1084 plugin_result: &plugins::AggregatedPluginResult,
1085) -> discover::CategorizedEntryPoints {
1086 let mut entry_points = discover::CategorizedEntryPoints::default();
1087 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
1088 config,
1089 files,
1090 root_pkg,
1091 workspaces.is_empty(),
1092 );
1093
1094 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
1095 workspace_pkgs
1096 .iter()
1097 .map(|(ws, pkg)| (ws.root.clone(), pkg))
1098 .collect();
1099
1100 let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
1101 .par_iter()
1102 .map(|ws| {
1103 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
1104 discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
1105 })
1106 .collect();
1107 let mut skipped_entries = rustc_hash::FxHashMap::default();
1108 entry_points.extend_runtime(root_discovery.entries);
1109 for (path, count) in root_discovery.skipped_entries {
1110 *skipped_entries.entry(path).or_insert(0) += count;
1111 }
1112 let mut ws_entries = Vec::new();
1113 for workspace in workspace_discovery {
1114 ws_entries.extend(workspace.entries);
1115 for (path, count) in workspace.skipped_entries {
1116 *skipped_entries.entry(path).or_insert(0) += count;
1117 }
1118 }
1119 discover::warn_skipped_entry_summary(&skipped_entries);
1120 entry_points.extend_runtime(ws_entries);
1121
1122 let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
1123 entry_points.extend(plugin_entries);
1124
1125 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
1126 entry_points.extend_runtime(infra_entries);
1127
1128 if !config.dynamically_loaded.is_empty() {
1130 let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
1131 entry_points.extend_runtime(dynamic_entries);
1132 }
1133
1134 entry_points.dedup()
1135}
1136
1137fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
1139 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
1140 for ep in entry_points {
1141 let category = match &ep.source {
1142 discover::EntryPointSource::PackageJsonMain
1143 | discover::EntryPointSource::PackageJsonModule
1144 | discover::EntryPointSource::PackageJsonExports
1145 | discover::EntryPointSource::PackageJsonBin
1146 | discover::EntryPointSource::PackageJsonScript => "package.json",
1147 discover::EntryPointSource::Plugin { .. } => "plugin",
1148 discover::EntryPointSource::TestFile => "test file",
1149 discover::EntryPointSource::DefaultIndex => "default index",
1150 discover::EntryPointSource::ManualEntry => "manual entry",
1151 discover::EntryPointSource::InfrastructureConfig => "config",
1152 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
1153 };
1154 *counts.entry(category.to_string()).or_insert(0) += 1;
1155 }
1156 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
1157 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1158 results::EntryPointSummary {
1159 total: entry_points.len(),
1160 by_source,
1161 }
1162}
1163
1164fn append_package_file_asset_patterns(
1165 result: &mut plugins::AggregatedPluginResult,
1166 prefix: &str,
1167 pkg: &PackageJson,
1168) {
1169 let prefix = prefix.trim_matches('/');
1170 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
1171 let pattern = if prefix.is_empty() {
1172 pattern
1173 } else {
1174 format!("{prefix}/{pattern}")
1175 };
1176 result
1177 .discovered_always_used
1178 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
1179 }
1180}
1181
1182fn append_workspace_package_file_asset_patterns(
1183 result: &mut plugins::AggregatedPluginResult,
1184 config: &ResolvedConfig,
1185 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1186) {
1187 for (ws, ws_pkg) in workspace_pkgs {
1188 let ws_prefix = ws
1189 .root
1190 .strip_prefix(&config.root)
1191 .unwrap_or(&ws.root)
1192 .to_string_lossy()
1193 .replace('\\', "/");
1194 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
1195 }
1196}
1197
1198fn run_plugins(
1200 config: &ResolvedConfig,
1201 files: &[discover::DiscoveredFile],
1202 workspaces: &[fallow_config::WorkspaceInfo],
1203 root_pkg: Option<&PackageJson>,
1204 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1205) -> plugins::AggregatedPluginResult {
1206 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
1207 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
1208 let root_config_search_roots = collect_config_search_roots(&config.root, &file_paths);
1209 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
1210 .iter()
1211 .map(std::path::PathBuf::as_path)
1212 .collect();
1213
1214 let mut result = root_pkg.map_or_else(plugins::AggregatedPluginResult::default, |pkg| {
1216 registry.run_with_search_roots(
1217 pkg,
1218 &config.root,
1219 &file_paths,
1220 &root_config_search_root_refs,
1221 config.production,
1222 )
1223 });
1224 if let Some(pkg) = root_pkg {
1225 append_package_file_asset_patterns(&mut result, "", pkg);
1226 }
1227
1228 if workspaces.is_empty() {
1229 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1230 return result;
1231 }
1232
1233 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
1234
1235 let root_active_plugins: rustc_hash::FxHashSet<&str> =
1236 result.active_plugins.iter().map(String::as_str).collect();
1237
1238 let precompiled_matchers = registry.precompile_config_matchers();
1242 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, &file_paths);
1243
1244 let ws_results: Vec<_> = workspace_pkgs
1246 .par_iter()
1247 .zip(workspace_relative_files.par_iter())
1248 .filter_map(|((ws, ws_pkg), relative_files)| {
1249 let ws_result = registry.run_workspace_fast(
1250 ws_pkg,
1251 &ws.root,
1252 &config.root,
1253 &precompiled_matchers,
1254 relative_files,
1255 &root_active_plugins,
1256 config.production,
1257 );
1258 if ws_result.active_plugins.is_empty() {
1259 return None;
1260 }
1261 let ws_prefix = ws
1262 .root
1263 .strip_prefix(&config.root)
1264 .unwrap_or(&ws.root)
1265 .to_string_lossy()
1266 .into_owned();
1267 Some((ws_result, ws_prefix))
1268 })
1269 .collect();
1270
1271 for (mut ws_result, ws_prefix) in ws_results {
1278 ws_result.apply_workspace_prefix(&ws_prefix);
1279 ws_result.config_patterns.clear();
1300 ws_result.script_used_packages.clear();
1301 result.merge_into(ws_result);
1302 }
1303
1304 gate_auto_import_entry_patterns(&mut result, config, workspaces);
1305
1306 result
1307}
1308
1309fn gate_auto_import_entry_patterns(
1318 result: &mut plugins::AggregatedPluginResult,
1319 config: &ResolvedConfig,
1320 workspaces: &[fallow_config::WorkspaceInfo],
1321) {
1322 if !config.auto_imports {
1323 return;
1324 }
1325 if !result.active_plugins.iter().any(|name| name == "nuxt") {
1326 return;
1327 }
1328 if plugins::nuxt::config_declares_components(&config.root)
1329 || workspaces
1330 .iter()
1331 .any(|ws| plugins::nuxt::config_declares_components(&ws.root))
1332 {
1333 return;
1334 }
1335 result.entry_patterns.retain(|(rule, plugin)| {
1336 !(plugin == "nuxt" && plugins::nuxt::is_component_entry_pattern(&rule.pattern))
1337 });
1338}
1339
1340fn bucket_files_by_workspace(
1341 workspace_pkgs: &[LoadedWorkspacePackage<'_>],
1342 file_paths: &[std::path::PathBuf],
1343) -> Vec<Vec<(std::path::PathBuf, String)>> {
1344 let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
1345
1346 for file_path in file_paths {
1347 for (idx, (ws, _)) in workspace_pkgs.iter().enumerate() {
1348 if let Ok(relative) = file_path.strip_prefix(&ws.root) {
1349 buckets[idx].push((file_path.clone(), relative.to_string_lossy().into_owned()));
1350 break;
1351 }
1352 }
1353 }
1354
1355 buckets
1356}
1357
1358fn collect_config_search_roots(
1359 root: &Path,
1360 file_paths: &[std::path::PathBuf],
1361) -> Vec<std::path::PathBuf> {
1362 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
1363 roots.insert(root.to_path_buf());
1364
1365 for file_path in file_paths {
1366 let mut current = file_path.parent();
1367 while let Some(dir) = current {
1368 if !dir.starts_with(root) {
1369 break;
1370 }
1371 roots.insert(dir.to_path_buf());
1372 if dir == root {
1373 break;
1374 }
1375 current = dir.parent();
1376 }
1377 }
1378
1379 let mut roots_vec: Vec<_> = roots.into_iter().collect();
1380 roots_vec.sort();
1381 roots_vec
1382}
1383
1384#[deprecated(
1390 since = "2.76.0",
1391 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."
1392)]
1393pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
1394 let config = default_config(root);
1395 #[expect(
1396 deprecated,
1397 reason = "ADR-008: thin wrapper, internal call into the same deprecated surface"
1398 )]
1399 analyze_with_usages(&config)
1400}
1401
1402pub fn config_for_project(
1410 root: &Path,
1411 config_path: Option<&Path>,
1412) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
1413 let user_config = if let Some(path) = config_path {
1414 Some((
1415 fallow_config::FallowConfig::load(path)
1416 .map_err(|e| FallowError::config(format!("{e:#}")))?,
1417 path.to_path_buf(),
1418 ))
1419 } else {
1420 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
1421 };
1422
1423 let config = match user_config {
1424 Some((mut config, path)) => {
1425 let dead_code_production = config
1426 .production
1427 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
1428 config.production = dead_code_production.into();
1429 config
1435 .validate_resolved_boundaries(root)
1436 .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 boundary configuration:\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");
1651 let pkg_good = dir.path().join("packages").join("good");
1652 let pkg_bad = dir.path().join("packages").join("bad");
1653 std::fs::create_dir_all(&pkg_good).unwrap();
1654 std::fs::create_dir_all(&pkg_bad).unwrap();
1655 std::fs::write(
1656 dir.path().join("package.json"),
1657 r#"{"workspaces": ["packages/*"]}"#,
1658 )
1659 .unwrap();
1660 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1661 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1662
1663 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
1667 dir.path(),
1668 &globset::GlobSet::empty(),
1669 )
1670 .expect("root package.json is valid");
1671 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
1672 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
1673
1674 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
1678
1679 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1680 let mut malformed = 0;
1681 let mut undeclared_for_bad = 0;
1682 for diag in &diagnostics {
1683 if matches!(
1684 diag.kind,
1685 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1686 ) && diag.path.ends_with("bad")
1687 {
1688 malformed += 1;
1689 }
1690 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
1691 && diag.path.ends_with("bad")
1692 {
1693 undeclared_for_bad += 1;
1694 }
1695 }
1696 assert_eq!(
1697 malformed, 1,
1698 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
1699 );
1700 assert_eq!(
1701 undeclared_for_bad, 0,
1702 "warn_undeclared_workspaces must NOT re-flag a path that already \
1703 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
1704 );
1705 }
1706}