1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
13#![cfg_attr(
14 test,
15 allow(
16 clippy::unwrap_used,
17 clippy::expect_used,
18 reason = "tests use unwrap and expect to keep fixture setup concise"
19 )
20)]
21
22pub mod analyze;
23pub mod cache;
24pub mod discover;
25pub(crate) mod errors;
26mod external_style_usage;
27pub mod extract;
28pub mod git_env;
29mod package_assets;
30pub mod plugins;
31pub(crate) mod progress;
32pub mod results;
33pub(crate) mod scripts;
34#[doc(hidden)]
37pub use scripts::parse_script;
38pub mod suppress;
39
40pub use fallow_graph::cache as graph_cache;
41pub 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_with_diagnostics,
51 find_undeclared_workspaces_with_ignores,
52};
53use fallow_types::trace::PipelineTimings;
54use rayon::prelude::*;
55use results::AnalysisResults;
56use rustc_hash::FxHashSet;
57
58const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
59type LoadedWorkspacePackage = (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: &FxHashSet<&'a 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!(
105 import.target,
106 resolve::ResolveResult::InternalModule(_)
107 | resolve::ResolveResult::CommonJsInternalModule(_)
108 ) && let Some(package_name) =
109 workspace_package_name(&import.info.source, &workspace_names)
110 {
111 record_graph_package_usage(
112 graph,
113 package_name,
114 module.file_id,
115 import.info.is_type_only,
116 );
117 }
118 }
119
120 for re_export in &module.re_exports {
121 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
122 && let Some(package_name) =
123 workspace_package_name(&re_export.info.source, &workspace_names)
124 {
125 record_graph_package_usage(
126 graph,
127 package_name,
128 module.file_id,
129 re_export.info.is_type_only,
130 );
131 }
132 }
133 }
134}
135
136fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
137 for module in modules {
138 for package_name in &module.package_path_references {
139 record_graph_package_usage(graph, package_name, module.file_id, false);
140 }
141 }
142}
143
144#[doc(hidden)]
146pub struct AnalysisOutput {
147 pub results: AnalysisResults,
148 pub timings: Option<PipelineTimings>,
149 pub graph: Option<graph::ModuleGraph>,
150 pub modules: Option<Vec<extract::ModuleInfo>>,
154 pub files: Option<Vec<discover::DiscoveredFile>>,
156 pub script_used_packages: rustc_hash::FxHashSet<String>,
161 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
170}
171
172#[derive(Debug, Clone, Copy)]
175#[doc(hidden)]
176pub struct AnalysisParseMetrics {
177 parse_ms: f64,
178 cache_ms: f64,
179 cache_hits: usize,
180 cache_misses: usize,
181 parse_cpu_ms: f64,
182}
183
184fn update_cache(
186 store: &mut cache::CacheStore,
187 modules: &[extract::ModuleInfo],
188 files: &[discover::DiscoveredFile],
189) -> bool {
190 let mut dirty = false;
191 for module in modules {
192 if let Some(file) = files.get(module.file_id.0 as usize) {
193 let fingerprint = file_fingerprint(&file.path);
194 if let Some(cached) = store.get_by_path_only(&file.path)
195 && cached.content_hash == module.content_hash
196 {
197 if cached.source_fingerprint() != fingerprint {
198 let preserved_last_access = cached.last_access_secs;
199 let mut refreshed = cache::module_to_cached(module, fingerprint);
200 refreshed.last_access_secs = preserved_last_access;
201 store.insert(&file.path, refreshed);
202 dirty = true;
203 }
204 continue;
205 }
206 store.insert(&file.path, cache::module_to_cached(module, fingerprint));
207 dirty = true;
208 }
209 }
210 let removed_stale_paths = store.retain_paths(files);
211 dirty || removed_stale_paths
212}
213
214#[must_use]
222fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
223 config
224 .cache_max_size_mb
225 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
226 (mb as usize).saturating_mul(1024 * 1024)
227 })
228}
229
230fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
232 std::fs::metadata(path).map_or(
233 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
234 |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
235 )
236}
237
238fn format_undeclared_workspace_warning(
239 root: &Path,
240 undeclared: &[fallow_config::WorkspaceDiagnostic],
241) -> Option<String> {
242 if undeclared.is_empty() {
243 return None;
244 }
245
246 let preview = undeclared
247 .iter()
248 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
249 .map(|diag| {
250 diag.path
251 .strip_prefix(root)
252 .unwrap_or(&diag.path)
253 .display()
254 .to_string()
255 .replace('\\', "/")
256 })
257 .collect::<Vec<_>>();
258 let remaining = undeclared
259 .len()
260 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
261 let tail = if remaining > 0 {
262 format!(" (and {remaining} more)")
263 } else {
264 String::new()
265 };
266 let noun = if undeclared.len() == 1 {
267 "directory with package.json is"
268 } else {
269 "directories with package.json are"
270 };
271 let guidance = if undeclared.len() == 1 {
272 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
273 } else {
274 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
275 };
276
277 Some(format!(
278 "{} {} not declared as {}: {}{}. {}",
279 undeclared.len(),
280 noun,
281 if undeclared.len() == 1 {
282 "a workspace"
283 } else {
284 "workspaces"
285 },
286 preview.join(", "),
287 tail,
288 guidance
289 ))
290}
291
292fn warn_undeclared_workspaces(
293 root: &Path,
294 workspaces_vec: &[fallow_config::WorkspaceInfo],
295 ignore_patterns: &globset::GlobSet,
296 quiet: bool,
297) {
298 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
299 if undeclared.is_empty() {
300 return;
301 }
302
303 let existing = fallow_config::workspace_diagnostics_for(root);
304 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
305 .iter()
306 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
307 .collect();
308 let undeclared: Vec<_> = undeclared
309 .into_iter()
310 .filter(|diag| {
311 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
312 !already_flagged.contains(&canonical)
313 })
314 .collect();
315 if undeclared.is_empty() {
316 return;
317 }
318
319 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
320
321 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
322 tracing::warn!("{message}");
323 }
324}
325
326#[doc(hidden)]
332#[deprecated(
333 since = "2.76.0",
334 note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
335)]
336pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
337 let output = analyze_full(config, false, false, false, false)?;
338 Ok(output.results)
339}
340
341#[doc(hidden)]
347#[deprecated(
348 since = "2.76.0",
349 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
350)]
351pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
352 let output = analyze_full(config, false, true, false, false)?;
353 Ok(output.results)
354}
355
356#[doc(hidden)]
362#[deprecated(
363 since = "2.76.0",
364 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. 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."
365)]
366pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
367 analyze_full(config, true, false, false, false)
368}
369
370#[doc(hidden)]
380#[deprecated(
381 since = "2.76.0",
382 note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md."
383)]
384pub fn analyze_retaining_modules(
385 config: &ResolvedConfig,
386 need_complexity: bool,
387 retain_graph: bool,
388) -> Result<AnalysisOutput, FallowError> {
389 analyze_full(config, retain_graph, false, need_complexity, true)
390}
391
392fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
393 let show_progress = !config.quiet
394 && std::io::IsTerminal::is_terminal(&std::io::stderr())
395 && matches!(
396 config.output,
397 fallow_config::OutputFormat::Human
398 | fallow_config::OutputFormat::Compact
399 | fallow_config::OutputFormat::Markdown
400 );
401 progress::AnalysisProgress::new(show_progress)
402}
403
404fn warn_missing_node_modules(config: &ResolvedConfig) {
405 if config.root.join("node_modules").is_dir() {
406 return;
407 }
408 if fallow_config::is_deno_without_node_modules(&config.root) {
409 return;
410 }
411
412 tracing::warn!(
413 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
414 );
415}
416
417fn discover_analysis_workspaces(
418 config: &ResolvedConfig,
419) -> Result<(Vec<fallow_config::WorkspaceInfo>, f64), FallowError> {
420 let t = Instant::now();
421 let (workspaces, diagnostics) =
422 discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
423 .map_err(|error| FallowError::config(error.to_string()))?;
424 fallow_config::stash_workspace_diagnostics(&config.root, diagnostics);
425 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
426 if !workspaces.is_empty() {
427 tracing::info!(count = workspaces.len(), "workspaces discovered");
428 }
429
430 warn_undeclared_workspaces(
431 &config.root,
432 &workspaces,
433 &config.ignore_patterns,
434 config.quiet,
435 );
436
437 Ok((workspaces, workspaces_ms))
438}
439
440struct AnalysisSetup {
444 progress: progress::AnalysisProgress,
445 project: project::ProjectState,
446 root_pkg: Option<PackageJson>,
447 config_candidates: Vec<std::path::PathBuf>,
452 discover_ms: f64,
453 workspaces_ms: f64,
454}
455
456#[derive(Debug, Clone)]
462#[doc(hidden)]
463pub struct AnalysisDiscovery {
464 files: Vec<discover::DiscoveredFile>,
465 workspaces: Vec<fallow_config::WorkspaceInfo>,
466 root_pkg: Option<PackageJson>,
467 config_candidates: Vec<std::path::PathBuf>,
468 discover_ms: f64,
469 workspaces_ms: f64,
470}
471
472impl AnalysisDiscovery {
473 #[must_use]
475 pub fn from_parts(
476 files: Vec<discover::DiscoveredFile>,
477 workspaces: Vec<fallow_config::WorkspaceInfo>,
478 root_pkg: Option<PackageJson>,
479 config_candidates: Vec<std::path::PathBuf>,
480 discover_ms: f64,
481 workspaces_ms: f64,
482 ) -> Self {
483 Self {
484 files,
485 workspaces,
486 root_pkg,
487 config_candidates,
488 discover_ms,
489 workspaces_ms,
490 }
491 }
492
493 #[must_use]
495 fn files(&self) -> &[discover::DiscoveredFile] {
496 &self.files
497 }
498
499 #[must_use]
501 pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
502 &self.workspaces
503 }
504
505 #[must_use]
507 pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
508 self.files
509 }
510}
511
512pub(crate) struct AnalysisSession<'a> {
517 config: &'a ResolvedConfig,
518 pipeline_start: Instant,
519 progress: progress::AnalysisProgress,
520 project: project::ProjectState,
521 root_pkg: Option<PackageJson>,
522 config_candidates: Vec<std::path::PathBuf>,
523 discover_ms: f64,
524 workspaces_ms: f64,
525}
526
527impl<'a> AnalysisSession<'a> {
528 fn new(config: &'a ResolvedConfig) -> Result<Self, FallowError> {
529 let pipeline_start = Instant::now();
530 let AnalysisSetup {
531 progress,
532 project,
533 root_pkg,
534 config_candidates,
535 discover_ms,
536 workspaces_ms,
537 } = run_analysis_setup(config)?;
538
539 Ok(Self {
540 config,
541 pipeline_start,
542 progress,
543 project,
544 root_pkg,
545 config_candidates,
546 discover_ms,
547 workspaces_ms,
548 })
549 }
550
551 fn files(&self) -> &[discover::DiscoveredFile] {
552 self.project.files()
553 }
554
555 fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
556 self.project.workspaces()
557 }
558
559 fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage> {
560 load_workspace_packages(self.workspaces())
561 }
562
563 fn run_plugins_and_scripts(
564 &self,
565 workspace_pkgs: &[LoadedWorkspacePackage],
566 ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
567 run_plugins_and_scripts(&PluginScriptInput {
568 config: self.config,
569 progress: &self.progress,
570 files: self.files(),
571 workspaces: self.workspaces(),
572 root_pkg: self.root_pkg.as_ref(),
573 workspace_pkgs,
574 config_candidates: &self.config_candidates,
575 })
576 }
577
578 fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
579 PreludeTimings {
580 discover_ms: self.discover_ms,
581 workspaces_ms: self.workspaces_ms,
582 plugins_ms,
583 scripts_ms,
584 }
585 }
586
587 fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
588 let t = Instant::now();
589 self.progress
590 .set_stage(&format!("parsing {} files...", self.files().len()));
591 parse_analysis_modules(self.config, self.files(), need_complexity, t)
592 }
593
594 fn run_owned_core(
595 &self,
596 workspace_pkgs: &[LoadedWorkspacePackage],
597 plugin_result: &plugins::AggregatedPluginResult,
598 mut modules: Vec<extract::ModuleInfo>,
599 collect_usages: bool,
600 ) -> OwnedAnalysisCore {
601 let shared = AnalysisCoreSharedInput {
602 config: self.config,
603 progress: &self.progress,
604 files: self.files(),
605 workspaces: self.workspaces(),
606 root_pkg: self.root_pkg.as_ref(),
607 workspace_pkgs,
608 plugin_result,
609 };
610
611 let entry_points = discover_analysis_entry_points(&shared);
612 let (resolved, graph) =
613 if let Some(hit) = try_load_analysis_graph_cache(&shared, &entry_points, &modules) {
614 (
615 TimedResolvedModules {
616 project: hit.project,
617 elapsed_ms: 0.0,
618 },
619 TimedGraph {
620 graph: hit.graph,
621 elapsed_ms: hit.elapsed_ms,
622 },
623 )
624 } else {
625 let resolved = resolve_analysis_imports_timed(&shared, &modules);
626 let graph =
627 build_analysis_graph_timed(&shared, &resolved.project, &entry_points, &modules);
628 (resolved, graph)
629 };
630 release_resolution_payloads(&mut modules);
631 let analysis = analyze_dead_code_timed(
632 &shared,
633 &graph.graph,
634 &resolved.project.modules,
635 &modules,
636 collect_usages,
637 entry_points.summary,
638 );
639
640 OwnedAnalysisCore {
641 result: analysis.result,
642 graph: graph.graph,
643 modules,
644 entry_point_count: entry_points.count,
645 entry_points_ms: entry_points.elapsed_ms,
646 resolve_ms: resolved.elapsed_ms,
647 graph_ms: graph.elapsed_ms,
648 analyze_ms: analysis.elapsed_ms,
649 }
650 }
651
652 fn run_full(
653 self,
654 retain: bool,
655 collect_usages: bool,
656 need_complexity: bool,
657 retain_modules: bool,
658 ) -> Result<AnalysisOutput, FallowError> {
659 let workspace_pkgs = self.load_workspace_packages();
660 let (plugin_result, plugins_ms, scripts_ms) =
661 self.run_plugins_and_scripts(&workspace_pkgs)?;
662
663 let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
664 let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
665 self.progress.finish();
666
667 let profile = full_analysis_pipeline_profile(
668 &self.prelude_timings(plugins_ms, scripts_ms),
669 self.pipeline_start,
670 self.files(),
671 self.workspaces(),
672 &core,
673 &metrics,
674 );
675 trace_pipeline_profile(&profile);
676
677 Ok(assemble_full_output(
678 core,
679 plugin_result,
680 &profile,
681 self.files(),
682 retain,
683 retain_modules,
684 ))
685 }
686}
687
688fn run_analysis_setup(config: &ResolvedConfig) -> Result<AnalysisSetup, FallowError> {
691 let progress = new_analysis_progress(config);
692 warn_missing_node_modules(config);
693
694 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config)?;
695 let root_pkg = load_root_package_json(config);
696 let discovery_hidden_dir_scopes =
697 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
698
699 let t = Instant::now();
700 progress.set_stage("discovering files...");
701 let (discovered_files, config_candidates) =
702 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
703 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
704
705 let project = project::ProjectState::new(discovered_files, workspaces_vec);
706
707 Ok(AnalysisSetup {
708 progress,
709 project,
710 root_pkg,
711 config_candidates,
712 discover_ms,
713 workspaces_ms,
714 })
715}
716
717struct PluginScriptInput<'a> {
719 config: &'a ResolvedConfig,
720 progress: &'a progress::AnalysisProgress,
721 files: &'a [discover::DiscoveredFile],
722 workspaces: &'a [fallow_config::WorkspaceInfo],
723 root_pkg: Option<&'a PackageJson>,
724 workspace_pkgs: &'a [LoadedWorkspacePackage],
725 config_candidates: &'a [std::path::PathBuf],
726}
727
728fn run_plugins_and_scripts(
731 input: &PluginScriptInput<'_>,
732) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
733 let t = Instant::now();
734 input.progress.set_stage("detecting plugins...");
735 let mut plugin_result = run_plugins(
736 input.config,
737 input.files,
738 input.workspaces,
739 input.root_pkg,
740 input.workspace_pkgs,
741 input.config_candidates,
742 )?;
743 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
744
745 let t = Instant::now();
746 analyze_all_scripts(
747 input.config,
748 input.workspaces,
749 input.root_pkg,
750 input.workspace_pkgs,
751 &mut plugin_result,
752 );
753 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
754
755 Ok((plugin_result, plugins_ms, scripts_ms))
756}
757
758#[derive(Debug, Clone, Copy)]
760#[doc(hidden)]
761pub struct DeadCodePreludeTimings {
762 pub discover_ms: f64,
763 pub workspaces_ms: f64,
764 pub plugins_ms: f64,
765 pub scripts_ms: f64,
766}
767
768#[doc(hidden)]
773pub struct DeadCodeBackendPrelude<'a> {
774 config: &'a ResolvedConfig,
775 pipeline_start: Instant,
776 progress: progress::AnalysisProgress,
777 discovery: AnalysisDiscovery,
778 workspace_pkgs: Vec<LoadedWorkspacePackage>,
779 plugin_result: plugins::AggregatedPluginResult,
780 plugins_ms: f64,
781 scripts_ms: f64,
782}
783
784impl DeadCodeBackendPrelude<'_> {
785 #[must_use]
786 pub fn timings(&self) -> DeadCodePreludeTimings {
787 DeadCodePreludeTimings {
788 discover_ms: self.discovery.discover_ms,
789 workspaces_ms: self.discovery.workspaces_ms,
790 plugins_ms: self.plugins_ms,
791 scripts_ms: self.scripts_ms,
792 }
793 }
794
795 #[must_use]
796 pub fn elapsed_ms(&self) -> f64 {
797 self.pipeline_start.elapsed().as_secs_f64() * 1000.0
798 }
799
800 #[must_use]
801 pub fn script_used_packages(&self) -> FxHashSet<String> {
802 self.plugin_result.script_used_packages.clone()
803 }
804
805 pub fn finish(&self) {
806 self.progress.finish();
807 }
808}
809
810#[doc(hidden)]
812pub struct DeadCodeEntryPoints {
813 inner: TimedEntryPoints,
814}
815
816impl DeadCodeEntryPoints {
817 #[must_use]
818 pub fn count(&self) -> usize {
819 self.inner.count
820 }
821
822 #[must_use]
823 pub fn elapsed_ms(&self) -> f64 {
824 self.inner.elapsed_ms
825 }
826}
827
828#[doc(hidden)]
830pub struct DeadCodeResolvedModules {
831 pub project: resolve::ResolvedProject,
832 pub elapsed_ms: f64,
833}
834
835#[doc(hidden)]
837pub struct DeadCodeGraphRun {
838 pub graph: graph::ModuleGraph,
839 pub elapsed_ms: f64,
840}
841
842#[doc(hidden)]
844pub struct DeadCodeDetectorRun {
845 pub results: AnalysisResults,
846 pub elapsed_ms: f64,
847}
848
849pub fn prepare_dead_code_backend_prelude(
855 config: &ResolvedConfig,
856 discovery: AnalysisDiscovery,
857) -> Result<DeadCodeBackendPrelude<'_>, FallowError> {
858 let progress = new_analysis_progress(config);
859 let pipeline_start = Instant::now();
860 let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
861 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
862 config,
863 progress: &progress,
864 files: discovery.files(),
865 workspaces: &discovery.workspaces,
866 root_pkg: discovery.root_pkg.as_ref(),
867 workspace_pkgs: &workspace_pkgs,
868 config_candidates: &discovery.config_candidates,
869 })?;
870
871 Ok(DeadCodeBackendPrelude {
872 config,
873 pipeline_start,
874 progress,
875 discovery,
876 workspace_pkgs,
877 plugin_result,
878 plugins_ms,
879 scripts_ms,
880 })
881}
882
883#[must_use]
885pub fn discover_dead_code_entry_points(
886 prelude: &DeadCodeBackendPrelude<'_>,
887) -> DeadCodeEntryPoints {
888 let shared = prelude.shared_input();
889 DeadCodeEntryPoints {
890 inner: discover_analysis_entry_points(&shared),
891 }
892}
893
894#[must_use]
896pub fn try_load_dead_code_graph_cache(
897 prelude: &DeadCodeBackendPrelude<'_>,
898 entry_points: &DeadCodeEntryPoints,
899 modules: &[extract::ModuleInfo],
900) -> Option<(DeadCodeResolvedModules, DeadCodeGraphRun)> {
901 let shared = prelude.shared_input();
902 try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
903 (
904 DeadCodeResolvedModules {
905 project: hit.project,
906 elapsed_ms: 0.0,
907 },
908 DeadCodeGraphRun {
909 graph: hit.graph,
910 elapsed_ms: hit.elapsed_ms,
911 },
912 )
913 })
914}
915
916#[must_use]
918pub fn resolve_dead_code_imports(
919 prelude: &DeadCodeBackendPrelude<'_>,
920 modules: &[extract::ModuleInfo],
921) -> DeadCodeResolvedModules {
922 let shared = prelude.shared_input();
923 let resolved = resolve_analysis_imports_timed(&shared, modules);
924 DeadCodeResolvedModules {
925 project: resolved.project,
926 elapsed_ms: resolved.elapsed_ms,
927 }
928}
929
930#[must_use]
932pub fn build_dead_code_graph(
933 prelude: &DeadCodeBackendPrelude<'_>,
934 project: &resolve::ResolvedProject,
935 entry_points: &DeadCodeEntryPoints,
936 modules: &[extract::ModuleInfo],
937) -> DeadCodeGraphRun {
938 let shared = prelude.shared_input();
939 let graph = build_analysis_graph_timed(&shared, project, &entry_points.inner, modules);
940 DeadCodeGraphRun {
941 graph: graph.graph,
942 elapsed_ms: graph.elapsed_ms,
943 }
944}
945
946#[must_use]
948pub fn run_dead_code_detectors(
949 prelude: &DeadCodeBackendPrelude<'_>,
950 graph: &graph::ModuleGraph,
951 resolved: &[resolve::ResolvedModule],
952 modules: &[extract::ModuleInfo],
953 collect_usages: bool,
954 entry_points: &DeadCodeEntryPoints,
955) -> DeadCodeDetectorRun {
956 let shared = prelude.shared_input();
957 let analysis = analyze_dead_code_timed(
958 &shared,
959 graph,
960 resolved,
961 modules,
962 collect_usages,
963 entry_points.inner.summary.clone(),
964 );
965 DeadCodeDetectorRun {
966 results: analysis.result,
967 elapsed_ms: analysis.elapsed_ms,
968 }
969}
970
971impl<'a> DeadCodeBackendPrelude<'a> {
972 fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
973 AnalysisCoreSharedInput {
974 config: self.config,
975 progress: &self.progress,
976 files: self.discovery.files(),
977 workspaces: &self.discovery.workspaces,
978 root_pkg: self.discovery.root_pkg.as_ref(),
979 workspace_pkgs: &self.workspace_pkgs,
980 plugin_result: &self.plugin_result,
981 }
982 }
983}
984
985struct PreludeMetrics {
988 discover_ms: f64,
989 workspaces_ms: f64,
990 plugins_ms: f64,
991 scripts_ms: f64,
992 total_ms: f64,
993 file_count: usize,
994 workspace_count: usize,
995 module_count: usize,
996}
997
998#[expect(
1000 clippy::struct_field_names,
1001 reason = "timings are all milliseconds; the _ms suffix is the unit"
1002)]
1003struct PreludeTimings {
1004 discover_ms: f64,
1005 workspaces_ms: f64,
1006 plugins_ms: f64,
1007 scripts_ms: f64,
1008}
1009
1010fn prelude_metrics(
1013 timings: &PreludeTimings,
1014 pipeline_start: Instant,
1015 files: &[discover::DiscoveredFile],
1016 workspaces: &[fallow_config::WorkspaceInfo],
1017 module_count: usize,
1018) -> PreludeMetrics {
1019 PreludeMetrics {
1020 discover_ms: timings.discover_ms,
1021 workspaces_ms: timings.workspaces_ms,
1022 plugins_ms: timings.plugins_ms,
1023 scripts_ms: timings.scripts_ms,
1024 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
1025 file_count: files.len(),
1026 workspace_count: workspaces.len(),
1027 module_count,
1028 }
1029}
1030
1031struct AnalysisCoreSharedInput<'a> {
1032 config: &'a ResolvedConfig,
1033 progress: &'a progress::AnalysisProgress,
1034 files: &'a [discover::DiscoveredFile],
1035 workspaces: &'a [fallow_config::WorkspaceInfo],
1036 root_pkg: Option<&'a PackageJson>,
1037 workspace_pkgs: &'a [LoadedWorkspacePackage],
1038 plugin_result: &'a plugins::AggregatedPluginResult,
1039}
1040
1041struct TimedEntryPoints {
1042 entry_points: discover::CategorizedEntryPoints,
1043 summary: results::EntryPointSummary,
1044 count: usize,
1045 elapsed_ms: f64,
1046}
1047
1048struct TimedResolvedModules {
1049 project: resolve::ResolvedProject,
1050 elapsed_ms: f64,
1051}
1052
1053struct TimedGraph {
1054 graph: graph::ModuleGraph,
1055 elapsed_ms: f64,
1056}
1057
1058struct GraphCacheHit {
1059 graph: graph::ModuleGraph,
1060 project: resolve::ResolvedProject,
1061 elapsed_ms: f64,
1062}
1063
1064#[derive(Clone, Copy)]
1065struct DiscoverAllEntryPointsInput<'a> {
1066 config: &'a ResolvedConfig,
1067 files: &'a [discover::DiscoveredFile],
1068 workspaces: &'a [fallow_config::WorkspaceInfo],
1069 root_pkg: Option<&'a PackageJson>,
1070 workspace_pkgs: &'a [LoadedWorkspacePackage],
1071 plugin_result: &'a plugins::AggregatedPluginResult,
1072}
1073
1074struct TimedAnalysis {
1075 result: AnalysisResults,
1076 elapsed_ms: f64,
1077}
1078
1079fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1080 let t = Instant::now();
1081 let entry_points = discover_all_entry_points(DiscoverAllEntryPointsInput {
1082 config: input.config,
1083 files: input.files,
1084 workspaces: input.workspaces,
1085 root_pkg: input.root_pkg,
1086 workspace_pkgs: input.workspace_pkgs,
1087 plugin_result: input.plugin_result,
1088 });
1089 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1090 let summary = summarize_entry_points(&entry_points.all);
1091 let count = entry_points.all.len();
1092
1093 TimedEntryPoints {
1094 entry_points,
1095 summary,
1096 count,
1097 elapsed_ms,
1098 }
1099}
1100
1101fn try_load_analysis_graph_cache(
1102 input: &AnalysisCoreSharedInput<'_>,
1103 entry_points: &TimedEntryPoints,
1104 modules: &[extract::ModuleInfo],
1105) -> Option<GraphCacheHit> {
1106 if input.config.no_cache {
1107 return None;
1108 }
1109
1110 let t = Instant::now();
1111 input.progress.set_stage("loading module graph cache...");
1112 let current = build_graph_cache_manifest(
1113 input.config,
1114 input.plugin_result,
1115 &entry_points.entry_points,
1116 input.files,
1117 );
1118 let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir)?;
1119 if store.manifest.matches_inputs(¤t) {
1120 let project = graph_cache::restore_resolved_project(
1121 &input.config.root,
1122 modules,
1123 input.files,
1124 &store.resolved_project,
1125 )?;
1126 tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1127
1128 return Some(GraphCacheHit {
1129 graph: store.graph,
1130 project,
1131 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1132 });
1133 }
1134
1135 if !store.manifest.matches_resolution_inputs(¤t) {
1136 return None;
1137 }
1138
1139 let project = graph_cache::restore_resolved_project(
1140 &input.config.root,
1141 modules,
1142 input.files,
1143 &store.resolved_project,
1144 )?;
1145 tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1146 let graph = build_analysis_graph_timed(input, &project, entry_points, modules);
1147
1148 Some(GraphCacheHit {
1149 graph: graph.graph,
1150 project,
1151 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1152 })
1153}
1154
1155fn resolve_analysis_imports_timed(
1156 input: &AnalysisCoreSharedInput<'_>,
1157 modules: &[extract::ModuleInfo],
1158) -> TimedResolvedModules {
1159 let t = Instant::now();
1160 input.progress.set_stage("resolving imports...");
1161 let project = resolve_analysis_imports(
1162 modules,
1163 input.files,
1164 input.workspaces,
1165 input.plugin_result,
1166 input.config,
1167 );
1168 TimedResolvedModules {
1169 project,
1170 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1171 }
1172}
1173
1174fn build_analysis_graph_timed(
1175 input: &AnalysisCoreSharedInput<'_>,
1176 project: &resolve::ResolvedProject,
1177 entry_points: &TimedEntryPoints,
1178 modules: &[extract::ModuleInfo],
1179) -> TimedGraph {
1180 let t = Instant::now();
1181 input.progress.set_stage("building module graph...");
1182 let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1183 config: input.config,
1184 plugin_result: input.plugin_result,
1185 project,
1186 entry_points: &entry_points.entry_points,
1187 files: input.files,
1188 modules,
1189 workspaces: input.workspaces,
1190 });
1191 TimedGraph {
1192 graph,
1193 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1194 }
1195}
1196
1197fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1198 for module in modules {
1199 module.release_resolution_payload();
1200 }
1201}
1202
1203fn analyze_dead_code_timed(
1204 input: &AnalysisCoreSharedInput<'_>,
1205 graph: &graph::ModuleGraph,
1206 resolved: &[resolve::ResolvedModule],
1207 modules: &[extract::ModuleInfo],
1208 collect_usages: bool,
1209 entry_point_summary: results::EntryPointSummary,
1210) -> TimedAnalysis {
1211 let t = Instant::now();
1212 input.progress.set_stage("analyzing...");
1213 #[expect(
1214 deprecated,
1215 reason = "Core-internal policy keeps workspace path-dependency calls while warning external fallow-core consumers"
1216 )]
1217 let mut result = analyze::find_dead_code_full(
1218 graph,
1219 input.config,
1220 resolved,
1221 Some(input.plugin_result),
1222 input.workspaces,
1223 modules,
1224 collect_usages,
1225 );
1226 result.entry_point_summary = Some(entry_point_summary);
1227 TimedAnalysis {
1228 result,
1229 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1230 }
1231}
1232
1233fn analyze_full(
1234 config: &ResolvedConfig,
1235 retain: bool,
1236 collect_usages: bool,
1237 need_complexity: bool,
1238 retain_modules: bool,
1239) -> Result<AnalysisOutput, FallowError> {
1240 let _span = tracing::info_span!("fallow_analyze").entered();
1241 AnalysisSession::new(config)?.run_full(retain, collect_usages, need_complexity, retain_modules)
1242}
1243
1244fn full_analysis_pipeline_profile(
1245 timings: &PreludeTimings,
1246 pipeline_start: Instant,
1247 files: &[discover::DiscoveredFile],
1248 workspaces: &[fallow_config::WorkspaceInfo],
1249 core: &OwnedAnalysisCore,
1250 metrics: &ParseMetrics,
1251) -> PipelineProfile {
1252 let prelude = prelude_metrics(
1253 timings,
1254 pipeline_start,
1255 files,
1256 workspaces,
1257 core.modules.len(),
1258 );
1259 full_pipeline_profile(&prelude, core, metrics)
1260}
1261
1262fn assemble_full_output(
1265 core: OwnedAnalysisCore,
1266 plugin_result: plugins::AggregatedPluginResult,
1267 profile: &PipelineProfile,
1268 files: &[discover::DiscoveredFile],
1269 retain: bool,
1270 retain_modules: bool,
1271) -> AnalysisOutput {
1272 let file_hashes = collect_file_hashes(&core.modules, files);
1273 AnalysisOutput {
1274 results: core.result,
1275 timings: retained_pipeline_timings(retain, profile),
1276 graph: if retain { Some(core.graph) } else { None },
1277 modules: if retain_modules {
1278 Some(core.modules)
1279 } else {
1280 None
1281 },
1282 files: if retain_modules {
1283 Some(files.to_vec())
1284 } else {
1285 None
1286 },
1287 script_used_packages: plugin_result.script_used_packages,
1288 file_hashes,
1289 }
1290}
1291
1292struct OwnedAnalysisCore {
1295 result: AnalysisResults,
1296 graph: graph::ModuleGraph,
1297 modules: Vec<extract::ModuleInfo>,
1298 entry_point_count: usize,
1299 entry_points_ms: f64,
1300 resolve_ms: f64,
1301 graph_ms: f64,
1302 analyze_ms: f64,
1303}
1304
1305fn full_pipeline_profile(
1307 prelude: &PreludeMetrics,
1308 core: &OwnedAnalysisCore,
1309 parse: &ParseMetrics,
1310) -> PipelineProfile {
1311 PipelineProfile {
1312 discover_ms: prelude.discover_ms,
1313 workspaces_ms: prelude.workspaces_ms,
1314 plugins_ms: prelude.plugins_ms,
1315 scripts_ms: prelude.scripts_ms,
1316 parse_ms: parse.parse_ms,
1317 cache_ms: parse.cache_ms,
1318 entry_points_ms: core.entry_points_ms,
1319 resolve_ms: core.resolve_ms,
1320 graph_ms: core.graph_ms,
1321 analyze_ms: core.analyze_ms,
1322 total_ms: prelude.total_ms,
1323 file_count: prelude.file_count,
1324 workspace_count: prelude.workspace_count,
1325 module_count: prelude.module_count,
1326 entry_point_count: core.entry_point_count,
1327 cache_hits: parse.cache_hits,
1328 cache_misses: parse.cache_misses,
1329 parse_cpu_ms: parse.parse_cpu_ms,
1330 }
1331}
1332
1333#[derive(Clone, Copy)]
1334struct PipelineProfile {
1335 discover_ms: f64,
1336 workspaces_ms: f64,
1337 plugins_ms: f64,
1338 scripts_ms: f64,
1339 parse_ms: f64,
1340 cache_ms: f64,
1341 entry_points_ms: f64,
1342 resolve_ms: f64,
1343 graph_ms: f64,
1344 analyze_ms: f64,
1345 total_ms: f64,
1346 file_count: usize,
1347 workspace_count: usize,
1348 module_count: usize,
1349 entry_point_count: usize,
1350 cache_hits: usize,
1351 cache_misses: usize,
1352 parse_cpu_ms: f64,
1353}
1354
1355struct AnalysisParseOutput {
1356 modules: Vec<extract::ModuleInfo>,
1357 metrics: ParseMetrics,
1358}
1359
1360struct ParseMetrics {
1362 parse_ms: f64,
1363 cache_ms: f64,
1364 cache_hits: usize,
1365 cache_misses: usize,
1366 parse_cpu_ms: f64,
1367}
1368
1369impl From<AnalysisParseMetrics> for ParseMetrics {
1370 fn from(metrics: AnalysisParseMetrics) -> Self {
1371 Self {
1372 parse_ms: metrics.parse_ms,
1373 cache_ms: metrics.cache_ms,
1374 cache_hits: metrics.cache_hits,
1375 cache_misses: metrics.cache_misses,
1376 parse_cpu_ms: metrics.parse_cpu_ms,
1377 }
1378 }
1379}
1380
1381fn parse_analysis_modules(
1382 config: &ResolvedConfig,
1383 files: &[discover::DiscoveredFile],
1384 need_complexity: bool,
1385 start: Instant,
1386) -> AnalysisParseOutput {
1387 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1388 let mut cache_store = if config.no_cache {
1389 None
1390 } else {
1391 cache::CacheStore::load(
1392 &config.cache_dir,
1393 config.cache_config_hash,
1394 cache_max_size_bytes,
1395 )
1396 };
1397
1398 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1399 let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1400 let modules = parse_result.modules;
1401 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1402 let cache_ms = update_parse_cache_if_enabled(
1403 config,
1404 &mut cache_store,
1405 &modules,
1406 files,
1407 cache_max_size_bytes,
1408 );
1409
1410 AnalysisParseOutput {
1411 modules,
1412 metrics: ParseMetrics {
1413 parse_ms,
1414 cache_ms,
1415 cache_hits: parse_result.cache_hits,
1416 cache_misses: parse_result.cache_misses,
1417 parse_cpu_ms: parse_result.parse_cpu_ms,
1418 },
1419 }
1420}
1421
1422fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1423 retain.then_some(PipelineTimings {
1424 discover_files_ms: profile.discover_ms,
1425 file_count: profile.file_count,
1426 workspaces_ms: profile.workspaces_ms,
1427 workspace_count: profile.workspace_count,
1428 plugins_ms: profile.plugins_ms,
1429 script_analysis_ms: profile.scripts_ms,
1430 parse_extract_ms: profile.parse_ms,
1431 parse_cpu_ms: profile.parse_cpu_ms,
1432 module_count: profile.module_count,
1433 cache_hits: profile.cache_hits,
1434 cache_misses: profile.cache_misses,
1435 cache_update_ms: profile.cache_ms,
1436 entry_points_ms: profile.entry_points_ms,
1437 entry_point_count: profile.entry_point_count,
1438 resolve_imports_ms: profile.resolve_ms,
1439 build_graph_ms: profile.graph_ms,
1440 analyze_ms: profile.analyze_ms,
1441 duplication_ms: None,
1442 total_ms: profile.total_ms,
1443 })
1444}
1445
1446fn update_parse_cache_if_enabled(
1447 config: &ResolvedConfig,
1448 cache_store: &mut Option<cache::CacheStore>,
1449 modules: &[extract::ModuleInfo],
1450 files: &[discover::DiscoveredFile],
1451 cache_max_size_bytes: usize,
1452) -> f64 {
1453 let t = Instant::now();
1454 if !config.no_cache {
1455 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
1456 if update_cache(store, modules, files)
1457 && let Err(error) = store.save(
1458 &config.cache_dir,
1459 config.cache_config_hash,
1460 cache_max_size_bytes,
1461 )
1462 {
1463 tracing::warn!("Failed to save cache: {error}");
1464 }
1465 }
1466 t.elapsed().as_secs_f64() * 1000.0
1467}
1468
1469fn resolve_analysis_imports(
1470 modules: &[extract::ModuleInfo],
1471 files: &[discover::DiscoveredFile],
1472 workspaces: &[fallow_config::WorkspaceInfo],
1473 plugin_result: &plugins::AggregatedPluginResult,
1474 config: &ResolvedConfig,
1475) -> resolve::ResolvedProject {
1476 let mut project = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1477 modules,
1478 files,
1479 workspaces,
1480 active_plugins: &plugin_result.active_plugins,
1481 path_aliases: &plugin_result.path_aliases,
1482 auto_imports: &plugin_result.auto_imports,
1483 scss_include_paths: &plugin_result.scss_include_paths,
1484 static_dir_mappings: &plugin_result.static_dir_mappings,
1485 root: &config.root,
1486 extra_conditions: &config.resolve.conditions,
1487 });
1488 external_style_usage::augment_external_style_package_usage(
1489 &mut project.modules,
1490 config,
1491 workspaces,
1492 plugin_result,
1493 );
1494 project
1495}
1496
1497struct BuildAnalysisGraphInput<'a> {
1498 config: &'a ResolvedConfig,
1499 plugin_result: &'a plugins::AggregatedPluginResult,
1500 project: &'a resolve::ResolvedProject,
1501 entry_points: &'a discover::CategorizedEntryPoints,
1502 files: &'a [discover::DiscoveredFile],
1503 modules: &'a [extract::ModuleInfo],
1504 workspaces: &'a [fallow_config::WorkspaceInfo],
1505}
1506
1507fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1515 let caching_enabled = !input.config.no_cache;
1516 let current_manifest = caching_enabled.then(|| {
1517 build_graph_cache_manifest(
1518 input.config,
1519 input.plugin_result,
1520 input.entry_points,
1521 input.files,
1522 )
1523 });
1524
1525 let mut graph = graph::ModuleGraph::build_with_reachability_roots_and_replacements(
1526 &input.project.modules,
1527 &input.project.replaced_module_targets,
1528 &input.entry_points.all,
1529 &input.entry_points.runtime,
1530 &input.entry_points.test,
1531 input.files,
1532 );
1533 credit_package_path_references(&mut graph, input.modules);
1534 credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1535
1536 if let Some(manifest) = current_manifest {
1537 let Some(resolved_project) =
1538 graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1539 else {
1540 return graph;
1541 };
1542 let store = graph_cache::GraphCacheStore {
1543 version: graph_cache::GRAPH_CACHE_VERSION,
1544 manifest,
1545 graph,
1546 resolved_project,
1547 };
1548 store.save(&input.config.cache_dir);
1549 return store.graph;
1554 }
1555
1556 graph
1557}
1558
1559fn build_graph_cache_manifest(
1562 config: &ResolvedConfig,
1563 plugin_result: &plugins::AggregatedPluginResult,
1564 entry_points: &discover::CategorizedEntryPoints,
1565 files: &[discover::DiscoveredFile],
1566) -> graph_cache::GraphCacheManifest {
1567 let mode = graph_cache::GraphCacheMode::new(
1568 resolver_options_hash(config),
1569 entry_points_hash(entry_points),
1570 plugin_config_hash(plugin_result),
1571 );
1572 graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
1573 std::fs::metadata(path).map_or(
1574 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1575 |metadata| {
1576 fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1577 },
1578 )
1579 })
1580}
1581
1582fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1590 use std::hash::{Hash, Hasher};
1591 let mut hasher = rustc_hash::FxHasher::default();
1592 config.root.hash(&mut hasher);
1593 config.cache_config_hash.hash(&mut hasher);
1594 config.resolve.conditions.hash(&mut hasher);
1595 hasher.finish()
1596}
1597
1598fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1601 use std::hash::{Hash, Hasher};
1602 let mut hasher = rustc_hash::FxHasher::default();
1603 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1604 let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1605 paths.sort_unstable();
1606 paths.len().hash(&mut hasher);
1607 for path in paths {
1608 path.hash(&mut hasher);
1609 }
1610 }
1611 hasher.finish()
1612}
1613
1614fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1616 use std::hash::{Hash, Hasher};
1617 let mut hasher = rustc_hash::FxHasher::default();
1618
1619 hash_active_plugins(plugin_result, &mut hasher);
1620 hash_path_aliases(plugin_result, &mut hasher);
1621
1622 let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1623 plugin_result
1624 .auto_imports
1625 .iter()
1626 .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1627 .collect();
1628 auto_imports.sort_unstable_by(|a, b| {
1629 a.0.cmp(b.0)
1630 .then_with(|| a.1.cmp(b.1))
1631 .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1632 });
1633 auto_imports.len().hash(&mut hasher);
1634 for (name, source, kind) in auto_imports {
1635 name.hash(&mut hasher);
1636 source.hash(&mut hasher);
1637 auto_import_kind_rank(kind).hash(&mut hasher);
1638 }
1639
1640 let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1641 .scss_include_paths
1642 .iter()
1643 .map(std::path::PathBuf::as_path)
1644 .collect();
1645 scss_include_paths.sort_unstable();
1646 scss_include_paths.len().hash(&mut hasher);
1647 for path in scss_include_paths {
1648 path.hash(&mut hasher);
1649 }
1650
1651 let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1652 .static_dir_mappings
1653 .iter()
1654 .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1655 .collect();
1656 static_dir_mappings.sort_unstable();
1657 static_dir_mappings.len().hash(&mut hasher);
1658 for (from_dir, mount) in static_dir_mappings {
1659 from_dir.hash(&mut hasher);
1660 mount.hash(&mut hasher);
1661 }
1662
1663 hasher.finish()
1664}
1665
1666fn hash_active_plugins(
1667 plugin_result: &plugins::AggregatedPluginResult,
1668 hasher: &mut rustc_hash::FxHasher,
1669) {
1670 use std::hash::Hash;
1671 let mut active: Vec<&str> = plugin_result
1672 .active_plugins
1673 .iter()
1674 .map(String::as_str)
1675 .collect();
1676 active.sort_unstable();
1677 active.len().hash(hasher);
1678 for name in active {
1679 name.hash(hasher);
1680 }
1681}
1682
1683fn hash_path_aliases(
1684 plugin_result: &plugins::AggregatedPluginResult,
1685 hasher: &mut rustc_hash::FxHasher,
1686) {
1687 use std::hash::Hash;
1688 let mut aliases: Vec<(&str, &str)> = plugin_result
1689 .path_aliases
1690 .iter()
1691 .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1692 .collect();
1693 aliases.sort_unstable();
1694 aliases.len().hash(hasher);
1695 for (prefix, replacement) in aliases {
1696 prefix.hash(hasher);
1697 replacement.hash(hasher);
1698 }
1699}
1700
1701fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1702 match kind {
1703 fallow_config::AutoImportKind::Named => 0,
1704 fallow_config::AutoImportKind::Default => 1,
1705 fallow_config::AutoImportKind::DefaultComponent => 2,
1706 }
1707}
1708
1709fn collect_file_hashes(
1710 modules: &[extract::ModuleInfo],
1711 files: &[discover::DiscoveredFile],
1712) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1713 modules
1714 .iter()
1715 .filter_map(|module| {
1716 files
1717 .get(module.file_id.0 as usize)
1718 .map(|file| (file.path.clone(), module.content_hash))
1719 })
1720 .collect()
1721}
1722
1723fn trace_pipeline_profile(profile: &PipelineProfile) {
1724 let PipelineProfile {
1725 discover_ms,
1726 workspaces_ms,
1727 plugins_ms,
1728 scripts_ms,
1729 parse_ms,
1730 cache_ms,
1731 entry_points_ms,
1732 resolve_ms,
1733 graph_ms,
1734 analyze_ms,
1735 total_ms,
1736 file_count,
1737 module_count,
1738 entry_point_count,
1739 cache_hits,
1740 cache_misses,
1741 ..
1742 } = *profile;
1743 let cache_summary = if cache_hits > 0 {
1744 format!(" ({cache_hits} cached, {cache_misses} parsed)")
1745 } else {
1746 String::new()
1747 };
1748
1749 tracing::debug!(
1750 "\n┌─ Pipeline Profile ─────────────────────────────\n\
1751 │ discover files: {:>8.1}ms ({} files)\n\
1752 │ workspaces: {:>8.1}ms\n\
1753 │ plugins: {:>8.1}ms\n\
1754 │ script analysis: {:>8.1}ms\n\
1755 │ parse/extract: {:>8.1}ms ({} modules{})\n\
1756 │ cache update: {:>8.1}ms\n\
1757 │ entry points: {:>8.1}ms ({} entries)\n\
1758 │ resolve imports: {:>8.1}ms\n\
1759 │ build graph: {:>8.1}ms\n\
1760 │ analyze: {:>8.1}ms\n\
1761 │ ────────────────────────────────────────────\n\
1762 │ TOTAL: {:>8.1}ms\n\
1763 └─────────────────────────────────────────────────",
1764 discover_ms,
1765 file_count,
1766 workspaces_ms,
1767 plugins_ms,
1768 scripts_ms,
1769 parse_ms,
1770 module_count,
1771 cache_summary,
1772 cache_ms,
1773 entry_points_ms,
1774 entry_point_count,
1775 resolve_ms,
1776 graph_ms,
1777 analyze_ms,
1778 total_ms,
1779 );
1780}
1781
1782fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1787 fallow_config::load_dir_package_json(&config.root)
1788}
1789
1790fn load_workspace_packages(
1791 workspaces: &[fallow_config::WorkspaceInfo],
1792) -> Vec<LoadedWorkspacePackage> {
1793 workspaces
1794 .iter()
1795 .filter_map(|ws| {
1796 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1797 })
1798 .collect()
1799}
1800
1801fn analyze_all_scripts(
1802 config: &ResolvedConfig,
1803 workspaces: &[fallow_config::WorkspaceInfo],
1804 root_pkg: Option<&PackageJson>,
1805 workspace_pkgs: &[LoadedWorkspacePackage],
1806 plugin_result: &mut plugins::AggregatedPluginResult,
1807) {
1808 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1809 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1810 let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs);
1811
1812 let nm_roots = collect_node_modules_roots(config, workspaces);
1813 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1814
1815 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1816 analyze_workspace_scripts(
1817 config,
1818 workspace_pkgs,
1819 &bin_map,
1820 &all_dep_set,
1821 plugin_result,
1822 );
1823 analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
1824
1825 plugin_result
1826 .entry_point_roles
1827 .entry("scripts".to_string())
1828 .or_insert(EntryPointRole::Support);
1829}
1830
1831fn collect_all_dependency_names(
1833 root_pkg: Option<&PackageJson>,
1834 workspace_pkgs: &[LoadedWorkspacePackage],
1835) -> Vec<String> {
1836 let mut all_dep_names: Vec<String> = Vec::new();
1837 if let Some(pkg) = root_pkg {
1838 all_dep_names.extend(pkg.all_dependency_names());
1839 }
1840 for (_, ws_pkg) in workspace_pkgs {
1841 all_dep_names.extend(ws_pkg.all_dependency_names());
1842 }
1843 all_dep_names.sort_unstable();
1844 all_dep_names.dedup();
1845 all_dep_names
1846}
1847
1848fn collect_all_scripts(
1850 root_pkg: Option<&PackageJson>,
1851 workspace_pkgs: &[LoadedWorkspacePackage],
1852) -> scripts::ScriptCatalog {
1853 let mut catalog = scripts::ScriptCatalog::default();
1854 if let Some(pkg) = root_pkg
1855 && let Some(ref pkg_scripts) = pkg.scripts
1856 {
1857 catalog.merge_scripts(pkg_scripts);
1858 }
1859 for (_, ws_pkg) in workspace_pkgs {
1860 if let Some(ref ws_scripts) = ws_pkg.scripts {
1861 catalog.merge_workspace_scripts(ws_scripts);
1862 }
1863 }
1864 catalog
1865}
1866
1867fn collect_node_modules_roots<'a>(
1869 config: &'a ResolvedConfig,
1870 workspaces: &'a [fallow_config::WorkspaceInfo],
1871) -> Vec<&'a std::path::Path> {
1872 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1873 if config.root.join("node_modules").is_dir() {
1874 nm_roots.push(&config.root);
1875 }
1876 for ws in workspaces {
1877 if ws.root.join("node_modules").is_dir() {
1878 nm_roots.push(&ws.root);
1879 }
1880 }
1881 nm_roots
1882}
1883
1884fn analyze_root_scripts(
1886 config: &ResolvedConfig,
1887 root_pkg: Option<&PackageJson>,
1888 bin_map: &rustc_hash::FxHashMap<String, String>,
1889 all_dep_set: &FxHashSet<String>,
1890 plugin_result: &mut plugins::AggregatedPluginResult,
1891) {
1892 let Some(pkg) = root_pkg else {
1893 return;
1894 };
1895 let Some(ref pkg_scripts) = pkg.scripts else {
1896 return;
1897 };
1898 let scripts_to_analyze = if config.production {
1899 scripts::filter_production_scripts(pkg_scripts)
1900 } else {
1901 pkg_scripts.clone()
1902 };
1903 let catalog =
1904 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1905 let script_analysis = scripts::analyze_scripts_with_dependency_context(
1906 &scripts_to_analyze,
1907 &config.root,
1908 bin_map,
1909 all_dep_set,
1910 &catalog,
1911 );
1912 plugin_result.script_used_packages = script_analysis.used_packages;
1913
1914 for config_file in &script_analysis.config_files {
1915 plugin_result
1916 .discovered_always_used
1917 .push((config_file.clone(), "scripts".to_string()));
1918 }
1919 for entry in &script_analysis.entry_files {
1920 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1921 plugin_result
1922 .entry_patterns
1923 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1924 }
1925 }
1926}
1927
1928type WsScriptOut = (
1930 Vec<String>,
1931 Vec<(String, String)>,
1932 Vec<(plugins::PathRule, String)>,
1933);
1934
1935fn analyze_workspace_scripts(
1936 config: &ResolvedConfig,
1937 workspace_pkgs: &[LoadedWorkspacePackage],
1938 bin_map: &rustc_hash::FxHashMap<String, String>,
1939 all_dep_set: &FxHashSet<String>,
1940 plugin_result: &mut plugins::AggregatedPluginResult,
1941) {
1942 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1943 .par_iter()
1944 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1945 .collect();
1946 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1947 plugin_result.script_used_packages.extend(used_packages);
1948 plugin_result
1949 .discovered_always_used
1950 .extend(discovered_always_used);
1951 plugin_result.entry_patterns.extend(entry_patterns);
1952 }
1953}
1954
1955fn analyze_one_workspace_scripts(
1958 config: &ResolvedConfig,
1959 ws: &fallow_config::WorkspaceInfo,
1960 ws_pkg: &PackageJson,
1961 bin_map: &rustc_hash::FxHashMap<String, String>,
1962 all_dep_set: &FxHashSet<String>,
1963) -> WsScriptOut {
1964 let mut used_packages = Vec::new();
1965 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1966 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1967 let Some(ref ws_scripts) = ws_pkg.scripts else {
1968 return (used_packages, discovered_always_used, entry_patterns);
1969 };
1970 let scripts_to_analyze = if config.production {
1971 scripts::filter_production_scripts(ws_scripts)
1972 } else {
1973 ws_scripts.clone()
1974 };
1975 let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze);
1976 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1977 &scripts_to_analyze,
1978 &ws.root,
1979 bin_map,
1980 all_dep_set,
1981 &catalog,
1982 );
1983 used_packages.extend(ws_analysis.used_packages);
1984
1985 let ws_prefix = ws
1986 .root
1987 .strip_prefix(&config.root)
1988 .unwrap_or(&ws.root)
1989 .to_string_lossy();
1990 for config_file in &ws_analysis.config_files {
1991 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1992 }
1993 for entry in &ws_analysis.entry_files {
1994 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1995 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1996 }
1997 }
1998 (used_packages, discovered_always_used, entry_patterns)
1999}
2000
2001fn analyze_ci_scripts(
2003 config: &ResolvedConfig,
2004 bin_map: &rustc_hash::FxHashMap<String, String>,
2005 all_dep_set: &FxHashSet<String>,
2006 all_scripts: &scripts::ScriptCatalog,
2007 plugin_result: &mut plugins::AggregatedPluginResult,
2008) {
2009 let ci_analysis =
2010 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_scripts);
2011 plugin_result
2012 .script_used_packages
2013 .extend(ci_analysis.used_packages);
2014 for entry in &ci_analysis.entry_files {
2015 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2016 plugin_result
2017 .entry_patterns
2018 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2019 }
2020 }
2021}
2022
2023fn discover_all_entry_points(
2025 input: DiscoverAllEntryPointsInput<'_>,
2026) -> discover::CategorizedEntryPoints {
2027 let mut entry_points = discover::CategorizedEntryPoints::default();
2028 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2029 input.config,
2030 input.files,
2031 input.root_pkg,
2032 input.workspaces.is_empty(),
2033 );
2034
2035 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2036 .workspace_pkgs
2037 .iter()
2038 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2039 .collect();
2040
2041 let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2042 .workspaces
2043 .par_iter()
2044 .map(|ws| {
2045 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2046 discover::discover_workspace_entry_points_with_warnings_from_pkg(
2047 &ws.root,
2048 input.files,
2049 pkg,
2050 )
2051 })
2052 .collect();
2053 let mut skipped_entries = rustc_hash::FxHashMap::default();
2054 entry_points.extend_runtime(root_discovery.entries);
2055 for (path, count) in root_discovery.skipped_entries {
2056 *skipped_entries.entry(path).or_insert(0) += count;
2057 }
2058 let mut ws_entries = Vec::new();
2059 for workspace in workspace_discovery {
2060 ws_entries.extend(workspace.entries);
2061 for (path, count) in workspace.skipped_entries {
2062 *skipped_entries.entry(path).or_insert(0) += count;
2063 }
2064 }
2065 discover::warn_skipped_entry_summary(&skipped_entries);
2066 entry_points.extend_runtime(ws_entries);
2067
2068 let plugin_entries =
2069 discover::discover_plugin_entry_point_sets(input.plugin_result, input.config, input.files);
2070 entry_points.extend(plugin_entries);
2071
2072 let infra_entries = discover::discover_infrastructure_entry_points(&input.config.root);
2073 entry_points.extend_runtime(infra_entries);
2074
2075 if !input.config.dynamically_loaded.is_empty() {
2076 let dynamic_entries =
2077 discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2078 entry_points.extend_runtime(dynamic_entries);
2079 }
2080
2081 entry_points.dedup()
2082}
2083
2084fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2086 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2087 for ep in entry_points {
2088 let category = match &ep.source {
2089 discover::EntryPointSource::PackageJsonMain
2090 | discover::EntryPointSource::PackageJsonModule
2091 | discover::EntryPointSource::PackageJsonExports
2092 | discover::EntryPointSource::PackageJsonBin
2093 | discover::EntryPointSource::PackageJsonScript => "package.json",
2094 discover::EntryPointSource::Plugin { .. } => "plugin",
2095 discover::EntryPointSource::TestFile => "test file",
2096 discover::EntryPointSource::DefaultIndex => "default index",
2097 discover::EntryPointSource::ManualEntry => "manual entry",
2098 discover::EntryPointSource::InfrastructureConfig => "config",
2099 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2100 };
2101 *counts.entry(category.to_string()).or_insert(0) += 1;
2102 }
2103 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2104 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2105 results::EntryPointSummary {
2106 total: entry_points.len(),
2107 by_source,
2108 }
2109}
2110
2111fn append_package_file_asset_patterns(
2112 result: &mut plugins::AggregatedPluginResult,
2113 prefix: &str,
2114 pkg: &PackageJson,
2115) {
2116 let prefix = prefix.trim_matches('/');
2117 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2118 let pattern = if prefix.is_empty() {
2119 pattern
2120 } else {
2121 format!("{prefix}/{pattern}")
2122 };
2123 result
2124 .discovered_always_used
2125 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2126 }
2127}
2128
2129fn append_workspace_package_file_asset_patterns(
2130 result: &mut plugins::AggregatedPluginResult,
2131 config: &ResolvedConfig,
2132 workspace_pkgs: &[LoadedWorkspacePackage],
2133) {
2134 for (ws, ws_pkg) in workspace_pkgs {
2135 let ws_prefix = ws
2136 .root
2137 .strip_prefix(&config.root)
2138 .unwrap_or(&ws.root)
2139 .to_string_lossy()
2140 .replace('\\', "/");
2141 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2142 }
2143}
2144
2145fn run_plugins(
2147 config: &ResolvedConfig,
2148 files: &[discover::DiscoveredFile],
2149 workspaces: &[fallow_config::WorkspaceInfo],
2150 root_pkg: Option<&PackageJson>,
2151 workspace_pkgs: &[LoadedWorkspacePackage],
2152 config_candidates: &[std::path::PathBuf],
2153) -> Result<plugins::AggregatedPluginResult, FallowError> {
2154 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2155 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2156
2157 let candidate_index = (!config.production).then(|| {
2162 plugins::registry::ConfigCandidateIndex::build(
2163 file_paths
2164 .iter()
2165 .map(std::path::PathBuf::as_path)
2166 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2167 )
2168 });
2169
2170 let mut result = run_root_plugins(
2171 ®istry,
2172 config,
2173 root_pkg,
2174 &file_paths,
2175 candidate_index.as_ref(),
2176 )?;
2177
2178 if workspaces.is_empty() {
2179 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2180 return Ok(result);
2181 }
2182
2183 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2184
2185 let ws_results = run_workspace_plugins(
2186 ®istry,
2187 config,
2188 workspace_pkgs,
2189 &file_paths,
2190 &result.active_plugins,
2191 candidate_index.as_ref(),
2192 );
2193 merge_workspace_plugin_results(&mut result, ws_results)?;
2194
2195 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2196
2197 Ok(result)
2198}
2199
2200type WorkspacePluginResult = Result<
2201 (plugins::AggregatedPluginResult, String),
2202 Vec<plugins::registry::PluginRegexValidationError>,
2203>;
2204
2205fn run_root_plugins(
2207 registry: &plugins::PluginRegistry,
2208 config: &ResolvedConfig,
2209 root_pkg: Option<&PackageJson>,
2210 file_paths: &[std::path::PathBuf],
2211 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2212) -> Result<plugins::AggregatedPluginResult, FallowError> {
2213 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2214 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2215 .iter()
2216 .map(std::path::PathBuf::as_path)
2217 .collect();
2218
2219 let mut result = if let Some(pkg) = root_pkg {
2220 registry
2221 .try_run_with_search_roots(
2222 pkg,
2223 &config.root,
2224 file_paths,
2225 &root_config_search_root_refs,
2226 config.production,
2227 candidate_index,
2228 )
2229 .map_err(|errors| {
2230 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2231 })?
2232 } else {
2233 plugins::AggregatedPluginResult::default()
2234 };
2235 if let Some(pkg) = root_pkg {
2236 append_package_file_asset_patterns(&mut result, "", pkg);
2237 }
2238 Ok(result)
2239}
2240
2241fn run_workspace_plugins(
2244 registry: &plugins::PluginRegistry,
2245 config: &ResolvedConfig,
2246 workspace_pkgs: &[LoadedWorkspacePackage],
2247 file_paths: &[std::path::PathBuf],
2248 root_active_plugins: &[String],
2249 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2250) -> Vec<WorkspacePluginResult> {
2251 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2252 root_active_plugins.iter().map(String::as_str).collect();
2253
2254 let precompiled_matchers = registry.precompile_config_matchers();
2255 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2256
2257 workspace_pkgs
2258 .par_iter()
2259 .zip(workspace_relative_files.par_iter())
2260 .filter_map(|((ws, ws_pkg), relative_files)| {
2261 let ws_result =
2262 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2263 pkg: ws_pkg,
2264 root: &ws.root,
2265 project_root: &config.root,
2266 precompiled_config_matchers: &precompiled_matchers,
2267 relative_files,
2268 skip_config_plugins: &root_active_plugins,
2269 production_mode: config.production,
2270 candidate_index,
2271 }) {
2272 Ok(result) => result,
2273 Err(errors) => return Some(Err(errors)),
2274 };
2275 if ws_result.active_plugins.is_empty() {
2276 return None;
2277 }
2278 let ws_prefix = ws
2279 .root
2280 .strip_prefix(&config.root)
2281 .unwrap_or(&ws.root)
2282 .to_string_lossy()
2283 .into_owned();
2284 Some(Ok((ws_result, ws_prefix)))
2285 })
2286 .collect::<Vec<_>>()
2287}
2288
2289fn merge_workspace_plugin_results(
2292 result: &mut plugins::AggregatedPluginResult,
2293 ws_results: Vec<WorkspacePluginResult>,
2294) -> Result<(), FallowError> {
2295 let mut regex_errors = Vec::new();
2296 for ws_result in ws_results {
2297 match ws_result {
2298 Ok((mut ws_result, ws_prefix)) => {
2299 ws_result.apply_workspace_prefix(&ws_prefix);
2300 ws_result.config_patterns.clear();
2301 ws_result.script_used_packages.clear();
2302 result.merge_into(ws_result);
2303 }
2304 Err(mut errors) => regex_errors.append(&mut errors),
2305 }
2306 }
2307 if !regex_errors.is_empty() {
2308 return Err(FallowError::config(
2309 plugins::registry::format_plugin_regex_errors(®ex_errors),
2310 ));
2311 }
2312 Ok(())
2313}
2314
2315fn gate_auto_import_entry_patterns(
2321 result: &mut plugins::AggregatedPluginResult,
2322 config: &ResolvedConfig,
2323 workspaces: &[fallow_config::WorkspaceInfo],
2324) {
2325 if !config.auto_imports {
2326 return;
2327 }
2328 if !result.active_plugins.iter().any(|name| name == "nuxt") {
2329 return;
2330 }
2331 let components_custom = plugins::nuxt::config_declares_components(&config.root)
2332 || workspaces
2333 .iter()
2334 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2335 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2336 || workspaces
2337 .iter()
2338 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2339 result.entry_patterns.retain(|(rule, plugin)| {
2340 if plugin != "nuxt" {
2341 return true;
2342 }
2343 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2344 return false;
2345 }
2346 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2347 return false;
2348 }
2349 true
2350 });
2351}
2352
2353fn bucket_files_by_workspace(
2354 workspace_pkgs: &[LoadedWorkspacePackage],
2355 file_paths: &[std::path::PathBuf],
2356) -> Vec<Vec<(std::path::PathBuf, String)>> {
2357 let workspace_roots: Vec<_> = workspace_pkgs
2358 .iter()
2359 .map(|(workspace, _)| workspace.root.as_path())
2360 .collect();
2361 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2362}
2363
2364fn bucket_files_by_workspace_roots(
2365 workspace_roots: &[&Path],
2366 file_paths: &[std::path::PathBuf],
2367) -> Vec<Vec<(std::path::PathBuf, String)>> {
2368 use rayon::prelude::*;
2369
2370 let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
2374 rustc_hash::FxHashMap::default();
2375 for (idx, root) in workspace_roots.iter().enumerate() {
2376 workspace_by_root.entry(root).or_insert(idx);
2377 }
2378
2379 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2380 .par_iter()
2381 .map(|file_path| {
2382 let idx = file_path
2383 .ancestors()
2384 .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
2385 .min()?;
2386 let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
2387 Some((
2388 idx,
2389 file_path.clone(),
2390 relative.to_string_lossy().into_owned(),
2391 ))
2392 })
2393 .collect();
2394
2395 let mut buckets = vec![Vec::new(); workspace_roots.len()];
2396 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2397 buckets[idx].push((file_path, relative));
2398 }
2399
2400 buckets
2401}
2402
2403#[doc(hidden)]
2405pub fn benchmark_bucket_files_by_workspace(
2406 workspace_roots: &[std::path::PathBuf],
2407 file_paths: &[std::path::PathBuf],
2408) -> Vec<Vec<(std::path::PathBuf, String)>> {
2409 let workspace_roots: Vec<_> = workspace_roots
2410 .iter()
2411 .map(std::path::PathBuf::as_path)
2412 .collect();
2413 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2414}
2415
2416fn collect_config_search_roots(
2417 root: &Path,
2418 file_paths: &[std::path::PathBuf],
2419) -> Vec<std::path::PathBuf> {
2420 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2421 roots.insert(root.to_path_buf());
2422
2423 for file_path in file_paths {
2424 let mut current = file_path.parent();
2425 while let Some(dir) = current {
2426 if !dir.starts_with(root) {
2427 break;
2428 }
2429 roots.insert(dir.to_path_buf());
2430 if dir == root {
2431 break;
2432 }
2433 current = dir.parent();
2434 }
2435 }
2436
2437 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2438 roots_vec.sort();
2439 roots_vec
2440}
2441
2442fn config_for_project(
2450 root: &Path,
2451 config_path: Option<&Path>,
2452) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2453 let user_config = if let Some(path) = config_path {
2454 Some((
2455 fallow_config::FallowConfig::load(path)
2456 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2457 path.to_path_buf(),
2458 ))
2459 } else {
2460 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2461 };
2462
2463 let config = match user_config {
2464 Some((config, path)) => resolve_user_config(config, path, root)?,
2465 None => (
2466 fallow_config::FallowConfig::default().resolve(
2467 root.to_path_buf(),
2468 fallow_config::OutputFormat::Human,
2469 num_cpus(),
2470 false,
2471 true,
2472 None,
2473 ),
2474 None,
2475 ),
2476 };
2477
2478 Ok(config)
2479}
2480
2481fn resolve_user_config(
2484 mut config: fallow_config::FallowConfig,
2485 path: std::path::PathBuf,
2486 root: &Path,
2487) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2488 let dead_code_production = config
2489 .production
2490 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2491 config.production = dead_code_production.into();
2492 config
2493 .validate_resolved_boundaries(root)
2494 .map_err(|errors| {
2495 let joined = errors
2496 .iter()
2497 .map(ToString::to_string)
2498 .collect::<Vec<_>>()
2499 .join("\n - ");
2500 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
2501 })?;
2502 let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2503 let joined = errors
2504 .iter()
2505 .map(ToString::to_string)
2506 .collect::<Vec<_>>()
2507 .join("\n - ");
2508 FallowError::config(format!("invalid rule pack:\n - {joined}"))
2509 })?;
2510 let boundaries =
2511 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
2512 let zone_errors = fallow_config::validate_rule_pack_zone_references(
2513 root,
2514 &config.rule_packs,
2515 &packs,
2516 &boundaries,
2517 );
2518 if !zone_errors.is_empty() {
2519 let joined = zone_errors
2520 .iter()
2521 .map(ToString::to_string)
2522 .collect::<Vec<_>>()
2523 .join("\n - ");
2524 return Err(FallowError::config(format!(
2525 "invalid rule pack:\n - {joined}"
2526 )));
2527 }
2528 Ok((
2529 config.resolve(
2530 root.to_path_buf(),
2531 fallow_config::OutputFormat::Human,
2532 num_cpus(),
2533 false,
2534 true, None, ),
2537 Some(path),
2538 ))
2539}
2540
2541#[cfg_attr(
2552 not(test),
2553 allow(
2554 dead_code,
2555 reason = "config resolution fallback is exercised by session tests"
2556 )
2557)]
2558pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2559 config_for_project(root, None).map_or_else(
2560 |_| {
2561 fallow_config::FallowConfig::default().resolve(
2562 root.to_path_buf(),
2563 fallow_config::OutputFormat::Human,
2564 num_cpus(),
2565 false,
2566 true,
2567 None,
2568 )
2569 },
2570 |(config, _)| config,
2571 )
2572}
2573
2574fn num_cpus() -> usize {
2575 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2576}
2577
2578#[cfg(test)]
2579mod tests {
2580 use super::{
2581 AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
2582 collect_config_search_roots, credit_workspace_package_usage, default_config,
2583 format_undeclared_workspace_warning, parse_analysis_modules, plugin_config_hash,
2584 resolver_options_hash, warn_undeclared_workspaces,
2585 };
2586 use std::path::{Path, PathBuf};
2587 use std::time::Instant;
2588
2589 use fallow_config::{
2590 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2591 };
2592 use fallow_types::discover::{DiscoveredFile, FileId};
2593 use fallow_types::extract::{ImportInfo, ImportedName};
2594
2595 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2596 let mut result = crate::plugins::AggregatedPluginResult::default();
2597 result.active_plugins.push("nuxt".to_string());
2598 result
2599 .path_aliases
2600 .push(("@/".to_string(), "src/".to_string()));
2601 result
2602 }
2603
2604 #[test]
2605 fn commonjs_internal_import_credits_workspace_package_usage() {
2606 let workspace = fallow_config::WorkspaceInfo {
2607 root: PathBuf::from("/repo/packages/shared"),
2608 name: "@repo/shared".to_string(),
2609 is_internal_dependency: true,
2610 };
2611 let resolved = vec![crate::resolve::ResolvedModule {
2612 file_id: FileId(0),
2613 resolved_imports: vec![crate::resolve::ResolvedImport {
2614 info: ImportInfo {
2615 source: "@repo/shared".to_string(),
2616 imported_name: ImportedName::Namespace,
2617 local_name: "shared".to_string(),
2618 is_type_only: false,
2619 from_style: false,
2620 span: oxc_span::Span::new(0, 20),
2621 source_span: oxc_span::Span::new(8, 20),
2622 },
2623 target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
2624 }],
2625 ..crate::resolve::ResolvedModule::default()
2626 }];
2627 let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
2628
2629 credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
2630
2631 assert_eq!(
2632 graph.package_usage.get("@repo/shared"),
2633 Some(&vec![FileId(0)])
2634 );
2635 }
2636
2637 #[test]
2638 fn graph_cache_resolver_hash_includes_project_root() {
2639 let dir_a = tempfile::tempdir().expect("create temp dir a");
2640 let dir_b = tempfile::tempdir().expect("create temp dir b");
2641 let config_a = session_config(dir_a.path());
2642 let config_b = session_config(dir_b.path());
2643
2644 assert_ne!(
2645 resolver_options_hash(&config_a),
2646 resolver_options_hash(&config_b),
2647 "shared cache dirs must not reuse graphs across project roots"
2648 );
2649 }
2650
2651 #[test]
2652 fn graph_cache_resolver_hash_includes_resolve_conditions() {
2653 let dir = tempfile::tempdir().expect("create temp dir");
2654 let config_a = session_config(dir.path());
2655 let mut config_b = session_config(dir.path());
2656 config_b.resolve.conditions.push("react-server".to_string());
2657
2658 assert_ne!(
2659 resolver_options_hash(&config_a),
2660 resolver_options_hash(&config_b),
2661 "resolve condition changes must invalidate the graph cache"
2662 );
2663 }
2664
2665 #[test]
2666 fn graph_cache_plugin_hash_includes_auto_imports() {
2667 let mut without_auto_import = plugin_result();
2668 let mut with_auto_import = plugin_result();
2669 with_auto_import.auto_imports.push(AutoImportRule {
2670 name: "useCounter".to_string(),
2671 source: PathBuf::from("/project/composables/useCounter.ts"),
2672 kind: AutoImportKind::Named,
2673 });
2674
2675 assert_ne!(
2676 plugin_config_hash(&without_auto_import),
2677 plugin_config_hash(&with_auto_import),
2678 "auto-import edge changes must invalidate the graph cache"
2679 );
2680
2681 without_auto_import.auto_imports.push(AutoImportRule {
2682 name: "useCounter".to_string(),
2683 source: PathBuf::from("/project/composables/useCounter.ts"),
2684 kind: AutoImportKind::Default,
2685 });
2686 assert_ne!(
2687 plugin_config_hash(&without_auto_import),
2688 plugin_config_hash(&with_auto_import),
2689 "auto-import kind changes must invalidate the graph cache"
2690 );
2691 }
2692
2693 #[test]
2694 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2695 let base = plugin_result();
2696 let mut with_scss = base.clone();
2697 with_scss
2698 .scss_include_paths
2699 .push(PathBuf::from("/project/styles"));
2700 assert_ne!(
2701 plugin_config_hash(&base),
2702 plugin_config_hash(&with_scss),
2703 "SCSS include path changes must invalidate the graph cache"
2704 );
2705
2706 let mut with_static_dir = base.clone();
2707 with_static_dir
2708 .static_dir_mappings
2709 .push((PathBuf::from("/project/public"), "/".to_string()));
2710 assert_ne!(
2711 plugin_config_hash(&base),
2712 plugin_config_hash(&with_static_dir),
2713 "static directory mapping changes must invalidate the graph cache"
2714 );
2715 }
2716
2717 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2718 WorkspaceDiagnostic::new(
2719 root,
2720 root.join(relative),
2721 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2722 )
2723 }
2724
2725 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2726 let mut config = default_config(root);
2727 config.no_cache = true;
2728 config.quiet = true;
2729 config
2730 }
2731
2732 fn write_session_fixture(root: &Path) {
2733 let src = root.join("src");
2734 std::fs::create_dir_all(&src).expect("create src");
2735 std::fs::write(
2736 root.join("package.json"),
2737 r#"{"name":"session-fixture","type":"module"}"#,
2738 )
2739 .expect("write package json");
2740 std::fs::write(
2741 src.join("index.ts"),
2742 "import { used } from './used';\nconsole.log(used);\n",
2743 )
2744 .expect("write index");
2745 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2746 }
2747
2748 #[test]
2749 fn analysis_session_discovers_project_files() {
2750 let dir = tempfile::tempdir().expect("create temp dir");
2751 write_session_fixture(dir.path());
2752 let config = session_config(dir.path());
2753
2754 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2755
2756 assert!(
2757 session
2758 .files()
2759 .iter()
2760 .any(|file| file.path.ends_with("src/index.ts")),
2761 "session should own discovered project files"
2762 );
2763 assert_eq!(session.workspaces().len(), 0);
2764 }
2765
2766 #[test]
2767 fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
2768 let project = tempfile::tempdir().expect("create project");
2769 let root = project.path();
2770 let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
2771 for (index, path) in paths.iter().enumerate() {
2772 std::fs::write(path, format!("export const value{index} = {index};\n"))
2773 .expect("write source");
2774 }
2775 let files: Vec<DiscoveredFile> = paths
2776 .iter()
2777 .enumerate()
2778 .map(|(index, path)| DiscoveredFile {
2779 id: FileId(u32::try_from(index).expect("test index fits u32")),
2780 path: path.clone(),
2781 size_bytes: std::fs::metadata(path).expect("source metadata").len(),
2782 })
2783 .collect();
2784 std::fs::remove_file(&paths[1]).expect("remove source after discovery");
2785 let config = session_config(root);
2786
2787 let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
2788
2789 assert_eq!(
2790 parsed
2791 .modules
2792 .iter()
2793 .map(|module| module.file_id)
2794 .collect::<Vec<_>>(),
2795 vec![FileId(0), FileId(2)]
2796 );
2797 let diagnostics = fallow_config::workspace_diagnostics_for(root);
2798 let diagnostic = diagnostics
2799 .iter()
2800 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2801 .expect("source read failure diagnostic");
2802 assert_eq!(diagnostic.path, paths[1]);
2803 assert!(matches!(
2804 diagnostic.kind,
2805 WorkspaceDiagnosticKind::SourceReadFailure { .. }
2806 ));
2807 }
2808
2809 #[test]
2810 fn analysis_session_parses_owned_modules() {
2811 let dir = tempfile::tempdir().expect("create temp dir");
2812 write_session_fixture(dir.path());
2813 let config = session_config(dir.path());
2814
2815 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2816 let parsed = session.parse_modules(false);
2817
2818 assert!(
2819 parsed
2820 .modules
2821 .iter()
2822 .any(|module| session.files()[module.file_id.0 as usize]
2823 .path
2824 .ends_with("src/index.ts")),
2825 "session parsing should return modules keyed to session files"
2826 );
2827 }
2828
2829 #[test]
2830 fn undeclared_workspace_warning_is_singular_for_one_path() {
2831 let root = Path::new("/repo");
2832 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2833 .expect("warning should be rendered");
2834
2835 assert_eq!(
2836 warning,
2837 "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."
2838 );
2839 }
2840
2841 #[test]
2842 fn undeclared_workspace_warning_summarizes_many_paths() {
2843 let root = PathBuf::from("/repo");
2844 let diagnostics = [
2845 "examples/a",
2846 "examples/b",
2847 "examples/c",
2848 "examples/d",
2849 "examples/e",
2850 "examples/f",
2851 ]
2852 .into_iter()
2853 .map(|path| diag(&root, path))
2854 .collect::<Vec<_>>();
2855
2856 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2857 .expect("warning should be rendered");
2858
2859 assert_eq!(
2860 warning,
2861 "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."
2862 );
2863 }
2864
2865 #[test]
2866 fn collect_config_search_roots_includes_file_ancestors_once() {
2867 let root = PathBuf::from("/repo");
2868 let search_roots = collect_config_search_roots(
2869 &root,
2870 &[
2871 root.join("apps/query/src/main.ts"),
2872 root.join("packages/shared/lib/index.ts"),
2873 ],
2874 );
2875
2876 assert_eq!(
2877 search_roots,
2878 vec![
2879 root.clone(),
2880 root.join("apps"),
2881 root.join("apps/query"),
2882 root.join("apps/query/src"),
2883 root.join("packages"),
2884 root.join("packages/shared"),
2885 root.join("packages/shared/lib"),
2886 ]
2887 );
2888 }
2889
2890 #[test]
2891 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2892 let root = PathBuf::from("/repo");
2893 let ui = fallow_config::WorkspaceInfo {
2894 root: root.join("apps/ui"),
2895 name: "ui".to_string(),
2896 is_internal_dependency: false,
2897 };
2898 let api = fallow_config::WorkspaceInfo {
2899 root: root.join("apps/api"),
2900 name: "api".to_string(),
2901 is_internal_dependency: false,
2902 };
2903 let workspace_pkgs = vec![
2904 (
2905 ui,
2906 fallow_config::PackageJson {
2907 name: Some("ui".to_string()),
2908 ..Default::default()
2909 },
2910 ),
2911 (
2912 api,
2913 fallow_config::PackageJson {
2914 name: Some("api".to_string()),
2915 ..Default::default()
2916 },
2917 ),
2918 ];
2919 let files = vec![
2920 root.join("apps/ui/vite.config.ts"),
2921 root.join("apps/ui/src/main.ts"),
2922 root.join("apps/api/src/server.ts"),
2923 root.join("tools/build.ts"),
2924 ];
2925
2926 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2927
2928 assert_eq!(
2929 buckets[0],
2930 vec![
2931 (
2932 root.join("apps/ui/vite.config.ts"),
2933 "vite.config.ts".to_string()
2934 ),
2935 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2936 ]
2937 );
2938 assert_eq!(
2939 buckets[1],
2940 vec![(
2941 root.join("apps/api/src/server.ts"),
2942 "src/server.ts".to_string()
2943 )]
2944 );
2945 }
2946
2947 #[test]
2948 fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
2949 let root = PathBuf::from("/repo");
2950 let parent = root.join("apps");
2951 let child = parent.join("web");
2952 let nested_first = child.join("src/first.ts");
2953 let nested_second = child.join("src/second.ts");
2954 let unmatched = root.join("tools/build.ts");
2955 let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
2956
2957 let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
2962 bucket
2963 .iter()
2964 .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
2965 .collect()
2966 };
2967
2968 let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
2969 assert_eq!(
2970 normalize(&parent_first[0]),
2971 vec![
2972 (nested_first.clone(), "web/src/first.ts".to_string()),
2973 (nested_second.clone(), "web/src/second.ts".to_string()),
2974 ]
2975 );
2976 assert!(parent_first[1].is_empty());
2977 assert!(parent_first[2].is_empty());
2978
2979 let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
2980 assert_eq!(
2981 normalize(&child_first[0]),
2982 vec![
2983 (nested_first, "src/first.ts".to_string()),
2984 (nested_second, "src/second.ts".to_string()),
2985 ]
2986 );
2987 assert!(child_first[1].is_empty());
2988 }
2989
2990 #[test]
2991 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
2992 let dir = tempfile::tempdir().expect("create temp dir");
2993 let pkg_good = dir.path().join("packages").join("good");
2994 let pkg_bad = dir.path().join("packages").join("bad");
2995 std::fs::create_dir_all(&pkg_good).unwrap();
2996 std::fs::create_dir_all(&pkg_bad).unwrap();
2997 std::fs::write(
2998 dir.path().join("package.json"),
2999 r#"{"workspaces": ["packages/*"]}"#,
3000 )
3001 .unwrap();
3002 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
3003 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
3004
3005 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
3006 dir.path(),
3007 &globset::GlobSet::empty(),
3008 )
3009 .expect("root package.json is valid");
3010 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
3011 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
3012
3013 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
3014
3015 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3016 let mut malformed = 0;
3017 let mut undeclared_for_bad = 0;
3018 for diag in &diagnostics {
3019 if matches!(
3020 diag.kind,
3021 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
3022 ) && diag.path.ends_with("bad")
3023 {
3024 malformed += 1;
3025 }
3026 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
3027 && diag.path.ends_with("bad")
3028 {
3029 undeclared_for_bad += 1;
3030 }
3031 }
3032 assert_eq!(
3033 malformed, 1,
3034 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
3035 );
3036 assert_eq!(
3037 undeclared_for_bad, 0,
3038 "warn_undeclared_workspaces must NOT re-flag a path that already \
3039 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
3040 );
3041 }
3042}