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