Skip to main content

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