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::cache_rejection::CacheRejection;
54use fallow_types::trace::{EntryPointSpans, PipelineTimings};
55use rayon::prelude::*;
56use results::AnalysisResults;
57use rustc_hash::FxHashSet;
58
59const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
60type LoadedWorkspacePackage = (fallow_config::WorkspaceInfo, PackageJson);
61
62fn record_graph_package_usage(
63 graph: &mut graph::ModuleGraph,
64 package_name: &str,
65 file_id: discover::FileId,
66 is_type_only: bool,
67) {
68 graph
69 .package_usage
70 .entry(package_name.to_owned())
71 .or_default()
72 .push(file_id);
73 if is_type_only {
74 graph
75 .type_only_package_usage
76 .entry(package_name.to_owned())
77 .or_default()
78 .push(file_id);
79 }
80}
81
82fn workspace_package_name<'a>(
83 source: &str,
84 workspace_names: &FxHashSet<&'a str>,
85) -> Option<&'a str> {
86 if !resolve::is_bare_specifier(source) {
87 return None;
88 }
89 let package_name = resolve::extract_package_name(source);
90 workspace_names.get(package_name.as_str()).copied()
91}
92
93fn credit_workspace_package_usage(
94 graph: &mut graph::ModuleGraph,
95 resolved: &[resolve::ResolvedModule],
96 workspaces: &[fallow_config::WorkspaceInfo],
97) {
98 if workspaces.is_empty() {
99 return;
100 }
101
102 let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
103 for module in resolved {
104 for import in module.all_resolved_imports() {
105 if matches!(
106 import.target,
107 resolve::ResolveResult::InternalModule(_)
108 | resolve::ResolveResult::CommonJsInternalModule(_)
109 ) && let Some(package_name) =
110 workspace_package_name(&import.info.source, &workspace_names)
111 {
112 record_graph_package_usage(
113 graph,
114 package_name,
115 module.file_id,
116 import.info.is_type_only,
117 );
118 }
119 }
120
121 for re_export in &module.re_exports {
122 if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
123 && let Some(package_name) =
124 workspace_package_name(&re_export.info.source, &workspace_names)
125 {
126 record_graph_package_usage(
127 graph,
128 package_name,
129 module.file_id,
130 re_export.info.is_type_only,
131 );
132 }
133 }
134 }
135}
136
137fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
138 for module in modules {
139 for package_name in &module.package_path_references {
140 record_graph_package_usage(graph, package_name, module.file_id, false);
141 }
142 }
143}
144
145#[doc(hidden)]
147pub struct AnalysisOutput {
148 pub results: AnalysisResults,
149 pub timings: Option<PipelineTimings>,
150 pub graph: Option<graph::ModuleGraph>,
151 pub modules: Option<Vec<extract::ModuleInfo>>,
155 pub files: Option<Vec<discover::DiscoveredFile>>,
157 pub script_used_packages: rustc_hash::FxHashSet<String>,
162 pub trace_provenance: fallow_types::trace::TraceProvenance,
165 pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
174}
175
176#[derive(Debug, Clone, Copy)]
179#[doc(hidden)]
180pub struct AnalysisParseMetrics {
181 parse_ms: f64,
182 cache_ms: f64,
183 cache_hits: usize,
184 cache_misses: usize,
185 parse_cpu_ms: f64,
186 cache_rejection: Option<CacheRejection>,
187}
188
189fn update_cache(
199 store: &mut cache::CacheStore,
200 modules: &[extract::ModuleInfo],
201 files: &[discover::DiscoveredFile],
202 need_complexity: bool,
203) -> bool {
204 let mut dirty = false;
205 for module in modules {
206 if let Some(file) = files.get(module.file_id.0 as usize) {
207 let fingerprint = file_fingerprint(&file.path);
208 if let Some(cached) = store.get_by_path_only(&file.path)
209 && cached.content_hash == module.content_hash
210 {
211 let stale_metadata = cached.source_fingerprint() != fingerprint;
212 let adds_complexity = need_complexity && !cached.complexity_extracted;
213 if stale_metadata || adds_complexity {
214 let preserved_last_access = cached.last_access_secs;
215 let preserved_complexity = (!need_complexity && cached.complexity_extracted)
216 .then(|| cached.complexity.clone());
217 let mut refreshed =
218 cache::module_to_cached(module, fingerprint, need_complexity);
219 refreshed.last_access_secs = preserved_last_access;
220 if let Some(complexity) = preserved_complexity {
221 refreshed.complexity = complexity;
222 refreshed.complexity_extracted = true;
223 }
224 store.insert(&file.path, refreshed);
225 dirty = true;
226 }
227 continue;
228 }
229 store.insert(
230 &file.path,
231 cache::module_to_cached(module, fingerprint, need_complexity),
232 );
233 dirty = true;
234 }
235 }
236 let removed_stale_paths = store.retain_paths(files);
237 dirty || removed_stale_paths
238}
239
240#[must_use]
248fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
249 config
250 .cache_max_size_mb
251 .map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
252 (mb as usize).saturating_mul(1024 * 1024)
253 })
254}
255
256fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
258 std::fs::metadata(path).map_or(
259 fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
260 |metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
261 )
262}
263
264fn format_undeclared_workspace_warning(
265 root: &Path,
266 undeclared: &[fallow_config::WorkspaceDiagnostic],
267) -> Option<String> {
268 if undeclared.is_empty() {
269 return None;
270 }
271
272 let preview = undeclared
273 .iter()
274 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
275 .map(|diag| {
276 diag.path
277 .strip_prefix(root)
278 .unwrap_or(&diag.path)
279 .display()
280 .to_string()
281 .replace('\\', "/")
282 })
283 .collect::<Vec<_>>();
284 let remaining = undeclared
285 .len()
286 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
287 let tail = if remaining > 0 {
288 format!(" (and {remaining} more)")
289 } else {
290 String::new()
291 };
292 let noun = if undeclared.len() == 1 {
293 "directory with package.json is"
294 } else {
295 "directories with package.json are"
296 };
297 let guidance = if undeclared.len() == 1 {
298 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
299 } else {
300 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
301 };
302
303 Some(format!(
304 "{} {} not declared as {}: {}{}. {}",
305 undeclared.len(),
306 noun,
307 if undeclared.len() == 1 {
308 "a workspace"
309 } else {
310 "workspaces"
311 },
312 preview.join(", "),
313 tail,
314 guidance
315 ))
316}
317
318fn warn_undeclared_workspaces(
319 root: &Path,
320 workspaces_vec: &[fallow_config::WorkspaceInfo],
321 ignore_patterns: &globset::GlobSet,
322 quiet: bool,
323) {
324 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
325 if undeclared.is_empty() {
326 return;
327 }
328
329 let existing = fallow_config::workspace_diagnostics_for(root);
330 let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
331 .iter()
332 .map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
333 .collect();
334 let undeclared: Vec<_> = undeclared
335 .into_iter()
336 .filter(|diag| {
337 let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
338 !already_flagged.contains(&canonical)
339 })
340 .collect();
341 if undeclared.is_empty() {
342 return;
343 }
344
345 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
346
347 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
348 tracing::warn!("{message}");
349 }
350}
351
352#[doc(hidden)]
358#[deprecated(
359 since = "2.76.0",
360 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."
361)]
362pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
363 let output = analyze_full(config, false, false, false, false)?;
364 Ok(output.results)
365}
366
367#[doc(hidden)]
373#[deprecated(
374 since = "2.76.0",
375 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."
376)]
377pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
378 let output = analyze_full(config, false, true, false, false)?;
379 Ok(output.results)
380}
381
382#[doc(hidden)]
388#[deprecated(
389 since = "2.76.0",
390 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."
391)]
392pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
393 analyze_full(config, true, false, false, false)
394}
395
396#[doc(hidden)]
406#[deprecated(
407 since = "2.76.0",
408 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."
409)]
410pub fn analyze_retaining_modules(
411 config: &ResolvedConfig,
412 need_complexity: bool,
413 retain_graph: bool,
414) -> Result<AnalysisOutput, FallowError> {
415 analyze_full(config, retain_graph, false, need_complexity, true)
416}
417
418fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
419 let show_progress = !config.quiet
420 && std::io::IsTerminal::is_terminal(&std::io::stderr())
421 && matches!(
422 config.output,
423 fallow_config::OutputFormat::Human
424 | fallow_config::OutputFormat::Compact
425 | fallow_config::OutputFormat::Markdown
426 );
427 progress::AnalysisProgress::new(show_progress)
428}
429
430fn discover_analysis_workspaces(
431 config: &ResolvedConfig,
432) -> Result<(Vec<fallow_config::WorkspaceInfo>, f64), FallowError> {
433 let t = Instant::now();
434 let (workspaces, diagnostics) =
435 discover_workspaces_with_diagnostics(&config.root, &config.ignore_patterns)
436 .map_err(|error| FallowError::config(error.to_string()))?;
437 fallow_config::stash_workspace_diagnostics(&config.root, diagnostics);
438 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
439 if !workspaces.is_empty() {
440 tracing::info!(count = workspaces.len(), "workspaces discovered");
441 }
442
443 warn_undeclared_workspaces(
444 &config.root,
445 &workspaces,
446 &config.ignore_patterns,
447 config.quiet,
448 );
449
450 Ok((workspaces, workspaces_ms))
451}
452
453struct AnalysisSetup {
457 progress: progress::AnalysisProgress,
458 project: project::ProjectState,
459 root_pkg: Option<PackageJson>,
460 config_candidates: Vec<std::path::PathBuf>,
465 discover_ms: f64,
466 workspaces_ms: f64,
467}
468
469#[derive(Debug, Clone)]
475#[doc(hidden)]
476pub struct AnalysisDiscovery {
477 files: Vec<discover::DiscoveredFile>,
478 workspaces: Vec<fallow_config::WorkspaceInfo>,
479 root_pkg: Option<PackageJson>,
480 config_candidates: Vec<std::path::PathBuf>,
481 discover_ms: f64,
482 workspaces_ms: f64,
483}
484
485impl AnalysisDiscovery {
486 #[must_use]
488 pub fn from_parts(
489 files: Vec<discover::DiscoveredFile>,
490 workspaces: Vec<fallow_config::WorkspaceInfo>,
491 root_pkg: Option<PackageJson>,
492 config_candidates: Vec<std::path::PathBuf>,
493 discover_ms: f64,
494 workspaces_ms: f64,
495 ) -> Self {
496 Self {
497 files,
498 workspaces,
499 root_pkg,
500 config_candidates,
501 discover_ms,
502 workspaces_ms,
503 }
504 }
505
506 #[must_use]
508 fn files(&self) -> &[discover::DiscoveredFile] {
509 &self.files
510 }
511
512 #[must_use]
514 pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
515 &self.workspaces
516 }
517
518 #[must_use]
520 pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
521 self.files
522 }
523}
524
525pub(crate) struct AnalysisSession<'a> {
530 config: &'a ResolvedConfig,
531 pipeline_start: Instant,
532 progress: progress::AnalysisProgress,
533 project: project::ProjectState,
534 root_pkg: Option<PackageJson>,
535 config_candidates: Vec<std::path::PathBuf>,
536 discover_ms: f64,
537 workspaces_ms: f64,
538}
539
540impl<'a> AnalysisSession<'a> {
541 fn new(config: &'a ResolvedConfig) -> Result<Self, FallowError> {
542 let pipeline_start = Instant::now();
543 let AnalysisSetup {
544 progress,
545 project,
546 root_pkg,
547 config_candidates,
548 discover_ms,
549 workspaces_ms,
550 } = run_analysis_setup(config)?;
551
552 Ok(Self {
553 config,
554 pipeline_start,
555 progress,
556 project,
557 root_pkg,
558 config_candidates,
559 discover_ms,
560 workspaces_ms,
561 })
562 }
563
564 fn files(&self) -> &[discover::DiscoveredFile] {
565 self.project.files()
566 }
567
568 fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
569 self.project.workspaces()
570 }
571
572 fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage> {
573 load_workspace_packages(self.workspaces())
574 }
575
576 fn run_plugins_and_scripts(
577 &self,
578 workspace_pkgs: &[LoadedWorkspacePackage],
579 ) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
580 run_plugins_and_scripts(&PluginScriptInput {
581 config: self.config,
582 progress: &self.progress,
583 files: self.files(),
584 workspaces: self.workspaces(),
585 root_pkg: self.root_pkg.as_ref(),
586 workspace_pkgs,
587 config_candidates: &self.config_candidates,
588 })
589 }
590
591 fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
592 PreludeTimings {
593 discover_ms: self.discover_ms,
594 workspaces_ms: self.workspaces_ms,
595 plugins_ms,
596 scripts_ms,
597 }
598 }
599
600 fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
601 let t = Instant::now();
602 self.progress
603 .set_stage(&format!("parsing {} files...", self.files().len()));
604 parse_analysis_modules(self.config, self.files(), need_complexity, t)
605 }
606
607 fn run_owned_core(
608 &self,
609 workspace_pkgs: &[LoadedWorkspacePackage],
610 plugin_result: &plugins::AggregatedPluginResult,
611 mut modules: Vec<extract::ModuleInfo>,
612 collect_usages: bool,
613 ) -> OwnedAnalysisCore {
614 let shared = AnalysisCoreSharedInput {
615 config: self.config,
616 progress: &self.progress,
617 files: self.files(),
618 workspaces: self.workspaces(),
619 root_pkg: self.root_pkg.as_ref(),
620 workspace_pkgs,
621 plugin_result,
622 };
623
624 let entry_points = discover_analysis_entry_points(&shared);
625 let mut graph_cache_rejection = None;
626 let (resolved, graph) =
627 match try_load_analysis_graph_cache(&shared, &entry_points, &modules) {
628 Ok(hit) => (
629 TimedResolvedModules {
630 project: hit.project,
631 elapsed_ms: 0.0,
632 },
633 TimedGraph {
634 graph: hit.graph,
635 elapsed_ms: hit.elapsed_ms,
636 },
637 ),
638 Err(rejection) => {
639 graph_cache_rejection = rejection;
640 let resolved = resolve_analysis_imports_timed(&shared, &modules);
641 let graph = build_analysis_graph_timed(
642 &shared,
643 &resolved.project,
644 &entry_points,
645 &modules,
646 );
647 (resolved, graph)
648 }
649 };
650 release_resolution_payloads(&mut modules);
651 let analysis = analyze_dead_code_timed(
652 &shared,
653 &graph.graph,
654 &resolved.project.modules,
655 &modules,
656 collect_usages,
657 entry_points.summary,
658 );
659
660 OwnedAnalysisCore {
661 result: analysis.result,
662 graph: graph.graph,
663 modules,
664 entry_point_count: entry_points.count,
665 entry_points_ms: entry_points.elapsed_ms,
666 entry_point_spans: entry_points.spans,
667 resolve_ms: resolved.elapsed_ms,
668 graph_ms: graph.elapsed_ms,
669 analyze_ms: analysis.elapsed_ms,
670 graph_cache_rejection,
671 }
672 }
673
674 fn run_full(
675 self,
676 retain: bool,
677 collect_usages: bool,
678 need_complexity: bool,
679 retain_modules: bool,
680 ) -> Result<AnalysisOutput, FallowError> {
681 let workspace_pkgs = self.load_workspace_packages();
682 let (plugin_result, plugins_ms, scripts_ms) =
683 self.run_plugins_and_scripts(&workspace_pkgs)?;
684
685 let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
686 let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
687 self.progress.finish();
688
689 let profile = full_analysis_pipeline_profile(
690 &self.prelude_timings(plugins_ms, scripts_ms),
691 self.pipeline_start,
692 self.files(),
693 self.workspaces(),
694 &core,
695 &metrics,
696 );
697 trace_pipeline_profile(&profile);
698
699 let trace_provenance = plugins::federation_trace_provenance(
700 &self.config.root,
701 self.files(),
702 &plugin_result.federation_sources,
703 &core.modules,
704 );
705 let mut output = assemble_full_output(
706 core,
707 plugin_result,
708 &profile,
709 self.files(),
710 retain,
711 retain_modules,
712 );
713 output.trace_provenance = trace_provenance;
714 Ok(output)
715 }
716}
717
718fn run_analysis_setup(config: &ResolvedConfig) -> Result<AnalysisSetup, FallowError> {
721 let progress = new_analysis_progress(config);
722
723 let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config)?;
724 let root_pkg = fallow_config::load_dir_package_json(&config.root);
725 let discovery_hidden_dir_scopes =
726 discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
727
728 let t = Instant::now();
729 progress.set_stage("discovering files...");
730 let (discovered_files, config_candidates) =
731 discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
732 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
733
734 let project = project::ProjectState::new(discovered_files, workspaces_vec);
735
736 Ok(AnalysisSetup {
737 progress,
738 project,
739 root_pkg,
740 config_candidates,
741 discover_ms,
742 workspaces_ms,
743 })
744}
745
746struct PluginScriptInput<'a> {
748 config: &'a ResolvedConfig,
749 progress: &'a progress::AnalysisProgress,
750 files: &'a [discover::DiscoveredFile],
751 workspaces: &'a [fallow_config::WorkspaceInfo],
752 root_pkg: Option<&'a PackageJson>,
753 workspace_pkgs: &'a [LoadedWorkspacePackage],
754 config_candidates: &'a [std::path::PathBuf],
755}
756
757fn run_plugins_and_scripts(
760 input: &PluginScriptInput<'_>,
761) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
762 let t = Instant::now();
763 input.progress.set_stage("detecting plugins...");
764 let mut plugin_result = run_plugins(
765 input.config,
766 input.files,
767 input.workspaces,
768 input.root_pkg,
769 input.workspace_pkgs,
770 input.config_candidates,
771 )?;
772 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
773
774 let t = Instant::now();
775 analyze_all_scripts(
776 input.config,
777 input.workspaces,
778 input.root_pkg,
779 input.workspace_pkgs,
780 &mut plugin_result,
781 );
782 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
783
784 Ok((plugin_result, plugins_ms, scripts_ms))
785}
786
787#[derive(Debug, Clone, Copy)]
789#[doc(hidden)]
790pub struct DeadCodePreludeTimings {
791 pub discover_ms: f64,
792 pub workspaces_ms: f64,
793 pub plugins_ms: f64,
794 pub scripts_ms: f64,
795}
796
797#[doc(hidden)]
802pub struct DeadCodeBackendPrelude<'a> {
803 config: &'a ResolvedConfig,
804 pipeline_start: Instant,
805 progress: progress::AnalysisProgress,
806 discovery: AnalysisDiscovery,
807 workspace_pkgs: Vec<LoadedWorkspacePackage>,
808 plugin_result: plugins::AggregatedPluginResult,
809 plugins_ms: f64,
810 scripts_ms: f64,
811}
812
813impl DeadCodeBackendPrelude<'_> {
814 #[must_use]
815 pub fn timings(&self) -> DeadCodePreludeTimings {
816 DeadCodePreludeTimings {
817 discover_ms: self.discovery.discover_ms,
818 workspaces_ms: self.discovery.workspaces_ms,
819 plugins_ms: self.plugins_ms,
820 scripts_ms: self.scripts_ms,
821 }
822 }
823
824 #[must_use]
825 pub fn elapsed_ms(&self) -> f64 {
826 self.pipeline_start.elapsed().as_secs_f64() * 1000.0
827 }
828
829 #[must_use]
830 pub fn script_used_packages(&self) -> FxHashSet<String> {
831 self.plugin_result.script_used_packages.clone()
832 }
833
834 #[must_use]
837 pub fn trace_provenance(
838 &self,
839 modules: &[extract::ModuleInfo],
840 ) -> fallow_types::trace::TraceProvenance {
841 plugins::federation_trace_provenance(
842 &self.config.root,
843 self.discovery.files(),
844 &self.plugin_result.federation_sources,
845 modules,
846 )
847 }
848
849 #[must_use]
852 pub const fn plugin_result(&self) -> &plugins::AggregatedPluginResult {
853 &self.plugin_result
854 }
855
856 pub fn finish(&self) {
857 self.progress.finish();
858 }
859}
860
861#[doc(hidden)]
863pub struct DeadCodeEntryPoints {
864 inner: TimedEntryPoints,
865}
866
867impl DeadCodeEntryPoints {
868 #[must_use]
869 pub fn count(&self) -> usize {
870 self.inner.count
871 }
872
873 #[must_use]
874 pub fn elapsed_ms(&self) -> f64 {
875 self.inner.elapsed_ms
876 }
877
878 #[must_use]
880 pub fn spans(&self) -> EntryPointSpans {
881 self.inner.spans
882 }
883
884 #[must_use]
886 pub fn all(&self) -> &[discover::EntryPoint] {
887 &self.inner.entry_points.all
888 }
889}
890
891#[doc(hidden)]
893pub struct DeadCodeResolvedModules {
894 pub project: resolve::ResolvedProject,
895 pub elapsed_ms: f64,
896}
897
898#[doc(hidden)]
900pub struct DeadCodeGraphRun {
901 pub graph: graph::ModuleGraph,
902 pub elapsed_ms: f64,
903}
904
905#[doc(hidden)]
907pub struct DeadCodeDetectorRun {
908 pub results: AnalysisResults,
909 pub elapsed_ms: f64,
910}
911
912pub fn prepare_dead_code_backend_prelude(
918 config: &ResolvedConfig,
919 discovery: AnalysisDiscovery,
920) -> Result<DeadCodeBackendPrelude<'_>, FallowError> {
921 let progress = new_analysis_progress(config);
922 let pipeline_start = Instant::now();
923 let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
924 let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
925 config,
926 progress: &progress,
927 files: discovery.files(),
928 workspaces: &discovery.workspaces,
929 root_pkg: discovery.root_pkg.as_ref(),
930 workspace_pkgs: &workspace_pkgs,
931 config_candidates: &discovery.config_candidates,
932 })?;
933
934 Ok(DeadCodeBackendPrelude {
935 config,
936 pipeline_start,
937 progress,
938 discovery,
939 workspace_pkgs,
940 plugin_result,
941 plugins_ms,
942 scripts_ms,
943 })
944}
945
946#[must_use]
948pub fn discover_dead_code_entry_points(
949 prelude: &DeadCodeBackendPrelude<'_>,
950) -> DeadCodeEntryPoints {
951 let shared = prelude.shared_input();
952 DeadCodeEntryPoints {
953 inner: discover_analysis_entry_points(&shared),
954 }
955}
956
957pub fn try_load_dead_code_graph_cache(
967 prelude: &DeadCodeBackendPrelude<'_>,
968 entry_points: &DeadCodeEntryPoints,
969 modules: &[extract::ModuleInfo],
970) -> Result<(DeadCodeResolvedModules, DeadCodeGraphRun), Option<CacheRejection>> {
971 let shared = prelude.shared_input();
972 try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
973 (
974 DeadCodeResolvedModules {
975 project: hit.project,
976 elapsed_ms: 0.0,
977 },
978 DeadCodeGraphRun {
979 graph: hit.graph,
980 elapsed_ms: hit.elapsed_ms,
981 },
982 )
983 })
984}
985
986#[must_use]
988pub fn resolve_dead_code_imports(
989 prelude: &DeadCodeBackendPrelude<'_>,
990 modules: &[extract::ModuleInfo],
991) -> DeadCodeResolvedModules {
992 let shared = prelude.shared_input();
993 let resolved = resolve_analysis_imports_timed(&shared, modules);
994 DeadCodeResolvedModules {
995 project: resolved.project,
996 elapsed_ms: resolved.elapsed_ms,
997 }
998}
999
1000#[must_use]
1002pub fn build_dead_code_graph(
1003 prelude: &DeadCodeBackendPrelude<'_>,
1004 project: &resolve::ResolvedProject,
1005 entry_points: &DeadCodeEntryPoints,
1006 modules: &[extract::ModuleInfo],
1007) -> DeadCodeGraphRun {
1008 let shared = prelude.shared_input();
1009 let graph = build_analysis_graph_timed(&shared, project, &entry_points.inner, modules);
1010 DeadCodeGraphRun {
1011 graph: graph.graph,
1012 elapsed_ms: graph.elapsed_ms,
1013 }
1014}
1015
1016#[must_use]
1018pub fn run_dead_code_detectors(
1019 prelude: &DeadCodeBackendPrelude<'_>,
1020 graph: &graph::ModuleGraph,
1021 resolved: &[resolve::ResolvedModule],
1022 modules: &[extract::ModuleInfo],
1023 collect_usages: bool,
1024 entry_points: &DeadCodeEntryPoints,
1025) -> DeadCodeDetectorRun {
1026 let shared = prelude.shared_input();
1027 let analysis = analyze_dead_code_timed(
1028 &shared,
1029 graph,
1030 resolved,
1031 modules,
1032 collect_usages,
1033 entry_points.inner.summary.clone(),
1034 );
1035 DeadCodeDetectorRun {
1036 results: analysis.result,
1037 elapsed_ms: analysis.elapsed_ms,
1038 }
1039}
1040
1041impl<'a> DeadCodeBackendPrelude<'a> {
1042 fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
1043 AnalysisCoreSharedInput {
1044 config: self.config,
1045 progress: &self.progress,
1046 files: self.discovery.files(),
1047 workspaces: &self.discovery.workspaces,
1048 root_pkg: self.discovery.root_pkg.as_ref(),
1049 workspace_pkgs: &self.workspace_pkgs,
1050 plugin_result: &self.plugin_result,
1051 }
1052 }
1053}
1054
1055struct PreludeMetrics {
1058 discover_ms: f64,
1059 workspaces_ms: f64,
1060 plugins_ms: f64,
1061 scripts_ms: f64,
1062 total_ms: f64,
1063 file_count: usize,
1064 workspace_count: usize,
1065 module_count: usize,
1066}
1067
1068#[expect(
1070 clippy::struct_field_names,
1071 reason = "timings are all milliseconds; the _ms suffix is the unit"
1072)]
1073struct PreludeTimings {
1074 discover_ms: f64,
1075 workspaces_ms: f64,
1076 plugins_ms: f64,
1077 scripts_ms: f64,
1078}
1079
1080fn prelude_metrics(
1083 timings: &PreludeTimings,
1084 pipeline_start: Instant,
1085 files: &[discover::DiscoveredFile],
1086 workspaces: &[fallow_config::WorkspaceInfo],
1087 module_count: usize,
1088) -> PreludeMetrics {
1089 PreludeMetrics {
1090 discover_ms: timings.discover_ms,
1091 workspaces_ms: timings.workspaces_ms,
1092 plugins_ms: timings.plugins_ms,
1093 scripts_ms: timings.scripts_ms,
1094 total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
1095 file_count: files.len(),
1096 workspace_count: workspaces.len(),
1097 module_count,
1098 }
1099}
1100
1101struct AnalysisCoreSharedInput<'a> {
1102 config: &'a ResolvedConfig,
1103 progress: &'a progress::AnalysisProgress,
1104 files: &'a [discover::DiscoveredFile],
1105 workspaces: &'a [fallow_config::WorkspaceInfo],
1106 root_pkg: Option<&'a PackageJson>,
1107 workspace_pkgs: &'a [LoadedWorkspacePackage],
1108 plugin_result: &'a plugins::AggregatedPluginResult,
1109}
1110
1111struct TimedEntryPoints {
1112 entry_points: discover::CategorizedEntryPoints,
1113 summary: results::EntryPointSummary,
1114 count: usize,
1115 elapsed_ms: f64,
1116 spans: EntryPointSpans,
1117}
1118
1119struct TimedResolvedModules {
1120 project: resolve::ResolvedProject,
1121 elapsed_ms: f64,
1122}
1123
1124struct TimedGraph {
1125 graph: graph::ModuleGraph,
1126 elapsed_ms: f64,
1127}
1128
1129struct GraphCacheHit {
1130 graph: graph::ModuleGraph,
1131 project: resolve::ResolvedProject,
1132 elapsed_ms: f64,
1133}
1134
1135#[derive(Clone, Copy)]
1136struct DiscoverAllEntryPointsInput<'a> {
1137 config: &'a ResolvedConfig,
1138 files: &'a [discover::DiscoveredFile],
1139 workspaces: &'a [fallow_config::WorkspaceInfo],
1140 root_pkg: Option<&'a PackageJson>,
1141 workspace_pkgs: &'a [LoadedWorkspacePackage],
1142 plugin_result: &'a plugins::AggregatedPluginResult,
1143}
1144
1145struct TimedAnalysis {
1146 result: AnalysisResults,
1147 elapsed_ms: f64,
1148}
1149
1150fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
1151 let t = Instant::now();
1152 let (entry_points, spans) = discover_all_entry_points(DiscoverAllEntryPointsInput {
1153 config: input.config,
1154 files: input.files,
1155 workspaces: input.workspaces,
1156 root_pkg: input.root_pkg,
1157 workspace_pkgs: input.workspace_pkgs,
1158 plugin_result: input.plugin_result,
1159 });
1160 let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
1161 let summary = summarize_entry_points(&entry_points.all);
1162 let count = entry_points.all.len();
1163
1164 TimedEntryPoints {
1165 entry_points,
1166 summary,
1167 count,
1168 elapsed_ms,
1169 spans,
1170 }
1171}
1172
1173fn try_load_analysis_graph_cache(
1183 input: &AnalysisCoreSharedInput<'_>,
1184 entry_points: &TimedEntryPoints,
1185 modules: &[extract::ModuleInfo],
1186) -> Result<GraphCacheHit, Option<CacheRejection>> {
1187 if input.config.no_cache {
1188 return Err(None);
1189 }
1190
1191 let t = Instant::now();
1192 input.progress.set_stage("loading module graph cache...");
1193 let current = build_graph_cache_manifest(
1194 input.config,
1195 input.plugin_result,
1196 &entry_points.entry_points,
1197 input.files,
1198 modules,
1199 );
1200 let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir).map_err(Some)?;
1201 if store.manifest.matches_inputs(¤t) {
1202 let project = restore_cached_resolved_project(input, modules, &store.resolved_project)?;
1203 tracing::debug!("Graph cache hit: skipping import resolution and graph build");
1204
1205 return Ok(GraphCacheHit {
1206 graph: store.graph,
1207 project,
1208 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1209 });
1210 }
1211
1212 if let Some(rejection) = store.manifest.classify_resolution_mismatch(¤t) {
1213 if rejection.discarded_existing_work() {
1218 tracing::warn!(
1219 "Graph cache decoded but not reused: {}",
1220 rejection.describe()
1221 );
1222 } else {
1223 tracing::debug!(
1224 "Graph cache decoded but not reused: {}",
1225 rejection.describe()
1226 );
1227 }
1228 return Err(Some(rejection));
1229 }
1230
1231 let project = restore_cached_resolved_project(input, modules, &store.resolved_project)?;
1232 tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
1233 let graph = build_analysis_graph_timed(input, &project, entry_points, modules);
1234
1235 Ok(GraphCacheHit {
1236 graph: graph.graph,
1237 project,
1238 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1239 })
1240}
1241
1242fn restore_cached_resolved_project(
1248 input: &AnalysisCoreSharedInput<'_>,
1249 modules: &[extract::ModuleInfo],
1250 resolved_project: &graph_cache::CachedResolvedProject,
1251) -> Result<resolve::ResolvedProject, Option<CacheRejection>> {
1252 graph_cache::restore_resolved_project(
1253 &input.config.root,
1254 modules,
1255 input.files,
1256 resolved_project,
1257 )
1258 .inspect(|project| {
1259 record_unreadable_auto_import_reads(
1260 project,
1261 input.files,
1262 input.plugin_result,
1263 input.config,
1264 );
1265 })
1266 .ok_or_else(|| {
1267 tracing::debug!(
1271 "Graph cache decoded but its resolver payload no longer maps to the discovered files"
1272 );
1273 Some(CacheRejection::FileSetChanged)
1274 })
1275}
1276
1277fn resolve_analysis_imports_timed(
1278 input: &AnalysisCoreSharedInput<'_>,
1279 modules: &[extract::ModuleInfo],
1280) -> TimedResolvedModules {
1281 let t = Instant::now();
1282 input.progress.set_stage("resolving imports...");
1283 let project = resolve_analysis_imports(
1284 modules,
1285 input.files,
1286 input.workspaces,
1287 input.plugin_result,
1288 input.config,
1289 );
1290 TimedResolvedModules {
1291 project,
1292 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1293 }
1294}
1295
1296fn build_analysis_graph_timed(
1297 input: &AnalysisCoreSharedInput<'_>,
1298 project: &resolve::ResolvedProject,
1299 entry_points: &TimedEntryPoints,
1300 modules: &[extract::ModuleInfo],
1301) -> TimedGraph {
1302 let t = Instant::now();
1303 input.progress.set_stage("building module graph...");
1304 let graph = build_analysis_graph(&BuildAnalysisGraphInput {
1305 config: input.config,
1306 plugin_result: input.plugin_result,
1307 project,
1308 entry_points: &entry_points.entry_points,
1309 files: input.files,
1310 modules,
1311 workspaces: input.workspaces,
1312 });
1313 TimedGraph {
1314 graph,
1315 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1316 }
1317}
1318
1319fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
1320 for module in modules {
1321 module.release_resolution_payload();
1322 }
1323}
1324
1325fn analyze_dead_code_timed(
1326 input: &AnalysisCoreSharedInput<'_>,
1327 graph: &graph::ModuleGraph,
1328 resolved: &[resolve::ResolvedModule],
1329 modules: &[extract::ModuleInfo],
1330 collect_usages: bool,
1331 entry_point_summary: results::EntryPointSummary,
1332) -> TimedAnalysis {
1333 let t = Instant::now();
1334 input.progress.set_stage("analyzing...");
1335 let mut result = analyze::find_dead_code_full(
1336 graph,
1337 input.config,
1338 resolved,
1339 Some(input.plugin_result),
1340 input.workspaces,
1341 modules,
1342 collect_usages,
1343 );
1344 result.entry_point_summary = Some(entry_point_summary);
1345 TimedAnalysis {
1346 result,
1347 elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
1348 }
1349}
1350
1351fn analyze_full(
1352 config: &ResolvedConfig,
1353 retain: bool,
1354 collect_usages: bool,
1355 need_complexity: bool,
1356 retain_modules: bool,
1357) -> Result<AnalysisOutput, FallowError> {
1358 let _span = tracing::info_span!("fallow_analyze").entered();
1359 AnalysisSession::new(config)?.run_full(retain, collect_usages, need_complexity, retain_modules)
1360}
1361
1362fn full_analysis_pipeline_profile(
1363 timings: &PreludeTimings,
1364 pipeline_start: Instant,
1365 files: &[discover::DiscoveredFile],
1366 workspaces: &[fallow_config::WorkspaceInfo],
1367 core: &OwnedAnalysisCore,
1368 metrics: &ParseMetrics,
1369) -> PipelineProfile {
1370 let prelude = prelude_metrics(
1371 timings,
1372 pipeline_start,
1373 files,
1374 workspaces,
1375 core.modules.len(),
1376 );
1377 full_pipeline_profile(&prelude, core, metrics)
1378}
1379
1380fn assemble_full_output(
1383 core: OwnedAnalysisCore,
1384 plugin_result: plugins::AggregatedPluginResult,
1385 profile: &PipelineProfile,
1386 files: &[discover::DiscoveredFile],
1387 retain: bool,
1388 retain_modules: bool,
1389) -> AnalysisOutput {
1390 let file_hashes = collect_file_hashes(&core.modules, files);
1391 AnalysisOutput {
1392 results: core.result,
1393 timings: retained_pipeline_timings(retain, profile),
1394 graph: if retain { Some(core.graph) } else { None },
1395 modules: if retain_modules {
1396 Some(core.modules)
1397 } else {
1398 None
1399 },
1400 files: if retain_modules {
1401 Some(files.to_vec())
1402 } else {
1403 None
1404 },
1405 script_used_packages: plugin_result.script_used_packages,
1406 trace_provenance: fallow_types::trace::TraceProvenance::default(),
1407 file_hashes,
1408 }
1409}
1410
1411struct OwnedAnalysisCore {
1414 result: AnalysisResults,
1415 graph: graph::ModuleGraph,
1416 modules: Vec<extract::ModuleInfo>,
1417 entry_point_count: usize,
1418 entry_points_ms: f64,
1419 entry_point_spans: EntryPointSpans,
1420 resolve_ms: f64,
1421 graph_ms: f64,
1422 analyze_ms: f64,
1423 graph_cache_rejection: Option<CacheRejection>,
1424}
1425
1426fn full_pipeline_profile(
1428 prelude: &PreludeMetrics,
1429 core: &OwnedAnalysisCore,
1430 parse: &ParseMetrics,
1431) -> PipelineProfile {
1432 PipelineProfile {
1433 discover_ms: prelude.discover_ms,
1434 workspaces_ms: prelude.workspaces_ms,
1435 plugins_ms: prelude.plugins_ms,
1436 scripts_ms: prelude.scripts_ms,
1437 parse_ms: parse.parse_ms,
1438 cache_ms: parse.cache_ms,
1439 entry_points_ms: core.entry_points_ms,
1440 entry_point_spans: core.entry_point_spans,
1441 resolve_ms: core.resolve_ms,
1442 graph_ms: core.graph_ms,
1443 analyze_ms: core.analyze_ms,
1444 total_ms: prelude.total_ms,
1445 file_count: prelude.file_count,
1446 workspace_count: prelude.workspace_count,
1447 module_count: prelude.module_count,
1448 entry_point_count: core.entry_point_count,
1449 cache_hits: parse.cache_hits,
1450 cache_misses: parse.cache_misses,
1451 parse_cpu_ms: parse.parse_cpu_ms,
1452 cache_rejection: parse.cache_rejection,
1453 graph_cache_rejection: core.graph_cache_rejection,
1454 }
1455}
1456
1457#[derive(Clone, Copy)]
1458struct PipelineProfile {
1459 discover_ms: f64,
1460 workspaces_ms: f64,
1461 plugins_ms: f64,
1462 scripts_ms: f64,
1463 parse_ms: f64,
1464 cache_ms: f64,
1465 entry_points_ms: f64,
1466 entry_point_spans: EntryPointSpans,
1467 resolve_ms: f64,
1468 graph_ms: f64,
1469 analyze_ms: f64,
1470 total_ms: f64,
1471 file_count: usize,
1472 workspace_count: usize,
1473 module_count: usize,
1474 entry_point_count: usize,
1475 cache_hits: usize,
1476 cache_misses: usize,
1477 parse_cpu_ms: f64,
1478 cache_rejection: Option<CacheRejection>,
1479 graph_cache_rejection: Option<CacheRejection>,
1480}
1481
1482struct AnalysisParseOutput {
1483 modules: Vec<extract::ModuleInfo>,
1484 metrics: ParseMetrics,
1485}
1486
1487struct ParseMetrics {
1489 parse_ms: f64,
1490 cache_ms: f64,
1491 cache_hits: usize,
1492 cache_misses: usize,
1493 parse_cpu_ms: f64,
1494 cache_rejection: Option<CacheRejection>,
1496}
1497
1498impl From<AnalysisParseMetrics> for ParseMetrics {
1499 fn from(metrics: AnalysisParseMetrics) -> Self {
1500 Self {
1501 parse_ms: metrics.parse_ms,
1502 cache_ms: metrics.cache_ms,
1503 cache_hits: metrics.cache_hits,
1504 cache_misses: metrics.cache_misses,
1505 parse_cpu_ms: metrics.parse_cpu_ms,
1506 cache_rejection: metrics.cache_rejection,
1507 }
1508 }
1509}
1510
1511fn parse_analysis_modules(
1512 config: &ResolvedConfig,
1513 files: &[discover::DiscoveredFile],
1514 need_complexity: bool,
1515 start: Instant,
1516) -> AnalysisParseOutput {
1517 let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
1518 let mut cache_rejection = None;
1519 let mut cache_store = if config.no_cache {
1520 None
1521 } else {
1522 match cache::CacheStore::load(
1523 &config.cache_dir,
1524 &config.root,
1525 config.cache_config_hash,
1526 cache_max_size_bytes,
1527 ) {
1528 Ok(store) => Some(store),
1529 Err(rejection) => {
1530 cache_rejection = Some(rejection);
1531 None
1532 }
1533 }
1534 };
1535
1536 let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
1537 let _ = fallow_config::record_source_read_failures(&config.root, &parse_result.read_failures);
1538 let _ = fallow_config::record_source_parse_degradations(
1539 &config.root,
1540 &parse_result.parse_degradations,
1541 );
1542 let modules = parse_result.modules;
1543 let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
1544 let cache_ms = update_parse_cache_if_enabled(
1545 config,
1546 &mut cache_store,
1547 &modules,
1548 files,
1549 cache_max_size_bytes,
1550 need_complexity,
1551 );
1552
1553 AnalysisParseOutput {
1554 modules,
1555 metrics: ParseMetrics {
1556 parse_ms,
1557 cache_ms,
1558 cache_hits: parse_result.cache_hits,
1559 cache_misses: parse_result.cache_misses,
1560 parse_cpu_ms: parse_result.parse_cpu_ms,
1561 cache_rejection,
1562 },
1563 }
1564}
1565
1566fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
1567 retain.then_some(PipelineTimings {
1568 discover_files_ms: profile.discover_ms,
1569 file_count: profile.file_count,
1570 workspaces_ms: profile.workspaces_ms,
1571 workspace_count: profile.workspace_count,
1572 plugins_ms: profile.plugins_ms,
1573 script_analysis_ms: profile.scripts_ms,
1574 parse_extract_ms: profile.parse_ms,
1575 parse_cpu_ms: profile.parse_cpu_ms,
1576 module_count: profile.module_count,
1577 cache_hits: profile.cache_hits,
1578 cache_misses: profile.cache_misses,
1579 cache_rejection: profile.cache_rejection,
1580 graph_cache_rejection: profile.graph_cache_rejection,
1581 cache_update_ms: profile.cache_ms,
1582 entry_points_ms: profile.entry_points_ms,
1583 entry_point_spans: profile.entry_point_spans,
1584 entry_point_count: profile.entry_point_count,
1585 resolve_imports_ms: profile.resolve_ms,
1586 build_graph_ms: profile.graph_ms,
1587 analyze_ms: profile.analyze_ms,
1588 duplication_ms: None,
1589 total_ms: profile.total_ms,
1590 })
1591}
1592
1593fn update_parse_cache_if_enabled(
1594 config: &ResolvedConfig,
1595 cache_store: &mut Option<cache::CacheStore>,
1596 modules: &[extract::ModuleInfo],
1597 files: &[discover::DiscoveredFile],
1598 cache_max_size_bytes: usize,
1599 need_complexity: bool,
1600) -> f64 {
1601 let t = Instant::now();
1602 if !config.no_cache {
1603 let store = cache_store.get_or_insert_with(|| cache::CacheStore::new(&config.root));
1604 if update_cache(store, modules, files, need_complexity)
1605 && let Err(error) = store.save(
1606 &config.cache_dir,
1607 config.cache_config_hash,
1608 cache_max_size_bytes,
1609 )
1610 {
1611 tracing::warn!("Failed to save cache: {error}");
1612 }
1613 }
1614 t.elapsed().as_secs_f64() * 1000.0
1615}
1616
1617fn resolve_analysis_imports(
1618 modules: &[extract::ModuleInfo],
1619 files: &[discover::DiscoveredFile],
1620 workspaces: &[fallow_config::WorkspaceInfo],
1621 plugin_result: &plugins::AggregatedPluginResult,
1622 config: &ResolvedConfig,
1623) -> resolve::ResolvedProject {
1624 let mut project = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
1625 modules,
1626 files,
1627 workspaces,
1628 active_plugins: &plugin_result.active_plugins,
1629 path_aliases: &plugin_result.path_aliases,
1630 auto_imports: &plugin_result.auto_imports,
1631 scss_include_paths: &plugin_result.scss_include_paths,
1632 static_dir_mappings: &plugin_result.static_dir_mappings,
1633 framework_static_dir_mappings: &plugin_result.framework_static_dir_mappings,
1634 root: &config.root,
1635 extra_conditions: &config.resolve.conditions,
1636 });
1637 external_style_usage::augment_external_style_package_usage(
1638 &mut project.modules,
1639 config,
1640 workspaces,
1641 plugin_result,
1642 );
1643 record_unreadable_auto_import_reads(&project, files, plugin_result, config);
1644 project
1645}
1646
1647fn record_unreadable_auto_import_reads(
1659 project: &resolve::ResolvedProject,
1660 files: &[discover::DiscoveredFile],
1661 plugin_result: &plugins::AggregatedPluginResult,
1662 config: &ResolvedConfig,
1663) {
1664 if !config.auto_imports
1665 || plugin_result.auto_imports.is_empty()
1666 || !plugin_result
1667 .active_plugins
1668 .iter()
1669 .any(|name| name == "nuxt")
1670 {
1671 return;
1672 }
1673 let diagnostics: Vec<fallow_config::WorkspaceDiagnostic> =
1674 resolve::unreadable_auto_import_reads(&project.modules)
1675 .into_iter()
1676 .filter_map(|read| {
1677 let file = files.get(read.file_id.0 as usize)?;
1678 let diagnostic = plugins::PluginConfigDiagnostic::not_modeled(
1679 &file.path,
1680 "nuxt",
1681 read.module,
1682 AUTO_IMPORT_KEY_NOT_MODELED,
1683 );
1684 Some(diagnostic.into_workspace_diagnostic(&config.root))
1685 })
1686 .collect();
1687 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
1688}
1689
1690struct BuildAnalysisGraphInput<'a> {
1691 config: &'a ResolvedConfig,
1692 plugin_result: &'a plugins::AggregatedPluginResult,
1693 project: &'a resolve::ResolvedProject,
1694 entry_points: &'a discover::CategorizedEntryPoints,
1695 files: &'a [discover::DiscoveredFile],
1696 modules: &'a [extract::ModuleInfo],
1697 workspaces: &'a [fallow_config::WorkspaceInfo],
1698}
1699
1700fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
1708 let caching_enabled = !input.config.no_cache;
1709 let current_manifest = caching_enabled.then(|| {
1710 build_graph_cache_manifest(
1711 input.config,
1712 input.plugin_result,
1713 input.entry_points,
1714 input.files,
1715 input.modules,
1716 )
1717 });
1718
1719 let mut graph = graph::ModuleGraph::build_with_reachability_roots_and_replacements(
1720 &input.project.modules,
1721 &input.project.replaced_module_targets,
1722 &input.entry_points.all,
1723 &input.entry_points.runtime,
1724 &input.entry_points.test,
1725 input.files,
1726 );
1727 credit_package_path_references(&mut graph, input.modules);
1728 credit_workspace_package_usage(&mut graph, &input.project.modules, input.workspaces);
1729
1730 if let Some(manifest) = current_manifest {
1731 let Some(resolved_project) =
1732 graph_cache::cache_resolved_project(&input.config.root, input.files, input.project)
1733 else {
1734 return graph;
1735 };
1736 let store = graph_cache::GraphCacheStore {
1737 version: graph_cache::GRAPH_CACHE_VERSION,
1738 manifest,
1739 graph,
1740 resolved_project,
1741 };
1742 store.save(&input.config.cache_dir);
1743 return store.graph;
1748 }
1749
1750 graph
1751}
1752
1753fn build_graph_cache_manifest(
1756 config: &ResolvedConfig,
1757 plugin_result: &plugins::AggregatedPluginResult,
1758 entry_points: &discover::CategorizedEntryPoints,
1759 files: &[discover::DiscoveredFile],
1760 modules: &[extract::ModuleInfo],
1761) -> graph_cache::GraphCacheManifest {
1762 let mode = graph_cache::GraphCacheMode::new(
1763 resolver_options_hash(config),
1764 entry_points_hash(entry_points, &config.root),
1765 plugin_config_hash(plugin_result, &config.root),
1766 );
1767 let mut content_hashes = vec![0u64; files.len()];
1772 for module in modules {
1773 if let Some(slot) = content_hashes.get_mut(module.file_id.0 as usize) {
1774 *slot = module.content_hash;
1775 }
1776 }
1777 graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |file| {
1778 content_hashes
1779 .get(file.id.0 as usize)
1780 .copied()
1781 .unwrap_or_default()
1782 })
1783}
1784
1785fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
1803 use std::hash::{Hash, Hasher};
1804 let mut hasher = rustc_hash::FxHasher::default();
1805 config.cache_config_hash.hash(&mut hasher);
1806 config.resolve.conditions.hash(&mut hasher);
1807 hasher.finish()
1808}
1809
1810fn root_relative_key(root: &std::path::Path, path: &std::path::Path) -> String {
1814 path.strip_prefix(root)
1815 .unwrap_or(path)
1816 .to_string_lossy()
1817 .replace('\\', "/")
1818}
1819
1820fn entry_points_hash(
1823 entry_points: &discover::CategorizedEntryPoints,
1824 root: &std::path::Path,
1825) -> u64 {
1826 use std::hash::{Hash, Hasher};
1827 let mut hasher = rustc_hash::FxHasher::default();
1828 for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
1829 let mut keys: Vec<String> = role
1830 .iter()
1831 .map(|ep| root_relative_key(root, &ep.path))
1832 .collect();
1833 keys.sort_unstable();
1834 keys.len().hash(&mut hasher);
1835 for key in keys {
1836 key.hash(&mut hasher);
1837 }
1838 }
1839 hasher.finish()
1840}
1841
1842fn plugin_config_hash(
1845 plugin_result: &plugins::AggregatedPluginResult,
1846 root: &std::path::Path,
1847) -> u64 {
1848 use std::hash::{Hash, Hasher};
1849 let mut hasher = rustc_hash::FxHasher::default();
1850
1851 hash_active_plugins(plugin_result, &mut hasher);
1852 hash_path_aliases(plugin_result, root, &mut hasher);
1853
1854 let mut auto_imports: Vec<AutoImportHashKey<'_>> = plugin_result
1855 .auto_imports
1856 .iter()
1857 .map(|rule| {
1858 let mut scope: Vec<String> = rule
1859 .scope
1860 .iter()
1861 .map(|scope_root| root_relative_key(root, scope_root))
1862 .collect();
1863 scope.sort_unstable();
1864 (
1865 rule.name.as_str(),
1866 root_relative_key(root, &rule.source),
1867 auto_import_kind_rank(rule.kind),
1868 scope,
1869 )
1870 })
1871 .collect();
1872 auto_imports.sort_unstable();
1873 auto_imports.len().hash(&mut hasher);
1874 for key in &auto_imports {
1875 key.hash(&mut hasher);
1876 }
1877
1878 let mut scss_include_paths: Vec<String> = plugin_result
1879 .scss_include_paths
1880 .iter()
1881 .map(|path| root_relative_key(root, path))
1882 .collect();
1883 scss_include_paths.sort_unstable();
1884 scss_include_paths.len().hash(&mut hasher);
1885 for path in scss_include_paths {
1886 path.hash(&mut hasher);
1887 }
1888
1889 let mut static_dir_mappings: Vec<(String, &str)> = plugin_result
1890 .static_dir_mappings
1891 .iter()
1892 .map(|(from_dir, mount)| (root_relative_key(root, from_dir), mount.as_str()))
1893 .collect();
1894 static_dir_mappings.sort_unstable();
1895 static_dir_mappings.len().hash(&mut hasher);
1896 for (from_dir, mount) in static_dir_mappings {
1897 from_dir.hash(&mut hasher);
1898 mount.hash(&mut hasher);
1899 }
1900
1901 hasher.finish()
1902}
1903
1904fn hash_active_plugins(
1905 plugin_result: &plugins::AggregatedPluginResult,
1906 hasher: &mut rustc_hash::FxHasher,
1907) {
1908 use std::hash::Hash;
1909 let mut active: Vec<&str> = plugin_result
1910 .active_plugins
1911 .iter()
1912 .map(String::as_str)
1913 .collect();
1914 active.sort_unstable();
1915 active.len().hash(hasher);
1916 for name in active {
1917 name.hash(hasher);
1918 }
1919}
1920
1921fn hash_path_aliases(
1925 plugin_result: &plugins::AggregatedPluginResult,
1926 root: &std::path::Path,
1927 hasher: &mut rustc_hash::FxHasher,
1928) {
1929 use std::hash::Hash;
1930 let mut aliases: Vec<(&str, String)> = plugin_result
1931 .path_aliases
1932 .iter()
1933 .map(|(prefix, replacement)| {
1934 (
1935 prefix.as_str(),
1936 root_relative_key(root, std::path::Path::new(replacement)),
1937 )
1938 })
1939 .collect();
1940 aliases.sort_unstable();
1941 aliases.len().hash(hasher);
1942 for (prefix, replacement) in aliases {
1943 prefix.hash(hasher);
1944 replacement.hash(hasher);
1945 }
1946}
1947
1948type AutoImportHashKey<'a> = (&'a str, String, u8, Vec<String>);
1951
1952fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
1953 match kind {
1954 fallow_config::AutoImportKind::Named => 0,
1955 fallow_config::AutoImportKind::Default => 1,
1956 fallow_config::AutoImportKind::DefaultComponent => 2,
1957 }
1958}
1959
1960fn collect_file_hashes(
1961 modules: &[extract::ModuleInfo],
1962 files: &[discover::DiscoveredFile],
1963) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
1964 modules
1965 .iter()
1966 .filter_map(|module| {
1967 files
1968 .get(module.file_id.0 as usize)
1969 .map(|file| (file.path.clone(), module.content_hash))
1970 })
1971 .collect()
1972}
1973
1974fn trace_pipeline_profile(profile: &PipelineProfile) {
1975 let PipelineProfile {
1976 discover_ms,
1977 workspaces_ms,
1978 plugins_ms,
1979 scripts_ms,
1980 parse_ms,
1981 cache_ms,
1982 entry_points_ms,
1983 resolve_ms,
1984 graph_ms,
1985 analyze_ms,
1986 total_ms,
1987 file_count,
1988 module_count,
1989 entry_point_count,
1990 cache_hits,
1991 cache_misses,
1992 cache_rejection,
1993 ..
1994 } = *profile;
1995 let cache_summary = cache_rejection.map_or_else(
1996 || format!(" ({cache_hits} cached, {cache_misses} parsed)"),
1997 |rejection| {
1998 format!(
1999 " ({cache_hits} cached, {cache_misses} parsed, cache refused: {})",
2000 rejection.describe()
2001 )
2002 },
2003 );
2004
2005 tracing::debug!(
2006 "\n┌─ Pipeline Profile ─────────────────────────────\n\
2007 │ discover files: {:>8.1}ms ({} files)\n\
2008 │ workspaces: {:>8.1}ms\n\
2009 │ plugin detection: {:>8.1}ms\n\
2010 │ script analysis: {:>8.1}ms\n\
2011 │ parse/extract: {:>8.1}ms ({} modules{})\n\
2012 │ cache update: {:>8.1}ms\n\
2013 │ entry points: {:>8.1}ms ({} entries)\n\
2014 │ resolve imports: {:>8.1}ms\n\
2015 │ build graph: {:>8.1}ms\n\
2016 │ analyze: {:>8.1}ms\n\
2017 │ ────────────────────────────────────────────\n\
2018 │ TOTAL: {:>8.1}ms\n\
2019 └─────────────────────────────────────────────────",
2020 discover_ms,
2021 file_count,
2022 workspaces_ms,
2023 plugins_ms,
2024 scripts_ms,
2025 parse_ms,
2026 module_count,
2027 cache_summary,
2028 cache_ms,
2029 entry_points_ms,
2030 entry_point_count,
2031 resolve_ms,
2032 graph_ms,
2033 analyze_ms,
2034 total_ms,
2035 );
2036}
2037
2038fn load_workspace_packages(
2039 workspaces: &[fallow_config::WorkspaceInfo],
2040) -> Vec<LoadedWorkspacePackage> {
2041 workspaces
2042 .iter()
2043 .filter_map(|ws| {
2044 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
2045 })
2046 .collect()
2047}
2048
2049fn analyze_all_scripts(
2054 config: &ResolvedConfig,
2055 workspaces: &[fallow_config::WorkspaceInfo],
2056 root_pkg: Option<&PackageJson>,
2057 workspace_pkgs: &[LoadedWorkspacePackage],
2058 plugin_result: &mut plugins::AggregatedPluginResult,
2059) {
2060 let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
2061 let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
2062 let all_scripts = collect_all_scripts(root_pkg, workspace_pkgs);
2063
2064 let nm_roots = collect_node_modules_roots(config, workspaces);
2065 let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
2066
2067 analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
2068 analyze_workspace_scripts(
2069 config,
2070 workspace_pkgs,
2071 &bin_map,
2072 &all_dep_set,
2073 plugin_result,
2074 );
2075 analyze_ci_scripts(config, &bin_map, &all_dep_set, &all_scripts, plugin_result);
2076
2077 plugin_result
2078 .entry_point_roles
2079 .entry("scripts".to_string())
2080 .or_insert(EntryPointRole::Support);
2081}
2082
2083fn collect_all_dependency_names(
2085 root_pkg: Option<&PackageJson>,
2086 workspace_pkgs: &[LoadedWorkspacePackage],
2087) -> Vec<String> {
2088 let mut all_dep_names: Vec<String> = Vec::new();
2089 if let Some(pkg) = root_pkg {
2090 all_dep_names.extend(pkg.all_dependency_names());
2091 }
2092 for (_, ws_pkg) in workspace_pkgs {
2093 all_dep_names.extend(ws_pkg.all_dependency_names());
2094 }
2095 all_dep_names.sort_unstable();
2096 all_dep_names.dedup();
2097 all_dep_names
2098}
2099
2100fn collect_all_scripts(
2102 root_pkg: Option<&PackageJson>,
2103 workspace_pkgs: &[LoadedWorkspacePackage],
2104) -> scripts::ScriptCatalog {
2105 let mut catalog = scripts::ScriptCatalog::default();
2106 if let Some(pkg) = root_pkg
2107 && let Some(ref pkg_scripts) = pkg.scripts
2108 {
2109 catalog.merge_scripts(pkg_scripts);
2110 }
2111 for (_, ws_pkg) in workspace_pkgs {
2112 if let Some(ref ws_scripts) = ws_pkg.scripts {
2113 catalog.merge_workspace_scripts(ws_scripts);
2114 }
2115 }
2116 catalog
2117}
2118
2119fn collect_node_modules_roots<'a>(
2121 config: &'a ResolvedConfig,
2122 workspaces: &'a [fallow_config::WorkspaceInfo],
2123) -> Vec<&'a std::path::Path> {
2124 let mut nm_roots: Vec<&std::path::Path> = Vec::new();
2125 if config.root.join("node_modules").is_dir() {
2126 nm_roots.push(&config.root);
2127 }
2128 for ws in workspaces {
2129 if ws.root.join("node_modules").is_dir() {
2130 nm_roots.push(&ws.root);
2131 }
2132 }
2133 nm_roots
2134}
2135
2136fn analyze_root_scripts(
2138 config: &ResolvedConfig,
2139 root_pkg: Option<&PackageJson>,
2140 bin_map: &rustc_hash::FxHashMap<String, String>,
2141 all_dep_set: &FxHashSet<String>,
2142 plugin_result: &mut plugins::AggregatedPluginResult,
2143) {
2144 let Some(pkg) = root_pkg else {
2145 return;
2146 };
2147 let Some(ref pkg_scripts) = pkg.scripts else {
2148 return;
2149 };
2150 let scripts_to_analyze = if config.production {
2151 scripts::filter_production_scripts(pkg_scripts)
2152 } else {
2153 pkg_scripts.clone()
2154 };
2155 let catalog =
2156 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
2157 let script_analysis = scripts::analyze_scripts_with_dependency_context(
2158 &scripts_to_analyze,
2159 &config.root,
2160 bin_map,
2161 all_dep_set,
2162 &catalog,
2163 );
2164 plugin_result.script_used_packages = script_analysis.used_packages;
2165
2166 for config_file in &script_analysis.config_files {
2167 plugin_result
2168 .discovered_always_used
2169 .push((config_file.clone(), "scripts".to_string()));
2170 }
2171 for entry in &script_analysis.entry_files {
2172 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2173 plugin_result
2174 .entry_patterns
2175 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2176 }
2177 }
2178}
2179
2180type WsScriptOut = (
2182 Vec<String>,
2183 Vec<(String, String)>,
2184 Vec<(plugins::PathRule, String)>,
2185);
2186
2187fn analyze_workspace_scripts(
2188 config: &ResolvedConfig,
2189 workspace_pkgs: &[LoadedWorkspacePackage],
2190 bin_map: &rustc_hash::FxHashMap<String, String>,
2191 all_dep_set: &FxHashSet<String>,
2192 plugin_result: &mut plugins::AggregatedPluginResult,
2193) {
2194 let ws_results: Vec<WsScriptOut> = workspace_pkgs
2195 .par_iter()
2196 .map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
2197 .collect();
2198 for (used_packages, discovered_always_used, entry_patterns) in ws_results {
2199 plugin_result.script_used_packages.extend(used_packages);
2200 plugin_result
2201 .discovered_always_used
2202 .extend(discovered_always_used);
2203 plugin_result.entry_patterns.extend(entry_patterns);
2204 }
2205}
2206
2207fn analyze_one_workspace_scripts(
2210 config: &ResolvedConfig,
2211 ws: &fallow_config::WorkspaceInfo,
2212 ws_pkg: &PackageJson,
2213 bin_map: &rustc_hash::FxHashMap<String, String>,
2214 all_dep_set: &FxHashSet<String>,
2215) -> WsScriptOut {
2216 let mut used_packages = Vec::new();
2217 let mut discovered_always_used: Vec<(String, String)> = Vec::new();
2218 let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
2219 let Some(ref ws_scripts) = ws_pkg.scripts else {
2220 return (used_packages, discovered_always_used, entry_patterns);
2221 };
2222 let scripts_to_analyze = if config.production {
2223 scripts::filter_production_scripts(ws_scripts)
2224 } else {
2225 ws_scripts.clone()
2226 };
2227 let catalog = scripts::ScriptCatalog::from_scripts_with_bodies(ws_scripts, &scripts_to_analyze);
2228 let ws_analysis = scripts::analyze_scripts_with_dependency_context(
2229 &scripts_to_analyze,
2230 &ws.root,
2231 bin_map,
2232 all_dep_set,
2233 &catalog,
2234 );
2235 used_packages.extend(ws_analysis.used_packages);
2236
2237 let ws_prefix = ws
2238 .root
2239 .strip_prefix(&config.root)
2240 .unwrap_or(&ws.root)
2241 .to_string_lossy();
2242 for config_file in &ws_analysis.config_files {
2243 discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
2244 }
2245 for entry in &ws_analysis.entry_files {
2246 if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
2247 entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
2248 }
2249 }
2250 (used_packages, discovered_always_used, entry_patterns)
2251}
2252
2253fn analyze_ci_scripts(
2255 config: &ResolvedConfig,
2256 bin_map: &rustc_hash::FxHashMap<String, String>,
2257 all_dep_set: &FxHashSet<String>,
2258 all_scripts: &scripts::ScriptCatalog,
2259 plugin_result: &mut plugins::AggregatedPluginResult,
2260) {
2261 let ci_analysis =
2262 scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_scripts);
2263 plugin_result
2264 .script_used_packages
2265 .extend(ci_analysis.used_packages);
2266 for entry in &ci_analysis.entry_files {
2267 if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
2268 plugin_result
2269 .entry_patterns
2270 .push((plugins::PathRule::new(pat), "scripts".to_string()));
2271 }
2272 }
2273}
2274
2275fn discover_all_entry_points(
2277 input: DiscoverAllEntryPointsInput<'_>,
2278) -> (discover::CategorizedEntryPoints, EntryPointSpans) {
2279 let mut spans = EntryPointSpans::default();
2280 let mut mark = Instant::now();
2281 let mut entry_points = discover::CategorizedEntryPoints::default();
2282 let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
2283 input.config,
2284 input.files,
2285 input.root_pkg,
2286 input.workspaces.is_empty(),
2287 );
2288 spans.root_ms = split_ms(&mut mark);
2289
2290 let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> = input
2291 .workspace_pkgs
2292 .iter()
2293 .map(|(ws, pkg)| (ws.root.clone(), pkg))
2294 .collect();
2295 let workspace_script_seeds = discover::workspace_runtime_script_seeds(
2296 &input.config.root,
2297 input.root_pkg,
2298 input.workspace_pkgs,
2299 );
2300
2301 let workspace_discovery: Vec<discover::EntryPointDiscovery> = input
2302 .workspaces
2303 .par_iter()
2304 .map(|ws| {
2305 let pkg = workspace_pkg_by_root.get(&ws.root).copied();
2306 let seeds = workspace_script_seeds
2307 .get(&ws.name)
2308 .cloned()
2309 .unwrap_or_default();
2310 discover::discover_workspace_entry_points_with_runtime_scripts(
2311 &ws.root,
2312 input.files,
2313 pkg,
2314 &seeds,
2315 )
2316 })
2317 .collect();
2318 let mut skipped_entries = rustc_hash::FxHashMap::default();
2319 entry_points.extend_runtime(root_discovery.entries);
2320 entry_points.extend_support(root_discovery.support_entries);
2321 for (path, count) in root_discovery.skipped_entries {
2322 *skipped_entries.entry(path).or_insert(0) += count;
2323 }
2324 let mut ws_entries = Vec::new();
2325 let mut ws_support_entries = Vec::new();
2326 for workspace in workspace_discovery {
2327 ws_entries.extend(workspace.entries);
2328 ws_support_entries.extend(workspace.support_entries);
2329 for (path, count) in workspace.skipped_entries {
2330 *skipped_entries.entry(path).or_insert(0) += count;
2331 }
2332 }
2333 discover::warn_skipped_entry_summary(&skipped_entries);
2334 entry_points.extend_runtime(ws_entries);
2335 entry_points.extend_support(ws_support_entries);
2336 spans.workspaces_ms = split_ms(&mut mark);
2337
2338 let plugin_entries = discover::discover_plugin_entry_point_sets_timed(
2339 input.plugin_result,
2340 input.config,
2341 input.files,
2342 );
2343 spans.plugin_glob_build_ms = plugin_entries.build_ms;
2344 spans.plugin_glob_match_ms = plugin_entries.match_ms;
2345 entry_points.extend(plugin_entries.entries);
2346 spans.plugins_ms = split_ms(&mut mark);
2347
2348 let infra_entries = discover::discover_infrastructure_entry_points(&input.config.root);
2349 entry_points.extend_runtime(infra_entries);
2350 spans.infrastructure_ms = split_ms(&mut mark);
2351
2352 if !input.config.dynamically_loaded.is_empty() {
2353 let dynamic_entries =
2354 discover::discover_dynamically_loaded_entry_points(input.config, input.files);
2355 entry_points.extend_runtime(dynamic_entries);
2356 }
2357 spans.dynamic_ms = split_ms(&mut mark);
2358
2359 let deduped = entry_points.dedup();
2360 spans.dedup_ms = split_ms(&mut mark);
2361 (deduped, spans)
2362}
2363
2364fn split_ms(mark: &mut Instant) -> f64 {
2369 let now = Instant::now();
2370 let elapsed = now.duration_since(*mark).as_secs_f64() * 1000.0;
2371 *mark = now;
2372 elapsed
2373}
2374
2375fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
2377 let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
2378 for ep in entry_points {
2379 let category = match &ep.source {
2380 discover::EntryPointSource::PackageJsonMain
2381 | discover::EntryPointSource::PackageJsonModule
2382 | discover::EntryPointSource::PackageJsonExports
2383 | discover::EntryPointSource::PackageJsonBin
2384 | discover::EntryPointSource::PackageJsonScript => "package.json",
2385 discover::EntryPointSource::Plugin { .. } => "plugin",
2386 discover::EntryPointSource::TestFile => "test file",
2387 discover::EntryPointSource::DefaultIndex => "default index",
2388 discover::EntryPointSource::ManualEntry => "manual entry",
2389 discover::EntryPointSource::InfrastructureConfig => "config",
2390 discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
2391 };
2392 *counts.entry(category.to_string()).or_insert(0) += 1;
2393 }
2394 let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
2395 by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2396 results::EntryPointSummary {
2397 total: entry_points.len(),
2398 by_source,
2399 }
2400}
2401
2402fn append_package_file_asset_patterns(
2403 result: &mut plugins::AggregatedPluginResult,
2404 prefix: &str,
2405 pkg: &PackageJson,
2406) {
2407 let prefix = prefix.trim_matches('/');
2408 for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
2409 let pattern = if prefix.is_empty() {
2410 pattern
2411 } else {
2412 format!("{prefix}/{pattern}")
2413 };
2414 result
2415 .discovered_always_used
2416 .push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
2417 }
2418}
2419
2420fn append_workspace_package_file_asset_patterns(
2421 result: &mut plugins::AggregatedPluginResult,
2422 config: &ResolvedConfig,
2423 workspace_pkgs: &[LoadedWorkspacePackage],
2424) {
2425 for (ws, ws_pkg) in workspace_pkgs {
2426 let ws_prefix = ws
2427 .root
2428 .strip_prefix(&config.root)
2429 .unwrap_or(&ws.root)
2430 .to_string_lossy()
2431 .replace('\\', "/");
2432 append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
2433 }
2434}
2435
2436fn run_plugins(
2438 config: &ResolvedConfig,
2439 files: &[discover::DiscoveredFile],
2440 workspaces: &[fallow_config::WorkspaceInfo],
2441 root_pkg: Option<&PackageJson>,
2442 workspace_pkgs: &[LoadedWorkspacePackage],
2443 config_candidates: &[std::path::PathBuf],
2444) -> Result<plugins::AggregatedPluginResult, FallowError> {
2445 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
2446 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
2447
2448 let candidate_index = (!config.production).then(|| {
2453 plugins::registry::ConfigCandidateIndex::build(
2454 file_paths
2455 .iter()
2456 .map(std::path::PathBuf::as_path)
2457 .chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
2458 )
2459 });
2460
2461 let mut result = run_root_plugins(
2462 ®istry,
2463 config,
2464 root_pkg,
2465 &file_paths,
2466 candidate_index.as_ref(),
2467 )?;
2468
2469 if workspaces.is_empty() {
2470 share_auto_imports_across_layers(&mut result, config, workspaces);
2471 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2472 record_plugin_config_diagnostics(&result, &config.root);
2473 return Ok(result);
2474 }
2475
2476 append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
2477
2478 let ws_results = run_workspace_plugins(
2479 ®istry,
2480 config,
2481 workspace_pkgs,
2482 &file_paths,
2483 &result.active_plugins,
2484 candidate_index.as_ref(),
2485 );
2486 merge_workspace_plugin_results(&mut result, ws_results)?;
2487
2488 share_auto_imports_across_layers(&mut result, config, workspaces);
2489 gate_auto_import_entry_patterns(&mut result, config, workspaces);
2490 record_plugin_config_diagnostics(&result, &config.root);
2491
2492 Ok(result)
2493}
2494
2495fn record_plugin_config_diagnostics(result: &plugins::AggregatedPluginResult, root: &Path) {
2507 let diagnostics = result
2508 .config_diagnostics
2509 .iter()
2510 .cloned()
2511 .map(|diagnostic| diagnostic.into_workspace_diagnostic(root))
2512 .collect();
2513 let _ = fallow_config::record_plugin_config_diagnostics(root, diagnostics);
2514}
2515
2516type WorkspacePluginResult = Result<
2517 (plugins::AggregatedPluginResult, String),
2518 Vec<plugins::registry::PluginRegexValidationError>,
2519>;
2520
2521fn run_root_plugins(
2523 registry: &plugins::PluginRegistry,
2524 config: &ResolvedConfig,
2525 root_pkg: Option<&PackageJson>,
2526 file_paths: &[std::path::PathBuf],
2527 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2528) -> Result<plugins::AggregatedPluginResult, FallowError> {
2529 let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
2530 let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
2531 .iter()
2532 .map(std::path::PathBuf::as_path)
2533 .collect();
2534
2535 let mut result = if let Some(pkg) = root_pkg {
2536 registry
2537 .try_run_with_search_roots(
2538 pkg,
2539 &config.root,
2540 file_paths,
2541 &root_config_search_root_refs,
2542 config.production,
2543 candidate_index,
2544 )
2545 .map_err(|errors| {
2546 FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
2547 })?
2548 } else {
2549 plugins::AggregatedPluginResult::default()
2550 };
2551 if let Some(pkg) = root_pkg {
2552 append_package_file_asset_patterns(&mut result, "", pkg);
2553 }
2554 Ok(result)
2555}
2556
2557fn run_workspace_plugins(
2560 registry: &plugins::PluginRegistry,
2561 config: &ResolvedConfig,
2562 workspace_pkgs: &[LoadedWorkspacePackage],
2563 file_paths: &[std::path::PathBuf],
2564 root_active_plugins: &[String],
2565 candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
2566) -> Vec<WorkspacePluginResult> {
2567 let root_active_plugins: rustc_hash::FxHashSet<&str> =
2568 root_active_plugins.iter().map(String::as_str).collect();
2569
2570 let precompiled_matchers = registry.precompile_config_matchers();
2571 let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
2572
2573 workspace_pkgs
2574 .par_iter()
2575 .zip(workspace_relative_files.par_iter())
2576 .filter_map(|((ws, ws_pkg), relative_files)| {
2577 let ws_result =
2578 match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
2579 pkg: ws_pkg,
2580 root: &ws.root,
2581 project_root: &config.root,
2582 precompiled_config_matchers: &precompiled_matchers,
2583 relative_files,
2584 skip_config_plugins: &root_active_plugins,
2585 production_mode: config.production,
2586 candidate_index,
2587 }) {
2588 Ok(result) => result,
2589 Err(errors) => return Some(Err(errors)),
2590 };
2591 if ws_result.active_plugins.is_empty() {
2592 return None;
2593 }
2594 Some(Ok((ws_result, workspace_prefix(&config.root, &ws.root))))
2595 })
2596 .collect::<Vec<_>>()
2597}
2598
2599fn merge_workspace_plugin_results(
2602 result: &mut plugins::AggregatedPluginResult,
2603 ws_results: Vec<WorkspacePluginResult>,
2604) -> Result<(), FallowError> {
2605 let mut regex_errors = Vec::new();
2606 for ws_result in ws_results {
2607 match ws_result {
2608 Ok((mut ws_result, ws_prefix)) => {
2609 ws_result.apply_workspace_prefix(&ws_prefix);
2610 ws_result.config_patterns.clear();
2611 ws_result.script_used_packages.clear();
2612 result.merge_into(ws_result);
2613 }
2614 Err(mut errors) => regex_errors.append(&mut errors),
2615 }
2616 }
2617 if !regex_errors.is_empty() {
2618 return Err(FallowError::config(
2619 plugins::registry::format_plugin_regex_errors(®ex_errors),
2620 ));
2621 }
2622 Ok(())
2623}
2624
2625fn workspace_prefix(root: &Path, workspace_root: &Path) -> String {
2629 workspace_root
2630 .strip_prefix(root)
2631 .unwrap_or(workspace_root)
2632 .to_string_lossy()
2633 .into_owned()
2634}
2635
2636fn share_auto_imports_across_layers(
2652 result: &mut plugins::AggregatedPluginResult,
2653 config: &ResolvedConfig,
2654 workspaces: &[fallow_config::WorkspaceInfo],
2655) {
2656 if result.auto_imports.is_empty() || !result.active_plugins.iter().any(|name| name == "nuxt") {
2657 return;
2658 }
2659 let links = layer_links(config, workspaces);
2660 if links.is_empty() {
2661 return;
2662 }
2663 let mut related: rustc_hash::FxHashMap<PathBuf, Vec<PathBuf>> =
2664 rustc_hash::FxHashMap::default();
2665 for rule in &mut result.auto_imports {
2666 let declared = rule.scope.clone();
2667 for root in &declared {
2668 let roots = related.entry(root.clone()).or_insert_with(|| {
2669 let mut roots = reachable_roots(root, &links, |(app, layer)| (app, layer));
2670 roots.extend(reachable_roots(root, &links, |(app, layer)| (layer, app)));
2671 roots
2672 });
2673 for extra in roots.iter() {
2674 if !rule.scope.contains(extra) {
2675 rule.scope.push(extra.clone());
2676 }
2677 }
2678 }
2679 }
2680}
2681
2682fn reachable_roots<'a>(
2686 start: &Path,
2687 links: &'a [(PathBuf, PathBuf)],
2688 direction: impl Fn(&'a (PathBuf, PathBuf)) -> (&'a PathBuf, &'a PathBuf),
2689) -> Vec<PathBuf> {
2690 let mut found: Vec<PathBuf> = Vec::new();
2691 let mut pending: Vec<&Path> = vec![start];
2692 while let Some(current) = pending.pop() {
2693 for link in links {
2694 let (from, to) = direction(link);
2695 if from.as_path() == current && to.as_path() != start && !found.contains(to) {
2696 found.push(to.clone());
2697 pending.push(to.as_path());
2698 }
2699 }
2700 }
2701 found
2702}
2703
2704fn layer_links(
2708 config: &ResolvedConfig,
2709 workspaces: &[fallow_config::WorkspaceInfo],
2710) -> Vec<(PathBuf, PathBuf)> {
2711 let roots_by_name: rustc_hash::FxHashMap<&str, &Path> = workspaces
2712 .iter()
2713 .map(|ws| (ws.name.as_str(), ws.root.as_path()))
2714 .collect();
2715 let app_roots =
2716 std::iter::once(config.root.as_path()).chain(workspaces.iter().map(|ws| ws.root.as_path()));
2717 let mut links: Vec<(PathBuf, PathBuf)> = Vec::new();
2718 for app in app_roots {
2719 let package_layers = plugins::nuxt::package_layer_names(app)
2720 .into_iter()
2721 .filter_map(|name| {
2722 roots_by_name
2723 .get(name.as_str())
2724 .map(|root| root.to_path_buf())
2725 });
2726 for layer in plugins::nuxt::outside_layer_roots(app)
2727 .into_iter()
2728 .chain(package_layers)
2729 {
2730 let link = (app.to_path_buf(), layer);
2731 if link.0 != link.1 && !links.contains(&link) {
2732 links.push(link);
2733 }
2734 }
2735 }
2736 links
2737}
2738
2739fn gate_auto_import_entry_patterns(
2758 result: &mut plugins::AggregatedPluginResult,
2759 config: &ResolvedConfig,
2760 workspaces: &[fallow_config::WorkspaceInfo],
2761) {
2762 if !config.auto_imports {
2763 return;
2764 }
2765 if !result.active_plugins.iter().any(|name| name == "nuxt") {
2766 return;
2767 }
2768 let root_settings = plugins::nuxt::auto_import_settings(&config.root);
2769 let workspace_settings: Vec<_> = workspaces
2770 .iter()
2771 .map(|ws| {
2772 (
2773 workspace_prefix(&config.root, &ws.root),
2774 plugins::nuxt::auto_import_settings(&ws.root),
2775 )
2776 })
2777 .collect();
2778 let mut retained: Vec<plugins::PluginConfigDiagnostic> = Vec::new();
2779 result.entry_patterns.retain(|(rule, plugin)| {
2780 if plugin != "nuxt" {
2781 return true;
2782 }
2783 let setting =
2784 settings_for_entry_pattern(&root_settings, &workspace_settings, &rule.pattern);
2785 if plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
2786 if !setting.components.is_custom() {
2787 return false;
2788 }
2789 record_retained_auto_import_surface(
2790 &mut retained,
2791 &setting.components_origin,
2792 "components",
2793 );
2794 return true;
2795 }
2796 if plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
2797 if !setting.scripts.is_custom() {
2798 return false;
2799 }
2800 record_retained_auto_import_surface(&mut retained, &setting.scripts_origin, "imports");
2801 return true;
2802 }
2803 true
2804 });
2805 result.config_diagnostics.extend(retained);
2806}
2807
2808const AUTO_IMPORT_PROPERTY_UNREADABLE: &str = "config-property-unreadable";
2813const AUTO_IMPORT_KEY_NOT_MODELED: &str = "key-effect-not-modeled";
2814
2815fn record_retained_auto_import_surface(
2818 retained: &mut Vec<plugins::PluginConfigDiagnostic>,
2819 origin: &plugins::nuxt::SurfaceOrigin,
2820 key: &str,
2821) {
2822 let Some(config_path) = origin.config_path.as_deref() else {
2823 return;
2824 };
2825 let reason = if origin.unreadable_property {
2826 AUTO_IMPORT_PROPERTY_UNREADABLE
2827 } else {
2828 AUTO_IMPORT_KEY_NOT_MODELED
2829 };
2830 let diagnostic = plugins::PluginConfigDiagnostic::not_modeled(config_path, "nuxt", key, reason);
2831 if !retained.contains(&diagnostic) {
2832 retained.push(diagnostic);
2833 }
2834}
2835
2836fn settings_for_entry_pattern<'a>(
2841 root: &'a plugins::nuxt::AutoImportSettings,
2842 workspaces: &'a [(String, plugins::nuxt::AutoImportSettings)],
2843 pattern: &str,
2844) -> &'a plugins::nuxt::AutoImportSettings {
2845 workspaces
2846 .iter()
2847 .filter(|(prefix, _)| {
2848 !prefix.is_empty()
2849 && pattern
2850 .strip_prefix(prefix.as_str())
2851 .is_some_and(|rest| rest.starts_with('/'))
2852 })
2853 .max_by_key(|(prefix, _)| prefix.len())
2854 .map_or(root, |(_, setting)| setting)
2855}
2856
2857fn bucket_files_by_workspace(
2858 workspace_pkgs: &[LoadedWorkspacePackage],
2859 file_paths: &[std::path::PathBuf],
2860) -> Vec<Vec<(std::path::PathBuf, String)>> {
2861 let workspace_roots: Vec<_> = workspace_pkgs
2862 .iter()
2863 .map(|(workspace, _)| workspace.root.as_path())
2864 .collect();
2865 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2866}
2867
2868fn bucket_files_by_workspace_roots(
2869 workspace_roots: &[&Path],
2870 file_paths: &[std::path::PathBuf],
2871) -> Vec<Vec<(std::path::PathBuf, String)>> {
2872 use rayon::prelude::*;
2873
2874 let mut workspace_by_root: rustc_hash::FxHashMap<&Path, usize> =
2878 rustc_hash::FxHashMap::default();
2879 for (idx, root) in workspace_roots.iter().enumerate() {
2880 workspace_by_root.entry(root).or_insert(idx);
2881 }
2882
2883 let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
2884 .par_iter()
2885 .map(|file_path| {
2886 let idx = file_path
2887 .ancestors()
2888 .filter_map(|ancestor| workspace_by_root.get(ancestor).copied())
2889 .min()?;
2890 let relative = file_path.strip_prefix(workspace_roots[idx]).ok()?;
2891 Some((
2892 idx,
2893 file_path.clone(),
2894 relative.to_string_lossy().into_owned(),
2895 ))
2896 })
2897 .collect();
2898
2899 let mut buckets = vec![Vec::new(); workspace_roots.len()];
2900 for (idx, file_path, relative) in assignments.into_iter().flatten() {
2901 buckets[idx].push((file_path, relative));
2902 }
2903
2904 buckets
2905}
2906
2907#[doc(hidden)]
2909pub fn benchmark_bucket_files_by_workspace(
2910 workspace_roots: &[std::path::PathBuf],
2911 file_paths: &[std::path::PathBuf],
2912) -> Vec<Vec<(std::path::PathBuf, String)>> {
2913 let workspace_roots: Vec<_> = workspace_roots
2914 .iter()
2915 .map(std::path::PathBuf::as_path)
2916 .collect();
2917 bucket_files_by_workspace_roots(&workspace_roots, file_paths)
2918}
2919
2920fn collect_config_search_roots(
2921 root: &Path,
2922 file_paths: &[std::path::PathBuf],
2923) -> Vec<std::path::PathBuf> {
2924 let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
2925 roots.insert(root.to_path_buf());
2926
2927 for file_path in file_paths {
2928 let mut current = file_path.parent();
2929 while let Some(dir) = current {
2930 if !dir.starts_with(root) {
2931 break;
2932 }
2933 roots.insert(dir.to_path_buf());
2934 if dir == root {
2935 break;
2936 }
2937 current = dir.parent();
2938 }
2939 }
2940
2941 let mut roots_vec: Vec<_> = roots.into_iter().collect();
2942 roots_vec.sort();
2943 roots_vec
2944}
2945
2946fn config_for_project(
2954 root: &Path,
2955 config_path: Option<&Path>,
2956) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2957 let user_config = if let Some(path) = config_path {
2958 Some((
2959 fallow_config::FallowConfig::load(path)
2960 .map_err(|e| FallowError::config(format!("{e:#}")))?,
2961 path.to_path_buf(),
2962 ))
2963 } else {
2964 fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
2965 };
2966
2967 let config = match user_config {
2968 Some((config, path)) => resolve_user_config(config, path, root)?,
2969 None => (
2970 fallow_config::FallowConfig::default().resolve(
2971 root.to_path_buf(),
2972 fallow_config::OutputFormat::Human,
2973 num_cpus(),
2974 false,
2975 true,
2976 None,
2977 ),
2978 None,
2979 ),
2980 };
2981
2982 Ok(config)
2983}
2984
2985fn resolve_user_config(
2988 mut config: fallow_config::FallowConfig,
2989 path: std::path::PathBuf,
2990 root: &Path,
2991) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
2992 let dead_code_production = config
2993 .production
2994 .for_analysis(fallow_config::ProductionAnalysis::DeadCode);
2995 config.production = dead_code_production.into();
2996 config
2997 .validate_resolved_boundaries(root)
2998 .map_err(|errors| {
2999 let joined = errors
3000 .iter()
3001 .map(ToString::to_string)
3002 .collect::<Vec<_>>()
3003 .join("\n - ");
3004 FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
3005 })?;
3006 let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
3007 let joined = errors
3008 .iter()
3009 .map(ToString::to_string)
3010 .collect::<Vec<_>>()
3011 .join("\n - ");
3012 FallowError::config(format!("invalid rule pack:\n - {joined}"))
3013 })?;
3014 let zone_errors = fallow_config::validate_rule_pack_zones(
3015 root,
3016 &config.boundaries,
3017 &config.rule_packs,
3018 &packs,
3019 );
3020 if !zone_errors.is_empty() {
3021 let joined = zone_errors
3022 .iter()
3023 .map(ToString::to_string)
3024 .collect::<Vec<_>>()
3025 .join("\n - ");
3026 return Err(FallowError::config(format!(
3027 "invalid rule pack:\n - {joined}"
3028 )));
3029 }
3030 Ok((
3031 config.resolve(
3032 root.to_path_buf(),
3033 fallow_config::OutputFormat::Human,
3034 num_cpus(),
3035 false,
3036 true, None, ),
3039 Some(path),
3040 ))
3041}
3042
3043#[cfg_attr(
3054 not(test),
3055 allow(
3056 dead_code,
3057 reason = "config resolution fallback is exercised by session tests"
3058 )
3059)]
3060pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
3061 config_for_project(root, None).map_or_else(
3062 |_| {
3063 fallow_config::FallowConfig::default().resolve(
3064 root.to_path_buf(),
3065 fallow_config::OutputFormat::Human,
3066 num_cpus(),
3067 false,
3068 true,
3069 None,
3070 )
3071 },
3072 |(config, _)| config,
3073 )
3074}
3075
3076fn num_cpus() -> usize {
3077 std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
3078}
3079
3080#[cfg(test)]
3081mod tests {
3082 use super::{
3083 AnalysisSession, bucket_files_by_workspace, bucket_files_by_workspace_roots,
3084 collect_config_search_roots, credit_workspace_package_usage, default_config,
3085 format_undeclared_workspace_warning, gate_auto_import_entry_patterns,
3086 parse_analysis_modules, plugin_config_hash, resolver_options_hash,
3087 settings_for_entry_pattern, warn_undeclared_workspaces, workspace_prefix,
3088 };
3089 use std::path::{Path, PathBuf};
3090 use std::time::Instant;
3091
3092 use fallow_config::{
3093 AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
3094 };
3095 use fallow_types::discover::{DiscoveredFile, FileId};
3096 use fallow_types::extract::{ImportInfo, ImportedName};
3097
3098 fn plugin_result() -> crate::plugins::AggregatedPluginResult {
3099 let mut result = crate::plugins::AggregatedPluginResult::default();
3100 result.active_plugins.push("nuxt".to_string());
3101 result
3102 .path_aliases
3103 .push(("@/".to_string(), "src/".to_string()));
3104 result
3105 }
3106
3107 fn auto_import_settings(
3108 components: crate::plugins::nuxt::AutoImportSetting,
3109 ) -> crate::plugins::nuxt::AutoImportSettings {
3110 crate::plugins::nuxt::AutoImportSettings {
3111 components,
3112 scripts: crate::plugins::nuxt::AutoImportSetting::Default,
3113 components_origin: crate::plugins::nuxt::SurfaceOrigin {
3114 config_path: Some(std::path::PathBuf::from("nuxt.config.ts")),
3115 unreadable_property: false,
3116 },
3117 scripts_origin: crate::plugins::nuxt::SurfaceOrigin::default(),
3118 }
3119 }
3120
3121 #[test]
3122 fn entry_pattern_settings_prefer_the_longest_workspace_prefix() {
3123 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default);
3124 let workspaces = vec![
3125 (
3126 "packages/web".to_string(),
3127 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom),
3128 ),
3129 (
3130 "packages/web-admin".to_string(),
3131 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Disabled),
3132 ),
3133 ];
3134
3135 let admin = settings_for_entry_pattern(
3136 &root,
3137 &workspaces,
3138 "packages/web-admin/components/**/*.{vue,ts,tsx,js,jsx}",
3139 );
3140 assert_eq!(
3141 admin.components,
3142 crate::plugins::nuxt::AutoImportSetting::Disabled,
3143 "a sibling whose name starts with another workspace name is its own"
3144 );
3145
3146 let web = settings_for_entry_pattern(
3147 &root,
3148 &workspaces,
3149 "packages/web/components/**/*.{vue,ts,tsx,js,jsx}",
3150 );
3151 assert_eq!(
3152 web.components,
3153 crate::plugins::nuxt::AutoImportSetting::Custom
3154 );
3155 }
3156
3157 #[test]
3158 fn entry_pattern_without_a_workspace_prefix_belongs_to_the_project_root() {
3159 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom);
3160 let workspaces = vec![(
3161 "packages/web".to_string(),
3162 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default),
3163 )];
3164
3165 let setting = settings_for_entry_pattern(
3166 &root,
3167 &workspaces,
3168 "app/components/**/*.{vue,ts,tsx,js,jsx}",
3169 );
3170 assert_eq!(
3171 setting.components,
3172 crate::plugins::nuxt::AutoImportSetting::Custom
3173 );
3174 }
3175
3176 fn nuxt_gate_fixture(
3179 config_source: &str,
3180 ) -> (tempfile::TempDir, crate::plugins::AggregatedPluginResult) {
3181 let dir = tempfile::tempdir().expect("temp project");
3182 std::fs::write(dir.path().join("nuxt.config.ts"), config_source).expect("nuxt config");
3183 let mut result = crate::plugins::AggregatedPluginResult::default();
3184 result.active_plugins.push("nuxt".to_string());
3185 for pattern in [
3186 "app/components/**/*.{vue,ts,tsx,js,jsx}",
3187 "app/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
3188 ] {
3189 result.entry_patterns.push((
3190 crate::plugins::PathRule::new(pattern.to_string()),
3191 "nuxt".to_string(),
3192 ));
3193 }
3194 (dir, result)
3195 }
3196
3197 fn gate(root: &Path, result: &mut crate::plugins::AggregatedPluginResult) {
3198 let config = fallow_config::FallowConfig {
3199 auto_imports: true,
3200 ..fallow_config::FallowConfig::default()
3201 };
3202 let resolved = config.resolve(
3203 root.to_path_buf(),
3204 fallow_config::OutputFormat::Json,
3205 1,
3206 false,
3207 true,
3208 None,
3209 );
3210 assert!(resolved.auto_imports, "the gate only runs when opted in");
3211 gate_auto_import_entry_patterns(result, &resolved, &[]);
3212 }
3213
3214 #[test]
3218 fn a_retained_auto_import_surface_records_one_advisory_per_surface() {
3219 let (project, mut result) =
3220 nuxt_gate_fixture("export default { components: { dirs: ['~/ui'] } };\n");
3221 gate(project.path(), &mut result);
3222
3223 let recorded: Vec<(&str, &str, &str)> = result
3224 .config_diagnostics
3225 .iter()
3226 .map(|diagnostic| {
3227 (
3228 diagnostic.plugin.as_str(),
3229 diagnostic.key.as_str(),
3230 diagnostic.reason.as_str(),
3231 )
3232 })
3233 .collect();
3234 assert_eq!(
3235 recorded,
3236 vec![("nuxt", "components", "key-effect-not-modeled")],
3237 "only the surface that kept its patterns is reported: {:?}",
3238 result.config_diagnostics
3239 );
3240 assert_eq!(
3241 result.config_diagnostics[0].config_path,
3242 project.path().join("nuxt.config.ts")
3243 );
3244 assert_eq!(
3245 result.entry_patterns.len(),
3246 1,
3247 "the modeled surface still loses its patterns: {:?}",
3248 result.entry_patterns
3249 );
3250 }
3251
3252 #[test]
3256 fn an_unreadable_top_level_property_reports_both_surfaces_with_its_own_reason() {
3257 let (project, mut result) =
3258 nuxt_gate_fixture("export default { ...baseConfig, modules: [] };\n");
3259 gate(project.path(), &mut result);
3260
3261 let recorded: Vec<(&str, &str)> = result
3262 .config_diagnostics
3263 .iter()
3264 .map(|diagnostic| (diagnostic.key.as_str(), diagnostic.reason.as_str()))
3265 .collect();
3266 assert_eq!(
3267 recorded,
3268 vec![
3269 ("components", "config-property-unreadable"),
3270 ("imports", "config-property-unreadable"),
3271 ],
3272 "{:?}",
3273 result.config_diagnostics
3274 );
3275 assert_eq!(
3276 result.entry_patterns.len(),
3277 2,
3278 "a spread keeps every gated pattern"
3279 );
3280 }
3281
3282 #[test]
3285 fn a_modeled_nuxt_config_records_no_advisory() {
3286 let (project, mut result) = nuxt_gate_fixture("export default { modules: [] };\n");
3287 gate(project.path(), &mut result);
3288 assert!(
3289 result.config_diagnostics.is_empty(),
3290 "{:?}",
3291 result.config_diagnostics
3292 );
3293 assert!(
3294 result.entry_patterns.is_empty(),
3295 "both modeled surfaces lose their patterns: {:?}",
3296 result.entry_patterns
3297 );
3298 }
3299
3300 #[test]
3301 fn a_workspace_outside_the_project_keeps_its_own_prefix() {
3302 let project_root = Path::new("/repo");
3303 let outside = Path::new("/elsewhere/app");
3304 let prefix = workspace_prefix(project_root, outside);
3305 assert_eq!(prefix, "/elsewhere/app");
3306
3307 let root = auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Default);
3308 let workspaces = vec![(
3309 prefix,
3310 auto_import_settings(crate::plugins::nuxt::AutoImportSetting::Custom),
3311 )];
3312 let setting = settings_for_entry_pattern(
3313 &root,
3314 &workspaces,
3315 "/elsewhere/app/components/**/*.{vue,ts,tsx,js,jsx}",
3316 );
3317 assert_eq!(
3318 setting.components,
3319 crate::plugins::nuxt::AutoImportSetting::Custom,
3320 "an out-of-tree workspace is classified on its own config"
3321 );
3322 }
3323
3324 #[test]
3325 fn commonjs_internal_import_credits_workspace_package_usage() {
3326 let workspace = fallow_config::WorkspaceInfo {
3327 root: PathBuf::from("/repo/packages/shared"),
3328 name: "@repo/shared".to_string(),
3329 is_internal_dependency: true,
3330 };
3331 let resolved = vec![crate::resolve::ResolvedModule {
3332 file_id: FileId(0),
3333 resolved_imports: vec![crate::resolve::ResolvedImport {
3334 info: ImportInfo {
3335 source: "@repo/shared".to_string(),
3336 imported_name: ImportedName::Namespace,
3337 local_name: "shared".to_string(),
3338 is_type_only: false,
3339 is_type_only_star: false,
3340 from_style: false,
3341 span: oxc_span::Span::new(0, 20),
3342 source_span: oxc_span::Span::new(8, 20),
3343 },
3344 target: crate::resolve::ResolveResult::CommonJsInternalModule(FileId(1)),
3345 }],
3346 ..crate::resolve::ResolvedModule::default()
3347 }];
3348 let mut graph = crate::graph::ModuleGraph::build(&[], &[], &[]);
3349
3350 credit_workspace_package_usage(&mut graph, &resolved, &[workspace]);
3351
3352 assert_eq!(
3353 graph.package_usage.get("@repo/shared"),
3354 Some(&vec![FileId(0)])
3355 );
3356 }
3357
3358 #[test]
3361 fn graph_cache_resolver_hash_is_independent_of_the_project_root() {
3362 let dir_a = tempfile::tempdir().expect("create temp dir a");
3363 let dir_b = tempfile::tempdir().expect("create temp dir b");
3364 let config_a = session_config(dir_a.path());
3365 let config_b = session_config(dir_b.path());
3366
3367 assert_eq!(
3368 resolver_options_hash(&config_a),
3369 resolver_options_hash(&config_b),
3370 "root identity is handled separately from resolver options"
3371 );
3372 }
3373
3374 #[test]
3377 fn graph_cache_manifest_still_rejects_a_different_file_set() {
3378 let dir_a = tempfile::tempdir().expect("create temp dir a");
3379 let mode = crate::graph_cache::GraphCacheMode::new(1, 2, 3);
3380 let files_a = [crate::discover::DiscoveredFile {
3381 id: crate::discover::FileId(0),
3382 path: dir_a.path().join("src/a.ts"),
3383 size_bytes: 1,
3384 }];
3385 let files_b = [crate::discover::DiscoveredFile {
3386 id: crate::discover::FileId(0),
3387 path: dir_a.path().join("src/b.ts"),
3388 size_bytes: 1,
3389 }];
3390
3391 let manifest_a = crate::graph_cache::GraphCacheManifest::from_discovered_files(
3392 dir_a.path(),
3393 &files_a,
3394 mode,
3395 |_| 7,
3396 );
3397 let manifest_b = crate::graph_cache::GraphCacheManifest::from_discovered_files(
3398 dir_a.path(),
3399 &files_b,
3400 mode,
3401 |_| 7,
3402 );
3403
3404 assert_eq!(
3405 manifest_a.classify_resolution_mismatch(&manifest_b),
3406 Some(fallow_types::cache_rejection::CacheRejection::FileSetChanged)
3407 );
3408 }
3409
3410 #[test]
3411 fn graph_cache_resolver_hash_includes_resolve_conditions() {
3412 let dir = tempfile::tempdir().expect("create temp dir");
3413 let config_a = session_config(dir.path());
3414 let mut config_b = session_config(dir.path());
3415 config_b.resolve.conditions.push("react-server".to_string());
3416
3417 assert_ne!(
3418 resolver_options_hash(&config_a),
3419 resolver_options_hash(&config_b),
3420 "resolve condition changes must invalidate the graph cache"
3421 );
3422 }
3423
3424 #[test]
3425 fn graph_cache_plugin_hash_includes_auto_imports() {
3426 let mut without_auto_import = plugin_result();
3427 let mut with_auto_import = plugin_result();
3428 with_auto_import.auto_imports.push(AutoImportRule::new(
3429 "useCounter".to_string(),
3430 PathBuf::from("/project/composables/useCounter.ts"),
3431 AutoImportKind::Named,
3432 ));
3433
3434 assert_ne!(
3435 plugin_config_hash(&without_auto_import, std::path::Path::new("")),
3436 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3437 "auto-import edge changes must invalidate the graph cache"
3438 );
3439
3440 without_auto_import.auto_imports.push(AutoImportRule::new(
3441 "useCounter".to_string(),
3442 PathBuf::from("/project/composables/useCounter.ts"),
3443 AutoImportKind::Default,
3444 ));
3445 assert_ne!(
3446 plugin_config_hash(&without_auto_import, std::path::Path::new("")),
3447 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3448 "auto-import kind changes must invalidate the graph cache"
3449 );
3450
3451 let mut scoped = with_auto_import.auto_imports.clone();
3452 scoped[0].scope = vec![PathBuf::from("/project/packages/a")];
3453 let mut with_scoped_auto_import = plugin_result();
3454 with_scoped_auto_import.auto_imports = scoped;
3455 assert_ne!(
3456 plugin_config_hash(&with_scoped_auto_import, std::path::Path::new("")),
3457 plugin_config_hash(&with_auto_import, std::path::Path::new("")),
3458 "auto-import scope changes must invalidate the graph cache"
3459 );
3460 }
3461
3462 #[test]
3463 fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
3464 let base = plugin_result();
3465 let mut with_scss = base.clone();
3466 with_scss
3467 .scss_include_paths
3468 .push(PathBuf::from("/project/styles"));
3469 assert_ne!(
3470 plugin_config_hash(&base, std::path::Path::new("")),
3471 plugin_config_hash(&with_scss, std::path::Path::new("")),
3472 "SCSS include path changes must invalidate the graph cache"
3473 );
3474
3475 let mut with_static_dir = base.clone();
3476 with_static_dir
3477 .static_dir_mappings
3478 .push((PathBuf::from("/project/public"), "/".to_string()));
3479 assert_ne!(
3480 plugin_config_hash(&base, std::path::Path::new("")),
3481 plugin_config_hash(&with_static_dir, std::path::Path::new("")),
3482 "static directory mapping changes must invalidate the graph cache"
3483 );
3484 }
3485
3486 fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
3487 WorkspaceDiagnostic::new(
3488 root,
3489 root.join(relative),
3490 WorkspaceDiagnosticKind::UndeclaredWorkspace,
3491 )
3492 }
3493
3494 fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
3495 let mut config = default_config(root);
3496 config.no_cache = true;
3497 config.quiet = true;
3498 config
3499 }
3500
3501 fn write_session_fixture(root: &Path) {
3502 let src = root.join("src");
3503 std::fs::create_dir_all(&src).expect("create src");
3504 std::fs::write(
3505 root.join("package.json"),
3506 r#"{"name":"session-fixture","type":"module"}"#,
3507 )
3508 .expect("write package json");
3509 std::fs::write(
3510 src.join("index.ts"),
3511 "import { used } from './used';\nconsole.log(used);\n",
3512 )
3513 .expect("write index");
3514 std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
3515 }
3516
3517 #[test]
3518 fn analysis_session_discovers_project_files() {
3519 let dir = tempfile::tempdir().expect("create temp dir");
3520 write_session_fixture(dir.path());
3521 let config = session_config(dir.path());
3522
3523 let session = AnalysisSession::new(&config).expect("session setup should succeed");
3524
3525 assert!(
3526 session
3527 .files()
3528 .iter()
3529 .any(|file| file.path.ends_with("src/index.ts")),
3530 "session should own discovered project files"
3531 );
3532 assert_eq!(session.workspaces().len(), 0);
3533 }
3534
3535 #[test]
3536 fn direct_core_parse_surfaces_source_read_failure_diagnostic() {
3537 let project = tempfile::tempdir().expect("create project");
3538 let root = project.path();
3539 let paths = ["a.ts", "b.ts", "c.ts"].map(|name| root.join(name));
3540 for (index, path) in paths.iter().enumerate() {
3541 std::fs::write(path, format!("export const value{index} = {index};\n"))
3542 .expect("write source");
3543 }
3544 let files: Vec<DiscoveredFile> = paths
3545 .iter()
3546 .enumerate()
3547 .map(|(index, path)| DiscoveredFile {
3548 id: FileId(u32::try_from(index).expect("test index fits u32")),
3549 path: path.clone(),
3550 size_bytes: std::fs::metadata(path).expect("source metadata").len(),
3551 })
3552 .collect();
3553 std::fs::remove_file(&paths[1]).expect("remove source after discovery");
3554 let config = session_config(root);
3555
3556 let parsed = parse_analysis_modules(&config, &files, false, Instant::now());
3557
3558 assert_eq!(
3559 parsed
3560 .modules
3561 .iter()
3562 .map(|module| module.file_id)
3563 .collect::<Vec<_>>(),
3564 vec![FileId(0), FileId(2)]
3565 );
3566 let diagnostics = fallow_config::workspace_diagnostics_for(root);
3567 let diagnostic = diagnostics
3568 .iter()
3569 .find(|diagnostic| diagnostic.kind.id() == "source-read-failure")
3570 .expect("source read failure diagnostic");
3571 assert_eq!(diagnostic.path, paths[1]);
3572 assert!(matches!(
3573 diagnostic.kind,
3574 WorkspaceDiagnosticKind::SourceReadFailure { .. }
3575 ));
3576 }
3577
3578 #[test]
3579 fn analysis_session_parses_owned_modules() {
3580 let dir = tempfile::tempdir().expect("create temp dir");
3581 write_session_fixture(dir.path());
3582 let config = session_config(dir.path());
3583
3584 let session = AnalysisSession::new(&config).expect("session setup should succeed");
3585 let parsed = session.parse_modules(false);
3586
3587 assert!(
3588 parsed
3589 .modules
3590 .iter()
3591 .any(|module| session.files()[module.file_id.0 as usize]
3592 .path
3593 .ends_with("src/index.ts")),
3594 "session parsing should return modules keyed to session files"
3595 );
3596 }
3597
3598 #[test]
3599 fn undeclared_workspace_warning_is_singular_for_one_path() {
3600 let root = Path::new("/repo");
3601 let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
3602 .expect("warning should be rendered");
3603
3604 assert_eq!(
3605 warning,
3606 "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."
3607 );
3608 }
3609
3610 #[test]
3611 fn undeclared_workspace_warning_summarizes_many_paths() {
3612 let root = PathBuf::from("/repo");
3613 let diagnostics = [
3614 "examples/a",
3615 "examples/b",
3616 "examples/c",
3617 "examples/d",
3618 "examples/e",
3619 "examples/f",
3620 ]
3621 .into_iter()
3622 .map(|path| diag(&root, path))
3623 .collect::<Vec<_>>();
3624
3625 let warning = format_undeclared_workspace_warning(&root, &diagnostics)
3626 .expect("warning should be rendered");
3627
3628 assert_eq!(
3629 warning,
3630 "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."
3631 );
3632 }
3633
3634 #[test]
3635 fn collect_config_search_roots_includes_file_ancestors_once() {
3636 let root = PathBuf::from("/repo");
3637 let search_roots = collect_config_search_roots(
3638 &root,
3639 &[
3640 root.join("apps/query/src/main.ts"),
3641 root.join("packages/shared/lib/index.ts"),
3642 ],
3643 );
3644
3645 assert_eq!(
3646 search_roots,
3647 vec![
3648 root.clone(),
3649 root.join("apps"),
3650 root.join("apps/query"),
3651 root.join("apps/query/src"),
3652 root.join("packages"),
3653 root.join("packages/shared"),
3654 root.join("packages/shared/lib"),
3655 ]
3656 );
3657 }
3658
3659 #[test]
3660 fn bucket_files_by_workspace_uses_workspace_relative_paths() {
3661 let root = PathBuf::from("/repo");
3662 let ui = fallow_config::WorkspaceInfo {
3663 root: root.join("apps/ui"),
3664 name: "ui".to_string(),
3665 is_internal_dependency: false,
3666 };
3667 let api = fallow_config::WorkspaceInfo {
3668 root: root.join("apps/api"),
3669 name: "api".to_string(),
3670 is_internal_dependency: false,
3671 };
3672 let workspace_pkgs = vec![
3673 (
3674 ui,
3675 fallow_config::PackageJson {
3676 name: Some("ui".to_string()),
3677 ..Default::default()
3678 },
3679 ),
3680 (
3681 api,
3682 fallow_config::PackageJson {
3683 name: Some("api".to_string()),
3684 ..Default::default()
3685 },
3686 ),
3687 ];
3688 let files = vec![
3689 root.join("apps/ui/vite.config.ts"),
3690 root.join("apps/ui/src/main.ts"),
3691 root.join("apps/api/src/server.ts"),
3692 root.join("tools/build.ts"),
3693 ];
3694
3695 let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
3696
3697 assert_eq!(
3698 buckets[0],
3699 vec![
3700 (
3701 root.join("apps/ui/vite.config.ts"),
3702 "vite.config.ts".to_string()
3703 ),
3704 (root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
3705 ]
3706 );
3707 assert_eq!(
3708 buckets[1],
3709 vec![(
3710 root.join("apps/api/src/server.ts"),
3711 "src/server.ts".to_string()
3712 )]
3713 );
3714 }
3715
3716 #[test]
3717 fn workspace_bucketing_preserves_first_declared_match_and_file_order() {
3718 let root = PathBuf::from("/repo");
3719 let parent = root.join("apps");
3720 let child = parent.join("web");
3721 let nested_first = child.join("src/first.ts");
3722 let nested_second = child.join("src/second.ts");
3723 let unmatched = root.join("tools/build.ts");
3724 let files = vec![nested_first.clone(), unmatched, nested_second.clone()];
3725
3726 let normalize = |bucket: &[(PathBuf, String)]| -> Vec<(PathBuf, String)> {
3731 bucket
3732 .iter()
3733 .map(|(path, rel)| (path.clone(), rel.replace('\\', "/")))
3734 .collect()
3735 };
3736
3737 let parent_first = bucket_files_by_workspace_roots(&[&parent, &child, &child], &files);
3738 assert_eq!(
3739 normalize(&parent_first[0]),
3740 vec![
3741 (nested_first.clone(), "web/src/first.ts".to_string()),
3742 (nested_second.clone(), "web/src/second.ts".to_string()),
3743 ]
3744 );
3745 assert!(parent_first[1].is_empty());
3746 assert!(parent_first[2].is_empty());
3747
3748 let child_first = bucket_files_by_workspace_roots(&[&child, &parent], &files);
3749 assert_eq!(
3750 normalize(&child_first[0]),
3751 vec![
3752 (nested_first, "src/first.ts".to_string()),
3753 (nested_second, "src/second.ts".to_string()),
3754 ]
3755 );
3756 assert!(child_first[1].is_empty());
3757 }
3758
3759 #[test]
3760 fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
3761 let dir = tempfile::tempdir().expect("create temp dir");
3762 let pkg_good = dir.path().join("packages").join("good");
3763 let pkg_bad = dir.path().join("packages").join("bad");
3764 std::fs::create_dir_all(&pkg_good).unwrap();
3765 std::fs::create_dir_all(&pkg_bad).unwrap();
3766 std::fs::write(
3767 dir.path().join("package.json"),
3768 r#"{"workspaces": ["packages/*"]}"#,
3769 )
3770 .unwrap();
3771 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
3772 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
3773
3774 let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
3775 dir.path(),
3776 &globset::GlobSet::empty(),
3777 )
3778 .expect("root package.json is valid");
3779 assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
3780 fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
3781
3782 warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
3783
3784 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
3785 let mut malformed = 0;
3786 let mut undeclared_for_bad = 0;
3787 for diag in &diagnostics {
3788 if matches!(
3789 diag.kind,
3790 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
3791 ) && diag.path.ends_with("bad")
3792 {
3793 malformed += 1;
3794 }
3795 if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
3796 && diag.path.ends_with("bad")
3797 {
3798 undeclared_for_bad += 1;
3799 }
3800 }
3801 assert_eq!(
3802 malformed, 1,
3803 "expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
3804 );
3805 assert_eq!(
3806 undeclared_for_bad, 0,
3807 "warn_undeclared_workspaces must NOT re-flag a path that already \
3808 carries MalformedPackageJson; got duplicates: {diagnostics:?}"
3809 );
3810 }
3811}