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