fallow_core/plugins/registry/mod.rs
1//! Plugin registry: discovers active plugins, collects patterns, parses configs.
2
3use rustc_hash::FxHashSet;
4use std::path::{Path, PathBuf};
5
6use fallow_config::{
7 AutoImportRule, EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule,
8};
9
10use crate::scripts;
11
12use super::{PathRule, Plugin, PluginUsedExportRule, ProvidedDependencyRule};
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18 check_has_config_file, discover_config_files, is_external_plugin_active,
19 prepare_config_pattern, process_config_result, process_external_plugins,
20 process_static_patterns,
21};
22
23// ESLint is included because each workspace owns its own eslint.config.{mjs,js,...}
24// that may import a shared workspace eslint-config package. Those transitive deps
25// (e.g. eslint-config-next, eslint-plugin-react) are declared in the workspace's
26// devDependencies and will be flagged as unused if we skip config parsing here.
27fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
28 matches!(
29 plugin_name,
30 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
31 )
32}
33
34/// Registry of all available plugins (built-in + external).
35pub struct PluginRegistry {
36 plugins: Vec<Box<dyn Plugin>>,
37 external_plugins: Vec<ExternalPluginDef>,
38}
39
40/// Aggregated results from all active plugins for a project.
41#[derive(Debug, Clone, Default)]
42pub struct AggregatedPluginResult {
43 /// All entry point patterns from active plugins: (rule, plugin_name).
44 pub entry_patterns: Vec<(PathRule, String)>,
45 /// Coverage role for each plugin contributing entry point patterns.
46 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
47 /// All config file patterns from active plugins.
48 pub config_patterns: Vec<String>,
49 /// All always-used file patterns from active plugins: (pattern, plugin_name).
50 pub always_used: Vec<(String, String)>,
51 /// All used export rules from active plugins.
52 pub used_exports: Vec<PluginUsedExportRule>,
53 /// Class member rules contributed by active plugins that should never be
54 /// flagged as unused. Extends the built-in Angular/React lifecycle allowlist
55 /// with framework-invoked method names, optionally scoped by class heritage.
56 pub used_class_members: Vec<UsedClassMemberRule>,
57 /// Dependencies referenced in config files (should not be flagged unused).
58 pub referenced_dependencies: Vec<String>,
59 /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
60 pub discovered_always_used: Vec<(String, String)>,
61 /// Setup files discovered from config parsing: (path, plugin_name).
62 pub setup_files: Vec<(PathBuf, String)>,
63 /// Tooling dependencies (should not be flagged as unused devDeps).
64 pub tooling_dependencies: Vec<String>,
65 /// Package names discovered as used in package.json scripts (binary invocations).
66 pub script_used_packages: FxHashSet<String>,
67 /// Import prefixes for virtual modules provided by active frameworks.
68 /// Imports matching these prefixes should not be flagged as unlisted dependencies.
69 pub virtual_module_prefixes: Vec<String>,
70 /// Package name suffixes that identify virtual or convention-based specifiers.
71 /// Extracted package names ending with any of these suffixes are not flagged as unlisted.
72 pub virtual_package_suffixes: Vec<String>,
73 /// Import suffixes for build-time generated relative imports.
74 /// Unresolved imports ending with these suffixes are suppressed.
75 pub generated_import_patterns: Vec<String>,
76 /// Import prefixes for build-time generated type-only relative imports.
77 /// Unresolved type-only imports starting with these prefixes are suppressed.
78 pub generated_type_import_prefixes: Vec<String>,
79 /// Path alias mappings from active plugins (prefix → replacement directory).
80 /// Used by the resolver to substitute import prefixes before re-resolving.
81 pub path_aliases: Vec<(String, String)>,
82 /// Convention-based auto-import rules from active plugins (Nuxt components).
83 /// The resolver matches each file's captured `auto_import_candidates` against
84 /// these and synthesizes a graph edge to the rule's source. See issue #704.
85 pub auto_imports: Vec<AutoImportRule>,
86 /// Names of active plugins.
87 pub active_plugins: Vec<String>,
88 /// Test fixture glob patterns from active plugins: (pattern, plugin_name).
89 pub fixture_patterns: Vec<(String, String)>,
90 /// Absolute directories contributed by plugins that should be searched
91 /// when resolving SCSS/Sass `@import`/`@use` specifiers. Populated from
92 /// Angular's `stylePreprocessorOptions.includePaths` and equivalent
93 /// framework settings. See issue #103.
94 pub scss_include_paths: Vec<PathBuf>,
95 /// Static directory mappings contributed by plugins.
96 pub static_dir_mappings: Vec<(PathBuf, String)>,
97 /// File-scoped dependency provider rules from active plugins.
98 pub provided_dependencies: Vec<ProvidedDependencyRule>,
99}
100
101impl PluginRegistry {
102 /// Create a registry with all built-in plugins and optional external plugins.
103 #[must_use]
104 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
105 Self {
106 plugins: builtin::create_builtin_plugins(),
107 external_plugins: external,
108 }
109 }
110
111 /// Hidden directory names that should be traversed before full plugin execution.
112 ///
113 /// Source discovery runs before plugin config parsing, so this helper only uses
114 /// package-activation checks and static plugin metadata.
115 #[must_use]
116 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
117 let all_deps = pkg.all_dependency_names();
118 let mut seen = FxHashSet::default();
119 let mut dirs = Vec::new();
120
121 for plugin in &self.plugins {
122 if !plugin.is_enabled_with_deps(&all_deps, root) {
123 continue;
124 }
125 for dir in plugin.discovery_hidden_dirs() {
126 if seen.insert(*dir) {
127 dirs.push((*dir).to_string());
128 }
129 }
130 }
131
132 dirs
133 }
134
135 /// Run all plugins against a project, returning aggregated results.
136 ///
137 /// This discovers which plugins are active, collects their static patterns,
138 /// then parses any config files to extract dynamic information.
139 pub fn run(
140 &self,
141 pkg: &PackageJson,
142 root: &Path,
143 discovered_files: &[PathBuf],
144 ) -> AggregatedPluginResult {
145 self.run_with_search_roots(pkg, root, discovered_files, &[root], false)
146 }
147
148 /// Run all plugins against a project with explicit config-file search roots.
149 ///
150 /// `config_search_roots` should stay narrowly focused to directories that are
151 /// already known to matter for this project. Broad recursive scans are
152 /// intentionally avoided because they become prohibitively expensive on
153 /// large monorepos with populated `node_modules` trees.
154 ///
155 /// `production_mode` controls the FS fallback for source-extension config
156 /// patterns. In production mode the source walker excludes `*.config.*` so
157 /// the FS walk is required; otherwise Phase 3a's in-memory matcher covers
158 /// them and the walk is skipped.
159 pub fn run_with_search_roots(
160 &self,
161 pkg: &PackageJson,
162 root: &Path,
163 discovered_files: &[PathBuf],
164 config_search_roots: &[&Path],
165 production_mode: bool,
166 ) -> AggregatedPluginResult {
167 let _span = tracing::info_span!("run_plugins").entered();
168 let mut result = AggregatedPluginResult::default();
169
170 // Phase 1: Determine which plugins are active
171 // Compute deps once to avoid repeated Vec<String> allocation per plugin
172 let all_deps = pkg.all_dependency_names();
173 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
174 let active: Vec<&dyn Plugin> = self
175 .plugins
176 .iter()
177 .filter(|p| {
178 p.is_enabled_with_files(&all_deps, root, discovered_files)
179 || p.is_enabled_with_scripts(&script_packages, root)
180 })
181 .map(AsRef::as_ref)
182 .collect();
183
184 tracing::info!(
185 plugins = active
186 .iter()
187 .map(|p| p.name())
188 .collect::<Vec<_>>()
189 .join(", "),
190 "active plugins"
191 );
192
193 // Warn when meta-frameworks are active but their generated configs are missing.
194 // Without these, tsconfig extends chains break and import resolution fails.
195 check_meta_framework_prerequisites(&active, root);
196
197 // Silent-fail diagnostics for the plugin system (#479).
198 self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
199
200 // Phase 2: Collect static patterns from active plugins
201 for plugin in &active {
202 process_static_patterns(*plugin, root, &mut result);
203 }
204
205 // Phase 2b: Process external plugins (includes inline framework definitions)
206 process_external_plugins(
207 &self.external_plugins,
208 &all_deps,
209 root,
210 discovered_files,
211 &mut result,
212 );
213
214 // Phase 3: Find and parse config files for dynamic resolution
215 // Pre-compile all config patterns. Source-extension root-anchored
216 // patterns are wrapped with `**/` so they match nested files via the
217 // discovered file set (Phase 3a), letting Phase 3b skip those plugins
218 // and avoid a per-directory stat storm on large monorepos.
219 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
220 .iter()
221 .filter(|p| !p.config_patterns().is_empty())
222 .map(|p| {
223 let matchers: Vec<globset::GlobMatcher> = p
224 .config_patterns()
225 .iter()
226 .filter_map(|pat| {
227 let prepared = prepare_config_pattern(pat);
228 globset::Glob::new(&prepared)
229 .ok()
230 .map(|g| g.compile_matcher())
231 })
232 .collect();
233 (*p, matchers)
234 })
235 .collect();
236
237 use rayon::prelude::*;
238 // Build relative paths lazily: only needed when config matchers exist
239 // or plugins have package_json_config_key. Skip entirely for projects
240 // with no config-parsing plugins (e.g., only React), avoiding O(files)
241 // String allocations.
242 let needs_relative_files = !config_matchers.is_empty()
243 || active.iter().any(|p| p.package_json_config_key().is_some());
244 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
245 discovered_files
246 .par_iter()
247 .map(|f| {
248 let rel = f
249 .strip_prefix(root)
250 .unwrap_or(f)
251 .to_string_lossy()
252 .into_owned();
253 (f.clone(), rel)
254 })
255 .collect()
256 } else {
257 Vec::new()
258 };
259
260 if !config_matchers.is_empty() {
261 // Phase 3a: Match config files from discovered source files. Per-file
262 // glob matching is parallelized: on monorepos with tens of thousands
263 // of source files, the file-scan cost dominates the plugins phase.
264 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
265
266 for (plugin, matchers) in &config_matchers {
267 let plugin_hits: Vec<&PathBuf> = relative_files
268 .par_iter()
269 .filter_map(|(abs_path, rel_path)| {
270 matchers
271 .iter()
272 .any(|m| m.is_match(rel_path.as_str()))
273 .then_some(abs_path)
274 })
275 .collect();
276 for abs_path in plugin_hits {
277 if let Ok(source) = std::fs::read_to_string(abs_path) {
278 let plugin_result = plugin.resolve_config(abs_path, &source, root);
279 if !plugin_result.is_empty() {
280 resolved_plugins.insert(plugin.name());
281 tracing::debug!(
282 plugin = plugin.name(),
283 config = %abs_path.display(),
284 entries = plugin_result.entry_patterns.len(),
285 deps = plugin_result.referenced_dependencies.len(),
286 "resolved config"
287 );
288 process_config_result(
289 plugin.name(),
290 plugin_result,
291 &mut result,
292 Some(abs_path),
293 );
294 }
295 }
296 }
297 }
298
299 // Phase 3b: Filesystem fallback for JSON config files.
300 // JSON files (angular.json, project.json) are not in the discovered file set
301 // because fallow only discovers JS/TS/CSS/Vue/etc. files. In production
302 // mode, source-extension configs (`*.config.*`, dotfiles) are also
303 // excluded from the walker, so the FS walk runs for those patterns too.
304 let json_configs = discover_config_files(
305 &config_matchers,
306 &resolved_plugins,
307 config_search_roots,
308 production_mode,
309 );
310 for (abs_path, plugin) in &json_configs {
311 if let Ok(source) = std::fs::read_to_string(abs_path) {
312 let plugin_result = plugin.resolve_config(abs_path, &source, root);
313 if !plugin_result.is_empty() {
314 let rel = abs_path
315 .strip_prefix(root)
316 .map(|p| p.to_string_lossy())
317 .unwrap_or_default();
318 tracing::debug!(
319 plugin = plugin.name(),
320 config = %rel,
321 entries = plugin_result.entry_patterns.len(),
322 deps = plugin_result.referenced_dependencies.len(),
323 "resolved config (filesystem fallback)"
324 );
325 process_config_result(
326 plugin.name(),
327 plugin_result,
328 &mut result,
329 Some(abs_path),
330 );
331 }
332 }
333 }
334 }
335
336 // Phase 4: Package.json inline config fallback.
337 process_package_json_inline_configs(
338 &active,
339 &config_matchers,
340 &relative_files,
341 root,
342 &mut result,
343 );
344
345 result
346 }
347
348 /// Fast variant of `run()` for workspace packages.
349 ///
350 /// Reuses pre-compiled config matchers and pre-computed relative files from the root
351 /// project run, avoiding repeated glob compilation and path computation per workspace.
352 /// Skips package.json inline config (workspace packages rarely have inline configs).
353 #[expect(
354 clippy::too_many_arguments,
355 reason = "Each parameter is a distinct, small value with no natural grouping; \
356 bundling them into a struct hurts call-site readability."
357 )]
358 pub fn run_workspace_fast(
359 &self,
360 pkg: &PackageJson,
361 root: &Path,
362 project_root: &Path,
363 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
364 relative_files: &[(PathBuf, String)],
365 skip_config_plugins: &FxHashSet<&str>,
366 production_mode: bool,
367 ) -> AggregatedPluginResult {
368 let _span = tracing::info_span!("run_plugins").entered();
369 let mut result = AggregatedPluginResult::default();
370
371 // Phase 1: Determine which plugins are active (with pre-computed deps)
372 let all_deps = pkg.all_dependency_names();
373 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
374 let workspace_files: Vec<PathBuf> = relative_files
375 .iter()
376 .map(|(abs_path, _)| abs_path.clone())
377 .collect();
378
379 let active: Vec<&dyn Plugin> = self
380 .plugins
381 .iter()
382 .filter(|p| {
383 p.is_enabled_with_files(&all_deps, root, &workspace_files)
384 || p.is_enabled_with_scripts(&script_packages, root)
385 })
386 .map(AsRef::as_ref)
387 .collect();
388
389 tracing::info!(
390 plugins = active
391 .iter()
392 .map(|p| p.name())
393 .collect::<Vec<_>>()
394 .join(", "),
395 "active plugins"
396 );
397
398 // Silent-fail diagnostics (#479); the shared dedupe set means the
399 // same external plugin's enabler typo or pattern collision only warns
400 // once per process even when this fast path runs per workspace.
401 self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
402
403 process_external_plugins(
404 &self.external_plugins,
405 &all_deps,
406 root,
407 &workspace_files,
408 &mut result,
409 );
410
411 // Early exit if no plugins are active (common for leaf workspace packages)
412 if active.is_empty() && result.active_plugins.is_empty() {
413 return result;
414 }
415
416 // Phase 2: Collect static patterns from active plugins
417 for plugin in &active {
418 process_static_patterns(*plugin, root, &mut result);
419 }
420
421 // Phase 3: Find and parse config files using pre-compiled matchers
422 // Only check matchers for plugins that are active in this workspace
423 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
424 let workspace_matchers: Vec<_> = precompiled_config_matchers
425 .iter()
426 .filter(|(p, _)| {
427 active_names.contains(p.name())
428 && (!skip_config_plugins.contains(p.name())
429 || must_parse_workspace_config_when_root_active(p.name()))
430 })
431 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
432 .collect();
433
434 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
435 if !workspace_matchers.is_empty() {
436 use rayon::prelude::*;
437 for (plugin, matchers) in &workspace_matchers {
438 let plugin_hits: Vec<&PathBuf> = relative_files
439 .par_iter()
440 .filter_map(|(abs_path, rel_path)| {
441 matchers
442 .iter()
443 .any(|m| m.is_match(rel_path.as_str()))
444 .then_some(abs_path)
445 })
446 .collect();
447 for abs_path in plugin_hits {
448 if let Ok(source) = std::fs::read_to_string(abs_path) {
449 let plugin_result = plugin.resolve_config(abs_path, &source, root);
450 if !plugin_result.is_empty() {
451 resolved_ws_plugins.insert(plugin.name());
452 tracing::debug!(
453 plugin = plugin.name(),
454 config = %abs_path.display(),
455 entries = plugin_result.entry_patterns.len(),
456 deps = plugin_result.referenced_dependencies.len(),
457 "resolved config"
458 );
459 process_config_result(
460 plugin.name(),
461 plugin_result,
462 &mut result,
463 Some(abs_path),
464 );
465 }
466 }
467 }
468 }
469 }
470
471 // Phase 3b: Filesystem fallback for JSON config files at the project root.
472 // Config files like angular.json live at the monorepo root, but Angular is
473 // only active in workspace packages. Check the project root for unresolved
474 // config patterns.
475 let ws_json_configs = if root == project_root {
476 discover_config_files(
477 &workspace_matchers,
478 &resolved_ws_plugins,
479 &[root],
480 production_mode,
481 )
482 } else {
483 discover_config_files(
484 &workspace_matchers,
485 &resolved_ws_plugins,
486 &[root, project_root],
487 production_mode,
488 )
489 };
490 // Parse discovered JSON config files
491 for (abs_path, plugin) in &ws_json_configs {
492 if let Ok(source) = std::fs::read_to_string(abs_path) {
493 let plugin_result = plugin.resolve_config(abs_path, &source, root);
494 if !plugin_result.is_empty() {
495 let rel = abs_path
496 .strip_prefix(project_root)
497 .map(|p| p.to_string_lossy())
498 .unwrap_or_default();
499 tracing::debug!(
500 plugin = plugin.name(),
501 config = %rel,
502 entries = plugin_result.entry_patterns.len(),
503 deps = plugin_result.referenced_dependencies.len(),
504 "resolved config (workspace filesystem fallback)"
505 );
506 process_config_result(
507 plugin.name(),
508 plugin_result,
509 &mut result,
510 Some(abs_path),
511 );
512 }
513 }
514 }
515
516 result
517 }
518
519 /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
520 /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
521 #[must_use]
522 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
523 self.plugins
524 .iter()
525 .filter(|p| !p.config_patterns().is_empty())
526 .map(|p| {
527 let matchers: Vec<globset::GlobMatcher> = p
528 .config_patterns()
529 .iter()
530 .filter_map(|pat| {
531 let prepared = prepare_config_pattern(pat);
532 globset::Glob::new(&prepared)
533 .ok()
534 .map(|g| g.compile_matcher())
535 })
536 .collect();
537 (p.as_ref(), matchers)
538 })
539 .collect()
540 }
541}
542
543impl Default for PluginRegistry {
544 fn default() -> Self {
545 Self::new(vec![])
546 }
547}
548
549impl PluginRegistry {
550 /// Collect the active subset of external plugins, run the silent-fail
551 /// diagnostics (#479), and emit one `tracing::warn!` per finding (dedup'd
552 /// across analysis passes via [`plugin_warn_dedupe`]).
553 ///
554 /// Called from both `run_with_search_roots` (top-level) and
555 /// `run_workspace_fast` (per-workspace) so a typo'd enabler or pattern
556 /// collision surfaces regardless of which entry point dispatched the
557 /// analysis.
558 fn emit_silent_fail_diagnostics(
559 &self,
560 active: &[&dyn Plugin],
561 all_deps: &[String],
562 root: &Path,
563 discovered_files: &[PathBuf],
564 ) {
565 let active_external: Vec<&ExternalPluginDef> = self
566 .external_plugins
567 .iter()
568 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
569 .collect();
570 let mut diagnostics = detect_pattern_collisions(active, &active_external);
571 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
572 emit_plugin_diagnostics(&diagnostics);
573 }
574}
575
576/// Process-wide dedupe key cache for plugin-system diagnostic warnings.
577///
578/// Combined-mode runs `PluginRegistry::run_with_search_roots` three times
579/// (check + dupes + health) per analysis, so a naive warn would triple-emit
580/// every diagnostic. Each warn helper builds a unique key, inserts it here,
581/// and only emits when the key was previously absent.
582fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
583 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
584 std::sync::OnceLock::new();
585 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
586}
587
588/// Insert `key` into the dedupe set and return `true` when it was newly
589/// inserted (caller should emit). Returns `true` on a poisoned mutex so
590/// over-warning beats swallowing.
591fn should_warn(key: String) -> bool {
592 plugin_warn_dedupe()
593 .lock()
594 .map_or(true, |mut set| set.insert(key))
595}
596
597/// Structured diagnostic surfaced by the silent-fail plugin checks (#479).
598///
599/// Returned by [`detect_pattern_collisions`] and [`detect_enabler_typos`] so
600/// unit tests can assert on the findings without standing up a tracing
601/// subscriber. The runtime path calls [`emit_plugin_diagnostics`] to convert
602/// each variant into one `tracing::warn!` line.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub(crate) enum PluginDiagnostic {
605 /// Two or more plugins declared an identical `config_patterns` entry.
606 PatternCollision {
607 pattern: String,
608 owners: Vec<String>,
609 },
610 /// An external plugin enabler does not match any project dependency, but
611 /// at least one Levenshtein-close dep name exists.
612 EnablerTypo {
613 plugin: String,
614 enabler: String,
615 suggestion: String,
616 },
617}
618
619/// Detect plugins whose `config_patterns` collide byte-for-byte.
620///
621/// Detection is byte-equal on the pattern string. Overlapping but non-identical
622/// globs (e.g. `vite.config.{ts,js}` vs `vite.config.ts`) require pattern
623/// intersection logic and are intentionally out of scope; there are no known
624/// collisions in the built-in plugin set. The warning's purpose is to surface
625/// USER-AUTHORED collisions between external plugins or between an external
626/// plugin and a built-in, so the user can disambiguate by editing one side.
627///
628/// Precedence rule when two plugins claim the same pattern: the one registered
629/// first wins. For built-in plugins, registration order is defined in
630/// [`builtin::create_builtin_plugins`]. External plugins (file-loaded plus
631/// inline `framework[]`) run AFTER built-ins, so they cannot displace a
632/// built-in's `resolve_config` result for the same file.
633pub(crate) fn detect_pattern_collisions(
634 builtin_active: &[&dyn Plugin],
635 external_active: &[&ExternalPluginDef],
636) -> Vec<PluginDiagnostic> {
637 use rustc_hash::FxHashMap;
638
639 // Owners are stored as a Vec to preserve REGISTRATION ORDER: owners[0]
640 // is the plugin that wins Phase 3a config matching, and the warning text
641 // names it as the winner. A `FxHashSet` is held alongside to dedupe a
642 // single plugin that legitimately lists the same pattern twice in its
643 // own `config_patterns` (rare but legal) so it does not look like a
644 // self-vs-self collision.
645 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
646 FxHashMap::default();
647
648 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
649 pattern: String,
650 name: String| {
651 let (list, seen) = pattern_owners.entry(pattern).or_default();
652 if seen.insert(name.clone()) {
653 list.push(name);
654 }
655 };
656
657 for plugin in builtin_active {
658 for pat in plugin.config_patterns() {
659 record(
660 &mut pattern_owners,
661 (*pat).to_string(),
662 plugin.name().to_string(),
663 );
664 }
665 }
666 for ext in external_active {
667 for pat in &ext.config_patterns {
668 record(&mut pattern_owners, pat.clone(), ext.name.clone());
669 }
670 }
671
672 let mut findings: Vec<PluginDiagnostic> = pattern_owners
673 .into_iter()
674 .filter_map(|(pattern, (owners, _seen))| {
675 if owners.len() < 2 {
676 None
677 } else {
678 Some(PluginDiagnostic::PatternCollision { pattern, owners })
679 }
680 })
681 .collect();
682 findings.sort_unstable_by(|a, b| match (a, b) {
683 (
684 PluginDiagnostic::PatternCollision { pattern: ap, .. },
685 PluginDiagnostic::PatternCollision { pattern: bp, .. },
686 ) => ap.cmp(bp),
687 _ => std::cmp::Ordering::Equal,
688 });
689 findings
690}
691
692/// Detect external plugins whose enablers do not match any project dependency
693/// AND at least one enabler is a plausible typo of a real dep.
694///
695/// Scope:
696/// - Only external plugins (file-loaded plus inline `framework[]`). Built-in
697/// plugins' enablers are hard-coded so cannot be misspelled.
698/// - Skip plugins with a `detection` block: detection is the rich-logic path
699/// and false negatives there are not enabler typos.
700/// - Skip plugins with empty `enablers` (no signal to validate against).
701/// - Stay silent when no Levenshtein-close dep exists: the plugin may
702/// legitimately not apply to this project.
703///
704/// Matches the established #467 / #510 pattern: tracing-warn with a `did you
705/// mean` suggestion at the call site. No exit non-zero, no new CLI flag.
706pub(crate) fn detect_enabler_typos(
707 external_plugins: &[ExternalPluginDef],
708 all_deps: &[String],
709) -> Vec<PluginDiagnostic> {
710 let mut findings = Vec::new();
711
712 for ext in external_plugins {
713 if ext.detection.is_some() || ext.enablers.is_empty() {
714 continue;
715 }
716
717 let any_match = ext.enablers.iter().any(|enabler| {
718 if enabler.ends_with('/') {
719 all_deps.iter().any(|d| d.starts_with(enabler))
720 } else {
721 all_deps.iter().any(|d| d == enabler)
722 }
723 });
724 if any_match {
725 continue;
726 }
727
728 for enabler in &ext.enablers {
729 let candidates = all_deps.iter().map(String::as_str);
730 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
731 else {
732 continue;
733 };
734
735 findings.push(PluginDiagnostic::EnablerTypo {
736 plugin: ext.name.clone(),
737 enabler: enabler.clone(),
738 suggestion: suggestion.to_string(),
739 });
740 }
741 }
742
743 findings
744}
745
746/// Emit one `tracing::warn!` per finding, dedup'd against the process-wide
747/// `plugin_warn_dedupe` set so combined-mode does not triple-warn.
748fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
749 for finding in findings {
750 match finding {
751 PluginDiagnostic::PatternCollision { pattern, owners } => {
752 let key = format!("collision::{pattern}::{owners:?}");
753 if !should_warn(key) {
754 continue;
755 }
756 let winner = &owners[0];
757 let others = owners[1..].join(", ");
758 tracing::warn!(
759 "plugin config_patterns collision: identical pattern \
760 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
761 runs first (registration order), others ({others}) \
762 follow. Rename one of the patterns or remove the \
763 duplicate plugin to make resolution explicit. A future \
764 release may reject identical-pattern collisions.",
765 joined = owners.join(", "),
766 );
767 }
768 PluginDiagnostic::EnablerTypo {
769 plugin,
770 enabler,
771 suggestion,
772 } => {
773 let key = format!("enabler::{plugin}::{enabler}");
774 if !should_warn(key) {
775 continue;
776 }
777 tracing::warn!(
778 "plugin '{plugin}' enabler '{enabler}' does not match any \
779 dependency in package.json; did you mean '{suggestion}'? \
780 The plugin will not activate. A future release may reject \
781 unmatched enablers.",
782 );
783 }
784 }
785 }
786}
787
788/// Phase 4 of `PluginRegistry::run_with_search_roots`: for any active plugin
789/// that supports inline package.json configuration via
790/// [`Plugin::package_json_config_key`], read the root `package.json`, extract
791/// the relevant key, and feed the result through `resolve_config`.
792fn process_package_json_inline_configs(
793 active: &[&dyn Plugin],
794 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
795 relative_files: &[(PathBuf, String)],
796 root: &Path,
797 result: &mut AggregatedPluginResult,
798) {
799 for plugin in active {
800 let Some(key) = plugin.package_json_config_key() else {
801 continue;
802 };
803 if check_has_config_file(*plugin, config_matchers, relative_files) {
804 continue;
805 }
806 let pkg_path = root.join("package.json");
807 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
808 continue;
809 };
810 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
811 continue;
812 };
813 let Some(config_value) = json.get(key) else {
814 continue;
815 };
816 let config_json = serde_json::to_string(config_value).unwrap_or_default();
817 let fake_path = root.join(format!("{key}.config.json"));
818 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
819 if plugin_result.is_empty() {
820 continue;
821 }
822 tracing::debug!(
823 plugin = plugin.name(),
824 key = key,
825 "resolved inline package.json config"
826 );
827 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path));
828 }
829}
830
831/// A missing meta-framework prerequisite: the per-process dedupe key and the
832/// warning message to emit.
833#[derive(Debug)]
834struct MetaFrameworkWarning {
835 dedupe_key: &'static str,
836 message: &'static str,
837}
838
839/// Pure detection: which active meta-frameworks are missing their generated
840/// config/types directory under `root`. Separated from emission so the
841/// detection logic is unit-testable without a tracing subscriber or the
842/// process-wide dedupe set.
843///
844/// When adding a framework here, also extend `MATERIALIZED_CONTEXT_DIRS` in
845/// `fallow-cli`'s `audit.rs` with its generated dir, otherwise `fallow audit`'s
846/// base worktree will not symlink that dir and the broken-tsconfig-chain bug
847/// resurfaces on the base pass for the new framework.
848fn missing_meta_framework_prerequisites(
849 active_plugins: &[&dyn Plugin],
850 root: &Path,
851) -> Vec<MetaFrameworkWarning> {
852 active_plugins
853 .iter()
854 .filter_map(|plugin| match plugin.name() {
855 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
856 dedupe_key: "meta-prereq::nuxt",
857 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
858 before fallow for accurate analysis",
859 }),
860 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
861 dedupe_key: "meta-prereq::astro",
862 message: "Astro project missing .astro/ types: run `astro sync` \
863 before fallow for accurate analysis",
864 }),
865 _ => None,
866 })
867 .collect()
868}
869
870/// Warn when meta-frameworks are active but their generated configs are missing.
871///
872/// Meta-frameworks like Nuxt and Astro generate tsconfig/types files during a
873/// "prepare" step. Without these, the tsconfig extends chain breaks and
874/// extensionless imports fail wholesale (e.g. 2000+ unresolved imports).
875///
876/// Deduped per framework so combined-mode (check + dupes + health through one
877/// loader) does not re-warn. The advice is generic and does not name the root,
878/// so one line per process per framework is the right bound (issue #637).
879fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
880 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
881 if should_warn(warning.dedupe_key.to_owned()) {
882 tracing::warn!("{}", warning.message);
883 }
884 }
885}
886
887fn script_activation_packages(
888 pkg: &PackageJson,
889 root: &Path,
890 all_deps: &[String],
891 production_mode: bool,
892) -> FxHashSet<String> {
893 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
894 return FxHashSet::default();
895 };
896
897 let scripts_to_analyze = if production_mode {
898 scripts::filter_production_scripts(pkg_scripts)
899 } else {
900 pkg_scripts.clone()
901 };
902
903 let mut nm_roots = Vec::new();
904 if root.join("node_modules").is_dir() {
905 nm_roots.push(root);
906 }
907 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
908
909 scripts::analyze_scripts(&scripts_to_analyze, root, &bin_map).used_packages
910}
911
912#[cfg(test)]
913mod tests;