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