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 framework_static_dir_mappings: &plugin_result.framework_static_dir_mappings,
1486 root: &config.root,
1487 extra_conditions: &config.resolve.conditions,
1488 });
1489 external_style_usage::augment_external_style_package_usage(
1490 &mut project.modules,
1491 config,
1492 workspaces,
1493 plugin_result,
1494 );
1495 project
1496}
1497
1498struct BuildAnalysisGraphInput<'a> {
1499 config: &'a ResolvedConfig,
1500 plugin_result: &'a plugins::AggregatedPluginResult,
1501 project: &'a resolve::ResolvedProject,
1502 entry_points: &'a discover::CategorizedEntryPoints,
1503 files: &'a [discover::DiscoveredFile],
1504 modules: &'a [extract::ModuleInfo],
1505 workspaces: &'a [fallow_config::WorkspaceInfo],
1506}
1507
1508fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1516 let caching_enabled = !input.config.no_cache;
1517 let current_manifest = caching_enabled.then(|| {
1518 build_graph_cache_manifest(
1519 input.config,
1520 input.plugin_result,
1521 input.entry_points,
1522 input.files,
1523 )
1524 });
1525
1526 let mut graph = graph::ModuleGraph::build_with_reachability_roots_and_replacements(
1527 &input.project.modules,
1528 &input.project.replaced_module_targets,
1529 &input.entry_points.all,
1530 &input.entry_points.runtime,
1531 &input.entry_points.test,
1532 input.files,
1533 );
1534 credit_package_path_references(&mut graph, input.modules);
1535 credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1536
1537 if let Some(manifest) = current_manifest {
1538 let Some(resolved_project) =
1539 graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1540 else {
1541 return graph;
1542 };
1543 let store = graph_cache::GraphCacheStore {
1544 version: graph_cache::GRAPH_CACHE_VERSION,
1545 manifest,
1546 graph,
1547 resolved_project,
1548 };
1549 store.save(&input.config.cache_dir);
1550 return store.graph;
1555 }
1556
1557 graph
1558}
1559
1560fn build_graph_cache_manifest(
1563 config: &ResolvedConfig,
1564 plugin_result: &plugins::AggregatedPluginResult,
1565 entry_points: &discover::CategorizedEntryPoints,
1566 files: &[discover::DiscoveredFile],
1567) -> graph_cache::GraphCacheManifest {
1568 let mode = graph_cache::GraphCacheMode::new(
1569 resolver_options_hash(config),
1570 entry_points_hash(entry_points),
1571 plugin_config_hash(plugin_result),
1572 );
1573 graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
1574 std::fs::metadata(path).map_or(
1575 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
1576 |metadata| {
1577 fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
1578 },
1579 )
1580 })
1581}
1582
1583fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1591 use std::hash::{Hash, Hasher};
1592 let mut hasher = rustc_hash::FxHasher::default();
1593 config.root.hash(&mut hasher);
1594 config.cache_config_hash.hash(&mut hasher);
1595 config.resolve.conditions.hash(&mut hasher);
1596 hasher.finish()
1597}
1598
1599fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
1602 use std::hash::{Hash, Hasher};
1603 let mut hasher = rustc_hash::FxHasher::default();
1604 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1605 let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
1606 paths.sort_unstable();
1607 paths.len().hash(&mut hasher);
1608 for path in paths {
1609 path.hash(&mut hasher);
1610 }
1611 }
1612 hasher.finish()
1613}
1614
1615fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
1617 use std::hash::{Hash, Hasher};
1618 let mut hasher = rustc_hash::FxHasher::default();
1619
1620 hash_active_plugins(plugin_result, &mut hasher);
1621 hash_path_aliases(plugin_result, &mut hasher);
1622
1623 let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
1624 plugin_result
1625 .auto_imports
1626 .iter()
1627 .map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
1628 .collect();
1629 auto_imports.sort_unstable_by(|a, b| {
1630 a.0.cmp(b.0)
1631 .then_with(|| a.1.cmp(b.1))
1632 .then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
1633 });
1634 auto_imports.len().hash(&mut hasher);
1635 for (name, source, kind) in auto_imports {
1636 name.hash(&mut hasher);
1637 source.hash(&mut hasher);
1638 auto_import_kind_rank(kind).hash(&mut hasher);
1639 }
1640
1641 let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
1642 .scss_include_paths
1643 .iter()
1644 .map(std::path::PathBuf::as_path)
1645 .collect();
1646 scss_include_paths.sort_unstable();
1647 scss_include_paths.len().hash(&mut hasher);
1648 for path in scss_include_paths {
1649 path.hash(&mut hasher);
1650 }
1651
1652 let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
1653 .static_dir_mappings
1654 .iter()
1655 .map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
1656 .collect();
1657 static_dir_mappings.sort_unstable();
1658 static_dir_mappings.len().hash(&mut hasher);
1659 for (from_dir, mount) in static_dir_mappings {
1660 from_dir.hash(&mut hasher);
1661 mount.hash(&mut hasher);
1662 }
1663
1664 hasher.finish()
1665}
1666
1667fn hash_active_plugins(
1668 plugin_result: &plugins::AggregatedPluginResult,
1669 hasher: &mut rustc_hash::FxHasher,
1670) {
1671 use std::hash::Hash;
1672 let mut active: Vec<&str> = plugin_result
1673 .active_plugins
1674 .iter()
1675 .map(String::as_str)
1676 .collect();
1677 active.sort_unstable();
1678 active.len().hash(hasher);
1679 for name in active {
1680 name.hash(hasher);
1681 }
1682}
1683
1684fn hash_path_aliases(
1685 plugin_result: &plugins::AggregatedPluginResult,
1686 hasher: &mut rustc_hash::FxHasher,
1687) {
1688 use std::hash::Hash;
1689 let mut aliases: Vec<(&str, &str)> = plugin_result
1690 .path_aliases
1691 .iter()
1692 .map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
1693 .collect();
1694 aliases.sort_unstable();
1695 aliases.len().hash(hasher);
1696 for (prefix, replacement) in aliases {
1697 prefix.hash(hasher);
1698 replacement.hash(hasher);
1699 }
1700}
1701
1702fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1703 match kind {
1704 fallow_config::AutoImportKind::Named => 0,
1705 fallow_config::AutoImportKind::Default => 1,
1706 fallow_config::AutoImportKind::DefaultComponent => 2,
1707 }
1708}
1709
1710fn collect_file_hashes(
1711 modules: &[extract::ModuleInfo],
1712 files: &[discover::DiscoveredFile],
1713) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1714 modules
1715 .iter()
1716 .filter_map(|module| {
1717 files
1718 .get(module.file_id.0 as usize)
1719 .map(|file| (file.path.clone(), module.content_hash))
1720 })
1721 .collect()
1722}
1723
1724fn trace_pipeline_profile(profile: &PipelineProfile) {
1725 let PipelineProfile {
1726 discover_ms,
1727 workspaces_ms,
1728 plugins_ms,
1729 scripts_ms,
1730 parse_ms,
1731 cache_ms,
1732 entry_points_ms,
1733 resolve_ms,
1734 graph_ms,
1735 analyze_ms,
1736 total_ms,
1737 file_count,
1738 module_count,
1739 entry_point_count,
1740 cache_hits,
1741 cache_misses,
1742 ..
1743 } = *profile;
1744 let cache_summary = if cache_hits > 0 {
1745 format!(" ({cache_hits} cached, {cache_misses} parsed)")
1746 } else {
1747 String::new()
1748 };
1749
1750 tracing::debug!(
1751 "\n┌─ Pipeline Profile ─────────────────────────────\n\
1752 │ discover files: {:>8.1}ms ({} files)\n\
1753 │ workspaces: {:>8.1}ms\n\
1754 │ plugins: {:>8.1}ms\n\
1755 │ script analysis: {:>8.1}ms\n\
1756 │ parse/extract: {:>8.1}ms ({} modules{})\n\
1757 │ cache update: {:>8.1}ms\n\
1758 │ entry points: {:>8.1}ms ({} entries)\n\
1759 │ resolve imports: {:>8.1}ms\n\
1760 │ build graph: {:>8.1}ms\n\
1761 │ analyze: {:>8.1}ms\n\
1762 │ ────────────────────────────────────────────\n\
1763 │ TOTAL: {:>8.1}ms\n\
1764 └─────────────────────────────────────────────────",
1765 discover_ms,
1766 file_count,
1767 workspaces_ms,
1768 plugins_ms,
1769 scripts_ms,
1770 parse_ms,
1771 module_count,
1772 cache_summary,
1773 cache_ms,
1774 entry_points_ms,
1775 entry_point_count,
1776 resolve_ms,
1777 graph_ms,
1778 analyze_ms,
1779 total_ms,
1780 );
1781}
1782
1783fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
1788 fallow_config::load_dir_package_json(&config.root)
1789}
1790
1791fn load_workspace_packages(
1792 workspaces: &[fallow_config::WorkspaceInfo],
1793) -> Vec<LoadedWorkspacePackage> {
1794 workspaces
1795 .iter()
1796 .filter_map(|ws| {
1797 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1798 })
1799 .collect()
1800}
1801
1802fn analyze_all_scripts(
1803 config: &ResolvedConfig,
1804 workspaces: &[fallow_config::WorkspaceInfo],
1805 root_pkg: Option<&PackageJson>,
1806 workspace_pkgs: &[LoadedWorkspacePackage],
1807 plugin_result: &mut plugins::AggregatedPluginResult,
1808) {
1809 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
1810 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
1811 let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs);
1812
1813 let nm_roots = collect_node_modules_roots(config, workspaces);
1814 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
1815
1816 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
1817 analyze_workspace_scripts(
1818 config,
1819 workspace_pkgs,
1820 &bin_map,
1821 &all_dep_set,
1822 plugin_result,
1823 );
1824 analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
1825
1826 plugin_result
1827 .entry_point_roles
1828 .entry("scripts".to_string())
1829 .or_insert(EntryPointRole::Support);
1830}
1831
1832fn collect_all_dependency_names(
1834 root_pkg: Option<&PackageJson>,
1835 workspace_pkgs: &[LoadedWorkspacePackage],
1836) -> Vec<String> {
1837 let mut all_dep_names: Vec<String> = Vec::new();
1838 if let Some(pkg) = root_pkg {
1839 all_dep_names.extend(pkg.all_dependency_names());
1840 }
1841 for (_, ws_pkg) in workspace_pkgs {
1842 all_dep_names.extend(ws_pkg.all_dependency_names());
1843 }
1844 all_dep_names.sort_unstable();
1845 all_dep_names.dedup();
1846 all_dep_names
1847}
1848
1849fn collect_all_scripts(
1851 root_pkg: Option<&PackageJson>,
1852 workspace_pkgs: &[LoadedWorkspacePackage],
1853) -> scripts::ScriptCatalog {
1854 let mut catalog = scripts::ScriptCatalog::default();
1855 if let Some(pkg) = root_pkg
1856 && let Some(ref pkg_scripts) = pkg.scripts
1857 {
1858 catalog.merge_scripts(pkg_scripts);
1859 }
1860 for (_, ws_pkg) in workspace_pkgs {
1861 if let Some(ref ws_scripts) = ws_pkg.scripts {
1862 catalog.merge_workspace_scripts(ws_scripts);
1863 }
1864 }
1865 catalog
1866}
1867
1868fn collect_node_modules_roots<'a>(
1870 config: &'a ResolvedConfig,
1871 workspaces: &'a [fallow_config::WorkspaceInfo],
1872) -> Vec<&'a std::path::Path> {
1873 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
1874 if config.root.join("node_modules").is_dir() {
1875 nm_roots.push(&config.root);
1876 }
1877 for ws in workspaces {
1878 if ws.root.join("node_modules").is_dir() {
1879 nm_roots.push(&ws.root);
1880 }
1881 }
1882 nm_roots
1883}
1884
1885fn analyze_root_scripts(
1887 config: &ResolvedConfig,
1888 root_pkg: Option<&PackageJson>,
1889 bin_map: &rustc_hash::FxHashMap<String, String>,
1890 all_dep_set: &FxHashSet<String>,
1891 plugin_result: &mut plugins::AggregatedPluginResult,
1892) {
1893 let Some(pkg) = root_pkg else {
1894 return;
1895 };
1896 let Some(ref pkg_scripts) = pkg.scripts else {
1897 return;
1898 };
1899 let scripts_to_analyze = if config.production {
1900 scripts::filter_production_scripts(pkg_scripts)
1901 } else {
1902 pkg_scripts.clone()
1903 };
1904 let catalog =
1905 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1906 let script_analysis = scripts::analyze_scripts_with_dependency_context(
1907 &scripts_to_analyze,
1908 &config.root,
1909 bin_map,
1910 all_dep_set,
1911 &catalog,
1912 );
1913 plugin_result.script_used_packages = script_analysis.used_packages;
1914
1915 for config_file in &script_analysis.config_files {
1916 plugin_result
1917 .discovered_always_used
1918 .push((config_file.clone(), "scripts".to_string()));
1919 }
1920 for entry in &script_analysis.entry_files {
1921 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
1922 plugin_result
1923 .entry_patterns
1924 .push((plugins::PathRule::new(pat), "scripts".to_string()));
1925 }
1926 }
1927}
1928
1929type WsScriptOut = (
1931 Vec<String>,
1932 Vec<(String, String)>,
1933 Vec<(plugins::PathRule, String)>,
1934);
1935
1936fn analyze_workspace_scripts(
1937 config: &ResolvedConfig,
1938 workspace_pkgs: &[LoadedWorkspacePackage],
1939 bin_map: &rustc_hash::FxHashMap<String, String>,
1940 all_dep_set: &FxHashSet<String>,
1941 plugin_result: &mut plugins::AggregatedPluginResult,
1942) {
1943 let ws_results: Vec<WsScriptOut> = workspace_pkgs
1944 .par_iter()
1945 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
1946 .collect();
1947 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
1948 plugin_result.script_used_packages.extend(used_packages);
1949 plugin_result
1950 .discovered_always_used
1951 .extend(discovered_always_used);
1952 plugin_result.entry_patterns.extend(entry_patterns);
1953 }
1954}
1955
1956fn analyze_one_workspace_scripts(
1959 config: &ResolvedConfig,
1960 ws: &fallow_config::WorkspaceInfo,
1961 ws_pkg: &PackageJson,
1962 bin_map: &rustc_hash::FxHashMap<String, String>,
1963 all_dep_set: &FxHashSet<String>,
1964) -> WsScriptOut {
1965 let mut used_packages = Vec::new();
1966 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
1967 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
1968 let Some(ref ws_scripts) = ws_pkg.scripts else {
1969 return (used_packages, discovered_always_used, entry_patterns);
1970 };
1971 let scripts_to_analyze = if config.production {
1972 scripts::filter_production_scripts(ws_scripts)
1973 } else {
1974 ws_scripts.clone()
1975 };
1976 let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze);
1977 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
1978 &scripts_to_analyze,
1979 &ws.root,
1980 bin_map,
1981 all_dep_set,
1982 &catalog,
1983 );
1984 used_packages.extend(ws_analysis.used_packages);
1985
1986 let ws_prefix = ws
1987 .root
1988 .strip_prefix(&config.root)
1989 .unwrap_or(&ws.root)
1990 .to_string_lossy();
1991 for config_file in &ws_analysis.config_files {
1992 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
1993 }
1994 for entry in &ws_analysis.entry_files {
1995 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
1996 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
1997 }
1998 }
1999 (used_packages, discovered_always_used, entry_patterns)
2000}
2001
2002fn analyze_ci_scripts(
2004 config: &ResolvedConfig,
2005 bin_map: &rustc_hash::FxHashMap<String, String>,
2006 all_dep_set: &FxHashSet<String>,
2007 all_scripts: &scripts::ScriptCatalog,
2008 plugin_result: &mut plugins::AggregatedPluginResult,
2009) {
2010 let ci_analysis =
2011 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_scripts);
2012 plugin_result
2013 .script_used_packages
2014 .extend(ci_analysis.used_packages);
2015 for entry in &ci_analysis.entry_files {
2016 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2017 plugin_result
2018 .entry_patterns
2019 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2020 }
2021 }
2022}
2023
2024fn discover_all_entry_points(
2026 input: DiscoverAllEntryPointsInput<'_>,
2027) -> discover::CategorizedEntryPoints {
2028 let mut entry_points = discover::CategorizedEntryPoints::default();
2029 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2030 input.config,
2031 input.files,
2032 input.root_pkg,
2033 input.workspaces.is_empty(),
2034 );
2035
2036 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2037 .workspace_pkgs
2038 .iter()
2039 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2040 .collect();
2041 let workspace_script_seeds = discover::workspace_runtime_script_seeds(
2042 &input.config.root,
2043 input.root_pkg,
2044 input.workspace_pkgs,
2045 );
2046
2047 let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2048 .workspaces
2049 .par_iter()
2050 .map(|ws| {
2051 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2052 let seeds = workspace_script_seeds
2053 .get(&ws.name)
2054 .cloned()
2055 .unwrap_or_default();
2056 discover::discover_workspace_entry_points_with_runtime_scripts(
2057 &ws.root,
2058 input.files,
2059 pkg,
2060 &seeds,
2061 )
2062 })
2063 .collect();
2064 let mut skipped_entries = rustc_hash::FxHashMap::default();
2065 entry_points.extend_runtime(root_discovery.entries);
2066 entry_points.extend_support(root_discovery.support_entries);
2067 for (path, count) in root_discovery.skipped_entries {
2068 *skipped_entries.entry(path).or_insert(0) += count;
2069 }
2070 let mut ws_entries = Vec::new();
2071 let mut ws_support_entries = Vec::new();
2072 for workspace in workspace_discovery {
2073 ws_entries.extend(workspace.entries);
2074 ws_support_entries.extend(workspace.support_entries);
2075 for (path, count) in workspace.skipped_entries {
2076 *skipped_entries.entry(path).or_insert(0) += count;
2077 }
2078 }
2079 discover::warn_skipped_entry_summary(&skipped_entries);
2080 entry_points.extend_runtime(ws_entries);
2081 entry_points.extend_support(ws_support_entries);
2082
2083 let plugin_entries =
2084 discover::discover_plugin_entry_point_sets(input.plugin_result, input.config, input.files);
2085 entry_points.extend(plugin_entries);
2086
2087 let infra_entries = discover::discover_infrastructure_entry_points(&input.config.root);
2088 entry_points.extend_runtime(infra_entries);
2089
2090 if !input.config.dynamically_loaded.is_empty() {
2091 let dynamic_entries =
2092 discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2093 entry_points.extend_runtime(dynamic_entries);
2094 }
2095
2096 entry_points.dedup()
2097}
2098
2099fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2101 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2102 for ep in entry_points {
2103 let category = match &ep.source {
2104 discover::EntryPointSource::PackageJsonMain
2105 | discover::EntryPointSource::PackageJsonModule
2106 | discover::EntryPointSource::PackageJsonExports
2107 | discover::EntryPointSource::PackageJsonBin
2108 | discover::EntryPointSource::PackageJsonScript => "package.json",
2109 discover::EntryPointSource::Plugin { .. } => "plugin",
2110 discover::EntryPointSource::TestFile => "test file",
2111 discover::EntryPointSource::DefaultIndex => "default index",
2112 discover::EntryPointSource::ManualEntry => "manual entry",
2113 discover::EntryPointSource::InfrastructureConfig => "config",
2114 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2115 };
2116 *counts.entry(category.to_string()).or_insert(0) += 1;
2117 }
2118 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2119 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2120 results::EntryPointSummary {
2121 total: entry_points.len(),
2122 by_source,
2123 }
2124}
2125
2126fn append_package_file_asset_patterns(
2127 result: &mut plugins::AggregatedPluginResult,
2128 prefix: &str,
2129 pkg: &PackageJson,
2130) {
2131 let prefix = prefix.trim_matches('/');
2132 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2133 let pattern = if prefix.is_empty() {
2134 pattern
2135 } else {
2136 format!("{prefix}/{pattern}")
2137 };
2138 result
2139 .discovered_always_used
2140 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2141 }
2142}
2143
2144fn append_workspace_package_file_asset_patterns(
2145 result: &mut plugins::AggregatedPluginResult,
2146 config: &ResolvedConfig,
2147 workspace_pkgs: &[LoadedWorkspacePackage],
2148) {
2149 for (ws, ws_pkg) in workspace_pkgs {
2150 let ws_prefix = ws
2151 .root
2152 .strip_prefix(&config.root)
2153 .unwrap_or(&ws.root)
2154 .to_string_lossy()
2155 .replace('\\', "/");
2156 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2157 }
2158}
2159
2160fn run_plugins(
2162 config: &ResolvedConfig,
2163 files: &[discover::DiscoveredFile],
2164 workspaces: &[fallow_config::WorkspaceInfo],
2165 root_pkg: Option<&PackageJson>,
2166 workspace_pkgs: &[LoadedWorkspacePackage],
2167 config_candidates: &[std::path::PathBuf],
2168) -> Result<plugins::AggregatedPluginResult, FallowError> {
2169 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2170 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2171
2172 let candidate_index = (!config.production).then(|| {
2177 plugins::registry::ConfigCandidateIndex::build(
2178 file_paths
2179 .iter()
2180 .map(std::path::PathBuf::as_path)
2181 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2182 )
2183 });
2184
2185 let mut result = run_root_plugins(
2186 ®istry,
2187 config,
2188 root_pkg,
2189 &file_paths,
2190 candidate_index.as_ref(),
2191 )?;
2192
2193 if workspaces.is_empty() {
2194 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2195 return Ok(result);
2196 }
2197
2198 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2199
2200 let ws_results = run_workspace_plugins(
2201 ®istry,
2202 config,
2203 workspace_pkgs,
2204 &file_paths,
2205 &result.active_plugins,
2206 candidate_index.as_ref(),
2207 );
2208 merge_workspace_plugin_results(&mut result, ws_results)?;
2209
2210 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2211
2212 Ok(result)
2213}
2214
2215type WorkspacePluginResult = Result<
2216 (plugins::AggregatedPluginResult, String),
2217 Vec<plugins::registry::PluginRegexValidationError>,
2218>;
2219
2220fn run_root_plugins(
2222 registry: &plugins::PluginRegistry,
2223 config: &ResolvedConfig,
2224 root_pkg: Option<&PackageJson>,
2225 file_paths: &[std::path::PathBuf],
2226 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2227) -> Result<plugins::AggregatedPluginResult, FallowError> {
2228 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2229 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2230 .iter()
2231 .map(std::path::PathBuf::as_path)
2232 .collect();
2233
2234 let mut result = if let Some(pkg) = root_pkg {
2235 registry
2236 .try_run_with_search_roots(
2237 pkg,
2238 &config.root,
2239 file_paths,
2240 &root_config_search_root_refs,
2241 config.production,
2242 candidate_index,
2243 )
2244 .map_err(|errors| {
2245 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2246 })?
2247 } else {
2248 plugins::AggregatedPluginResult::default()
2249 };
2250 if let Some(pkg) = root_pkg {
2251 append_package_file_asset_patterns(&mut result, "", pkg);
2252 }
2253 Ok(result)
2254}
2255
2256fn run_workspace_plugins(
2259 registry: &plugins::PluginRegistry,
2260 config: &ResolvedConfig,
2261 workspace_pkgs: &[LoadedWorkspacePackage],
2262 file_paths: &[std::path::PathBuf],
2263 root_active_plugins: &[String],
2264 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2265) -> Vec<WorkspacePluginResult> {
2266 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2267 root_active_plugins.iter().map(String::as_str).collect();
2268
2269 let precompiled_matchers = registry.precompile_config_matchers();
2270 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2271
2272 workspace_pkgs
2273 .par_iter()
2274 .zip(workspace_relative_files.par_iter())
2275 .filter_map(|((ws, ws_pkg), relative_files)| {
2276 let ws_result =
2277 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2278 pkg: ws_pkg,
2279 root: &ws.root,
2280 project_root: &config.root,
2281 precompiled_config_matchers: &precompiled_matchers,
2282 relative_files,
2283 skip_config_plugins: &root_active_plugins,
2284 production_mode: config.production,
2285 candidate_index,
2286 }) {
2287 Ok(result) => result,
2288 Err(errors) => return Some(Err(errors)),
2289 };
2290 if ws_result.active_plugins.is_empty() {
2291 return None;
2292 }
2293 let ws_prefix = ws
2294 .root
2295 .strip_prefix(&config.root)
2296 .unwrap_or(&ws.root)
2297 .to_string_lossy()
2298 .into_owned();
2299 Some(Ok((ws_result, ws_prefix)))
2300 })
2301 .collect::<Vec<_>>()
2302}
2303
2304fn merge_workspace_plugin_results(
2307 result: &mut plugins::AggregatedPluginResult,
2308 ws_results: Vec<WorkspacePluginResult>,
2309) -> Result<(), FallowError> {
2310 let mut regex_errors = Vec::new();
2311 for ws_result in ws_results {
2312 match ws_result {
2313 Ok((mut ws_result, ws_prefix)) => {
2314 ws_result.apply_workspace_prefix(&ws_prefix);
2315 ws_result.config_patterns.clear();
2316 ws_result.script_used_packages.clear();
2317 result.merge_into(ws_result);
2318 }
2319 Err(mut errors) => regex_errors.append(&mut errors),
2320 }
2321 }
2322 if !regex_errors.is_empty() {
2323 return Err(FallowError::config(
2324 plugins::registry::format_plugin_regex_errors(®ex_errors),
2325 ));
2326 }
2327 Ok(())
2328}
2329
2330fn gate_auto_import_entry_patterns(
2336 result: &mut plugins::AggregatedPluginResult,
2337 config: &ResolvedConfig,
2338 workspaces: &[fallow_config::WorkspaceInfo],
2339) {
2340 if !config.auto_imports {
2341 return;
2342 }
2343 if !result.active_plugins.iter().any(|name| name == "nuxt") {
2344 return;
2345 }
2346 let components_custom = plugins::nuxt::config_declares_components(&config.root)
2347 || workspaces
2348 .iter()
2349 .any(|ws| plugins::nuxt::config_declares_components(&ws.root));
2350 let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
2351 || workspaces
2352 .iter()
2353 .any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
2354 result.entry_patterns.retain(|(rule, plugin)| {
2355 if plugin != "nuxt" {
2356 return true;
2357 }
2358 if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2359 return false;
2360 }
2361 if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2362 return false;
2363 }
2364 true
2365 });
2366}
2367
2368fn bucket_files_by_workspace(
2369 workspace_pkgs: &[LoadedWorkspacePackage],
2370 file_paths: &[std::path::PathBuf],
2371) -> Vec<Vec<(std::path::PathBuf, String)>> {
2372 let workspace_roots: Vec<_> = workspace_pkgs
2373 .iter()
2374 .map(|(workspace, _)| workspace.root.as_path())
2375 .collect();
2376 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2377}
2378
2379fn bucket_files_by_workspace_roots(
2380 workspace_roots: &[&Path],
2381 file_paths: &[std::path::PathBuf],
2382) -> Vec<Vec<(std::path::PathBuf, String)>> {
2383 use rayon::prelude::*;
2384
2385 let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
2389 rustc_hash::FxHashMap::default();
2390 for (idx, root) in workspace_roots.iter().enumerate() {
2391 workspace_by_root.entry(root).or_insert(idx);
2392 }
2393
2394 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2395 .par_iter()
2396 .map(|file_path| {
2397 let idx = file_path
2398 .ancestors()
2399 .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
2400 .min()?;
2401 let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
2402 Some((
2403 idx,
2404 file_path.clone(),
2405 relative.to_string_lossy().into_owned(),
2406 ))
2407 })
2408 .collect();
2409
2410 let mut buckets = vec![Vec::new(); workspace_roots.len()];
2411 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2412 buckets[idx].push((file_path, relative));
2413 }
2414
2415 buckets
2416}
2417
2418#[doc(hidden)]
2420pub fn benchmark_bucket_files_by_workspace(
2421 workspace_roots: &[std::path::PathBuf],
2422 file_paths: &[std::path::PathBuf],
2423) -> Vec<Vec<(std::path::PathBuf, String)>> {
2424 let workspace_roots: Vec<_> = workspace_roots
2425 .iter()
2426 .map(std::path::PathBuf::as_path)
2427 .collect();
2428 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2429}
2430
2431fn collect_config_search_roots(
2432 root: &Path,
2433 file_paths: &[std::path::PathBuf],
2434) -> Vec<std::path::PathBuf> {
2435 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2436 roots.insert(root.to_path_buf());
2437
2438 for file_path in file_paths {
2439 let mut current = file_path.parent();
2440 while let Some(dir) = current {
2441 if !dir.starts_with(root) {
2442 break;
2443 }
2444 roots.insert(dir.to_path_buf());
2445 if dir == root {
2446 break;
2447 }
2448 current = dir.parent();
2449 }
2450 }
2451
2452 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2453 roots_vec.sort();
2454 roots_vec
2455}
2456
2457fn config_for_project(
2465 root: &Path,
2466 config_path: Option<&Path>,
2467) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2468 let user_config = if let Some(path) = config_path {
2469 Some((
2470 fallow_config::FallowConfig::load(path)
2471 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2472 path.to_path_buf(),
2473 ))
2474 } else {
2475 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2476 };
2477
2478 let config = match user_config {
2479 Some((config, path)) => resolve_user_config(config, path, root)?,
2480 None => (
2481 fallow_config::FallowConfig::default().resolve(
2482 root.to_path_buf(),
2483 fallow_config::OutputFormat::Human,
2484 num_cpus(),
2485 false,
2486 true,
2487 None,
2488 ),
2489 None,
2490 ),
2491 };
2492
2493 Ok(config)
2494}
2495
2496fn resolve_user_config(
2499 mut config: fallow_config::FallowConfig,
2500 path: std::path::PathBuf,
2501 root: &Path,
2502) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2503 let dead_code_production = config
2504 .production
2505 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2506 config.production = dead_code_production.into();
2507 config
2508 .validate_resolved_boundaries(root)
2509 .map_err(|errors| {
2510 let joined = errors
2511 .iter()
2512 .map(ToString::to_string)
2513 .collect::<Vec<_>>()
2514 .join("\n - ");
2515 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
2516 })?;
2517 let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
2518 let joined = errors
2519 .iter()
2520 .map(ToString::to_string)
2521 .collect::<Vec<_>>()
2522 .join("\n - ");
2523 FallowError::config(format!("invalid rule pack:\n - {joined}"))
2524 })?;
2525 let boundaries =
2526 fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
2527 let zone_errors = fallow_config::validate_rule_pack_zone_references(
2528 root,
2529 &config.rule_packs,
2530 &packs,
2531 &boundaries,
2532 );
2533 if !zone_errors.is_empty() {
2534 let joined = zone_errors
2535 .iter()
2536 .map(ToString::to_string)
2537 .collect::<Vec<_>>()
2538 .join("\n - ");
2539 return Err(FallowError::config(format!(
2540 "invalid rule pack:\n - {joined}"
2541 )));
2542 }
2543 Ok((
2544 config.resolve(
2545 root.to_path_buf(),
2546 fallow_config::OutputFormat::Human,
2547 num_cpus(),
2548 false,
2549 true, None, ),
2552 Some(path),
2553 ))
2554}
2555
2556#[cfg_attr(
2567 not(test),
2568 allow(
2569 dead_code,
2570 reason = "config resolution fallback is exercised by session tests"
2571 )
2572)]
2573pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
2574 config_for_project(root, None).map_or_else(
2575 |_| {
2576 fallow_config::FallowConfig::default().resolve(
2577 root.to_path_buf(),
2578 fallow_config::OutputFormat::Human,
2579 num_cpus(),
2580 false,
2581 true,
2582 None,
2583 )
2584 },
2585 |(config, _)| config,
2586 )
2587}
2588
2589fn num_cpus() -> usize {
2590 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
2591}
2592
2593#[cfg(test)]
2594mod tests {
2595 use super::{
2596 AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
2597 collect_config_search_roots, credit_workspace_package_usage, default_config,
2598 format_undeclared_workspace_warning, parse_analysis_modules, plugin_config_hash,
2599 resolver_options_hash, warn_undeclared_workspaces,
2600 };
2601 use std::path::{Path, PathBuf};
2602 use std::time::Instant;
2603
2604 use fallow_config::{
2605 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
2606 };
2607 use fallow_types::discover::{DiscoveredFile, FileId};
2608 use fallow_types::extract::{ImportInfo, ImportedName};
2609
2610 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
2611 let mut result = crate::plugins::AggregatedPluginResult::default();
2612 result.active_plugins.push("nuxt".to_string());
2613 result
2614 .path_aliases
2615 .push(("@/".to_string(), "src/".to_string()));
2616 result
2617 }
2618
2619 #[test]
2620 fn commonjs_internal_import_credits_workspace_package_usage() {
2621 let workspace = fallow_config::WorkspaceInfo {
2622 root: PathBuf::from("/repo/packages/shared"),
2623 name: "@repo/shared".to_string(),
2624 is_internal_dependency: true,
2625 };
2626 let resolved = vec![crate::resolve::ResolvedModule {
2627 file_id: FileId(0),
2628 resolved_imports: vec![crate::resolve::ResolvedImport {
2629 info: ImportInfo {
2630 source: "@repo/shared".to_string(),
2631 imported_name: ImportedName::Namespace,
2632 local_name: "shared".to_string(),
2633 is_type_only: false,
2634 is_type_only_star: false,
2635 from_style: false,
2636 span: oxc_span::Span::new(0, 20),
2637 source_span: oxc_span::Span::new(8, 20),
2638 },
2639 target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
2640 }],
2641 ..crate::resolve::ResolvedModule::default()
2642 }];
2643 let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
2644
2645 credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
2646
2647 assert_eq!(
2648 graph.package_usage.get("@repo/shared"),
2649 Some(&vec![FileId(0)])
2650 );
2651 }
2652
2653 #[test]
2654 fn graph_cache_resolver_hash_includes_project_root() {
2655 let dir_a = tempfile::tempdir().expect("create temp dir a");
2656 let dir_b = tempfile::tempdir().expect("create temp dir b");
2657 let config_a = session_config(dir_a.path());
2658 let config_b = session_config(dir_b.path());
2659
2660 assert_ne!(
2661 resolver_options_hash(&config_a),
2662 resolver_options_hash(&config_b),
2663 "shared cache dirs must not reuse graphs across project roots"
2664 );
2665 }
2666
2667 #[test]
2668 fn graph_cache_resolver_hash_includes_resolve_conditions() {
2669 let dir = tempfile::tempdir().expect("create temp dir");
2670 let config_a = session_config(dir.path());
2671 let mut config_b = session_config(dir.path());
2672 config_b.resolve.conditions.push("react-server".to_string());
2673
2674 assert_ne!(
2675 resolver_options_hash(&config_a),
2676 resolver_options_hash(&config_b),
2677 "resolve condition changes must invalidate the graph cache"
2678 );
2679 }
2680
2681 #[test]
2682 fn graph_cache_plugin_hash_includes_auto_imports() {
2683 let mut without_auto_import = plugin_result();
2684 let mut with_auto_import = plugin_result();
2685 with_auto_import.auto_imports.push(AutoImportRule {
2686 name: "useCounter".to_string(),
2687 source: PathBuf::from("/project/composables/useCounter.ts"),
2688 kind: AutoImportKind::Named,
2689 });
2690
2691 assert_ne!(
2692 plugin_config_hash(&without_auto_import),
2693 plugin_config_hash(&with_auto_import),
2694 "auto-import edge changes must invalidate the graph cache"
2695 );
2696
2697 without_auto_import.auto_imports.push(AutoImportRule {
2698 name: "useCounter".to_string(),
2699 source: PathBuf::from("/project/composables/useCounter.ts"),
2700 kind: AutoImportKind::Default,
2701 });
2702 assert_ne!(
2703 plugin_config_hash(&without_auto_import),
2704 plugin_config_hash(&with_auto_import),
2705 "auto-import kind changes must invalidate the graph cache"
2706 );
2707 }
2708
2709 #[test]
2710 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
2711 let base = plugin_result();
2712 let mut with_scss = base.clone();
2713 with_scss
2714 .scss_include_paths
2715 .push(PathBuf::from("/project/styles"));
2716 assert_ne!(
2717 plugin_config_hash(&base),
2718 plugin_config_hash(&with_scss),
2719 "SCSS include path changes must invalidate the graph cache"
2720 );
2721
2722 let mut with_static_dir = base.clone();
2723 with_static_dir
2724 .static_dir_mappings
2725 .push((PathBuf::from("/project/public"), "/".to_string()));
2726 assert_ne!(
2727 plugin_config_hash(&base),
2728 plugin_config_hash(&with_static_dir),
2729 "static directory mapping changes must invalidate the graph cache"
2730 );
2731 }
2732
2733 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
2734 WorkspaceDiagnostic::new(
2735 root,
2736 root.join(relative),
2737 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2738 )
2739 }
2740
2741 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
2742 let mut config = default_config(root);
2743 config.no_cache = true;
2744 config.quiet = true;
2745 config
2746 }
2747
2748 fn write_session_fixture(root: &Path) {
2749 let src = root.join("src");
2750 std::fs::create_dir_all(&src).expect("create src");
2751 std::fs::write(
2752 root.join("package.json"),
2753 r#"{"name":"session-fixture","type":"module"}"#,
2754 )
2755 .expect("write package json");
2756 std::fs::write(
2757 src.join("index.ts"),
2758 "import { used } from './used';\nconsole.log(used);\n",
2759 )
2760 .expect("write index");
2761 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
2762 }
2763
2764 #[test]
2765 fn analysis_session_discovers_project_files() {
2766 let dir = tempfile::tempdir().expect("create temp dir");
2767 write_session_fixture(dir.path());
2768 let config = session_config(dir.path());
2769
2770 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2771
2772 assert!(
2773 session
2774 .files()
2775 .iter()
2776 .any(|file| file.path.ends_with("src/index.ts")),
2777 "session should own discovered project files"
2778 );
2779 assert_eq!(session.workspaces().len(), 0);
2780 }
2781
2782 #[test]
2783 fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
2784 let project = tempfile::tempdir().expect("create project");
2785 let root = project.path();
2786 let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
2787 for (index, path) in paths.iter().enumerate() {
2788 std::fs::write(path, format!("export const value{index} = {index};\n"))
2789 .expect("write source");
2790 }
2791 let files: Vec<DiscoveredFile> = paths
2792 .iter()
2793 .enumerate()
2794 .map(|(index, path)| DiscoveredFile {
2795 id: FileId(u32::try_from(index).expect("test index fits u32")),
2796 path: path.clone(),
2797 size_bytes: std::fs::metadata(path).expect("source metadata").len(),
2798 })
2799 .collect();
2800 std::fs::remove_file(&paths[1]).expect("remove source after discovery");
2801 let config = session_config(root);
2802
2803 let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
2804
2805 assert_eq!(
2806 parsed
2807 .modules
2808 .iter()
2809 .map(|module| module.file_id)
2810 .collect::<Vec<_>>(),
2811 vec![FileId(0), FileId(2)]
2812 );
2813 let diagnostics = fallow_config::workspace_diagnostics_for(root);
2814 let diagnostic = diagnostics
2815 .iter()
2816 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
2817 .expect("source read failure diagnostic");
2818 assert_eq!(diagnostic.path, paths[1]);
2819 assert!(matches!(
2820 diagnostic.kind,
2821 WorkspaceDiagnosticKind::SourceReadFailure { .. }
2822 ));
2823 }
2824
2825 #[test]
2826 fn analysis_session_parses_owned_modules() {
2827 let dir = tempfile::tempdir().expect("create temp dir");
2828 write_session_fixture(dir.path());
2829 let config = session_config(dir.path());
2830
2831 let session = AnalysisSession::new(&config).expect("session setup should succeed");
2832 let parsed = session.parse_modules(false);
2833
2834 assert!(
2835 parsed
2836 .modules
2837 .iter()
2838 .any(|module| session.files()[module.file_id.0 as usize]
2839 .path
2840 .ends_with("src/index.ts")),
2841 "session parsing should return modules keyed to session files"
2842 );
2843 }
2844
2845 #[test]
2846 fn undeclared_workspace_warning_is_singular_for_one_path() {
2847 let root = Path::new("/repo");
2848 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
2849 .expect("warning should be rendered");
2850
2851 assert_eq!(
2852 warning,
2853 "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."
2854 );
2855 }
2856
2857 #[test]
2858 fn undeclared_workspace_warning_summarizes_many_paths() {
2859 let root = PathBuf::from("/repo");
2860 let diagnostics = [
2861 "examples/a",
2862 "examples/b",
2863 "examples/c",
2864 "examples/d",
2865 "examples/e",
2866 "examples/f",
2867 ]
2868 .into_iter()
2869 .map(|path| diag(&root, path))
2870 .collect::<Vec<_>>();
2871
2872 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
2873 .expect("warning should be rendered");
2874
2875 assert_eq!(
2876 warning,
2877 "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."
2878 );
2879 }
2880
2881 #[test]
2882 fn collect_config_search_roots_includes_file_ancestors_once() {
2883 let root = PathBuf::from("/repo");
2884 let search_roots = collect_config_search_roots(
2885 &root,
2886 &[
2887 root.join("apps/query/src/main.ts"),
2888 root.join("packages/shared/lib/index.ts"),
2889 ],
2890 );
2891
2892 assert_eq!(
2893 search_roots,
2894 vec![
2895 root.clone(),
2896 root.join("apps"),
2897 root.join("apps/query"),
2898 root.join("apps/query/src"),
2899 root.join("packages"),
2900 root.join("packages/shared"),
2901 root.join("packages/shared/lib"),
2902 ]
2903 );
2904 }
2905
2906 #[test]
2907 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
2908 let root = PathBuf::from("/repo");
2909 let ui = fallow_config::WorkspaceInfo {
2910 root: root.join("apps/ui"),
2911 name: "ui".to_string(),
2912 is_internal_dependency: false,
2913 };
2914 let api = fallow_config::WorkspaceInfo {
2915 root: root.join("apps/api"),
2916 name: "api".to_string(),
2917 is_internal_dependency: false,
2918 };
2919 let workspace_pkgs = vec![
2920 (
2921 ui,
2922 fallow_config::PackageJson {
2923 name: Some("ui".to_string()),
2924 ..Default::default()
2925 },
2926 ),
2927 (
2928 api,
2929 fallow_config::PackageJson {
2930 name: Some("api".to_string()),
2931 ..Default::default()
2932 },
2933 ),
2934 ];
2935 let files = vec![
2936 root.join("apps/ui/vite.config.ts"),
2937 root.join("apps/ui/src/main.ts"),
2938 root.join("apps/api/src/server.ts"),
2939 root.join("tools/build.ts"),
2940 ];
2941
2942 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
2943
2944 assert_eq!(
2945 buckets[0],
2946 vec![
2947 (
2948 root.join("apps/ui/vite.config.ts"),
2949 "vite.config.ts".to_string()
2950 ),
2951 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
2952 ]
2953 );
2954 assert_eq!(
2955 buckets[1],
2956 vec![(
2957 root.join("apps/api/src/server.ts"),
2958 "src/server.ts".to_string()
2959 )]
2960 );
2961 }
2962
2963 #[test]
2964 fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
2965 let root = PathBuf::from("/repo");
2966 let parent = root.join("apps");
2967 let child = parent.join("web");
2968 let nested_first = child.join("src/first.ts");
2969 let nested_second = child.join("src/second.ts");
2970 let unmatched = root.join("tools/build.ts");
2971 let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
2972
2973 let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
2978 bucket
2979 .iter()
2980 .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
2981 .collect()
2982 };
2983
2984 let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
2985 assert_eq!(
2986 normalize(&parent_first[0]),
2987 vec![
2988 (nested_first.clone(), "web/src/first.ts".to_string()),
2989 (nested_second.clone(), "web/src/second.ts".to_string()),
2990 ]
2991 );
2992 assert!(parent_first[1].is_empty());
2993 assert!(parent_first[2].is_empty());
2994
2995 let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
2996 assert_eq!(
2997 normalize(&child_first[0]),
2998 vec![
2999 (nested_first, "src/first.ts".to_string()),
3000 (nested_second, "src/second.ts".to_string()),
3001 ]
3002 );
3003 assert!(child_first[1].is_empty());
3004 }
3005
3006 #[test]
3007 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
3008 let dir = tempfile::tempdir().expect("create temp dir");
3009 let pkg_good = dir.path().join("packages").join("good");
3010 let pkg_bad = dir.path().join("packages").join("bad");
3011 std::fs::create_dir_all(&pkg_good).unwrap();
3012 std::fs::create_dir_all(&pkg_bad).unwrap();
3013 std::fs::write(
3014 dir.path().join("package.json"),
3015 r#"{"workspaces": ["packages/*"]}"#,
3016 )
3017 .unwrap();
3018 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
3019 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
3020
3021 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
3022 dir.path(),
3023 &globset::GlobSet::empty(),
3024 )
3025 .expect("root package.json is valid");
3026 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
3027 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
3028
3029 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
3030
3031 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3032 let mut malformed = 0;
3033 let mut undeclared_for_bad = 0;
3034 for diag in &diagnostics {
3035 if matches!(
3036 diag.kind,
3037 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
3038 ) && diag.path.ends_with("bad")
3039 {
3040 malformed += 1;
3041 }
3042 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
3043 && diag.path.ends_with("bad")
3044 {
3045 undeclared_for_bad += 1;
3046 }
3047 }
3048 assert_eq!(
3049 malformed, 1,
3050 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
3051 );
3052 assert_eq!(
3053 undeclared_for_bad, 0,
3054 "warn_undeclared_workspaces must NOT re-flag a path that already \
3055 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
3056 );
3057 }
3058}