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