Skip to main content

fallow_core/plugins/registry/
mod.rs

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