Skip to main content

fallow_core/plugins/
mod.rs

1//! Plugin system for framework-aware codebase analysis.
2//!
3//! Unlike knip's JavaScript plugin system that evaluates config files at runtime,
4//! fallow's plugin system uses Oxc's parser to extract configuration values from
5//! JS/TS/JSON config files via AST walking — no JavaScript evaluation needed.
6//!
7//! Each plugin implements the [`Plugin`] trait with:
8//! - **Static defaults**: Entry patterns, config file patterns, used exports
9//! - **Dynamic resolution**: Parse tool config files to discover additional entries,
10//!   referenced dependencies, and setup files
11
12use rustc_hash::FxHashSet;
13use std::path::{Path, PathBuf};
14
15use fallow_config::{ExternalPluginDef, PackageJson, PluginDetection};
16
17/// Result of resolving a plugin's config file.
18#[derive(Debug, Default)]
19pub struct PluginResult {
20    /// Additional entry point glob patterns discovered from config.
21    pub entry_patterns: Vec<String>,
22    /// Dependencies referenced in config files (should not be flagged as unused).
23    pub referenced_dependencies: Vec<String>,
24    /// Additional files that are always considered used.
25    pub always_used_files: Vec<String>,
26    /// Setup/helper files referenced from config.
27    pub setup_files: Vec<PathBuf>,
28}
29
30impl PluginResult {
31    pub const fn is_empty(&self) -> bool {
32        self.entry_patterns.is_empty()
33            && self.referenced_dependencies.is_empty()
34            && self.always_used_files.is_empty()
35            && self.setup_files.is_empty()
36    }
37}
38
39/// A framework/tool plugin that contributes to dead code analysis.
40pub trait Plugin: Send + Sync {
41    /// Human-readable plugin name.
42    fn name(&self) -> &'static str;
43
44    /// Package names that activate this plugin when found in package.json.
45    /// Supports exact matches and prefix patterns (ending with `/`).
46    fn enablers(&self) -> &'static [&'static str] {
47        &[]
48    }
49
50    /// Check if this plugin should be active for the given project.
51    /// Default implementation checks `enablers()` against package.json dependencies.
52    fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
53        let deps = pkg.all_dependency_names();
54        self.is_enabled_with_deps(&deps, root)
55    }
56
57    /// Fast variant of `is_enabled` that accepts a pre-computed deps list.
58    /// Avoids repeated `all_dependency_names()` allocation when checking many plugins.
59    fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
60        let enablers = self.enablers();
61        if enablers.is_empty() {
62            return false;
63        }
64        enablers.iter().any(|enabler| {
65            if enabler.ends_with('/') {
66                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
67                deps.iter().any(|d| d.starts_with(enabler))
68            } else {
69                deps.iter().any(|d| d == enabler)
70            }
71        })
72    }
73
74    /// Default glob patterns for entry point files.
75    fn entry_patterns(&self) -> &'static [&'static str] {
76        &[]
77    }
78
79    /// Glob patterns for config files this plugin can parse.
80    fn config_patterns(&self) -> &'static [&'static str] {
81        &[]
82    }
83
84    /// Files that are always considered "used" when this plugin is active.
85    fn always_used(&self) -> &'static [&'static str] {
86        &[]
87    }
88
89    /// Exports that are always considered used for matching file patterns.
90    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
91        vec![]
92    }
93
94    /// Dependencies that are tooling (used via CLI/config, not source imports).
95    /// These should not be flagged as unused devDependencies.
96    fn tooling_dependencies(&self) -> &'static [&'static str] {
97        &[]
98    }
99
100    /// Import prefixes that are virtual modules provided by this framework at build time.
101    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
102    /// Each entry is matched as a prefix against the extracted package name
103    /// (e.g., `"@theme/"` matches `@theme/Layout`).
104    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
105        &[]
106    }
107
108    /// Path alias mappings provided by this framework at build time.
109    ///
110    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
111    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
112    /// `replacement_dir` (relative to the project root) and retry.
113    ///
114    /// Called once when plugins are activated. The project `root` is provided so
115    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
116    /// to determine the `srcDir`).
117    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
118        vec![]
119    }
120
121    /// Parse a config file's AST to discover additional entries, dependencies, etc.
122    ///
123    /// Called for each config file matching `config_patterns()`. The source code
124    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
125    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
126        PluginResult::default()
127    }
128
129    /// The key name in package.json that holds inline configuration for this tool.
130    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
131    /// system will extract that key's value and call `resolve_config` with its
132    /// JSON content if no standalone config file was found.
133    fn package_json_config_key(&self) -> Option<&'static str> {
134        None
135    }
136}
137
138/// Macro to eliminate boilerplate in plugin implementations.
139///
140/// Generates a struct and a `Plugin` trait impl with the standard static methods
141/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
142/// `used_exports`).
143///
144/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
145/// manual `impl Plugin for ...` blocks instead of using this macro.
146///
147/// # Usage
148///
149/// ```ignore
150/// // Simple plugin (most common):
151/// define_plugin! {
152///     struct VitePlugin => "vite",
153///     enablers: ENABLERS,
154///     entry_patterns: ENTRY_PATTERNS,
155///     config_patterns: CONFIG_PATTERNS,
156///     always_used: ALWAYS_USED,
157///     tooling_dependencies: TOOLING_DEPENDENCIES,
158/// }
159///
160/// // Plugin with used_exports:
161/// define_plugin! {
162///     struct RemixPlugin => "remix",
163///     enablers: ENABLERS,
164///     entry_patterns: ENTRY_PATTERNS,
165///     always_used: ALWAYS_USED,
166///     tooling_dependencies: TOOLING_DEPENDENCIES,
167///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
168/// }
169/// ```
170///
171/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
172macro_rules! define_plugin {
173    (
174        struct $name:ident => $display:expr,
175        enablers: $enablers:expr
176        $(, entry_patterns: $entry:expr)?
177        $(, config_patterns: $config:expr)?
178        $(, always_used: $always:expr)?
179        $(, tooling_dependencies: $tooling:expr)?
180        $(, virtual_module_prefixes: $virtual:expr)?
181        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
182        $(,)?
183    ) => {
184        pub struct $name;
185
186        impl Plugin for $name {
187            fn name(&self) -> &'static str {
188                $display
189            }
190
191            fn enablers(&self) -> &'static [&'static str] {
192                $enablers
193            }
194
195            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
196            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
197            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
198            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
199            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
200
201            $(
202                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
203                    vec![$( ($pat, $exports) ),*]
204                }
205            )?
206        }
207    };
208}
209
210pub mod config_parser;
211mod tooling;
212
213pub use tooling::is_known_tooling_dependency;
214
215mod angular;
216mod astro;
217mod ava;
218mod babel;
219mod biome;
220mod bun;
221mod c8;
222mod capacitor;
223mod changesets;
224mod commitizen;
225mod commitlint;
226mod cspell;
227mod cucumber;
228mod cypress;
229mod dependency_cruiser;
230mod docusaurus;
231mod drizzle;
232mod electron;
233mod eslint;
234mod expo;
235mod gatsby;
236mod graphql_codegen;
237mod husky;
238mod i18next;
239mod jest;
240mod karma;
241mod knex;
242mod kysely;
243mod lefthook;
244mod lint_staged;
245mod markdownlint;
246mod mocha;
247mod msw;
248mod nestjs;
249mod next_intl;
250mod nextjs;
251mod nitro;
252mod nodemon;
253mod nuxt;
254mod nx;
255mod nyc;
256mod openapi_ts;
257mod oxlint;
258mod parcel;
259mod playwright;
260mod plop;
261mod pm2;
262mod postcss;
263mod prettier;
264mod prisma;
265mod react_native;
266mod react_router;
267mod relay;
268mod remark;
269mod remix;
270mod rolldown;
271mod rollup;
272mod rsbuild;
273mod rspack;
274mod sanity;
275mod semantic_release;
276mod sentry;
277mod simple_git_hooks;
278mod storybook;
279mod stylelint;
280mod sveltekit;
281mod svgo;
282mod svgr;
283mod swc;
284mod syncpack;
285mod tailwind;
286mod tanstack_router;
287mod tsdown;
288mod tsup;
289mod turborepo;
290mod typedoc;
291mod typeorm;
292mod typescript;
293mod vite;
294mod vitepress;
295mod vitest;
296mod webdriverio;
297mod webpack;
298mod wrangler;
299
300/// Registry of all available plugins (built-in + external).
301pub struct PluginRegistry {
302    plugins: Vec<Box<dyn Plugin>>,
303    external_plugins: Vec<ExternalPluginDef>,
304}
305
306/// Aggregated results from all active plugins for a project.
307#[derive(Debug, Default)]
308pub struct AggregatedPluginResult {
309    /// All entry point patterns from active plugins: (pattern, plugin_name).
310    pub entry_patterns: Vec<(String, String)>,
311    /// All config file patterns from active plugins.
312    pub config_patterns: Vec<String>,
313    /// All always-used file patterns from active plugins: (pattern, plugin_name).
314    pub always_used: Vec<(String, String)>,
315    /// All used export rules from active plugins.
316    pub used_exports: Vec<(String, Vec<String>)>,
317    /// Dependencies referenced in config files (should not be flagged unused).
318    pub referenced_dependencies: Vec<String>,
319    /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
320    pub discovered_always_used: Vec<(String, String)>,
321    /// Setup files discovered from config parsing: (path, plugin_name).
322    pub setup_files: Vec<(PathBuf, String)>,
323    /// Tooling dependencies (should not be flagged as unused devDeps).
324    pub tooling_dependencies: Vec<String>,
325    /// Package names discovered as used in package.json scripts (binary invocations).
326    pub script_used_packages: FxHashSet<String>,
327    /// Import prefixes for virtual modules provided by active frameworks.
328    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
329    pub virtual_module_prefixes: Vec<String>,
330    /// Path alias mappings from active plugins (prefix → replacement directory).
331    /// Used by the resolver to substitute import prefixes before re-resolving.
332    pub path_aliases: Vec<(String, String)>,
333    /// Names of active plugins.
334    pub active_plugins: Vec<String>,
335}
336
337impl PluginRegistry {
338    /// Create a registry with all built-in plugins and optional external plugins.
339    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
340        let plugins: Vec<Box<dyn Plugin>> = vec![
341            // Frameworks
342            Box::new(nextjs::NextJsPlugin),
343            Box::new(nuxt::NuxtPlugin),
344            Box::new(remix::RemixPlugin),
345            Box::new(astro::AstroPlugin),
346            Box::new(angular::AngularPlugin),
347            Box::new(react_router::ReactRouterPlugin),
348            Box::new(tanstack_router::TanstackRouterPlugin),
349            Box::new(react_native::ReactNativePlugin),
350            Box::new(expo::ExpoPlugin),
351            Box::new(nestjs::NestJsPlugin),
352            Box::new(docusaurus::DocusaurusPlugin),
353            Box::new(gatsby::GatsbyPlugin),
354            Box::new(sveltekit::SvelteKitPlugin),
355            Box::new(nitro::NitroPlugin),
356            Box::new(capacitor::CapacitorPlugin),
357            Box::new(sanity::SanityPlugin),
358            Box::new(vitepress::VitePressPlugin),
359            Box::new(next_intl::NextIntlPlugin),
360            Box::new(relay::RelayPlugin),
361            Box::new(electron::ElectronPlugin),
362            Box::new(i18next::I18nextPlugin),
363            // Bundlers
364            Box::new(vite::VitePlugin),
365            Box::new(webpack::WebpackPlugin),
366            Box::new(rollup::RollupPlugin),
367            Box::new(rolldown::RolldownPlugin),
368            Box::new(rspack::RspackPlugin),
369            Box::new(rsbuild::RsbuildPlugin),
370            Box::new(tsup::TsupPlugin),
371            Box::new(tsdown::TsdownPlugin),
372            Box::new(parcel::ParcelPlugin),
373            // Testing
374            Box::new(vitest::VitestPlugin),
375            Box::new(jest::JestPlugin),
376            Box::new(playwright::PlaywrightPlugin),
377            Box::new(cypress::CypressPlugin),
378            Box::new(mocha::MochaPlugin),
379            Box::new(ava::AvaPlugin),
380            Box::new(storybook::StorybookPlugin),
381            Box::new(karma::KarmaPlugin),
382            Box::new(cucumber::CucumberPlugin),
383            Box::new(webdriverio::WebdriverioPlugin),
384            // Linting & formatting
385            Box::new(eslint::EslintPlugin),
386            Box::new(biome::BiomePlugin),
387            Box::new(stylelint::StylelintPlugin),
388            Box::new(prettier::PrettierPlugin),
389            Box::new(oxlint::OxlintPlugin),
390            Box::new(markdownlint::MarkdownlintPlugin),
391            Box::new(cspell::CspellPlugin),
392            Box::new(remark::RemarkPlugin),
393            // Transpilation & language
394            Box::new(typescript::TypeScriptPlugin),
395            Box::new(babel::BabelPlugin),
396            Box::new(swc::SwcPlugin),
397            // CSS
398            Box::new(tailwind::TailwindPlugin),
399            Box::new(postcss::PostCssPlugin),
400            // Database & ORM
401            Box::new(prisma::PrismaPlugin),
402            Box::new(drizzle::DrizzlePlugin),
403            Box::new(knex::KnexPlugin),
404            Box::new(typeorm::TypeormPlugin),
405            Box::new(kysely::KyselyPlugin),
406            // Monorepo
407            Box::new(turborepo::TurborepoPlugin),
408            Box::new(nx::NxPlugin),
409            Box::new(changesets::ChangesetsPlugin),
410            Box::new(syncpack::SyncpackPlugin),
411            // CI/CD & release
412            Box::new(commitlint::CommitlintPlugin),
413            Box::new(commitizen::CommitizenPlugin),
414            Box::new(semantic_release::SemanticReleasePlugin),
415            // Deployment
416            Box::new(wrangler::WranglerPlugin),
417            Box::new(sentry::SentryPlugin),
418            // Git hooks
419            Box::new(husky::HuskyPlugin),
420            Box::new(lint_staged::LintStagedPlugin),
421            Box::new(lefthook::LefthookPlugin),
422            Box::new(simple_git_hooks::SimpleGitHooksPlugin),
423            // Media & assets
424            Box::new(svgo::SvgoPlugin),
425            Box::new(svgr::SvgrPlugin),
426            // Code generation & docs
427            Box::new(graphql_codegen::GraphqlCodegenPlugin),
428            Box::new(typedoc::TypedocPlugin),
429            Box::new(openapi_ts::OpenapiTsPlugin),
430            Box::new(plop::PlopPlugin),
431            // Coverage
432            Box::new(c8::C8Plugin),
433            Box::new(nyc::NycPlugin),
434            // Other tools
435            Box::new(msw::MswPlugin),
436            Box::new(nodemon::NodemonPlugin),
437            Box::new(pm2::Pm2Plugin),
438            Box::new(dependency_cruiser::DependencyCruiserPlugin),
439            // Runtime
440            Box::new(bun::BunPlugin),
441        ];
442        Self {
443            plugins,
444            external_plugins: external,
445        }
446    }
447
448    /// Run all plugins against a project, returning aggregated results.
449    ///
450    /// This discovers which plugins are active, collects their static patterns,
451    /// then parses any config files to extract dynamic information.
452    #[expect(clippy::cognitive_complexity)] // Plugin discovery and aggregation logic is inherently complex
453    pub fn run(
454        &self,
455        pkg: &PackageJson,
456        root: &Path,
457        discovered_files: &[PathBuf],
458    ) -> AggregatedPluginResult {
459        let _span = tracing::info_span!("run_plugins").entered();
460        let mut result = AggregatedPluginResult::default();
461
462        // Phase 1: Determine which plugins are active
463        // Compute deps once to avoid repeated Vec<String> allocation per plugin
464        let all_deps = pkg.all_dependency_names();
465        let active: Vec<&dyn Plugin> = self
466            .plugins
467            .iter()
468            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
469            .map(|p| p.as_ref())
470            .collect();
471
472        tracing::info!(
473            plugins = active
474                .iter()
475                .map(|p| p.name())
476                .collect::<Vec<_>>()
477                .join(", "),
478            "active plugins"
479        );
480
481        // Phase 2: Collect static patterns from active plugins
482        for plugin in &active {
483            result.active_plugins.push(plugin.name().to_string());
484
485            let pname = plugin.name().to_string();
486            for pat in plugin.entry_patterns() {
487                result
488                    .entry_patterns
489                    .push(((*pat).to_string(), pname.clone()));
490            }
491            for pat in plugin.config_patterns() {
492                result.config_patterns.push((*pat).to_string());
493            }
494            for pat in plugin.always_used() {
495                result.always_used.push(((*pat).to_string(), pname.clone()));
496            }
497            for (file_pat, exports) in plugin.used_exports() {
498                result.used_exports.push((
499                    file_pat.to_string(),
500                    exports.iter().map(|s| s.to_string()).collect(),
501                ));
502            }
503            for dep in plugin.tooling_dependencies() {
504                result.tooling_dependencies.push((*dep).to_string());
505            }
506            for prefix in plugin.virtual_module_prefixes() {
507                result.virtual_module_prefixes.push((*prefix).to_string());
508            }
509            for (prefix, replacement) in plugin.path_aliases(root) {
510                result.path_aliases.push((prefix.to_string(), replacement));
511            }
512        }
513
514        // Phase 2b: Process external plugins (includes inline framework definitions)
515        // Reuse `all_deps` from Phase 1 (already computed above)
516        let all_dep_refs: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
517        for ext in &self.external_plugins {
518            let is_active = if let Some(detection) = &ext.detection {
519                check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
520            } else if !ext.enablers.is_empty() {
521                ext.enablers.iter().any(|enabler| {
522                    if enabler.ends_with('/') {
523                        all_deps.iter().any(|d| d.starts_with(enabler))
524                    } else {
525                        all_deps.iter().any(|d| d == enabler)
526                    }
527                })
528            } else {
529                false
530            };
531            if is_active {
532                result.active_plugins.push(ext.name.clone());
533                result.entry_patterns.extend(
534                    ext.entry_points
535                        .iter()
536                        .map(|p| (p.clone(), ext.name.clone())),
537                );
538                // Track config patterns for introspection (not used for AST parsing —
539                // external plugins cannot do resolve_config())
540                result.config_patterns.extend(ext.config_patterns.clone());
541                result.always_used.extend(
542                    ext.config_patterns
543                        .iter()
544                        .chain(ext.always_used.iter())
545                        .map(|p| (p.clone(), ext.name.clone())),
546                );
547                result
548                    .tooling_dependencies
549                    .extend(ext.tooling_dependencies.clone());
550                for ue in &ext.used_exports {
551                    result
552                        .used_exports
553                        .push((ue.pattern.clone(), ue.exports.clone()));
554                }
555            }
556        }
557
558        // Phase 3: Find and parse config files for dynamic resolution
559        // Pre-compile all config patterns
560        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
561            .iter()
562            .filter(|p| !p.config_patterns().is_empty())
563            .map(|p| {
564                let matchers: Vec<globset::GlobMatcher> = p
565                    .config_patterns()
566                    .iter()
567                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
568                    .collect();
569                (*p, matchers)
570            })
571            .collect();
572
573        // Build relative paths for matching (used by Phase 3 and 4)
574        let relative_files: Vec<(&PathBuf, String)> = discovered_files
575            .iter()
576            .map(|f| {
577                let rel = f
578                    .strip_prefix(root)
579                    .unwrap_or(f)
580                    .to_string_lossy()
581                    .into_owned();
582                (f, rel)
583            })
584            .collect();
585
586        if !config_matchers.is_empty() {
587            // Track which plugins already found config files (to avoid double-parsing)
588            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
589
590            for (plugin, matchers) in &config_matchers {
591                for (abs_path, rel_path) in &relative_files {
592                    if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
593                        // Found a config file — parse it.
594                        // Mark as resolved regardless of result to prevent Phase 3b
595                        // from re-parsing a JSON config for the same plugin.
596                        resolved_plugins.insert(plugin.name());
597                        if let Ok(source) = std::fs::read_to_string(abs_path) {
598                            let plugin_result = plugin.resolve_config(abs_path, &source, root);
599                            if !plugin_result.is_empty() {
600                                tracing::debug!(
601                                    plugin = plugin.name(),
602                                    config = rel_path.as_str(),
603                                    entries = plugin_result.entry_patterns.len(),
604                                    deps = plugin_result.referenced_dependencies.len(),
605                                    "resolved config"
606                                );
607                                let pname = plugin.name().to_string();
608                                result.entry_patterns.extend(
609                                    plugin_result
610                                        .entry_patterns
611                                        .into_iter()
612                                        .map(|p| (p, pname.clone())),
613                                );
614                                result
615                                    .referenced_dependencies
616                                    .extend(plugin_result.referenced_dependencies);
617                                result.discovered_always_used.extend(
618                                    plugin_result
619                                        .always_used_files
620                                        .into_iter()
621                                        .map(|p| (p, pname.clone())),
622                                );
623                                result.setup_files.extend(
624                                    plugin_result
625                                        .setup_files
626                                        .into_iter()
627                                        .map(|p| (p, pname.clone())),
628                                );
629                            }
630                        }
631                    }
632                }
633            }
634
635            // Phase 3b: Filesystem fallback for JSON config files.
636            // JSON files (angular.json, project.json) are not in the discovered file set
637            // because fallow only discovers JS/TS/CSS/Vue/etc. files. For plugins with
638            // JSON config patterns that weren't matched above, try reading directly from
639            // the filesystem.
640            //
641            // Collect candidate JSON config files to parse (path, plugin_name).
642            let mut json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
643            for (plugin, _) in &config_matchers {
644                if resolved_plugins.contains(plugin.name()) {
645                    continue;
646                }
647                for pat in plugin.config_patterns() {
648                    let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
649                    if !has_glob {
650                        // Simple pattern (e.g., "angular.json") — check at root
651                        let abs_path = root.join(pat);
652                        if abs_path.is_file() {
653                            json_configs.push((abs_path, *plugin));
654                        }
655                    } else {
656                        // Glob pattern (e.g., "**/project.json") — check directories
657                        // that contain discovered source files
658                        let filename = std::path::Path::new(pat)
659                            .file_name()
660                            .and_then(|n| n.to_str())
661                            .unwrap_or(pat);
662                        let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
663                        if let Some(matcher) = matcher {
664                            let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
665                            checked_dirs.insert(root);
666                            for (abs_path, _) in &relative_files {
667                                if let Some(parent) = abs_path.parent() {
668                                    checked_dirs.insert(parent);
669                                }
670                            }
671                            for dir in checked_dirs {
672                                let candidate = dir.join(filename);
673                                if candidate.is_file() {
674                                    let rel = candidate
675                                        .strip_prefix(root)
676                                        .map(|p| p.to_string_lossy())
677                                        .unwrap_or_default();
678                                    if matcher.is_match(rel.as_ref()) {
679                                        json_configs.push((candidate, *plugin));
680                                    }
681                                }
682                            }
683                        }
684                    }
685                }
686            }
687            // Parse discovered JSON config files
688            for (abs_path, plugin) in &json_configs {
689                if let Ok(source) = std::fs::read_to_string(abs_path) {
690                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
691                    if !plugin_result.is_empty() {
692                        let rel = abs_path
693                            .strip_prefix(root)
694                            .map(|p| p.to_string_lossy())
695                            .unwrap_or_default();
696                        tracing::debug!(
697                            plugin = plugin.name(),
698                            config = %rel,
699                            entries = plugin_result.entry_patterns.len(),
700                            deps = plugin_result.referenced_dependencies.len(),
701                            "resolved config (filesystem fallback)"
702                        );
703                        let pname = plugin.name().to_string();
704                        result.entry_patterns.extend(
705                            plugin_result
706                                .entry_patterns
707                                .into_iter()
708                                .map(|p| (p, pname.clone())),
709                        );
710                        result
711                            .referenced_dependencies
712                            .extend(plugin_result.referenced_dependencies);
713                        result.discovered_always_used.extend(
714                            plugin_result
715                                .always_used_files
716                                .into_iter()
717                                .map(|p| (p, pname.clone())),
718                        );
719                        result.setup_files.extend(
720                            plugin_result
721                                .setup_files
722                                .into_iter()
723                                .map(|p| (p, pname.clone())),
724                        );
725                    }
726                }
727            }
728        }
729
730        // Phase 4: Package.json inline config fallback
731        // For plugins that define `package_json_config_key()`, check if the root
732        // package.json contains that key and no standalone config file was found.
733        for plugin in &active {
734            if let Some(key) = plugin.package_json_config_key() {
735                // Check if any config file was already found for this plugin
736                let has_config_file = !plugin.config_patterns().is_empty()
737                    && config_matchers.iter().any(|(p, matchers)| {
738                        p.name() == plugin.name()
739                            && relative_files
740                                .iter()
741                                .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
742                    });
743                if !has_config_file {
744                    // Try to extract the key from package.json
745                    let pkg_path = root.join("package.json");
746                    if let Ok(content) = std::fs::read_to_string(&pkg_path)
747                        && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
748                        && let Some(config_value) = json.get(key)
749                    {
750                        let config_json = serde_json::to_string(config_value).unwrap_or_default();
751                        let fake_path = root.join(format!("{key}.config.json"));
752                        let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
753                        if !plugin_result.is_empty() {
754                            let pname = plugin.name().to_string();
755                            tracing::debug!(
756                                plugin = pname.as_str(),
757                                key = key,
758                                "resolved inline package.json config"
759                            );
760                            result.entry_patterns.extend(
761                                plugin_result
762                                    .entry_patterns
763                                    .into_iter()
764                                    .map(|p| (p, pname.clone())),
765                            );
766                            result
767                                .referenced_dependencies
768                                .extend(plugin_result.referenced_dependencies);
769                            result.discovered_always_used.extend(
770                                plugin_result
771                                    .always_used_files
772                                    .into_iter()
773                                    .map(|p| (p, pname.clone())),
774                            );
775                            result.setup_files.extend(
776                                plugin_result
777                                    .setup_files
778                                    .into_iter()
779                                    .map(|p| (p, pname.clone())),
780                            );
781                        }
782                    }
783                }
784            }
785        }
786
787        result
788    }
789
790    /// Fast variant of `run()` for workspace packages.
791    ///
792    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
793    /// project run, avoiding repeated glob compilation and path computation per workspace.
794    /// Skips external plugins (they only activate at root level) and package.json inline
795    /// config (workspace packages rarely have inline configs).
796    pub fn run_workspace_fast(
797        &self,
798        pkg: &PackageJson,
799        root: &Path,
800        project_root: &Path,
801        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
802        relative_files: &[(&PathBuf, String)],
803    ) -> AggregatedPluginResult {
804        let _span = tracing::info_span!("run_plugins").entered();
805        let mut result = AggregatedPluginResult::default();
806
807        // Phase 1: Determine which plugins are active (with pre-computed deps)
808        let all_deps = pkg.all_dependency_names();
809        let active: Vec<&dyn Plugin> = self
810            .plugins
811            .iter()
812            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
813            .map(|p| p.as_ref())
814            .collect();
815
816        tracing::info!(
817            plugins = active
818                .iter()
819                .map(|p| p.name())
820                .collect::<Vec<_>>()
821                .join(", "),
822            "active plugins"
823        );
824
825        // Early exit if no plugins are active (common for leaf workspace packages)
826        if active.is_empty() {
827            return result;
828        }
829
830        // Phase 2: Collect static patterns from active plugins
831        for plugin in &active {
832            result.active_plugins.push(plugin.name().to_string());
833
834            let pname = plugin.name().to_string();
835            for pat in plugin.entry_patterns() {
836                result
837                    .entry_patterns
838                    .push(((*pat).to_string(), pname.clone()));
839            }
840            for pat in plugin.config_patterns() {
841                result.config_patterns.push((*pat).to_string());
842            }
843            for pat in plugin.always_used() {
844                result.always_used.push(((*pat).to_string(), pname.clone()));
845            }
846            for (file_pat, exports) in plugin.used_exports() {
847                result.used_exports.push((
848                    file_pat.to_string(),
849                    exports.iter().map(|s| s.to_string()).collect(),
850                ));
851            }
852            for dep in plugin.tooling_dependencies() {
853                result.tooling_dependencies.push((*dep).to_string());
854            }
855            for prefix in plugin.virtual_module_prefixes() {
856                result.virtual_module_prefixes.push((*prefix).to_string());
857            }
858            for (prefix, replacement) in plugin.path_aliases(root) {
859                result.path_aliases.push((prefix.to_string(), replacement));
860            }
861        }
862
863        // Phase 3: Find and parse config files using pre-compiled matchers
864        // Only check matchers for plugins that are active in this workspace
865        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
866        let workspace_matchers: Vec<_> = precompiled_config_matchers
867            .iter()
868            .filter(|(p, _)| active_names.contains(p.name()))
869            .collect();
870
871        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
872        if !workspace_matchers.is_empty() {
873            for (plugin, matchers) in &workspace_matchers {
874                for (abs_path, rel_path) in relative_files {
875                    if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
876                        && let Ok(source) = std::fs::read_to_string(abs_path)
877                    {
878                        // Mark resolved regardless of result to prevent Phase 3b
879                        // from re-parsing a JSON config for the same plugin.
880                        resolved_ws_plugins.insert(plugin.name());
881                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
882                        if !plugin_result.is_empty() {
883                            let pname = plugin.name().to_string();
884                            tracing::debug!(
885                                plugin = pname.as_str(),
886                                config = rel_path.as_str(),
887                                entries = plugin_result.entry_patterns.len(),
888                                deps = plugin_result.referenced_dependencies.len(),
889                                "resolved config"
890                            );
891                            result.entry_patterns.extend(
892                                plugin_result
893                                    .entry_patterns
894                                    .into_iter()
895                                    .map(|p| (p, pname.clone())),
896                            );
897                            result
898                                .referenced_dependencies
899                                .extend(plugin_result.referenced_dependencies);
900                            result.discovered_always_used.extend(
901                                plugin_result
902                                    .always_used_files
903                                    .into_iter()
904                                    .map(|p| (p, pname.clone())),
905                            );
906                            result.setup_files.extend(
907                                plugin_result
908                                    .setup_files
909                                    .into_iter()
910                                    .map(|p| (p, pname.clone())),
911                            );
912                        }
913                    }
914                }
915            }
916        }
917
918        // Phase 3b: Filesystem fallback for JSON config files at the project root.
919        // Config files like angular.json live at the monorepo root, but Angular is
920        // only active in workspace packages. Check the project root for unresolved
921        // config patterns.
922        let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
923        let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
924        for plugin in &active {
925            if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
926                continue;
927            }
928            for pat in plugin.config_patterns() {
929                let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
930                if !has_glob {
931                    // Check both workspace root and project root (deduplicate when equal)
932                    let check_roots: Vec<&Path> = if root == project_root {
933                        vec![root]
934                    } else {
935                        vec![root, project_root]
936                    };
937                    for check_root in check_roots {
938                        let abs_path = check_root.join(pat);
939                        if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
940                            ws_json_configs.push((abs_path, *plugin));
941                            break; // Found it — don't check other roots for this pattern
942                        }
943                    }
944                } else {
945                    // Glob pattern (e.g., "**/project.json") — check directories
946                    // that contain discovered source files
947                    let filename = std::path::Path::new(pat)
948                        .file_name()
949                        .and_then(|n| n.to_str())
950                        .unwrap_or(pat);
951                    let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
952                    if let Some(matcher) = matcher {
953                        let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
954                        checked_dirs.insert(root);
955                        if root != project_root {
956                            checked_dirs.insert(project_root);
957                        }
958                        for (abs_path, _) in relative_files {
959                            if let Some(parent) = abs_path.parent() {
960                                checked_dirs.insert(parent);
961                            }
962                        }
963                        for dir in checked_dirs {
964                            let candidate = dir.join(filename);
965                            if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
966                                let rel = candidate
967                                    .strip_prefix(project_root)
968                                    .map(|p| p.to_string_lossy())
969                                    .unwrap_or_default();
970                                if matcher.is_match(rel.as_ref()) {
971                                    ws_json_configs.push((candidate, *plugin));
972                                }
973                            }
974                        }
975                    }
976                }
977            }
978        }
979        // Parse discovered JSON config files
980        for (abs_path, plugin) in &ws_json_configs {
981            if let Ok(source) = std::fs::read_to_string(abs_path) {
982                let plugin_result = plugin.resolve_config(abs_path, &source, root);
983                if !plugin_result.is_empty() {
984                    let rel = abs_path
985                        .strip_prefix(project_root)
986                        .map(|p| p.to_string_lossy())
987                        .unwrap_or_default();
988                    tracing::debug!(
989                        plugin = plugin.name(),
990                        config = %rel,
991                        entries = plugin_result.entry_patterns.len(),
992                        deps = plugin_result.referenced_dependencies.len(),
993                        "resolved config (workspace filesystem fallback)"
994                    );
995                    let pname = plugin.name().to_string();
996                    result.entry_patterns.extend(
997                        plugin_result
998                            .entry_patterns
999                            .into_iter()
1000                            .map(|p| (p, pname.clone())),
1001                    );
1002                    result
1003                        .referenced_dependencies
1004                        .extend(plugin_result.referenced_dependencies);
1005                    result.discovered_always_used.extend(
1006                        plugin_result
1007                            .always_used_files
1008                            .into_iter()
1009                            .map(|p| (p, pname.clone())),
1010                    );
1011                    result.setup_files.extend(
1012                        plugin_result
1013                            .setup_files
1014                            .into_iter()
1015                            .map(|p| (p, pname.clone())),
1016                    );
1017                }
1018            }
1019        }
1020
1021        result
1022    }
1023
1024    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
1025    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
1026    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
1027        self.plugins
1028            .iter()
1029            .filter(|p| !p.config_patterns().is_empty())
1030            .map(|p| {
1031                let matchers: Vec<globset::GlobMatcher> = p
1032                    .config_patterns()
1033                    .iter()
1034                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
1035                    .collect();
1036                (p.as_ref(), matchers)
1037            })
1038            .collect()
1039    }
1040}
1041
1042/// Check if a `PluginDetection` condition is satisfied.
1043fn check_plugin_detection(
1044    detection: &PluginDetection,
1045    all_deps: &[&str],
1046    root: &Path,
1047    discovered_files: &[PathBuf],
1048) -> bool {
1049    match detection {
1050        PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
1051        PluginDetection::FileExists { pattern } => {
1052            // Check against discovered files first (fast path)
1053            if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
1054                for file in discovered_files {
1055                    let relative = file.strip_prefix(root).unwrap_or(file);
1056                    if matcher.is_match(relative) {
1057                        return true;
1058                    }
1059                }
1060            }
1061            // Fall back to glob on disk for non-source files (e.g., config files)
1062            let full_pattern = root.join(pattern).to_string_lossy().to_string();
1063            glob::glob(&full_pattern)
1064                .ok()
1065                .is_some_and(|mut g| g.next().is_some())
1066        }
1067        PluginDetection::All { conditions } => conditions
1068            .iter()
1069            .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
1070        PluginDetection::Any { conditions } => conditions
1071            .iter()
1072            .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
1073    }
1074}
1075
1076impl Default for PluginRegistry {
1077    fn default() -> Self {
1078        Self::new(vec![])
1079    }
1080}
1081
1082#[cfg(test)]
1083#[expect(clippy::disallowed_types)]
1084mod tests {
1085    use super::*;
1086    use fallow_config::{ExternalPluginDef, ExternalUsedExport, PluginDetection};
1087    use std::collections::HashMap;
1088
1089    /// Helper: build a PackageJson with given dependency names.
1090    fn make_pkg(deps: &[&str]) -> PackageJson {
1091        let map: HashMap<String, String> =
1092            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
1093        PackageJson {
1094            dependencies: Some(map),
1095            ..Default::default()
1096        }
1097    }
1098
1099    /// Helper: build a PackageJson with dev dependencies.
1100    fn make_pkg_dev(deps: &[&str]) -> PackageJson {
1101        let map: HashMap<String, String> =
1102            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
1103        PackageJson {
1104            dev_dependencies: Some(map),
1105            ..Default::default()
1106        }
1107    }
1108
1109    // ── Plugin detection via enablers ────────────────────────────
1110
1111    #[test]
1112    fn nextjs_detected_when_next_in_deps() {
1113        let registry = PluginRegistry::default();
1114        let pkg = make_pkg(&["next", "react"]);
1115        let result = registry.run(&pkg, Path::new("/project"), &[]);
1116        assert!(
1117            result.active_plugins.contains(&"nextjs".to_string()),
1118            "nextjs plugin should be active when 'next' is in deps"
1119        );
1120    }
1121
1122    #[test]
1123    fn nextjs_not_detected_without_next() {
1124        let registry = PluginRegistry::default();
1125        let pkg = make_pkg(&["react", "react-dom"]);
1126        let result = registry.run(&pkg, Path::new("/project"), &[]);
1127        assert!(
1128            !result.active_plugins.contains(&"nextjs".to_string()),
1129            "nextjs plugin should not be active without 'next' in deps"
1130        );
1131    }
1132
1133    #[test]
1134    fn prefix_enabler_matches_scoped_packages() {
1135        // Storybook uses "@storybook/" prefix matcher
1136        let registry = PluginRegistry::default();
1137        let pkg = make_pkg(&["@storybook/react"]);
1138        let result = registry.run(&pkg, Path::new("/project"), &[]);
1139        assert!(
1140            result.active_plugins.contains(&"storybook".to_string()),
1141            "storybook should activate via prefix match on @storybook/react"
1142        );
1143    }
1144
1145    #[test]
1146    fn prefix_enabler_does_not_match_without_slash() {
1147        // "storybook" (exact) should match, but "@storybook" (without /) should not match via prefix
1148        let registry = PluginRegistry::default();
1149        // This only has a package called "@storybookish" — it should NOT match
1150        let mut map = HashMap::new();
1151        map.insert("@storybookish".to_string(), "*".to_string());
1152        let pkg = PackageJson {
1153            dependencies: Some(map),
1154            ..Default::default()
1155        };
1156        let result = registry.run(&pkg, Path::new("/project"), &[]);
1157        assert!(
1158            !result.active_plugins.contains(&"storybook".to_string()),
1159            "storybook should not activate for '@storybookish' (no slash prefix match)"
1160        );
1161    }
1162
1163    #[test]
1164    fn multiple_plugins_detected_simultaneously() {
1165        let registry = PluginRegistry::default();
1166        let pkg = make_pkg(&["next", "vitest", "typescript"]);
1167        let result = registry.run(&pkg, Path::new("/project"), &[]);
1168        assert!(result.active_plugins.contains(&"nextjs".to_string()));
1169        assert!(result.active_plugins.contains(&"vitest".to_string()));
1170        assert!(result.active_plugins.contains(&"typescript".to_string()));
1171    }
1172
1173    #[test]
1174    fn no_plugins_for_empty_deps() {
1175        let registry = PluginRegistry::default();
1176        let pkg = PackageJson::default();
1177        let result = registry.run(&pkg, Path::new("/project"), &[]);
1178        assert!(
1179            result.active_plugins.is_empty(),
1180            "no plugins should activate with empty package.json"
1181        );
1182    }
1183
1184    // ── Aggregation: entry patterns, tooling deps ────────────────
1185
1186    #[test]
1187    fn active_plugin_contributes_entry_patterns() {
1188        let registry = PluginRegistry::default();
1189        let pkg = make_pkg(&["next"]);
1190        let result = registry.run(&pkg, Path::new("/project"), &[]);
1191        // Next.js should contribute App Router entry patterns
1192        assert!(
1193            result
1194                .entry_patterns
1195                .iter()
1196                .any(|(p, _)| p.contains("app/**/page")),
1197            "nextjs plugin should add app/**/page entry pattern"
1198        );
1199    }
1200
1201    #[test]
1202    fn inactive_plugin_does_not_contribute_entry_patterns() {
1203        let registry = PluginRegistry::default();
1204        let pkg = make_pkg(&["react"]);
1205        let result = registry.run(&pkg, Path::new("/project"), &[]);
1206        // Next.js patterns should not be present
1207        assert!(
1208            !result
1209                .entry_patterns
1210                .iter()
1211                .any(|(p, _)| p.contains("app/**/page")),
1212            "nextjs patterns should not appear when plugin is inactive"
1213        );
1214    }
1215
1216    #[test]
1217    fn active_plugin_contributes_tooling_deps() {
1218        let registry = PluginRegistry::default();
1219        let pkg = make_pkg(&["next"]);
1220        let result = registry.run(&pkg, Path::new("/project"), &[]);
1221        assert!(
1222            result.tooling_dependencies.contains(&"next".to_string()),
1223            "nextjs plugin should list 'next' as a tooling dependency"
1224        );
1225    }
1226
1227    #[test]
1228    fn dev_deps_also_trigger_plugins() {
1229        let registry = PluginRegistry::default();
1230        let pkg = make_pkg_dev(&["vitest"]);
1231        let result = registry.run(&pkg, Path::new("/project"), &[]);
1232        assert!(
1233            result.active_plugins.contains(&"vitest".to_string()),
1234            "vitest should activate from devDependencies"
1235        );
1236    }
1237
1238    // ── External plugins ─────────────────────────────────────────
1239
1240    #[test]
1241    fn external_plugin_detected_by_enablers() {
1242        let ext = ExternalPluginDef {
1243            schema: None,
1244            name: "my-framework".to_string(),
1245            detection: None,
1246            enablers: vec!["my-framework".to_string()],
1247            entry_points: vec!["src/routes/**/*.ts".to_string()],
1248            config_patterns: vec![],
1249            always_used: vec!["my.config.ts".to_string()],
1250            tooling_dependencies: vec!["my-framework-cli".to_string()],
1251            used_exports: vec![],
1252        };
1253        let registry = PluginRegistry::new(vec![ext]);
1254        let pkg = make_pkg(&["my-framework"]);
1255        let result = registry.run(&pkg, Path::new("/project"), &[]);
1256        assert!(result.active_plugins.contains(&"my-framework".to_string()));
1257        assert!(
1258            result
1259                .entry_patterns
1260                .iter()
1261                .any(|(p, _)| p == "src/routes/**/*.ts")
1262        );
1263        assert!(
1264            result
1265                .tooling_dependencies
1266                .contains(&"my-framework-cli".to_string())
1267        );
1268    }
1269
1270    #[test]
1271    fn external_plugin_not_detected_when_dep_missing() {
1272        let ext = ExternalPluginDef {
1273            schema: None,
1274            name: "my-framework".to_string(),
1275            detection: None,
1276            enablers: vec!["my-framework".to_string()],
1277            entry_points: vec!["src/routes/**/*.ts".to_string()],
1278            config_patterns: vec![],
1279            always_used: vec![],
1280            tooling_dependencies: vec![],
1281            used_exports: vec![],
1282        };
1283        let registry = PluginRegistry::new(vec![ext]);
1284        let pkg = make_pkg(&["react"]);
1285        let result = registry.run(&pkg, Path::new("/project"), &[]);
1286        assert!(!result.active_plugins.contains(&"my-framework".to_string()));
1287        assert!(
1288            !result
1289                .entry_patterns
1290                .iter()
1291                .any(|(p, _)| p == "src/routes/**/*.ts")
1292        );
1293    }
1294
1295    #[test]
1296    fn external_plugin_prefix_enabler() {
1297        let ext = ExternalPluginDef {
1298            schema: None,
1299            name: "custom-plugin".to_string(),
1300            detection: None,
1301            enablers: vec!["@custom/".to_string()],
1302            entry_points: vec!["custom/**/*.ts".to_string()],
1303            config_patterns: vec![],
1304            always_used: vec![],
1305            tooling_dependencies: vec![],
1306            used_exports: vec![],
1307        };
1308        let registry = PluginRegistry::new(vec![ext]);
1309        let pkg = make_pkg(&["@custom/core"]);
1310        let result = registry.run(&pkg, Path::new("/project"), &[]);
1311        assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
1312    }
1313
1314    #[test]
1315    fn external_plugin_detection_dependency() {
1316        let ext = ExternalPluginDef {
1317            schema: None,
1318            name: "detected-plugin".to_string(),
1319            detection: Some(PluginDetection::Dependency {
1320                package: "special-dep".to_string(),
1321            }),
1322            enablers: vec![],
1323            entry_points: vec!["special/**/*.ts".to_string()],
1324            config_patterns: vec![],
1325            always_used: vec![],
1326            tooling_dependencies: vec![],
1327            used_exports: vec![],
1328        };
1329        let registry = PluginRegistry::new(vec![ext]);
1330        let pkg = make_pkg(&["special-dep"]);
1331        let result = registry.run(&pkg, Path::new("/project"), &[]);
1332        assert!(
1333            result
1334                .active_plugins
1335                .contains(&"detected-plugin".to_string())
1336        );
1337    }
1338
1339    #[test]
1340    fn external_plugin_detection_any_combinator() {
1341        let ext = ExternalPluginDef {
1342            schema: None,
1343            name: "any-plugin".to_string(),
1344            detection: Some(PluginDetection::Any {
1345                conditions: vec![
1346                    PluginDetection::Dependency {
1347                        package: "pkg-a".to_string(),
1348                    },
1349                    PluginDetection::Dependency {
1350                        package: "pkg-b".to_string(),
1351                    },
1352                ],
1353            }),
1354            enablers: vec![],
1355            entry_points: vec!["any/**/*.ts".to_string()],
1356            config_patterns: vec![],
1357            always_used: vec![],
1358            tooling_dependencies: vec![],
1359            used_exports: vec![],
1360        };
1361        let registry = PluginRegistry::new(vec![ext]);
1362        // Only pkg-b present — should still match via Any
1363        let pkg = make_pkg(&["pkg-b"]);
1364        let result = registry.run(&pkg, Path::new("/project"), &[]);
1365        assert!(result.active_plugins.contains(&"any-plugin".to_string()));
1366    }
1367
1368    #[test]
1369    fn external_plugin_detection_all_combinator_fails_partial() {
1370        let ext = ExternalPluginDef {
1371            schema: None,
1372            name: "all-plugin".to_string(),
1373            detection: Some(PluginDetection::All {
1374                conditions: vec![
1375                    PluginDetection::Dependency {
1376                        package: "pkg-a".to_string(),
1377                    },
1378                    PluginDetection::Dependency {
1379                        package: "pkg-b".to_string(),
1380                    },
1381                ],
1382            }),
1383            enablers: vec![],
1384            entry_points: vec![],
1385            config_patterns: vec![],
1386            always_used: vec![],
1387            tooling_dependencies: vec![],
1388            used_exports: vec![],
1389        };
1390        let registry = PluginRegistry::new(vec![ext]);
1391        // Only pkg-a present — All requires both
1392        let pkg = make_pkg(&["pkg-a"]);
1393        let result = registry.run(&pkg, Path::new("/project"), &[]);
1394        assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
1395    }
1396
1397    #[test]
1398    fn external_plugin_used_exports_aggregated() {
1399        let ext = ExternalPluginDef {
1400            schema: None,
1401            name: "ue-plugin".to_string(),
1402            detection: None,
1403            enablers: vec!["ue-dep".to_string()],
1404            entry_points: vec![],
1405            config_patterns: vec![],
1406            always_used: vec![],
1407            tooling_dependencies: vec![],
1408            used_exports: vec![ExternalUsedExport {
1409                pattern: "pages/**/*.tsx".to_string(),
1410                exports: vec!["default".to_string(), "getServerSideProps".to_string()],
1411            }],
1412        };
1413        let registry = PluginRegistry::new(vec![ext]);
1414        let pkg = make_pkg(&["ue-dep"]);
1415        let result = registry.run(&pkg, Path::new("/project"), &[]);
1416        assert!(result.used_exports.iter().any(|(pat, exports)| {
1417            pat == "pages/**/*.tsx" && exports.contains(&"default".to_string())
1418        }));
1419    }
1420
1421    #[test]
1422    fn external_plugin_without_enablers_or_detection_stays_inactive() {
1423        let ext = ExternalPluginDef {
1424            schema: None,
1425            name: "orphan-plugin".to_string(),
1426            detection: None,
1427            enablers: vec![],
1428            entry_points: vec!["orphan/**/*.ts".to_string()],
1429            config_patterns: vec![],
1430            always_used: vec![],
1431            tooling_dependencies: vec![],
1432            used_exports: vec![],
1433        };
1434        let registry = PluginRegistry::new(vec![ext]);
1435        let pkg = make_pkg(&["anything"]);
1436        let result = registry.run(&pkg, Path::new("/project"), &[]);
1437        assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
1438    }
1439
1440    // ── is_enabled_with_deps edge cases ──────────────────────────
1441
1442    #[test]
1443    fn is_enabled_with_deps_exact_match() {
1444        let plugin = nextjs::NextJsPlugin;
1445        let deps = vec!["next".to_string()];
1446        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1447    }
1448
1449    #[test]
1450    fn is_enabled_with_deps_no_match() {
1451        let plugin = nextjs::NextJsPlugin;
1452        let deps = vec!["react".to_string()];
1453        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1454    }
1455
1456    #[test]
1457    fn is_enabled_with_empty_deps() {
1458        let plugin = nextjs::NextJsPlugin;
1459        let deps: Vec<String> = vec![];
1460        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1461    }
1462
1463    // ── Virtual module prefixes ──────────────────────────────────
1464
1465    #[test]
1466    fn nuxt_contributes_virtual_module_prefixes() {
1467        let registry = PluginRegistry::default();
1468        let pkg = make_pkg(&["nuxt"]);
1469        let result = registry.run(&pkg, Path::new("/project"), &[]);
1470        assert!(
1471            result.virtual_module_prefixes.contains(&"#".to_string()),
1472            "nuxt should contribute '#' virtual module prefix"
1473        );
1474    }
1475
1476    // ── PluginResult::is_empty ───────────────────────────────────
1477
1478    #[test]
1479    fn plugin_result_is_empty_when_default() {
1480        let r = PluginResult::default();
1481        assert!(r.is_empty());
1482    }
1483
1484    #[test]
1485    fn plugin_result_not_empty_with_entry_patterns() {
1486        let r = PluginResult {
1487            entry_patterns: vec!["*.ts".to_string()],
1488            ..Default::default()
1489        };
1490        assert!(!r.is_empty());
1491    }
1492
1493    #[test]
1494    fn plugin_result_not_empty_with_referenced_deps() {
1495        let r = PluginResult {
1496            referenced_dependencies: vec!["lodash".to_string()],
1497            ..Default::default()
1498        };
1499        assert!(!r.is_empty());
1500    }
1501
1502    #[test]
1503    fn plugin_result_not_empty_with_setup_files() {
1504        let r = PluginResult {
1505            setup_files: vec![PathBuf::from("/setup.ts")],
1506            ..Default::default()
1507        };
1508        assert!(!r.is_empty());
1509    }
1510
1511    // ── Precompile config matchers ───────────────────────────────
1512
1513    #[test]
1514    fn precompile_config_matchers_returns_entries() {
1515        let registry = PluginRegistry::default();
1516        let matchers = registry.precompile_config_matchers();
1517        // At minimum, nextjs, vite, jest, typescript, etc. all have config patterns
1518        assert!(
1519            !matchers.is_empty(),
1520            "precompile_config_matchers should return entries for plugins with config patterns"
1521        );
1522    }
1523
1524    #[test]
1525    fn precompile_config_matchers_only_for_plugins_with_patterns() {
1526        let registry = PluginRegistry::default();
1527        let matchers = registry.precompile_config_matchers();
1528        for (plugin, _) in &matchers {
1529            assert!(
1530                !plugin.config_patterns().is_empty(),
1531                "plugin '{}' in matchers should have config patterns",
1532                plugin.name()
1533            );
1534        }
1535    }
1536}