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::{
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;