Skip to main content

fallow_core/plugins/registry/
mod.rs

1//! Plugin registry: discovers active plugins, collects patterns, parses configs.
2
3use rustc_hash::{FxHashMap, FxHashSet};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use std::sync::{OnceLock, RwLock};
7
8use fallow_config::{
9    AutoImportRule, EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule,
10};
11
12use crate::scripts;
13
14use super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, ProvidedDependencyRule};
15
16pub(crate) mod builtin;
17mod helpers;
18mod tooling_evidence;
19
20pub(crate) use tooling_evidence::find_config_file;
21
22/// Names of every built-in framework plugin, in registry order.
23///
24/// Derived live from the plugin registry so capability introspection
25/// (`fallow schema`) can list plugins without a hand-maintained mirror.
26#[must_use]
27pub fn builtin_plugin_names() -> Vec<&'static str> {
28    builtin::create_builtin_plugins()
29        .iter()
30        .map(|plugin| plugin.name())
31        .collect()
32}
33
34/// Config file globs that any built-in framework plugin can read, sorted and
35/// without duplicates. A pattern is relative to the directory the plugin
36/// searches, so a caller that matches root-relative paths also tries it
37/// under `**/`.
38#[must_use]
39pub fn builtin_config_patterns() -> Vec<&'static str> {
40    let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
41        .iter()
42        .flat_map(|plugin| plugin.config_patterns().iter().copied())
43        .collect();
44    patterns.sort_unstable();
45    patterns.dedup();
46    patterns
47}
48
49pub use helpers::ConfigCandidateIndex;
50pub use helpers::is_external_plugin_active;
51use helpers::{
52    check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
53    process_external_plugins, process_package_json_metadata, process_static_patterns,
54};
55
56fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
57    matches!(
58        plugin_name,
59        "eslint" | "docusaurus" | "jest" | "storybook" | "tanstack-router" | "vitest"
60    )
61}
62
63fn compile_config_matchers<'a>(
64    active: &[&'a dyn Plugin],
65) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
66    active
67        .iter()
68        .filter(|plugin| !plugin.config_patterns().is_empty())
69        .map(|plugin| (*plugin, cached_plugin_config_matchers(*plugin)))
70        .collect()
71}
72
73fn compile_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
74    plugin
75        .config_patterns()
76        .iter()
77        .filter_map(|pattern| {
78            let prepared = prepare_config_pattern(pattern);
79            globset::Glob::new(&prepared)
80                .ok()
81                .map(|glob| glob.compile_matcher())
82        })
83        .collect()
84}
85
86struct CachedPluginConfigMatchers {
87    patterns: &'static [&'static str],
88    matchers: Vec<globset::GlobMatcher>,
89}
90
91#[derive(Default)]
92struct PluginConfigMatcherCache {
93    by_name: RwLock<FxHashMap<&'static str, Vec<CachedPluginConfigMatchers>>>,
94}
95
96impl PluginConfigMatcherCache {
97    fn get_or_compile(&self, plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
98        let patterns = plugin.config_patterns();
99        let cached = self
100            .by_name
101            .read()
102            .unwrap_or_else(std::sync::PoisonError::into_inner)
103            .get(plugin.name())
104            .and_then(|variants| {
105                variants
106                    .iter()
107                    .find(|entry| entry.patterns == patterns)
108                    .map(|entry| entry.matchers.clone())
109            });
110        if let Some(matchers) = cached {
111            return matchers;
112        }
113
114        let matchers = compile_plugin_config_matchers(plugin);
115        {
116            let mut by_name = self
117                .by_name
118                .write()
119                .unwrap_or_else(std::sync::PoisonError::into_inner);
120            let variants = by_name.entry(plugin.name()).or_default();
121            if let Some(entry) = variants.iter().find(|entry| entry.patterns == patterns) {
122                return entry.matchers.clone();
123            }
124            variants.push(CachedPluginConfigMatchers {
125                patterns,
126                matchers: matchers.clone(),
127            });
128            drop(by_name);
129        }
130        matchers
131    }
132}
133
134fn cached_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
135    static MATCHERS: OnceLock<PluginConfigMatcherCache> = OnceLock::new();
136    MATCHERS
137        .get_or_init(PluginConfigMatcherCache::default)
138        .get_or_compile(plugin)
139}
140
141/// Emit one info-level line naming every active plugin.
142fn log_active_plugins(active: &[&dyn Plugin]) {
143    tracing::info!(
144        plugins = active
145            .iter()
146            .map(|p| p.name())
147            .collect::<Vec<_>>()
148            .join(", "),
149        "active plugins"
150    );
151}
152
153/// Compute `(absolute, root-relative)` file pairs, but only when at least one
154/// active plugin needs config matching or a package.json config key. Returns an
155/// empty vec otherwise to skip the per-file path work.
156fn compute_relative_files(
157    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
158    active: &[&dyn Plugin],
159    discovered_files: &[PathBuf],
160    root: &Path,
161) -> Vec<(PathBuf, String)> {
162    use rayon::prelude::*;
163    let needs_relative_files =
164        !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
165    if !needs_relative_files {
166        return Vec::new();
167    }
168    discovered_files
169        .par_iter()
170        .map(|f| {
171            let rel = f
172                .strip_prefix(root)
173                .unwrap_or(f)
174                .to_string_lossy()
175                .into_owned();
176            (f.clone(), rel)
177        })
178        .collect()
179}
180
181/// Registry of all available plugins (built-in + external).
182pub struct PluginRegistry {
183    plugins: Vec<Box<dyn Plugin>>,
184    external_plugins: Vec<ExternalPluginDef>,
185}
186
187/// Inputs for the workspace-fast plugin path.
188pub(crate) struct WorkspacePluginRunInput<'a> {
189    pub(crate) pkg: &'a PackageJson,
190    pub(crate) root: &'a Path,
191    pub(crate) project_root: &'a Path,
192    pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
193    pub(crate) relative_files: &'a [(PathBuf, String)],
194    pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
195    pub(crate) production_mode: bool,
196    pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
197}
198
199struct PluginRunContext<'a> {
200    all_deps: Vec<String>,
201    active: Vec<&'a dyn Plugin>,
202}
203
204/// Inputs governing which built-in plugins activate for a project.
205struct PluginActivationInput<'a> {
206    pkg: &'a PackageJson,
207    root: &'a Path,
208    discovered_files: &'a [PathBuf],
209    all_deps: &'a [String],
210    script_packages: &'a FxHashSet<String>,
211    candidate_index: Option<&'a ConfigCandidateIndex>,
212}
213
214/// Invalid user-authored regex extracted from a plugin config file.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct PluginRegexValidationError {
217    plugin_name: String,
218    config_path: Option<PathBuf>,
219    rule_kind: &'static str,
220    field: &'static str,
221    rule_pattern: String,
222    regex_pattern: String,
223    source: String,
224}
225
226impl PluginRegexValidationError {
227    fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
228        Self {
229            plugin_name: input.plugin_name.to_owned(),
230            config_path: input.config_path.map(Path::to_path_buf),
231            rule_kind: input.rule_kind,
232            field: input.field,
233            rule_pattern: input.rule_pattern.to_owned(),
234            regex_pattern: input.regex_pattern.to_owned(),
235            source: input.source.to_string(),
236        }
237    }
238}
239
240#[derive(Clone, Copy)]
241pub(crate) struct PluginRegexValidationErrorInput<'a> {
242    plugin_name: &'a str,
243    config_path: Option<&'a Path>,
244    rule_kind: &'static str,
245    field: &'static str,
246    rule_pattern: &'a str,
247    regex_pattern: &'a str,
248    source: &'a regex::Error,
249}
250
251impl fmt::Display for PluginRegexValidationError {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        let location = self
254            .config_path
255            .as_ref()
256            .map(|path| format!(" in {}", path.display()))
257            .unwrap_or_default();
258        write!(
259            f,
260            "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
261            self.plugin_name,
262            location,
263            self.regex_pattern,
264            self.rule_kind,
265            self.field,
266            self.rule_pattern,
267            self.source
268        )
269    }
270}
271
272#[must_use]
273pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
274    let joined = errors
275        .iter()
276        .map(ToString::to_string)
277        .collect::<Vec<_>>()
278        .join("\n  - ");
279    format!(
280        "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."
281    )
282}
283
284/// The tooling dependencies of one active plugin, plus the evidence that the
285/// project uses the plugin.
286///
287/// The unused devDependency check credits `dependencies` only when the plugin
288/// found a config of its own (`own_config`), or when a package.json script, a
289/// CI workflow or a git hook invokes one of `references`. An active plugin
290/// alone is no evidence: a declared package activates it.
291#[derive(Debug, Clone, Default, PartialEq, Eq)]
292pub struct PluginToolingDependencies {
293    /// The plugin name.
294    pub plugin: String,
295    /// The tooling dependencies the plugin declares.
296    pub dependencies: Vec<String>,
297    /// The package names whose script, CI or hook invocation shows that the
298    /// project runs the plugin's tool: its tooling dependencies and its exact
299    /// enablers.
300    pub references: Vec<String>,
301    /// The config file the plugin found, or the package.json that holds its
302    /// config. Absolute path.
303    pub own_config: Option<PathBuf>,
304}
305
306/// Why a plugin's tooling dependencies are credited.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum PluginToolingEvidence<'a> {
309    /// The plugin found this config file, or the package.json with its config.
310    OwnConfig(&'a Path),
311    /// A script, CI workflow or git hook invokes this package.
312    Reference(&'a str),
313}
314
315impl PluginToolingDependencies {
316    /// The evidence that the project uses the plugin, given the packages that
317    /// scripts, CI workflows and git hooks invoke. `None` means the plugin's
318    /// tooling dependencies are not credited.
319    #[must_use]
320    pub fn evidence<'a>(
321        &'a self,
322        script_used_packages: &FxHashSet<String>,
323    ) -> Option<PluginToolingEvidence<'a>> {
324        if let Some(config) = &self.own_config {
325            return Some(PluginToolingEvidence::OwnConfig(config));
326        }
327        self.references
328            .iter()
329            .find(|name| script_used_packages.contains(name.as_str()))
330            .map(|name| PluginToolingEvidence::Reference(name))
331    }
332}
333
334/// Aggregated results from all active plugins for a project.
335#[derive(Debug, Clone, Default)]
336pub struct AggregatedPluginResult {
337    /// All entry point patterns from active plugins: (rule, plugin_name).
338    pub entry_patterns: Vec<(PathRule, String)>,
339    /// Plugins whose config already replaced their static entry patterns.
340    /// A later config of a plugin that sets
341    /// `accumulate_config_entry_patterns` adds to the config patterns and does
342    /// not remove them.
343    pub replaced_entry_plugins: FxHashSet<String>,
344    /// Coverage role for each plugin contributing entry point patterns.
345    pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
346    /// All config file patterns from active plugins.
347    pub config_patterns: Vec<String>,
348    /// All always-used file patterns from active plugins: (pattern, plugin_name).
349    pub always_used: Vec<(String, String)>,
350    /// All used export rules from active plugins.
351    pub used_exports: Vec<PluginUsedExportRule>,
352    /// Class member rules contributed by active plugins that should never be
353    /// flagged as unused. Extends the built-in Angular/React lifecycle allowlist
354    /// with framework-invoked method names, optionally scoped by class heritage.
355    pub used_class_members: Vec<UsedClassMemberRule>,
356    /// Exact framework contracts with plugin and package provenance.
357    pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
358    /// Dependencies referenced in config files (should not be flagged unused).
359    pub referenced_dependencies: Vec<String>,
360    /// Dependencies referenced by package.json metadata, scoped to that package.json path.
361    pub package_referenced_dependencies: Vec<(PathBuf, String)>,
362    /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
363    pub discovered_always_used: Vec<(String, String)>,
364    /// Setup files discovered from config parsing: (path, plugin_name).
365    pub setup_files: Vec<(PathBuf, String)>,
366    /// Tooling dependencies of every active plugin, whether or not the plugin
367    /// found evidence that the project uses it. The unlisted-dependency and
368    /// dev-dependency-in-production checks read this list.
369    pub tooling_dependencies: Vec<String>,
370    /// Tooling dependencies grouped by the active plugin that declares them,
371    /// with the evidence that decides whether an unused devDependency check
372    /// credits them. See [`PluginToolingDependencies`].
373    pub plugin_tooling: Vec<PluginToolingDependencies>,
374    /// Package names discovered as used in package.json scripts (binary invocations).
375    pub script_used_packages: FxHashSet<String>,
376    /// The binary names that the declared dependencies provide. Script
377    /// analysis fills it for the root result; workspace results keep the
378    /// default.
379    pub dependency_binaries: crate::scripts::DependencyBinaries,
380    /// The names that the root and workspace manifests declare in
381    /// `devDependencies`. Script analysis fills it for the root result, so
382    /// the trace credits a dependency only where the unused devDependency
383    /// check would.
384    pub dev_dependency_names: FxHashSet<String>,
385    /// Import prefixes for virtual modules provided by active frameworks.
386    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
387    pub virtual_module_prefixes: Vec<String>,
388    /// Package name suffixes that identify virtual or convention-based specifiers.
389    /// Extracted package names ending with any of these suffixes are not flagged as unlisted.
390    pub virtual_package_suffixes: Vec<String>,
391    /// Import suffixes for build-time generated relative imports.
392    /// Unresolved imports ending with these suffixes are suppressed.
393    pub generated_import_patterns: Vec<String>,
394    /// Import prefixes for build-time generated type-only relative imports.
395    /// Unresolved type-only imports starting with these prefixes are suppressed.
396    pub generated_type_import_prefixes: Vec<String>,
397    /// Path alias mappings from active plugins (prefix → replacement directory).
398    /// Used by the resolver to substitute import prefixes before re-resolving.
399    pub path_aliases: Vec<(String, String)>,
400    /// Convention-based auto-import rules from active plugins (Nuxt components).
401    /// The resolver matches each file's captured `auto_import_candidates` against
402    /// these and synthesizes a graph edge to the rule's source. See issue #704.
403    pub auto_imports: Vec<AutoImportRule>,
404    /// Names of active plugins.
405    pub active_plugins: Vec<String>,
406    /// Test fixture glob patterns from active plugins: (pattern, plugin_name).
407    pub fixture_patterns: Vec<(String, String)>,
408    /// Absolute directories contributed by plugins that should be searched
409    /// when resolving SCSS/Sass `@import`/`@use` specifiers. Populated from
410    /// Angular's `stylePreprocessorOptions.includePaths` and equivalent
411    /// framework settings. See issue #103.
412    pub scss_include_paths: Vec<PathBuf>,
413    /// Static directory mappings contributed by plugins.
414    pub static_dir_mappings: Vec<(PathBuf, String)>,
415    /// Mounts a framework serves for the whole project, so any HTML document
416    /// reaches them, unlike the tool-scoped mounts above.
417    pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
418    /// File-scoped dependency provider rules from active plugins.
419    pub provided_dependencies: Vec<ProvidedDependencyRule>,
420    /// Advisories about the config files active plugins read. Converted to
421    /// workspace diagnostics once, at the end of the plugin run, where the
422    /// project root is known.
423    pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
424    /// Where Module Federation configs expose files and declare remote
425    /// aliases, for the trace output.
426    pub federation_sources: Vec<super::FederationSource>,
427}
428
429/// Append `incoming` string items to `target`, skipping values already present
430/// in `target` or earlier in `incoming`. Matches the deduplication the
431/// workspace merge applied via per-field `seen` sets before #444 centralized
432/// it on [`AggregatedPluginResult::merge_into`].
433fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
434    let mut seen: FxHashSet<String> = target.iter().cloned().collect();
435    for item in incoming {
436        if seen.insert(item.clone()) {
437            target.push(item);
438        }
439    }
440}
441
442/// Prefix a workspace-relative pattern so it matches from the monorepo root,
443/// unless it is already workspace-prefixed or project-root-relative (leading
444/// `/`, e.g. an angular.json path). Mirrors the pre-#444 inline closure.
445fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
446    if pat.starts_with(ws_prefix) || pat.starts_with('/') {
447        pat.to_string()
448    } else {
449        format!("{ws_prefix}/{pat}")
450    }
451}
452
453impl AggregatedPluginResult {
454    /// Apply a workspace prefix to every path-bearing field in place.
455    ///
456    /// Workspace-package results are collected with patterns relative to the
457    /// package root; to be matchable from the monorepo root they need the
458    /// package's prefix applied. This transform is call-site-specific (it
459    /// depends on `ws_prefix`), so it stays separate from [`Self::merge_into`],
460    /// which is a prefix-agnostic union. The root project's own result is
461    /// never prefixed.
462    ///
463    /// Fields that carry package names, absolute paths, or import-specifier
464    /// boundaries (referenced/tooling deps, setup files, static dir mappings,
465    /// auto-imports, virtual prefixes/suffixes, generated patterns) are left
466    /// untouched, matching the pre-#444 merge loop. `config_diagnostics` is one
467    /// of those: it holds the ABSOLUTE path of the config file a plugin read,
468    /// which needs no prefix to name the right file from the monorepo root.
469    pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
470        for (rule, _) in &mut self.entry_patterns {
471            *rule = rule.prefixed(ws_prefix);
472        }
473        for (pat, _) in &mut self.always_used {
474            *pat = prefix_if_needed(pat, ws_prefix);
475        }
476        for (pat, _) in &mut self.discovered_always_used {
477            *pat = prefix_if_needed(pat, ws_prefix);
478        }
479        for (pat, _) in &mut self.fixture_patterns {
480            *pat = prefix_if_needed(pat, ws_prefix);
481        }
482        for rule in &mut self.used_exports {
483            *rule = rule.prefixed(ws_prefix);
484        }
485        for rule in &mut self.provided_dependencies {
486            *rule = rule.prefixed(ws_prefix);
487        }
488        for source in &mut self.federation_sources {
489            *source = source.prefixed(ws_prefix);
490        }
491        for (_, replacement) in &mut self.path_aliases {
492            *replacement = format!("{ws_prefix}/{replacement}");
493        }
494    }
495
496    /// Merge `other` into `self`, taking the union of every field.
497    ///
498    /// Exhaustively destructures `Self` so adding a field to
499    /// `AggregatedPluginResult` becomes a `missing field in pattern` compile
500    /// error here instead of a silently-dropped field. See issue #444.
501    ///
502    /// Callers that need the workspace prefix applied must call
503    /// [`Self::apply_workspace_prefix`] on `other` first; this method does not
504    /// transform any path. Dedup-bearing fields (`active_plugins`, the virtual
505    /// prefix/suffix, generated-pattern, and semantic framework-contract lists)
506    /// deduplicate the incoming values against the contents already in `self`,
507    /// matching the pre-#444 `seen`-set behavior. `entry_point_roles` is
508    /// first-writer-wins.
509    pub(crate) fn merge_into(&mut self, other: Self) {
510        let Self {
511            entry_patterns,
512            // Only the config pass of one plugin run reads this field.
513            replaced_entry_plugins: _,
514            entry_point_roles,
515            config_patterns,
516            always_used,
517            used_exports,
518            used_class_members,
519            framework_class_member_contracts,
520            referenced_dependencies,
521            package_referenced_dependencies,
522            discovered_always_used,
523            setup_files,
524            tooling_dependencies,
525            plugin_tooling,
526            script_used_packages,
527            dependency_binaries: _,
528            dev_dependency_names,
529            virtual_module_prefixes,
530            virtual_package_suffixes,
531            generated_import_patterns,
532            generated_type_import_prefixes,
533            path_aliases,
534            auto_imports,
535            active_plugins,
536            fixture_patterns,
537            scss_include_paths,
538            static_dir_mappings,
539            framework_static_dir_mappings,
540            provided_dependencies,
541            config_diagnostics,
542            federation_sources,
543        } = other;
544
545        self.entry_patterns.extend(entry_patterns);
546        for (plugin_name, role) in entry_point_roles {
547            self.entry_point_roles.entry(plugin_name).or_insert(role);
548        }
549        self.config_patterns.extend(config_patterns);
550        self.always_used.extend(always_used);
551        self.used_exports.extend(used_exports);
552        self.used_class_members.extend(used_class_members);
553        for contract in framework_class_member_contracts {
554            if !self.framework_class_member_contracts.contains(&contract) {
555                self.framework_class_member_contracts.push(contract);
556            }
557        }
558        self.referenced_dependencies.extend(referenced_dependencies);
559        self.package_referenced_dependencies
560            .extend(package_referenced_dependencies);
561        self.discovered_always_used.extend(discovered_always_used);
562        self.setup_files.extend(setup_files);
563        self.tooling_dependencies.extend(tooling_dependencies);
564        self.plugin_tooling.extend(plugin_tooling);
565        self.dev_dependency_names.extend(dev_dependency_names);
566        self.script_used_packages.extend(script_used_packages);
567        extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
568        extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
569        extend_unique(
570            &mut self.generated_import_patterns,
571            generated_import_patterns,
572        );
573        extend_unique(
574            &mut self.generated_type_import_prefixes,
575            generated_type_import_prefixes,
576        );
577        self.path_aliases.extend(path_aliases);
578        self.auto_imports.extend(auto_imports);
579        extend_unique(&mut self.active_plugins, active_plugins);
580        self.fixture_patterns.extend(fixture_patterns);
581        self.scss_include_paths.extend(scss_include_paths);
582        self.static_dir_mappings.extend(static_dir_mappings);
583        self.framework_static_dir_mappings
584            .extend(framework_static_dir_mappings);
585        self.provided_dependencies.extend(provided_dependencies);
586        self.federation_sources.extend(federation_sources);
587        for diagnostic in config_diagnostics {
588            if !self.config_diagnostics.contains(&diagnostic) {
589                self.config_diagnostics.push(diagnostic);
590            }
591        }
592    }
593}
594
595impl PluginRegistry {
596    /// Create a registry with all built-in plugins and optional external plugins.
597    #[must_use]
598    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
599        Self {
600            plugins: builtin::create_builtin_plugins(),
601            external_plugins: external,
602        }
603    }
604
605    /// Hidden directory names that should be traversed before full plugin execution.
606    ///
607    /// Source discovery runs before plugin config parsing, so this helper only uses
608    /// package-activation checks and static plugin metadata.
609    #[must_use]
610    pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
611        let all_deps = pkg.all_dependency_names();
612        let mut seen = FxHashSet::default();
613        let mut dirs = Vec::new();
614
615        for plugin in &self.plugins {
616            if !plugin.is_enabled_with_deps(&all_deps, root) {
617                continue;
618            }
619            for dir in plugin.discovery_hidden_dirs() {
620                if seen.insert(*dir) {
621                    dirs.push((*dir).to_string());
622                }
623            }
624        }
625
626        dirs
627    }
628
629    /// Test convenience wrapper for running all plugins against a project.
630    ///
631    /// This discovers which plugins are active, collects their static patterns,
632    /// then parses any config files to extract dynamic information.
633    #[cfg(test)]
634    fn run(
635        &self,
636        pkg: &PackageJson,
637        root: &Path,
638        discovered_files: &[PathBuf],
639    ) -> AggregatedPluginResult {
640        self.try_run(pkg, root, discovered_files)
641            .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
642    }
643
644    /// Run all plugins, returning invalid plugin regexes as hard errors.
645    pub fn try_run(
646        &self,
647        pkg: &PackageJson,
648        root: &Path,
649        discovered_files: &[PathBuf],
650    ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
651        self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
652    }
653
654    /// Run all plugins against a project with explicit config-file search roots,
655    /// returning invalid plugin regexes as hard errors.
656    #[expect(
657        clippy::too_many_arguments,
658        reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
659    )]
660    pub(crate) fn try_run_with_search_roots(
661        &self,
662        pkg: &PackageJson,
663        root: &Path,
664        discovered_files: &[PathBuf],
665        config_search_roots: &[&Path],
666        production_mode: bool,
667        candidate_index: Option<&ConfigCandidateIndex>,
668    ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
669        let _span = tracing::info_span!("run_plugins").entered();
670        let mut result = AggregatedPluginResult::default();
671        let mut regex_errors = Vec::new();
672
673        let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
674            pkg,
675            root,
676            discovered_files,
677            production_mode,
678            candidate_index,
679        );
680
681        self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
682
683        for plugin in &active {
684            process_static_patterns(*plugin, root, &mut result);
685        }
686        process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
687
688        process_external_plugins(
689            &self.external_plugins,
690            &all_deps,
691            root,
692            discovered_files,
693            &mut result,
694        );
695        tooling_evidence::record_plugin_tooling(
696            &tooling_evidence::ToolingEvidenceInput {
697                active: &active,
698                external_plugins: &self.external_plugins,
699                all_deps: &all_deps,
700                roots: &[root],
701                // Production mode turns the unused devDependency check off, so a
702                // workspace config file credits nothing there and the probe of
703                // every search root is pure cost.
704                extra_roots: if production_mode {
705                    &[]
706                } else {
707                    config_search_roots
708                },
709                candidate_index,
710                discovered_files,
711            },
712            &mut result,
713        );
714
715        let config_matchers = compile_config_matchers(&active);
716        let relative_files =
717            compute_relative_files(&config_matchers, &active, discovered_files, root);
718
719        resolve_plugin_config_files(&mut PluginConfigResolutionInput {
720            config_matchers: &config_matchers,
721            relative_files: &relative_files,
722            config_search_roots,
723            production_mode,
724            candidate_index,
725            root,
726            result: &mut result,
727            regex_errors: &mut regex_errors,
728        });
729
730        process_package_json_inline_configs(
731            &active,
732            &config_matchers,
733            &relative_files,
734            root,
735            &mut result,
736            &mut regex_errors,
737        );
738
739        if regex_errors.is_empty() {
740            Ok(result)
741        } else {
742            Err(regex_errors)
743        }
744    }
745
746    /// Test convenience wrapper for the fast workspace plugin path.
747    ///
748    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
749    /// project run, avoiding repeated glob compilation and path computation per workspace.
750    /// Skips package.json inline config (workspace packages rarely have inline configs).
751    #[cfg(test)]
752    fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
753        self.try_run_workspace_fast(input)
754            .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
755    }
756
757    /// Fast variant of `try_run()` for workspace packages.
758    ///
759    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
760    /// project run, avoiding repeated glob compilation and path computation per workspace.
761    /// Skips package.json inline config (workspace packages rarely have inline configs).
762    pub(crate) fn try_run_workspace_fast(
763        &self,
764        input: &WorkspacePluginRunInput<'_>,
765    ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
766        let _span = tracing::info_span!("run_plugins").entered();
767        let mut result = AggregatedPluginResult::default();
768        let mut regex_errors = Vec::new();
769
770        let all_deps = input.pkg.all_dependency_names();
771        let script_packages =
772            script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
773        let workspace_files: Vec<PathBuf> = input
774            .relative_files
775            .iter()
776            .map(|(abs_path, _)| abs_path.clone())
777            .collect();
778
779        let active = self.collect_active_plugins(&PluginActivationInput {
780            pkg: input.pkg,
781            root: input.root,
782            discovered_files: &workspace_files,
783            all_deps: &all_deps,
784            script_packages: &script_packages,
785            candidate_index: input.candidate_index,
786        });
787
788        log_active_plugins(&active);
789
790        self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
791
792        process_external_plugins(
793            &self.external_plugins,
794            &all_deps,
795            input.root,
796            &workspace_files,
797            &mut result,
798        );
799
800        if active.is_empty() && result.active_plugins.is_empty() {
801            return Ok(result);
802        }
803
804        process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
805        // A config file at the project root applies to a workspace package too.
806        let evidence_roots: &[&Path] = if input.root == input.project_root {
807            &[input.root]
808        } else {
809            &[input.root, input.project_root]
810        };
811        tooling_evidence::record_plugin_tooling(
812            &tooling_evidence::ToolingEvidenceInput {
813                active: &active,
814                external_plugins: &self.external_plugins,
815                all_deps: &all_deps,
816                roots: evidence_roots,
817                extra_roots: &[],
818                candidate_index: input.candidate_index,
819                discovered_files: &workspace_files,
820            },
821            &mut result,
822        );
823        resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
824
825        if regex_errors.is_empty() {
826            Ok(result)
827        } else {
828            Err(regex_errors)
829        }
830    }
831
832    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
833    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
834    #[must_use]
835    pub(crate) fn precompile_config_matchers(
836        &self,
837    ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
838        self.plugins
839            .iter()
840            .filter(|p| !p.config_patterns().is_empty())
841            .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
842            .collect()
843    }
844
845    /// Resolve the config files that package scripts pass to a plugin binary
846    /// with `--config` / `-c`.
847    ///
848    /// `package_root` is the directory of the `package.json` that holds the
849    /// scripts, and `ws_prefix` is its path relative to the project root
850    /// (empty for the root package). Each file is resolved into its own
851    /// result, so a `test.include` in a script config adds entry patterns
852    /// and does not replace the patterns of the plugin's default config.
853    /// A file that a `config_patterns()` entry matches is skipped, because
854    /// the normal plugin run resolves it already.
855    pub(crate) fn resolve_script_config_files(
856        &self,
857        config_files: &[scripts::BinaryConfigFile],
858        package_root: &Path,
859        ws_prefix: &str,
860    ) -> AggregatedPluginResult {
861        let mut aggregated = AggregatedPluginResult::default();
862        for config_file in config_files {
863            let Some(rel_path) =
864                scripts::normalize_script_entry_pattern(ws_prefix, &config_file.path)
865            else {
866                continue;
867            };
868            for plugin in &self.plugins {
869                let plugin = plugin.as_ref();
870                if !plugin
871                    .script_config_binaries()
872                    .contains(&config_file.binary.as_str())
873                    || cached_plugin_config_matchers(plugin)
874                        .iter()
875                        .any(|m| m.is_match(rel_path.as_str()))
876                {
877                    continue;
878                }
879                let abs_path = package_root.join(config_file.path.trim_start_matches("./"));
880                let Ok(source) = std::fs::read_to_string(&abs_path) else {
881                    continue;
882                };
883                let mut file_result = AggregatedPluginResult::default();
884                file_result
885                    .entry_point_roles
886                    .insert(plugin.name().to_string(), plugin.entry_point_role());
887                let plugin_result = plugin.resolve_config(&abs_path, &source, package_root);
888                if let Err(errors) = process_config_result(
889                    plugin.name(),
890                    plugin_result,
891                    &mut file_result,
892                    Some(&abs_path),
893                ) {
894                    for error in errors {
895                        tracing::warn!("{error}");
896                    }
897                    continue;
898                }
899                if !ws_prefix.is_empty() {
900                    file_result.apply_workspace_prefix(ws_prefix);
901                }
902                // The tool loads the config module and reads its default export.
903                file_result.used_exports.push(PluginUsedExportRule::new(
904                    plugin.name().to_string(),
905                    super::UsedExportRule::new(rel_path.clone(), ["default"]),
906                ));
907                aggregated.merge_into(file_result);
908            }
909        }
910        aggregated
911    }
912}
913
914fn process_workspace_active_plugins(
915    active: &[&dyn Plugin],
916    input: &WorkspacePluginRunInput<'_>,
917    result: &mut AggregatedPluginResult,
918    regex_errors: &mut Vec<PluginRegexValidationError>,
919) {
920    for plugin in active {
921        process_static_patterns(*plugin, input.root, result);
922    }
923    process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
924}
925
926fn resolve_workspace_plugin_configs(
927    active: &[&dyn Plugin],
928    input: &WorkspacePluginRunInput<'_>,
929    result: &mut AggregatedPluginResult,
930    regex_errors: &mut Vec<PluginRegexValidationError>,
931) {
932    let workspace_matchers = select_workspace_matchers(
933        input.precompiled_config_matchers,
934        active,
935        input.skip_config_plugins,
936    );
937
938    let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
939    for (plugin, matchers) in &workspace_matchers {
940        resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
941            plugin: *plugin,
942            matchers,
943            relative_files: input.relative_files,
944            root: input.root,
945            result,
946            regex_errors,
947            resolved_plugins: &mut resolved_ws_plugins,
948        });
949    }
950
951    load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
952        workspace_matchers: &workspace_matchers,
953        resolved_ws_plugins: &resolved_ws_plugins,
954        root: input.root,
955        project_root: input.project_root,
956        production_mode: input.production_mode,
957        candidate_index: input.candidate_index,
958        result,
959        regex_errors,
960    });
961}
962
963impl Default for PluginRegistry {
964    fn default() -> Self {
965        Self::new(vec![])
966    }
967}
968
969impl PluginRegistry {
970    fn prepare_plugin_run_context<'a>(
971        &'a self,
972        pkg: &PackageJson,
973        root: &Path,
974        discovered_files: &[PathBuf],
975        production_mode: bool,
976        candidate_index: Option<&ConfigCandidateIndex>,
977    ) -> PluginRunContext<'a> {
978        let all_deps = pkg.all_dependency_names();
979        let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
980        let active = self.collect_active_plugins(&PluginActivationInput {
981            pkg,
982            root,
983            discovered_files,
984            all_deps: &all_deps,
985            script_packages: &script_packages,
986            candidate_index,
987        });
988
989        PluginRunContext { all_deps, active }
990    }
991
992    fn run_plugin_preflight(
993        &self,
994        active: &[&dyn Plugin],
995        all_deps: &[String],
996        root: &Path,
997        discovered_files: &[PathBuf],
998    ) {
999        log_active_plugins(active);
1000        check_meta_framework_prerequisites(active, root);
1001        self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
1002    }
1003
1004    /// Collect every built-in plugin enabled for this project via files,
1005    /// scripts, or package.json. Shared by the root and workspace-fast paths.
1006    fn collect_active_plugins<'a>(
1007        &'a self,
1008        activation: &PluginActivationInput<'_>,
1009    ) -> Vec<&'a dyn Plugin> {
1010        self.plugins
1011            .iter()
1012            .filter(|p| {
1013                p.is_enabled_with_files(
1014                    activation.all_deps,
1015                    activation.root,
1016                    activation.discovered_files,
1017                    activation.candidate_index,
1018                ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
1019                    || p.is_enabled_with_package_json(activation.pkg, activation.root)
1020            })
1021            .map(AsRef::as_ref)
1022            .collect()
1023    }
1024
1025    /// Collect the active subset of external plugins, run the silent-fail
1026    /// diagnostics (#479), and emit one `tracing::warn!` per finding (dedup'd
1027    /// across analysis passes via [`plugin_warn_dedupe`]).
1028    ///
1029    /// Called from both `run_with_search_roots` (top-level) and
1030    /// `run_workspace_fast` (per-workspace) so a typo'd enabler or pattern
1031    /// collision surfaces regardless of which entry point dispatched the
1032    /// analysis.
1033    fn emit_silent_fail_diagnostics(
1034        &self,
1035        active: &[&dyn Plugin],
1036        all_deps: &[String],
1037        root: &Path,
1038        discovered_files: &[PathBuf],
1039    ) {
1040        let active_external: Vec<&ExternalPluginDef> = self
1041            .external_plugins
1042            .iter()
1043            .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
1044            .collect();
1045        let mut diagnostics = detect_pattern_collisions(active, &active_external);
1046        diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
1047        emit_plugin_diagnostics(&diagnostics);
1048    }
1049}
1050
1051/// Process-wide dedupe key cache for plugin-system diagnostic warnings.
1052///
1053/// Combined-mode runs `PluginRegistry::run_with_search_roots` three times
1054/// (check + dupes + health) per analysis, so a naive warn would triple-emit
1055/// every diagnostic. Each warn helper builds a unique key, inserts it here,
1056/// and only emits when the key was previously absent.
1057fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
1058    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
1059        std::sync::OnceLock::new();
1060    WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
1061}
1062
1063struct PluginConfigResolutionInput<'a> {
1064    config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1065    relative_files: &'a [(PathBuf, String)],
1066    config_search_roots: &'a [&'a Path],
1067    production_mode: bool,
1068    candidate_index: Option<&'a ConfigCandidateIndex>,
1069    root: &'a Path,
1070    result: &'a mut AggregatedPluginResult,
1071    regex_errors: &'a mut Vec<PluginRegexValidationError>,
1072}
1073
1074/// Filter pre-compiled matchers down to active plugins, keeping a config-skipped
1075/// plugin only when it must still parse its workspace config while root-active.
1076fn select_workspace_matchers<'a>(
1077    precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1078    active: &[&dyn Plugin],
1079    skip_config_plugins: &FxHashSet<&str>,
1080) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
1081    let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
1082    precompiled_config_matchers
1083        .iter()
1084        .filter(|(p, _)| {
1085            active_names.contains(p.name())
1086                && (!skip_config_plugins.contains(p.name())
1087                    || must_parse_workspace_config_when_root_active(p.name()))
1088        })
1089        .map(|(plugin, matchers)| (*plugin, matchers.clone()))
1090        .collect()
1091}
1092
1093struct WorkspaceFsConfigInput<'a> {
1094    workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1095    resolved_ws_plugins: &'a FxHashSet<&'a str>,
1096    root: &'a Path,
1097    project_root: &'a Path,
1098    production_mode: bool,
1099    candidate_index: Option<&'a ConfigCandidateIndex>,
1100    result: &'a mut AggregatedPluginResult,
1101    regex_errors: &'a mut Vec<PluginRegexValidationError>,
1102}
1103
1104/// Discover and parse workspace config files on disk for plugins not already
1105/// matched against discovered source files (workspace filesystem fallback).
1106fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
1107    let search_roots: &[&Path] = if input.root == input.project_root {
1108        &[input.root]
1109    } else {
1110        &[input.root, input.project_root]
1111    };
1112    let ws_json_configs = discover_config_files(
1113        input.workspace_matchers,
1114        input.resolved_ws_plugins,
1115        search_roots,
1116        input.production_mode,
1117        input.candidate_index,
1118    );
1119    for (abs_path, plugin) in &ws_json_configs {
1120        let Ok(source) = std::fs::read_to_string(abs_path) else {
1121            continue;
1122        };
1123        let plugin_result = plugin.resolve_config(abs_path, &source, input.root);
1124        if plugin_result.is_empty() {
1125            continue;
1126        }
1127        let rel = abs_path
1128            .strip_prefix(input.project_root)
1129            .map(|p| p.to_string_lossy())
1130            .unwrap_or_default();
1131        tracing::debug!(
1132            plugin = plugin.name(),
1133            config = %rel,
1134            entries = plugin_result.entry_patterns.len(),
1135            deps = plugin_result.referenced_dependencies.len(),
1136            "resolved config (workspace filesystem fallback)"
1137        );
1138        if let Err(mut errors) =
1139            process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
1140        {
1141            input.regex_errors.append(&mut errors);
1142        }
1143    }
1144}
1145
1146fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
1147    if input.config_matchers.is_empty() {
1148        return;
1149    }
1150
1151    let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
1152    for (plugin, matchers) in input.config_matchers {
1153        resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1154            plugin: *plugin,
1155            matchers,
1156            relative_files: input.relative_files,
1157            root: input.root,
1158            result: input.result,
1159            regex_errors: input.regex_errors,
1160            resolved_plugins: &mut resolved_plugins,
1161        });
1162    }
1163
1164    let json_configs = discover_config_files(
1165        input.config_matchers,
1166        &resolved_plugins,
1167        input.config_search_roots,
1168        input.production_mode,
1169        input.candidate_index,
1170    );
1171    for (abs_path, plugin) in &json_configs {
1172        resolve_plugin_filesystem_config(
1173            *plugin,
1174            abs_path,
1175            input.root,
1176            input.result,
1177            input.regex_errors,
1178        );
1179    }
1180}
1181
1182struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
1183    plugin: &'plugins dyn Plugin,
1184    matchers: &'data [globset::GlobMatcher],
1185    relative_files: &'data [(PathBuf, String)],
1186    root: &'data Path,
1187    result: &'state mut AggregatedPluginResult,
1188    regex_errors: &'state mut Vec<PluginRegexValidationError>,
1189    resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1190}
1191
1192fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1193    use rayon::prelude::*;
1194
1195    let plugin_hits: Vec<&PathBuf> = input
1196        .relative_files
1197        .par_iter()
1198        .filter_map(|(abs_path, rel_path)| {
1199            input
1200                .matchers
1201                .iter()
1202                .any(|m| m.is_match(rel_path.as_str()))
1203                .then_some(abs_path)
1204        })
1205        .collect();
1206    for abs_path in plugin_hits {
1207        let Ok(source) = std::fs::read_to_string(abs_path) else {
1208            continue;
1209        };
1210        let plugin_result = input.plugin.resolve_config(abs_path, &source, input.root);
1211        if plugin_result.is_empty() {
1212            continue;
1213        }
1214        input.resolved_plugins.insert(input.plugin.name());
1215        process_resolved_plugin_config(ResolvedPluginConfigInput {
1216            plugin: input.plugin,
1217            abs_path,
1218            plugin_result,
1219            result: input.result,
1220            regex_errors: input.regex_errors,
1221            message: "resolved config",
1222            config_display: abs_path.display(),
1223        });
1224    }
1225}
1226
1227fn resolve_plugin_filesystem_config(
1228    plugin: &dyn Plugin,
1229    abs_path: &Path,
1230    root: &Path,
1231    result: &mut AggregatedPluginResult,
1232    regex_errors: &mut Vec<PluginRegexValidationError>,
1233) {
1234    let Ok(source) = std::fs::read_to_string(abs_path) else {
1235        return;
1236    };
1237    let plugin_result = plugin.resolve_config(abs_path, &source, root);
1238    if plugin_result.is_empty() {
1239        return;
1240    }
1241    let rel = abs_path
1242        .strip_prefix(root)
1243        .map(|p| p.to_string_lossy())
1244        .unwrap_or_default();
1245    process_resolved_plugin_config(ResolvedPluginConfigInput {
1246        plugin,
1247        abs_path,
1248        plugin_result,
1249        result,
1250        regex_errors,
1251        message: "resolved config (filesystem fallback)",
1252        config_display: rel,
1253    });
1254}
1255
1256struct ResolvedPluginConfigInput<'a, D> {
1257    plugin: &'a dyn Plugin,
1258    abs_path: &'a Path,
1259    plugin_result: PluginResult,
1260    result: &'a mut AggregatedPluginResult,
1261    regex_errors: &'a mut Vec<PluginRegexValidationError>,
1262    message: &'static str,
1263    config_display: D,
1264}
1265
1266fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1267    tracing::debug!(
1268        plugin = input.plugin.name(),
1269        config = %input.config_display,
1270        entries = input.plugin_result.entry_patterns.len(),
1271        deps = input.plugin_result.referenced_dependencies.len(),
1272        input.message
1273    );
1274    if let Err(mut errors) = process_config_result(
1275        input.plugin.name(),
1276        input.plugin_result,
1277        input.result,
1278        Some(input.abs_path),
1279    ) {
1280        input.regex_errors.append(&mut errors);
1281    }
1282}
1283
1284/// Insert `key` into the dedupe set and return `true` when it was newly
1285/// inserted (caller should emit). Returns `true` on a poisoned mutex so
1286/// over-warning beats swallowing.
1287fn should_warn(key: String) -> bool {
1288    plugin_warn_dedupe()
1289        .lock()
1290        .map_or(true, |mut set| set.insert(key))
1291}
1292
1293/// Structured diagnostic surfaced by the silent-fail plugin checks (#479).
1294///
1295/// Returned by [`detect_pattern_collisions`] and [`detect_enabler_typos`] so
1296/// unit tests can assert on the findings without standing up a tracing
1297/// subscriber. The runtime path calls [`emit_plugin_diagnostics`] to convert
1298/// each variant into one `tracing::warn!` line.
1299#[derive(Debug, Clone, PartialEq, Eq)]
1300pub(crate) enum PluginDiagnostic {
1301    /// Two or more plugins declared an identical `config_patterns` entry.
1302    PatternCollision {
1303        pattern: String,
1304        owners: Vec<String>,
1305    },
1306    /// An external plugin enabler does not match any project dependency, but
1307    /// at least one Levenshtein-close dep name exists.
1308    EnablerTypo {
1309        plugin: String,
1310        enabler: String,
1311        suggestion: String,
1312    },
1313}
1314
1315/// Detect plugins whose `config_patterns` collide byte-for-byte.
1316///
1317/// Detection is byte-equal on the pattern string. Overlapping but non-identical
1318/// globs (e.g. `vite.config.{ts,js}` vs `vite.config.ts`) require pattern
1319/// intersection logic and are intentionally out of scope. The warning's purpose
1320/// is to surface USER-AUTHORED collisions between external plugins or between an
1321/// external plugin and a built-in, so the user can disambiguate by editing one
1322/// side.
1323///
1324/// Built-in-vs-built-in collisions are intentionally NOT reported: they are
1325/// curated and benign (Phase 3a config matching runs every matching plugin's
1326/// `resolve_config` independently, so there is no data loss), and the warning's
1327/// remediation advice ("rename one of the patterns or remove the duplicate
1328/// plugin") is impossible to follow for a built-in. Such a collision exists by
1329/// design, e.g. both `vite` and `tanstack-router` claim
1330/// `vite.config.{ts,js,mts,mjs}` because tanstack-router parses the
1331/// `tanstackRouter({...})` call inside the vite config to find a custom
1332/// `generatedRouteTree` path (#808). A finding is therefore emitted only when
1333/// at least one owner is an external (user-authored) plugin.
1334///
1335/// Precedence rule when two plugins claim the same pattern: the one registered
1336/// first wins. For built-in plugins, registration order is defined in
1337/// [`builtin::create_builtin_plugins`]. External plugins (file-loaded plus
1338/// inline `framework[]`) run AFTER built-ins, so they cannot displace a
1339/// built-in's `resolve_config` result for the same file.
1340fn detect_pattern_collisions(
1341    builtin_active: &[&dyn Plugin],
1342    external_active: &[&ExternalPluginDef],
1343) -> Vec<PluginDiagnostic> {
1344    use rustc_hash::FxHashMap;
1345
1346    let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1347        FxHashMap::default();
1348
1349    let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1350                  pattern: String,
1351                  name: String| {
1352        let (list, seen) = pattern_owners.entry(pattern).or_default();
1353        if seen.insert(name.clone()) {
1354            list.push(name);
1355        }
1356    };
1357
1358    for plugin in builtin_active {
1359        for pat in plugin.config_patterns() {
1360            record(
1361                &mut pattern_owners,
1362                (*pat).to_string(),
1363                plugin.name().to_string(),
1364            );
1365        }
1366    }
1367    for ext in external_active {
1368        for pat in &ext.config_patterns {
1369            record(&mut pattern_owners, pat.clone(), ext.name.clone());
1370        }
1371    }
1372
1373    // Names of built-in plugins. Built-in-only collisions are curated + benign
1374    // (every matching plugin runs `resolve_config` independently), so they must
1375    // not surface an un-actionable warning (#808). Keying on the built-in set
1376    // and emitting only when an owner is NOT built-in is robust even if a
1377    // user-authored external plugin happens to share a built-in's name: the
1378    // built-in owner alone never re-enables the warning.
1379    let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1380
1381    let mut findings: Vec<PluginDiagnostic> = pattern_owners
1382        .into_iter()
1383        .filter_map(|(pattern, (owners, _seen))| {
1384            if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1385                None
1386            } else {
1387                Some(PluginDiagnostic::PatternCollision { pattern, owners })
1388            }
1389        })
1390        .collect();
1391    findings.sort_unstable_by(|a, b| match (a, b) {
1392        (
1393            PluginDiagnostic::PatternCollision { pattern: ap, .. },
1394            PluginDiagnostic::PatternCollision { pattern: bp, .. },
1395        ) => ap.cmp(bp),
1396        _ => std::cmp::Ordering::Equal,
1397    });
1398    findings
1399}
1400
1401/// Detect external plugins whose enablers do not match any project dependency
1402/// AND at least one enabler is a plausible typo of a real dep.
1403///
1404/// Scope:
1405/// - Only external plugins (file-loaded plus inline `framework[]`). Built-in
1406///   plugins' enablers are hard-coded so cannot be misspelled.
1407/// - Skip plugins with a `detection` block: detection is the rich-logic path
1408///   and false negatives there are not enabler typos.
1409/// - Skip plugins with empty `enablers` (no signal to validate against).
1410/// - Stay silent when no Levenshtein-close dep exists: the plugin may
1411///   legitimately not apply to this project.
1412///
1413/// Matches the established #467 / #510 pattern: tracing-warn with a `did you
1414/// mean` suggestion at the call site. No exit non-zero, no new CLI flag.
1415fn detect_enabler_typos(
1416    external_plugins: &[ExternalPluginDef],
1417    all_deps: &[String],
1418) -> Vec<PluginDiagnostic> {
1419    let mut findings = Vec::new();
1420
1421    for ext in external_plugins {
1422        if ext.detection.is_some() || ext.enablers.is_empty() {
1423            continue;
1424        }
1425
1426        let any_match = ext.enablers.iter().any(|enabler| {
1427            if enabler.ends_with('/') {
1428                all_deps.iter().any(|d| d.starts_with(enabler))
1429            } else {
1430                all_deps.iter().any(|d| d == enabler)
1431            }
1432        });
1433        if any_match {
1434            continue;
1435        }
1436
1437        for enabler in &ext.enablers {
1438            let candidates = all_deps.iter().map(String::as_str);
1439            let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1440            else {
1441                continue;
1442            };
1443
1444            findings.push(PluginDiagnostic::EnablerTypo {
1445                plugin: ext.name.clone(),
1446                enabler: enabler.clone(),
1447                suggestion: suggestion.to_string(),
1448            });
1449        }
1450    }
1451
1452    findings
1453}
1454
1455/// Emit one `tracing::warn!` per finding, dedup'd against the process-wide
1456/// `plugin_warn_dedupe` set so combined-mode does not triple-warn.
1457fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1458    for finding in findings {
1459        match finding {
1460            PluginDiagnostic::PatternCollision { pattern, owners } => {
1461                let key = format!("collision::{pattern}::{owners:?}");
1462                if !should_warn(key) {
1463                    continue;
1464                }
1465                let winner = &owners[0];
1466                let others = owners[1..].join(", ");
1467                tracing::warn!(
1468                    "plugin config_patterns collision: identical pattern \
1469                     '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1470                     runs first (registration order), others ({others}) \
1471                     follow. Rename one of the patterns or remove the \
1472                     duplicate plugin to make resolution explicit. A future \
1473                     release may reject identical-pattern collisions.",
1474                    joined = owners.join(", "),
1475                );
1476            }
1477            PluginDiagnostic::EnablerTypo {
1478                plugin,
1479                enabler,
1480                suggestion,
1481            } => {
1482                let key = format!("enabler::{plugin}::{enabler}");
1483                if !should_warn(key) {
1484                    continue;
1485                }
1486                tracing::warn!(
1487                    "plugin '{plugin}' enabler '{enabler}' does not match any \
1488                     dependency in package.json; did you mean '{suggestion}'? \
1489                     The plugin will not activate. A future release may reject \
1490                     unmatched enablers.",
1491                );
1492            }
1493        }
1494    }
1495}
1496
1497/// Phase 4 of `PluginRegistry::run_with_search_roots`: for any active plugin
1498/// that supports inline package.json configuration via
1499/// [`Plugin::package_json_config_key`], read the root `package.json`, extract
1500/// the relevant key, and feed the result through `resolve_config`.
1501fn process_package_json_inline_configs(
1502    active: &[&dyn Plugin],
1503    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1504    relative_files: &[(PathBuf, String)],
1505    root: &Path,
1506    result: &mut AggregatedPluginResult,
1507    regex_errors: &mut Vec<PluginRegexValidationError>,
1508) {
1509    for plugin in active {
1510        let Some(key) = plugin.package_json_config_key() else {
1511            continue;
1512        };
1513        if check_has_config_file(*plugin, config_matchers, relative_files) {
1514            continue;
1515        }
1516        let pkg_path = root.join("package.json");
1517        let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1518            continue;
1519        };
1520        let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1521            continue;
1522        };
1523        let Some(config_value) = json.get(key) else {
1524            continue;
1525        };
1526        let config_json = serde_json::to_string(config_value).unwrap_or_default();
1527        let fake_path = root.join(format!("{key}.config.json"));
1528        let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1529        if plugin_result.is_empty() {
1530            continue;
1531        }
1532        tracing::debug!(
1533            plugin = plugin.name(),
1534            key = key,
1535            "resolved inline package.json config"
1536        );
1537        if let Err(mut errors) =
1538            process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1539        {
1540            regex_errors.append(&mut errors);
1541        }
1542    }
1543}
1544
1545/// A missing meta-framework prerequisite: the per-process dedupe key and the
1546/// warning message to emit.
1547#[derive(Debug)]
1548struct MetaFrameworkWarning {
1549    dedupe_key: &'static str,
1550    message: &'static str,
1551}
1552
1553/// Pure detection: which active meta-frameworks are missing their generated
1554/// config/types directory under `root`. Separated from emission so the
1555/// detection logic is unit-testable without a tracing subscriber or the
1556/// process-wide dedupe set.
1557///
1558/// When adding a framework here, also extend `MATERIALIZED_CONTEXT_DIRS` in
1559/// `fallow-cli`'s `audit.rs` with its generated dir, otherwise `fallow audit`'s
1560/// base worktree will not symlink that dir and the broken-tsconfig-chain bug
1561/// resurfaces on the base pass for the new framework.
1562fn missing_meta_framework_prerequisites(
1563    active_plugins: &[&dyn Plugin],
1564    root: &Path,
1565) -> Vec<MetaFrameworkWarning> {
1566    active_plugins
1567        .iter()
1568        .filter_map(|plugin| match plugin.name() {
1569            "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1570                dedupe_key: "meta-prereq::nuxt",
1571                message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1572                          before fallow for accurate analysis",
1573            }),
1574            "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1575                dedupe_key: "meta-prereq::astro",
1576                message: "Astro project missing .astro/ types: run `astro sync` \
1577                          before fallow for accurate analysis",
1578            }),
1579            _ => None,
1580        })
1581        .collect()
1582}
1583
1584/// Warn when meta-frameworks are active but their generated configs are missing.
1585///
1586/// Meta-frameworks like Nuxt and Astro generate tsconfig/types files during a
1587/// "prepare" step. Without these, the tsconfig extends chain breaks and
1588/// extensionless imports fail wholesale (e.g. 2000+ unresolved imports).
1589///
1590/// Deduped per framework so combined-mode (check + dupes + health through one
1591/// loader) does not re-warn. The advice is generic and does not name the root,
1592/// so one line per process per framework is the right bound (issue #637).
1593fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1594    for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1595        if should_warn(warning.dedupe_key.to_owned()) {
1596            tracing::warn!("{}", warning.message);
1597        }
1598    }
1599}
1600
1601fn script_activation_packages(
1602    pkg: &PackageJson,
1603    root: &Path,
1604    all_deps: &[String],
1605    production_mode: bool,
1606) -> FxHashSet<String> {
1607    let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1608        return FxHashSet::default();
1609    };
1610
1611    let scripts_to_analyze = if production_mode {
1612        scripts::filter_production_scripts(pkg_scripts)
1613    } else {
1614        pkg_scripts.clone()
1615    };
1616
1617    let mut nm_roots = Vec::new();
1618    if root.join("node_modules").is_dir() {
1619        nm_roots.push(root);
1620    }
1621    let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1622    let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1623    let catalog =
1624        scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1625
1626    scripts::analyze_scripts_with_dependency_context(
1627        &scripts_to_analyze,
1628        root,
1629        &bin_map,
1630        &dep_set,
1631        &catalog,
1632        scripts::IgnoredCommandEntries::NONE,
1633    )
1634    .used_packages
1635}
1636
1637#[cfg(test)]
1638mod tests;