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