Skip to main content

fallow_core/plugins/registry/
mod.rs

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