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