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