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