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
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, Clone, 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    /// Convention-based auto-import rules from active plugins (Nuxt components).
79    /// The resolver matches each file's captured `auto_import_candidates` against
80    /// these and synthesizes a graph edge to the rule's source. See issue #704.
81    pub auto_imports: Vec<AutoImportRule>,
82    /// Names of active plugins.
83    pub active_plugins: Vec<String>,
84    /// Test fixture glob patterns from active plugins: (pattern, plugin_name).
85    pub fixture_patterns: Vec<(String, String)>,
86    /// Absolute directories contributed by plugins that should be searched
87    /// when resolving SCSS/Sass `@import`/`@use` specifiers. Populated from
88    /// Angular's `stylePreprocessorOptions.includePaths` and equivalent
89    /// framework settings. See issue #103.
90    pub scss_include_paths: Vec<PathBuf>,
91    /// Static directory mappings contributed by plugins.
92    pub static_dir_mappings: Vec<(PathBuf, String)>,
93    /// File-scoped dependency provider rules from active plugins.
94    pub provided_dependencies: Vec<ProvidedDependencyRule>,
95}
96
97/// Append `incoming` string items to `target`, skipping values already present
98/// in `target` or earlier in `incoming`. Matches the deduplication the
99/// workspace merge applied via per-field `seen` sets before #444 centralized
100/// it on [`AggregatedPluginResult::merge_into`].
101fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
102    let mut seen: FxHashSet<String> = target.iter().cloned().collect();
103    for item in incoming {
104        if seen.insert(item.clone()) {
105            target.push(item);
106        }
107    }
108}
109
110/// Prefix a workspace-relative pattern so it matches from the monorepo root,
111/// unless it is already workspace-prefixed or project-root-relative (leading
112/// `/`, e.g. an angular.json path). Mirrors the pre-#444 inline closure.
113fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
114    if pat.starts_with(ws_prefix) || pat.starts_with('/') {
115        pat.to_string()
116    } else {
117        format!("{ws_prefix}/{pat}")
118    }
119}
120
121impl AggregatedPluginResult {
122    /// Apply a workspace prefix to every path-bearing field in place.
123    ///
124    /// Workspace-package results are collected with patterns relative to the
125    /// package root; to be matchable from the monorepo root they need the
126    /// package's prefix applied. This transform is call-site-specific (it
127    /// depends on `ws_prefix`), so it stays separate from [`Self::merge_into`],
128    /// which is a prefix-agnostic union. The root project's own result is
129    /// never prefixed.
130    ///
131    /// Fields that carry package names, absolute paths, or import-specifier
132    /// boundaries (referenced/tooling deps, setup files, static dir mappings,
133    /// auto-imports, virtual prefixes/suffixes, generated patterns) are left
134    /// untouched, matching the pre-#444 merge loop.
135    pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
136        for (rule, _) in &mut self.entry_patterns {
137            *rule = rule.prefixed(ws_prefix);
138        }
139        for (pat, _) in &mut self.always_used {
140            *pat = prefix_if_needed(pat, ws_prefix);
141        }
142        for (pat, _) in &mut self.discovered_always_used {
143            *pat = prefix_if_needed(pat, ws_prefix);
144        }
145        for (pat, _) in &mut self.fixture_patterns {
146            *pat = prefix_if_needed(pat, ws_prefix);
147        }
148        for rule in &mut self.used_exports {
149            *rule = rule.prefixed(ws_prefix);
150        }
151        for rule in &mut self.provided_dependencies {
152            *rule = rule.prefixed(ws_prefix);
153        }
154        for (_, replacement) in &mut self.path_aliases {
155            *replacement = format!("{ws_prefix}/{replacement}");
156        }
157    }
158
159    /// Merge `other` into `self`, taking the union of every field.
160    ///
161    /// Exhaustively destructures `Self` so adding a field to
162    /// `AggregatedPluginResult` becomes a `missing field in pattern` compile
163    /// error here instead of a silently-dropped field. See issue #444.
164    ///
165    /// Callers that need the workspace prefix applied must call
166    /// [`Self::apply_workspace_prefix`] on `other` first; this method does not
167    /// transform any path. Dedup-bearing fields (`active_plugins`, the virtual
168    /// prefix/suffix and generated-pattern lists) deduplicate the incoming
169    /// values against the contents already in `self`, matching the pre-#444
170    /// `seen`-set behavior. `entry_point_roles` is first-writer-wins.
171    pub fn merge_into(&mut self, other: Self) {
172        let Self {
173            entry_patterns,
174            entry_point_roles,
175            config_patterns,
176            always_used,
177            used_exports,
178            used_class_members,
179            referenced_dependencies,
180            discovered_always_used,
181            setup_files,
182            tooling_dependencies,
183            script_used_packages,
184            virtual_module_prefixes,
185            virtual_package_suffixes,
186            generated_import_patterns,
187            generated_type_import_prefixes,
188            path_aliases,
189            auto_imports,
190            active_plugins,
191            fixture_patterns,
192            scss_include_paths,
193            static_dir_mappings,
194            provided_dependencies,
195        } = other;
196
197        self.entry_patterns.extend(entry_patterns);
198        for (plugin_name, role) in entry_point_roles {
199            self.entry_point_roles.entry(plugin_name).or_insert(role);
200        }
201        self.config_patterns.extend(config_patterns);
202        self.always_used.extend(always_used);
203        self.used_exports.extend(used_exports);
204        self.used_class_members.extend(used_class_members);
205        self.referenced_dependencies.extend(referenced_dependencies);
206        self.discovered_always_used.extend(discovered_always_used);
207        self.setup_files.extend(setup_files);
208        self.tooling_dependencies.extend(tooling_dependencies);
209        self.script_used_packages.extend(script_used_packages);
210        extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
211        extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
212        extend_unique(
213            &mut self.generated_import_patterns,
214            generated_import_patterns,
215        );
216        extend_unique(
217            &mut self.generated_type_import_prefixes,
218            generated_type_import_prefixes,
219        );
220        self.path_aliases.extend(path_aliases);
221        self.auto_imports.extend(auto_imports);
222        extend_unique(&mut self.active_plugins, active_plugins);
223        self.fixture_patterns.extend(fixture_patterns);
224        self.scss_include_paths.extend(scss_include_paths);
225        self.static_dir_mappings.extend(static_dir_mappings);
226        self.provided_dependencies.extend(provided_dependencies);
227    }
228}
229
230impl PluginRegistry {
231    /// Create a registry with all built-in plugins and optional external plugins.
232    #[must_use]
233    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
234        Self {
235            plugins: builtin::create_builtin_plugins(),
236            external_plugins: external,
237        }
238    }
239
240    /// Hidden directory names that should be traversed before full plugin execution.
241    ///
242    /// Source discovery runs before plugin config parsing, so this helper only uses
243    /// package-activation checks and static plugin metadata.
244    #[must_use]
245    pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
246        let all_deps = pkg.all_dependency_names();
247        let mut seen = FxHashSet::default();
248        let mut dirs = Vec::new();
249
250        for plugin in &self.plugins {
251            if !plugin.is_enabled_with_deps(&all_deps, root) {
252                continue;
253            }
254            for dir in plugin.discovery_hidden_dirs() {
255                if seen.insert(*dir) {
256                    dirs.push((*dir).to_string());
257                }
258            }
259        }
260
261        dirs
262    }
263
264    /// Run all plugins against a project, returning aggregated results.
265    ///
266    /// This discovers which plugins are active, collects their static patterns,
267    /// then parses any config files to extract dynamic information.
268    pub fn run(
269        &self,
270        pkg: &PackageJson,
271        root: &Path,
272        discovered_files: &[PathBuf],
273    ) -> AggregatedPluginResult {
274        self.run_with_search_roots(pkg, root, discovered_files, &[root], false)
275    }
276
277    /// Run all plugins against a project with explicit config-file search roots.
278    ///
279    /// `config_search_roots` should stay narrowly focused to directories that are
280    /// already known to matter for this project. Broad recursive scans are
281    /// intentionally avoided because they become prohibitively expensive on
282    /// large monorepos with populated `node_modules` trees.
283    ///
284    /// `production_mode` controls the FS fallback for source-extension config
285    /// patterns. In production mode the source walker excludes `*.config.*` so
286    /// the FS walk is required; otherwise Phase 3a's in-memory matcher covers
287    /// them and the walk is skipped.
288    pub fn run_with_search_roots(
289        &self,
290        pkg: &PackageJson,
291        root: &Path,
292        discovered_files: &[PathBuf],
293        config_search_roots: &[&Path],
294        production_mode: bool,
295    ) -> AggregatedPluginResult {
296        let _span = tracing::info_span!("run_plugins").entered();
297        let mut result = AggregatedPluginResult::default();
298
299        let all_deps = pkg.all_dependency_names();
300        let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
301        let active: Vec<&dyn Plugin> = self
302            .plugins
303            .iter()
304            .filter(|p| {
305                p.is_enabled_with_files(&all_deps, root, discovered_files)
306                    || p.is_enabled_with_scripts(&script_packages, root)
307            })
308            .map(AsRef::as_ref)
309            .collect();
310
311        tracing::info!(
312            plugins = active
313                .iter()
314                .map(|p| p.name())
315                .collect::<Vec<_>>()
316                .join(", "),
317            "active plugins"
318        );
319
320        check_meta_framework_prerequisites(&active, root);
321
322        self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
323
324        for plugin in &active {
325            process_static_patterns(*plugin, root, &mut result);
326        }
327
328        process_external_plugins(
329            &self.external_plugins,
330            &all_deps,
331            root,
332            discovered_files,
333            &mut result,
334        );
335
336        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
337            .iter()
338            .filter(|p| !p.config_patterns().is_empty())
339            .map(|p| {
340                let matchers: Vec<globset::GlobMatcher> = p
341                    .config_patterns()
342                    .iter()
343                    .filter_map(|pat| {
344                        let prepared = prepare_config_pattern(pat);
345                        globset::Glob::new(&prepared)
346                            .ok()
347                            .map(|g| g.compile_matcher())
348                    })
349                    .collect();
350                (*p, matchers)
351            })
352            .collect();
353
354        use rayon::prelude::*;
355        let needs_relative_files = !config_matchers.is_empty()
356            || active.iter().any(|p| p.package_json_config_key().is_some());
357        let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
358            discovered_files
359                .par_iter()
360                .map(|f| {
361                    let rel = f
362                        .strip_prefix(root)
363                        .unwrap_or(f)
364                        .to_string_lossy()
365                        .into_owned();
366                    (f.clone(), rel)
367                })
368                .collect()
369        } else {
370            Vec::new()
371        };
372
373        if !config_matchers.is_empty() {
374            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
375
376            for (plugin, matchers) in &config_matchers {
377                let plugin_hits: Vec<&PathBuf> = relative_files
378                    .par_iter()
379                    .filter_map(|(abs_path, rel_path)| {
380                        matchers
381                            .iter()
382                            .any(|m| m.is_match(rel_path.as_str()))
383                            .then_some(abs_path)
384                    })
385                    .collect();
386                for abs_path in plugin_hits {
387                    if let Ok(source) = std::fs::read_to_string(abs_path) {
388                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
389                        if !plugin_result.is_empty() {
390                            resolved_plugins.insert(plugin.name());
391                            tracing::debug!(
392                                plugin = plugin.name(),
393                                config = %abs_path.display(),
394                                entries = plugin_result.entry_patterns.len(),
395                                deps = plugin_result.referenced_dependencies.len(),
396                                "resolved config"
397                            );
398                            process_config_result(
399                                plugin.name(),
400                                plugin_result,
401                                &mut result,
402                                Some(abs_path),
403                            );
404                        }
405                    }
406                }
407            }
408
409            let json_configs = discover_config_files(
410                &config_matchers,
411                &resolved_plugins,
412                config_search_roots,
413                production_mode,
414            );
415            for (abs_path, plugin) in &json_configs {
416                if let Ok(source) = std::fs::read_to_string(abs_path) {
417                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
418                    if !plugin_result.is_empty() {
419                        let rel = abs_path
420                            .strip_prefix(root)
421                            .map(|p| p.to_string_lossy())
422                            .unwrap_or_default();
423                        tracing::debug!(
424                            plugin = plugin.name(),
425                            config = %rel,
426                            entries = plugin_result.entry_patterns.len(),
427                            deps = plugin_result.referenced_dependencies.len(),
428                            "resolved config (filesystem fallback)"
429                        );
430                        process_config_result(
431                            plugin.name(),
432                            plugin_result,
433                            &mut result,
434                            Some(abs_path),
435                        );
436                    }
437                }
438            }
439        }
440
441        process_package_json_inline_configs(
442            &active,
443            &config_matchers,
444            &relative_files,
445            root,
446            &mut result,
447        );
448
449        result
450    }
451
452    /// Fast variant of `run()` for workspace packages.
453    ///
454    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
455    /// project run, avoiding repeated glob compilation and path computation per workspace.
456    /// Skips package.json inline config (workspace packages rarely have inline configs).
457    #[expect(
458        clippy::too_many_arguments,
459        reason = "Each parameter is a distinct, small value with no natural grouping; \
460                  bundling them into a struct hurts call-site readability."
461    )]
462    pub fn run_workspace_fast(
463        &self,
464        pkg: &PackageJson,
465        root: &Path,
466        project_root: &Path,
467        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
468        relative_files: &[(PathBuf, String)],
469        skip_config_plugins: &FxHashSet<&str>,
470        production_mode: bool,
471    ) -> AggregatedPluginResult {
472        let _span = tracing::info_span!("run_plugins").entered();
473        let mut result = AggregatedPluginResult::default();
474
475        let all_deps = pkg.all_dependency_names();
476        let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
477        let workspace_files: Vec<PathBuf> = relative_files
478            .iter()
479            .map(|(abs_path, _)| abs_path.clone())
480            .collect();
481
482        let active: Vec<&dyn Plugin> = self
483            .plugins
484            .iter()
485            .filter(|p| {
486                p.is_enabled_with_files(&all_deps, root, &workspace_files)
487                    || p.is_enabled_with_scripts(&script_packages, root)
488            })
489            .map(AsRef::as_ref)
490            .collect();
491
492        tracing::info!(
493            plugins = active
494                .iter()
495                .map(|p| p.name())
496                .collect::<Vec<_>>()
497                .join(", "),
498            "active plugins"
499        );
500
501        self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
502
503        process_external_plugins(
504            &self.external_plugins,
505            &all_deps,
506            root,
507            &workspace_files,
508            &mut result,
509        );
510
511        if active.is_empty() && result.active_plugins.is_empty() {
512            return result;
513        }
514
515        for plugin in &active {
516            process_static_patterns(*plugin, root, &mut result);
517        }
518
519        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
520        let workspace_matchers: Vec<_> = precompiled_config_matchers
521            .iter()
522            .filter(|(p, _)| {
523                active_names.contains(p.name())
524                    && (!skip_config_plugins.contains(p.name())
525                        || must_parse_workspace_config_when_root_active(p.name()))
526            })
527            .map(|(plugin, matchers)| (*plugin, matchers.clone()))
528            .collect();
529
530        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
531        if !workspace_matchers.is_empty() {
532            use rayon::prelude::*;
533            for (plugin, matchers) in &workspace_matchers {
534                let plugin_hits: Vec<&PathBuf> = relative_files
535                    .par_iter()
536                    .filter_map(|(abs_path, rel_path)| {
537                        matchers
538                            .iter()
539                            .any(|m| m.is_match(rel_path.as_str()))
540                            .then_some(abs_path)
541                    })
542                    .collect();
543                for abs_path in plugin_hits {
544                    if let Ok(source) = std::fs::read_to_string(abs_path) {
545                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
546                        if !plugin_result.is_empty() {
547                            resolved_ws_plugins.insert(plugin.name());
548                            tracing::debug!(
549                                plugin = plugin.name(),
550                                config = %abs_path.display(),
551                                entries = plugin_result.entry_patterns.len(),
552                                deps = plugin_result.referenced_dependencies.len(),
553                                "resolved config"
554                            );
555                            process_config_result(
556                                plugin.name(),
557                                plugin_result,
558                                &mut result,
559                                Some(abs_path),
560                            );
561                        }
562                    }
563                }
564            }
565        }
566
567        let ws_json_configs = if root == project_root {
568            discover_config_files(
569                &workspace_matchers,
570                &resolved_ws_plugins,
571                &[root],
572                production_mode,
573            )
574        } else {
575            discover_config_files(
576                &workspace_matchers,
577                &resolved_ws_plugins,
578                &[root, project_root],
579                production_mode,
580            )
581        };
582        for (abs_path, plugin) in &ws_json_configs {
583            if let Ok(source) = std::fs::read_to_string(abs_path) {
584                let plugin_result = plugin.resolve_config(abs_path, &source, root);
585                if !plugin_result.is_empty() {
586                    let rel = abs_path
587                        .strip_prefix(project_root)
588                        .map(|p| p.to_string_lossy())
589                        .unwrap_or_default();
590                    tracing::debug!(
591                        plugin = plugin.name(),
592                        config = %rel,
593                        entries = plugin_result.entry_patterns.len(),
594                        deps = plugin_result.referenced_dependencies.len(),
595                        "resolved config (workspace filesystem fallback)"
596                    );
597                    process_config_result(
598                        plugin.name(),
599                        plugin_result,
600                        &mut result,
601                        Some(abs_path),
602                    );
603                }
604            }
605        }
606
607        result
608    }
609
610    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
611    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
612    #[must_use]
613    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
614        self.plugins
615            .iter()
616            .filter(|p| !p.config_patterns().is_empty())
617            .map(|p| {
618                let matchers: Vec<globset::GlobMatcher> = p
619                    .config_patterns()
620                    .iter()
621                    .filter_map(|pat| {
622                        let prepared = prepare_config_pattern(pat);
623                        globset::Glob::new(&prepared)
624                            .ok()
625                            .map(|g| g.compile_matcher())
626                    })
627                    .collect();
628                (p.as_ref(), matchers)
629            })
630            .collect()
631    }
632}
633
634impl Default for PluginRegistry {
635    fn default() -> Self {
636        Self::new(vec![])
637    }
638}
639
640impl PluginRegistry {
641    /// Collect the active subset of external plugins, run the silent-fail
642    /// diagnostics (#479), and emit one `tracing::warn!` per finding (dedup'd
643    /// across analysis passes via [`plugin_warn_dedupe`]).
644    ///
645    /// Called from both `run_with_search_roots` (top-level) and
646    /// `run_workspace_fast` (per-workspace) so a typo'd enabler or pattern
647    /// collision surfaces regardless of which entry point dispatched the
648    /// analysis.
649    fn emit_silent_fail_diagnostics(
650        &self,
651        active: &[&dyn Plugin],
652        all_deps: &[String],
653        root: &Path,
654        discovered_files: &[PathBuf],
655    ) {
656        let active_external: Vec<&ExternalPluginDef> = self
657            .external_plugins
658            .iter()
659            .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
660            .collect();
661        let mut diagnostics = detect_pattern_collisions(active, &active_external);
662        diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
663        emit_plugin_diagnostics(&diagnostics);
664    }
665}
666
667/// Process-wide dedupe key cache for plugin-system diagnostic warnings.
668///
669/// Combined-mode runs `PluginRegistry::run_with_search_roots` three times
670/// (check + dupes + health) per analysis, so a naive warn would triple-emit
671/// every diagnostic. Each warn helper builds a unique key, inserts it here,
672/// and only emits when the key was previously absent.
673fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
674    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
675        std::sync::OnceLock::new();
676    WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
677}
678
679/// Insert `key` into the dedupe set and return `true` when it was newly
680/// inserted (caller should emit). Returns `true` on a poisoned mutex so
681/// over-warning beats swallowing.
682fn should_warn(key: String) -> bool {
683    plugin_warn_dedupe()
684        .lock()
685        .map_or(true, |mut set| set.insert(key))
686}
687
688/// Structured diagnostic surfaced by the silent-fail plugin checks (#479).
689///
690/// Returned by [`detect_pattern_collisions`] and [`detect_enabler_typos`] so
691/// unit tests can assert on the findings without standing up a tracing
692/// subscriber. The runtime path calls [`emit_plugin_diagnostics`] to convert
693/// each variant into one `tracing::warn!` line.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub(crate) enum PluginDiagnostic {
696    /// Two or more plugins declared an identical `config_patterns` entry.
697    PatternCollision {
698        pattern: String,
699        owners: Vec<String>,
700    },
701    /// An external plugin enabler does not match any project dependency, but
702    /// at least one Levenshtein-close dep name exists.
703    EnablerTypo {
704        plugin: String,
705        enabler: String,
706        suggestion: String,
707    },
708}
709
710/// Detect plugins whose `config_patterns` collide byte-for-byte.
711///
712/// Detection is byte-equal on the pattern string. Overlapping but non-identical
713/// globs (e.g. `vite.config.{ts,js}` vs `vite.config.ts`) require pattern
714/// intersection logic and are intentionally out of scope. The warning's purpose
715/// is to surface USER-AUTHORED collisions between external plugins or between an
716/// external plugin and a built-in, so the user can disambiguate by editing one
717/// side.
718///
719/// Built-in-vs-built-in collisions are intentionally NOT reported: they are
720/// curated and benign (Phase 3a config matching runs every matching plugin's
721/// `resolve_config` independently, so there is no data loss), and the warning's
722/// remediation advice ("rename one of the patterns or remove the duplicate
723/// plugin") is impossible to follow for a built-in. Such a collision exists by
724/// design, e.g. both `vite` and `tanstack-router` claim
725/// `vite.config.{ts,js,mts,mjs}` because tanstack-router parses the
726/// `tanstackRouter({...})` call inside the vite config to find a custom
727/// `generatedRouteTree` path (#808). A finding is therefore emitted only when
728/// at least one owner is an external (user-authored) plugin.
729///
730/// Precedence rule when two plugins claim the same pattern: the one registered
731/// first wins. For built-in plugins, registration order is defined in
732/// [`builtin::create_builtin_plugins`]. External plugins (file-loaded plus
733/// inline `framework[]`) run AFTER built-ins, so they cannot displace a
734/// built-in's `resolve_config` result for the same file.
735pub(crate) fn detect_pattern_collisions(
736    builtin_active: &[&dyn Plugin],
737    external_active: &[&ExternalPluginDef],
738) -> Vec<PluginDiagnostic> {
739    use rustc_hash::FxHashMap;
740
741    let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
742        FxHashMap::default();
743
744    let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
745                  pattern: String,
746                  name: String| {
747        let (list, seen) = pattern_owners.entry(pattern).or_default();
748        if seen.insert(name.clone()) {
749            list.push(name);
750        }
751    };
752
753    for plugin in builtin_active {
754        for pat in plugin.config_patterns() {
755            record(
756                &mut pattern_owners,
757                (*pat).to_string(),
758                plugin.name().to_string(),
759            );
760        }
761    }
762    for ext in external_active {
763        for pat in &ext.config_patterns {
764            record(&mut pattern_owners, pat.clone(), ext.name.clone());
765        }
766    }
767
768    // Names of built-in plugins. Built-in-only collisions are curated + benign
769    // (every matching plugin runs `resolve_config` independently), so they must
770    // not surface an un-actionable warning (#808). Keying on the built-in set
771    // and emitting only when an owner is NOT built-in is robust even if a
772    // user-authored external plugin happens to share a built-in's name: the
773    // built-in owner alone never re-enables the warning.
774    let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
775
776    let mut findings: Vec<PluginDiagnostic> = pattern_owners
777        .into_iter()
778        .filter_map(|(pattern, (owners, _seen))| {
779            if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
780                None
781            } else {
782                Some(PluginDiagnostic::PatternCollision { pattern, owners })
783            }
784        })
785        .collect();
786    findings.sort_unstable_by(|a, b| match (a, b) {
787        (
788            PluginDiagnostic::PatternCollision { pattern: ap, .. },
789            PluginDiagnostic::PatternCollision { pattern: bp, .. },
790        ) => ap.cmp(bp),
791        _ => std::cmp::Ordering::Equal,
792    });
793    findings
794}
795
796/// Detect external plugins whose enablers do not match any project dependency
797/// AND at least one enabler is a plausible typo of a real dep.
798///
799/// Scope:
800/// - Only external plugins (file-loaded plus inline `framework[]`). Built-in
801///   plugins' enablers are hard-coded so cannot be misspelled.
802/// - Skip plugins with a `detection` block: detection is the rich-logic path
803///   and false negatives there are not enabler typos.
804/// - Skip plugins with empty `enablers` (no signal to validate against).
805/// - Stay silent when no Levenshtein-close dep exists: the plugin may
806///   legitimately not apply to this project.
807///
808/// Matches the established #467 / #510 pattern: tracing-warn with a `did you
809/// mean` suggestion at the call site. No exit non-zero, no new CLI flag.
810pub(crate) fn detect_enabler_typos(
811    external_plugins: &[ExternalPluginDef],
812    all_deps: &[String],
813) -> Vec<PluginDiagnostic> {
814    let mut findings = Vec::new();
815
816    for ext in external_plugins {
817        if ext.detection.is_some() || ext.enablers.is_empty() {
818            continue;
819        }
820
821        let any_match = ext.enablers.iter().any(|enabler| {
822            if enabler.ends_with('/') {
823                all_deps.iter().any(|d| d.starts_with(enabler))
824            } else {
825                all_deps.iter().any(|d| d == enabler)
826            }
827        });
828        if any_match {
829            continue;
830        }
831
832        for enabler in &ext.enablers {
833            let candidates = all_deps.iter().map(String::as_str);
834            let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
835            else {
836                continue;
837            };
838
839            findings.push(PluginDiagnostic::EnablerTypo {
840                plugin: ext.name.clone(),
841                enabler: enabler.clone(),
842                suggestion: suggestion.to_string(),
843            });
844        }
845    }
846
847    findings
848}
849
850/// Emit one `tracing::warn!` per finding, dedup'd against the process-wide
851/// `plugin_warn_dedupe` set so combined-mode does not triple-warn.
852fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
853    for finding in findings {
854        match finding {
855            PluginDiagnostic::PatternCollision { pattern, owners } => {
856                let key = format!("collision::{pattern}::{owners:?}");
857                if !should_warn(key) {
858                    continue;
859                }
860                let winner = &owners[0];
861                let others = owners[1..].join(", ");
862                tracing::warn!(
863                    "plugin config_patterns collision: identical pattern \
864                     '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
865                     runs first (registration order), others ({others}) \
866                     follow. Rename one of the patterns or remove the \
867                     duplicate plugin to make resolution explicit. A future \
868                     release may reject identical-pattern collisions.",
869                    joined = owners.join(", "),
870                );
871            }
872            PluginDiagnostic::EnablerTypo {
873                plugin,
874                enabler,
875                suggestion,
876            } => {
877                let key = format!("enabler::{plugin}::{enabler}");
878                if !should_warn(key) {
879                    continue;
880                }
881                tracing::warn!(
882                    "plugin '{plugin}' enabler '{enabler}' does not match any \
883                     dependency in package.json; did you mean '{suggestion}'? \
884                     The plugin will not activate. A future release may reject \
885                     unmatched enablers.",
886                );
887            }
888        }
889    }
890}
891
892/// Phase 4 of `PluginRegistry::run_with_search_roots`: for any active plugin
893/// that supports inline package.json configuration via
894/// [`Plugin::package_json_config_key`], read the root `package.json`, extract
895/// the relevant key, and feed the result through `resolve_config`.
896fn process_package_json_inline_configs(
897    active: &[&dyn Plugin],
898    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
899    relative_files: &[(PathBuf, String)],
900    root: &Path,
901    result: &mut AggregatedPluginResult,
902) {
903    for plugin in active {
904        let Some(key) = plugin.package_json_config_key() else {
905            continue;
906        };
907        if check_has_config_file(*plugin, config_matchers, relative_files) {
908            continue;
909        }
910        let pkg_path = root.join("package.json");
911        let Ok(content) = std::fs::read_to_string(&pkg_path) else {
912            continue;
913        };
914        let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
915            continue;
916        };
917        let Some(config_value) = json.get(key) else {
918            continue;
919        };
920        let config_json = serde_json::to_string(config_value).unwrap_or_default();
921        let fake_path = root.join(format!("{key}.config.json"));
922        let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
923        if plugin_result.is_empty() {
924            continue;
925        }
926        tracing::debug!(
927            plugin = plugin.name(),
928            key = key,
929            "resolved inline package.json config"
930        );
931        process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path));
932    }
933}
934
935/// A missing meta-framework prerequisite: the per-process dedupe key and the
936/// warning message to emit.
937#[derive(Debug)]
938struct MetaFrameworkWarning {
939    dedupe_key: &'static str,
940    message: &'static str,
941}
942
943/// Pure detection: which active meta-frameworks are missing their generated
944/// config/types directory under `root`. Separated from emission so the
945/// detection logic is unit-testable without a tracing subscriber or the
946/// process-wide dedupe set.
947///
948/// When adding a framework here, also extend `MATERIALIZED_CONTEXT_DIRS` in
949/// `fallow-cli`'s `audit.rs` with its generated dir, otherwise `fallow audit`'s
950/// base worktree will not symlink that dir and the broken-tsconfig-chain bug
951/// resurfaces on the base pass for the new framework.
952fn missing_meta_framework_prerequisites(
953    active_plugins: &[&dyn Plugin],
954    root: &Path,
955) -> Vec<MetaFrameworkWarning> {
956    active_plugins
957        .iter()
958        .filter_map(|plugin| match plugin.name() {
959            "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
960                dedupe_key: "meta-prereq::nuxt",
961                message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
962                          before fallow for accurate analysis",
963            }),
964            "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
965                dedupe_key: "meta-prereq::astro",
966                message: "Astro project missing .astro/ types: run `astro sync` \
967                          before fallow for accurate analysis",
968            }),
969            _ => None,
970        })
971        .collect()
972}
973
974/// Warn when meta-frameworks are active but their generated configs are missing.
975///
976/// Meta-frameworks like Nuxt and Astro generate tsconfig/types files during a
977/// "prepare" step. Without these, the tsconfig extends chain breaks and
978/// extensionless imports fail wholesale (e.g. 2000+ unresolved imports).
979///
980/// Deduped per framework so combined-mode (check + dupes + health through one
981/// loader) does not re-warn. The advice is generic and does not name the root,
982/// so one line per process per framework is the right bound (issue #637).
983fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
984    for warning in missing_meta_framework_prerequisites(active_plugins, root) {
985        if should_warn(warning.dedupe_key.to_owned()) {
986            tracing::warn!("{}", warning.message);
987        }
988    }
989}
990
991fn script_activation_packages(
992    pkg: &PackageJson,
993    root: &Path,
994    all_deps: &[String],
995    production_mode: bool,
996) -> FxHashSet<String> {
997    let Some(pkg_scripts) = pkg.scripts.as_ref() else {
998        return FxHashSet::default();
999    };
1000
1001    let scripts_to_analyze = if production_mode {
1002        scripts::filter_production_scripts(pkg_scripts)
1003    } else {
1004        pkg_scripts.clone()
1005    };
1006
1007    let mut nm_roots = Vec::new();
1008    if root.join("node_modules").is_dir() {
1009        nm_roots.push(root);
1010    }
1011    let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1012
1013    scripts::analyze_scripts(&scripts_to_analyze, root, &bin_map).used_packages
1014}
1015
1016#[cfg(test)]
1017mod tests;