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