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