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