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