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