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 std::path::{Path, PathBuf};
13
14use fallow_config::{EntryPointRole, PackageJson, UsedClassMemberRule};
15use regex::Regex;
16
17const TEST_ENTRY_POINT_PLUGINS: &[&str] = &[
18    "ava",
19    "cucumber",
20    "cypress",
21    "jest",
22    "mocha",
23    "playwright",
24    "vitest",
25    "webdriverio",
26];
27
28const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
29    "angular",
30    "astro",
31    "convex",
32    "docusaurus",
33    "electron",
34    "expo",
35    "expo-router",
36    "gatsby",
37    "hardhat",
38    "nestjs",
39    "next-intl",
40    "nextjs",
41    "nitro",
42    "nuxt",
43    "parcel",
44    "qwik",
45    "react-native",
46    "react-router",
47    "remix",
48    "rolldown",
49    "rollup",
50    "rsbuild",
51    "rspack",
52    "sanity",
53    "sveltekit",
54    "tanstack-router",
55    "tsdown",
56    "tsup",
57    "vite",
58    "vitepress",
59    "webpack",
60    "wrangler",
61];
62
63#[cfg(test)]
64const SUPPORT_ENTRY_POINT_PLUGINS: &[&str] = &[
65    "drizzle",
66    "i18next",
67    "knex",
68    "kysely",
69    "msw",
70    "prisma",
71    "storybook",
72    "typeorm",
73];
74
75/// Result of resolving a plugin's config file.
76#[derive(Debug, Default)]
77pub struct PluginResult {
78    /// Additional entry point glob patterns discovered from config.
79    pub entry_patterns: Vec<PathRule>,
80    /// When true, `entry_patterns` from config replace the plugin's static
81    /// `entry_patterns()` defaults instead of adding to them. Tools like Vitest
82    /// and Jest treat their config's include/testMatch as a replacement for built-in
83    /// defaults, so when the config is explicit the static patterns must be dropped.
84    pub replace_entry_patterns: bool,
85    /// When true, `used_exports` from config replace the plugin's static
86    /// `used_export_rules()` defaults instead of adding to them.
87    pub replace_used_export_rules: bool,
88    /// Additional export-usage rules discovered from config.
89    pub used_exports: Vec<UsedExportRule>,
90    /// Class member rules that should never be flagged as unused. Contributed
91    /// by plugins that know their framework invokes these methods at runtime
92    /// and may scope suppression via `extends` / `implements` constraints when
93    /// the method name is too common to allowlist globally.
94    pub used_class_members: Vec<UsedClassMemberRule>,
95    /// Dependencies referenced in config files (should not be flagged as unused).
96    pub referenced_dependencies: Vec<String>,
97    /// Additional files that are always considered used.
98    pub always_used_files: Vec<String>,
99    /// Path alias mappings discovered from config (prefix -> replacement directory).
100    pub path_aliases: Vec<(String, String)>,
101    /// Setup/helper files referenced from config.
102    pub setup_files: Vec<PathBuf>,
103    /// Test fixture glob patterns discovered from config.
104    pub fixture_patterns: Vec<String>,
105    /// Absolute directories to include when resolving SCSS/Sass `@import` and
106    /// `@use` specifiers. Contributed by framework plugins that read their
107    /// tool's equivalent of `includePaths` (e.g. Angular's
108    /// `stylePreprocessorOptions.includePaths` from `angular.json` /
109    /// `project.json`). Bare SCSS specifiers that fail to resolve relative to
110    /// the importing file retry against each include path using the SCSS
111    /// partial / directory-index conventions.
112    pub scss_include_paths: Vec<PathBuf>,
113}
114
115impl PluginResult {
116    pub fn push_entry_pattern(&mut self, pattern: impl Into<String>) {
117        self.entry_patterns.push(PathRule::new(pattern));
118    }
119
120    pub fn extend_entry_patterns<I, S>(&mut self, patterns: I)
121    where
122        I: IntoIterator<Item = S>,
123        S: Into<String>,
124    {
125        self.entry_patterns
126            .extend(patterns.into_iter().map(PathRule::new));
127    }
128
129    pub fn push_used_export_rule(
130        &mut self,
131        pattern: impl Into<String>,
132        exports: impl IntoIterator<Item = impl Into<String>>,
133    ) {
134        self.used_exports
135            .push(UsedExportRule::new(pattern, exports));
136    }
137
138    #[must_use]
139    pub const fn is_empty(&self) -> bool {
140        self.entry_patterns.is_empty()
141            && self.used_exports.is_empty()
142            && self.used_class_members.is_empty()
143            && self.referenced_dependencies.is_empty()
144            && self.always_used_files.is_empty()
145            && self.path_aliases.is_empty()
146            && self.setup_files.is_empty()
147            && self.fixture_patterns.is_empty()
148            && self.scss_include_paths.is_empty()
149    }
150}
151
152/// A file-pattern rule with optional exclusion globs plus path-level or
153/// segment-level regex filters.
154///
155/// Exclusion regexes are matched against the project-relative path and should be
156/// anchored when generated dynamically so they can be safely workspace-prefixed.
157#[derive(Debug, Clone, Default, PartialEq, Eq)]
158pub struct PathRule {
159    pub pattern: String,
160    pub exclude_globs: Vec<String>,
161    pub exclude_regexes: Vec<String>,
162    /// Regexes matched against individual path segments. These are not prefixed
163    /// for workspaces because they intentionally operate on segment names rather
164    /// than the full project-relative path.
165    pub exclude_segment_regexes: Vec<String>,
166}
167
168impl PathRule {
169    #[must_use]
170    pub fn new(pattern: impl Into<String>) -> Self {
171        Self {
172            pattern: pattern.into(),
173            exclude_globs: Vec::new(),
174            exclude_regexes: Vec::new(),
175            exclude_segment_regexes: Vec::new(),
176        }
177    }
178
179    #[must_use]
180    pub fn from_static(pattern: &'static str) -> Self {
181        Self::new(pattern)
182    }
183
184    #[must_use]
185    pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
186    where
187        I: IntoIterator<Item = S>,
188        S: Into<String>,
189    {
190        self.exclude_globs
191            .extend(patterns.into_iter().map(Into::into));
192        self
193    }
194
195    #[must_use]
196    pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
197    where
198        I: IntoIterator<Item = S>,
199        S: Into<String>,
200    {
201        self.exclude_regexes
202            .extend(patterns.into_iter().map(Into::into));
203        self
204    }
205
206    #[must_use]
207    pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
208    where
209        I: IntoIterator<Item = S>,
210        S: Into<String>,
211    {
212        self.exclude_segment_regexes
213            .extend(patterns.into_iter().map(Into::into));
214        self
215    }
216
217    #[must_use]
218    pub fn prefixed(&self, ws_prefix: &str) -> Self {
219        Self {
220            pattern: prefix_workspace_pattern(&self.pattern, ws_prefix),
221            exclude_globs: self
222                .exclude_globs
223                .iter()
224                .map(|pattern| prefix_workspace_pattern(pattern, ws_prefix))
225                .collect(),
226            exclude_regexes: self
227                .exclude_regexes
228                .iter()
229                .map(|pattern| prefix_workspace_regex(pattern, ws_prefix))
230                .collect(),
231            exclude_segment_regexes: self.exclude_segment_regexes.clone(),
232        }
233    }
234}
235
236/// A used-export rule bound to a file-pattern rule.
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub struct UsedExportRule {
239    pub path: PathRule,
240    pub exports: Vec<String>,
241}
242
243impl UsedExportRule {
244    #[must_use]
245    pub fn new(
246        pattern: impl Into<String>,
247        exports: impl IntoIterator<Item = impl Into<String>>,
248    ) -> Self {
249        Self {
250            path: PathRule::new(pattern),
251            exports: exports.into_iter().map(Into::into).collect(),
252        }
253    }
254
255    #[must_use]
256    pub fn from_static(pattern: &'static str, exports: &'static [&'static str]) -> Self {
257        Self::new(pattern, exports.iter().copied())
258    }
259
260    #[must_use]
261    pub fn with_excluded_globs<I, S>(mut self, patterns: I) -> Self
262    where
263        I: IntoIterator<Item = S>,
264        S: Into<String>,
265    {
266        self.path = self.path.with_excluded_globs(patterns);
267        self
268    }
269
270    #[must_use]
271    pub fn with_excluded_regexes<I, S>(mut self, patterns: I) -> Self
272    where
273        I: IntoIterator<Item = S>,
274        S: Into<String>,
275    {
276        self.path = self.path.with_excluded_regexes(patterns);
277        self
278    }
279
280    #[must_use]
281    pub fn with_excluded_segment_regexes<I, S>(mut self, patterns: I) -> Self
282    where
283        I: IntoIterator<Item = S>,
284        S: Into<String>,
285    {
286        self.path = self.path.with_excluded_segment_regexes(patterns);
287        self
288    }
289
290    #[must_use]
291    pub fn prefixed(&self, ws_prefix: &str) -> Self {
292        Self {
293            path: self.path.prefixed(ws_prefix),
294            exports: self.exports.clone(),
295        }
296    }
297}
298
299/// A used-export rule tagged with the plugin that contributed it.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct PluginUsedExportRule {
302    pub plugin_name: String,
303    pub rule: UsedExportRule,
304}
305
306impl PluginUsedExportRule {
307    #[must_use]
308    pub fn new(plugin_name: impl Into<String>, rule: UsedExportRule) -> Self {
309        Self {
310            plugin_name: plugin_name.into(),
311            rule,
312        }
313    }
314
315    #[must_use]
316    pub fn prefixed(&self, ws_prefix: &str) -> Self {
317        Self {
318            plugin_name: self.plugin_name.clone(),
319            rule: self.rule.prefixed(ws_prefix),
320        }
321    }
322}
323
324/// A compiled path rule matcher shared by entry-point and used-export matching.
325#[derive(Debug, Clone)]
326pub(crate) struct CompiledPathRule {
327    include: globset::GlobMatcher,
328    exclude_globs: Vec<globset::GlobMatcher>,
329    exclude_regexes: Vec<Regex>,
330    exclude_segment_regexes: Vec<Regex>,
331}
332
333impl CompiledPathRule {
334    pub(crate) fn for_entry_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
335        let include = match globset::GlobBuilder::new(&rule.pattern)
336            .literal_separator(true)
337            .build()
338        {
339            Ok(glob) => glob.compile_matcher(),
340            Err(err) => {
341                tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
342                return None;
343            }
344        };
345        Some(Self {
346            include,
347            exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
348            exclude_regexes: compile_excluded_regexes(
349                &rule.exclude_regexes,
350                rule_kind,
351                &rule.pattern,
352            ),
353            exclude_segment_regexes: compile_excluded_segment_regexes(
354                &rule.exclude_segment_regexes,
355                rule_kind,
356                &rule.pattern,
357            ),
358        })
359    }
360
361    pub(crate) fn for_used_export_rule(rule: &PathRule, rule_kind: &str) -> Option<Self> {
362        let include = match globset::Glob::new(&rule.pattern) {
363            Ok(glob) => glob.compile_matcher(),
364            Err(err) => {
365                tracing::warn!("invalid {rule_kind} '{}': {err}", rule.pattern);
366                return None;
367            }
368        };
369        Some(Self {
370            include,
371            exclude_globs: compile_excluded_globs(&rule.exclude_globs, rule_kind, &rule.pattern),
372            exclude_regexes: compile_excluded_regexes(
373                &rule.exclude_regexes,
374                rule_kind,
375                &rule.pattern,
376            ),
377            exclude_segment_regexes: compile_excluded_segment_regexes(
378                &rule.exclude_segment_regexes,
379                rule_kind,
380                &rule.pattern,
381            ),
382        })
383    }
384
385    #[must_use]
386    pub(crate) fn matches(&self, path: &str) -> bool {
387        self.include.is_match(path)
388            && !self.exclude_globs.iter().any(|glob| glob.is_match(path))
389            && !self
390                .exclude_regexes
391                .iter()
392                .any(|regex| regex.is_match(path))
393            && !matches_segment_regex(path, &self.exclude_segment_regexes)
394    }
395}
396
397fn prefix_workspace_pattern(pattern: &str, ws_prefix: &str) -> String {
398    if pattern.starts_with(ws_prefix) || pattern.starts_with('/') {
399        pattern.to_string()
400    } else {
401        format!("{ws_prefix}/{pattern}")
402    }
403}
404
405fn prefix_workspace_regex(pattern: &str, ws_prefix: &str) -> String {
406    if let Some(pattern) = pattern.strip_prefix('^') {
407        format!("^{}/{}", regex::escape(ws_prefix), pattern)
408    } else {
409        format!("^{}/(?:{})", regex::escape(ws_prefix), pattern)
410    }
411}
412
413fn compile_excluded_globs(
414    patterns: &[String],
415    rule_kind: &str,
416    rule_pattern: &str,
417) -> Vec<globset::GlobMatcher> {
418    patterns
419        .iter()
420        .filter_map(|pattern| {
421            match globset::GlobBuilder::new(pattern)
422                .literal_separator(true)
423                .build()
424            {
425                Ok(glob) => Some(glob.compile_matcher()),
426                Err(err) => {
427                    tracing::warn!(
428                        "skipping invalid excluded glob '{}' for {} '{}': {err}",
429                        pattern,
430                        rule_kind,
431                        rule_pattern
432                    );
433                    None
434                }
435            }
436        })
437        .collect()
438}
439
440fn compile_excluded_regexes(
441    patterns: &[String],
442    rule_kind: &str,
443    rule_pattern: &str,
444) -> Vec<Regex> {
445    patterns
446        .iter()
447        .filter_map(|pattern| match Regex::new(pattern) {
448            Ok(regex) => Some(regex),
449            Err(err) => {
450                tracing::warn!(
451                    "skipping invalid excluded regex '{}' for {} '{}': {err}",
452                    pattern,
453                    rule_kind,
454                    rule_pattern
455                );
456                None
457            }
458        })
459        .collect()
460}
461
462fn compile_excluded_segment_regexes(
463    patterns: &[String],
464    rule_kind: &str,
465    rule_pattern: &str,
466) -> Vec<Regex> {
467    patterns
468        .iter()
469        .filter_map(|pattern| match Regex::new(pattern) {
470            Ok(regex) => Some(regex),
471            Err(err) => {
472                tracing::warn!(
473                    "skipping invalid excluded segment regex '{}' for {} '{}': {err}",
474                    pattern,
475                    rule_kind,
476                    rule_pattern
477                );
478                None
479            }
480        })
481        .collect()
482}
483
484fn matches_segment_regex(path: &str, regexes: &[Regex]) -> bool {
485    path.split('/')
486        .any(|segment| regexes.iter().any(|regex| regex.is_match(segment)))
487}
488
489impl From<String> for PathRule {
490    fn from(pattern: String) -> Self {
491        Self::new(pattern)
492    }
493}
494
495impl From<&str> for PathRule {
496    fn from(pattern: &str) -> Self {
497        Self::new(pattern)
498    }
499}
500
501impl std::ops::Deref for PathRule {
502    type Target = str;
503
504    fn deref(&self) -> &Self::Target {
505        &self.pattern
506    }
507}
508
509impl PartialEq<&str> for PathRule {
510    fn eq(&self, other: &&str) -> bool {
511        self.pattern == *other
512    }
513}
514
515impl PartialEq<str> for PathRule {
516    fn eq(&self, other: &str) -> bool {
517        self.pattern == other
518    }
519}
520
521impl PartialEq<String> for PathRule {
522    fn eq(&self, other: &String) -> bool {
523        &self.pattern == other
524    }
525}
526
527/// A framework/tool plugin that contributes to dead code analysis.
528pub trait Plugin: Send + Sync {
529    /// Human-readable plugin name.
530    fn name(&self) -> &'static str;
531
532    /// Package names that activate this plugin when found in package.json.
533    /// Supports exact matches and prefix patterns (ending with `/`).
534    fn enablers(&self) -> &'static [&'static str] {
535        &[]
536    }
537
538    /// Check if this plugin should be active for the given project.
539    /// Default implementation checks `enablers()` against package.json dependencies.
540    fn is_enabled(&self, pkg: &PackageJson, root: &Path) -> bool {
541        let deps = pkg.all_dependency_names();
542        self.is_enabled_with_deps(&deps, root)
543    }
544
545    /// Fast variant of `is_enabled` that accepts a pre-computed deps list.
546    /// Avoids repeated `all_dependency_names()` allocation when checking many plugins.
547    fn is_enabled_with_deps(&self, deps: &[String], _root: &Path) -> bool {
548        let enablers = self.enablers();
549        if enablers.is_empty() {
550            return false;
551        }
552        enablers.iter().any(|enabler| {
553            if enabler.ends_with('/') {
554                // Prefix match (e.g., "@storybook/" matches "@storybook/react")
555                deps.iter().any(|d| d.starts_with(enabler))
556            } else {
557                deps.iter().any(|d| d == enabler)
558            }
559        })
560    }
561
562    /// Default glob patterns for entry point files.
563    fn entry_patterns(&self) -> &'static [&'static str] {
564        &[]
565    }
566
567    /// Entry point rules with optional exclusions.
568    fn entry_pattern_rules(&self) -> Vec<PathRule> {
569        self.entry_patterns()
570            .iter()
571            .map(|pattern| PathRule::from_static(pattern))
572            .collect()
573    }
574
575    /// How this plugin's entry patterns should contribute to coverage reachability.
576    ///
577    /// `Support` roots keep files alive for dead-code analysis but do not count
578    /// as runtime or test reachability for static coverage gaps.
579    fn entry_point_role(&self) -> EntryPointRole {
580        builtin_entry_point_role(self.name())
581    }
582
583    /// Glob patterns for config files this plugin can parse.
584    fn config_patterns(&self) -> &'static [&'static str] {
585        &[]
586    }
587
588    /// Files that are always considered "used" when this plugin is active.
589    fn always_used(&self) -> &'static [&'static str] {
590        &[]
591    }
592
593    /// Exports that are always considered used for matching file patterns.
594    fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
595        vec![]
596    }
597
598    /// Used-export rules with optional exclusions.
599    fn used_export_rules(&self) -> Vec<UsedExportRule> {
600        self.used_exports()
601            .into_iter()
602            .map(|(pattern, exports)| UsedExportRule::from_static(pattern, exports))
603            .collect()
604    }
605
606    /// Class member names the framework invokes at runtime. Matching members
607    /// are skipped during `unused-class-members` analysis. Intended for
608    /// interface/contract patterns where the library calls methods on consumer
609    /// classes (e.g. ag-Grid's `agInit`, Web Components' `connectedCallback`).
610    fn used_class_members(&self) -> &'static [&'static str] {
611        &[]
612    }
613
614    /// Glob patterns for test fixture files consumed by this framework.
615    /// These files are implicitly used by the test runner and should not be
616    /// flagged as unused. Unlike `always_used()`, this carries semantic intent
617    /// for reporting purposes.
618    fn fixture_glob_patterns(&self) -> &'static [&'static str] {
619        &[]
620    }
621
622    /// Dependencies that are tooling (used via CLI/config, not source imports).
623    /// These should not be flagged as unused devDependencies.
624    fn tooling_dependencies(&self) -> &'static [&'static str] {
625        &[]
626    }
627
628    /// Import prefixes that are virtual modules provided by this framework at build time.
629    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
630    /// Each entry is matched as a prefix against the extracted package name
631    /// (e.g., `"@theme/"` matches `@theme/Layout`).
632    fn virtual_module_prefixes(&self) -> &'static [&'static str] {
633        &[]
634    }
635
636    /// Package name suffixes that are virtual modules provided by this framework
637    /// at build time (e.g., test runner mock conventions).
638    /// Imports matching these suffixes should not be flagged as unlisted dependencies.
639    /// Each entry is matched as a suffix against the extracted package name
640    /// (e.g., `"/__mocks__"` matches `@aws-sdk/__mocks__` and `some-pkg/__mocks__`).
641    fn virtual_package_suffixes(&self) -> &'static [&'static str] {
642        &[]
643    }
644
645    /// Import suffixes for build-time generated relative imports.
646    ///
647    /// Unresolved relative imports whose specifier ends with one of these suffixes
648    /// will not be flagged as unresolved. For example, SvelteKit generates
649    /// `./$types` imports in route files — returning `"/$types"` suppresses those.
650    fn generated_import_patterns(&self) -> &'static [&'static str] {
651        &[]
652    }
653
654    /// Path alias mappings provided by this framework at build time.
655    ///
656    /// Returns a list of `(prefix, replacement_dir)` tuples. When an import starting
657    /// with `prefix` fails to resolve, the resolver will substitute the prefix with
658    /// `replacement_dir` (relative to the project root) and retry.
659    ///
660    /// Called once when plugins are activated. The project `root` is provided so
661    /// plugins can inspect the filesystem (e.g., Nuxt checks whether `app/` exists
662    /// to determine the `srcDir`).
663    fn path_aliases(&self, _root: &Path) -> Vec<(&'static str, String)> {
664        vec![]
665    }
666
667    /// Parse a config file's AST to discover additional entries, dependencies, etc.
668    ///
669    /// Called for each config file matching `config_patterns()`. The source code
670    /// and parsed AST are provided — use [`config_parser`] utilities to extract values.
671    fn resolve_config(&self, _config_path: &Path, _source: &str, _root: &Path) -> PluginResult {
672        PluginResult::default()
673    }
674
675    /// The key name in package.json that holds inline configuration for this tool.
676    /// When set (e.g., `"jest"` for the `"jest"` key in package.json), the plugin
677    /// system will extract that key's value and call `resolve_config` with its
678    /// JSON content if no standalone config file was found.
679    fn package_json_config_key(&self) -> Option<&'static str> {
680        None
681    }
682}
683
684fn builtin_entry_point_role(name: &str) -> EntryPointRole {
685    if TEST_ENTRY_POINT_PLUGINS.contains(&name) {
686        EntryPointRole::Test
687    } else if RUNTIME_ENTRY_POINT_PLUGINS.contains(&name) {
688        EntryPointRole::Runtime
689    } else {
690        EntryPointRole::Support
691    }
692}
693
694/// Macro to eliminate boilerplate in plugin implementations.
695///
696/// Generates a struct and a `Plugin` trait impl with the standard static methods
697/// (`name`, `enablers`, `entry_patterns`, `config_patterns`, `always_used`, `tooling_dependencies`,
698/// `fixture_glob_patterns`, `used_exports`).
699///
700/// For plugins that need custom `resolve_config()` or `is_enabled()`, keep those as
701/// manual `impl Plugin for ...` blocks instead of using this macro.
702///
703/// # Usage
704///
705/// ```ignore
706/// // Simple plugin (most common):
707/// define_plugin! {
708///     struct VitePlugin => "vite",
709///     enablers: ENABLERS,
710///     entry_patterns: ENTRY_PATTERNS,
711///     config_patterns: CONFIG_PATTERNS,
712///     always_used: ALWAYS_USED,
713///     tooling_dependencies: TOOLING_DEPENDENCIES,
714/// }
715///
716/// // Plugin with used_exports:
717/// define_plugin! {
718///     struct RemixPlugin => "remix",
719///     enablers: ENABLERS,
720///     entry_patterns: ENTRY_PATTERNS,
721///     always_used: ALWAYS_USED,
722///     tooling_dependencies: TOOLING_DEPENDENCIES,
723///     used_exports: [("app/routes/**/*.{ts,tsx}", ROUTE_EXPORTS)],
724/// }
725///
726/// // Plugin with imports-only resolve_config (extracts imports from config as deps):
727/// define_plugin! {
728///     struct CypressPlugin => "cypress",
729///     enablers: ENABLERS,
730///     entry_patterns: ENTRY_PATTERNS,
731///     config_patterns: CONFIG_PATTERNS,
732///     always_used: ALWAYS_USED,
733///     tooling_dependencies: TOOLING_DEPENDENCIES,
734///     resolve_config: imports_only,
735/// }
736///
737/// // Plugin with custom resolve_config body:
738/// define_plugin! {
739///     struct RollupPlugin => "rollup",
740///     enablers: ENABLERS,
741///     config_patterns: CONFIG_PATTERNS,
742///     always_used: ALWAYS_USED,
743///     tooling_dependencies: TOOLING_DEPENDENCIES,
744///     resolve_config(config_path, source, _root) {
745///         let mut result = PluginResult::default();
746///         // custom config parsing...
747///         result
748///     }
749/// }
750/// ```
751///
752/// All fields except `struct` and `enablers` are optional and default to `&[]` / `vec![]`.
753macro_rules! define_plugin {
754    // Variant with `resolve_config: imports_only`: generates a resolve_config method
755    // that extracts imports from config files and registers them as referenced dependencies.
756    (
757        struct $name:ident => $display:expr,
758        enablers: $enablers:expr
759        $(, entry_patterns: $entry:expr)?
760        $(, config_patterns: $config:expr)?
761        $(, always_used: $always:expr)?
762        $(, tooling_dependencies: $tooling:expr)?
763        $(, fixture_glob_patterns: $fixtures:expr)?
764        $(, virtual_module_prefixes: $virtual:expr)?
765        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
766        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
767        , resolve_config: imports_only
768        $(,)?
769    ) => {
770        pub struct $name;
771
772        impl Plugin for $name {
773            fn name(&self) -> &'static str {
774                $display
775            }
776
777            fn enablers(&self) -> &'static [&'static str] {
778                $enablers
779            }
780
781            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
782            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
783            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
784            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
785            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
786            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
787            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
788
789            $(
790                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
791                    vec![$( ($pat, $exports) ),*]
792                }
793            )?
794
795            fn resolve_config(
796                &self,
797                config_path: &std::path::Path,
798                source: &str,
799                _root: &std::path::Path,
800            ) -> PluginResult {
801                let mut result = PluginResult::default();
802                let imports = crate::plugins::config_parser::extract_imports(source, config_path);
803                for imp in &imports {
804                    let dep = crate::resolve::extract_package_name(imp);
805                    result.referenced_dependencies.push(dep);
806                }
807                result
808            }
809        }
810    };
811
812    // Variant with custom resolve_config body: generates a resolve_config method
813    // with the caller-supplied block. Parameter names are caller-controlled (use
814    // `_root` for unused params to satisfy clippy).
815    (
816        struct $name:ident => $display:expr,
817        enablers: $enablers:expr
818        $(, entry_patterns: $entry:expr)?
819        $(, config_patterns: $config:expr)?
820        $(, always_used: $always:expr)?
821        $(, tooling_dependencies: $tooling:expr)?
822        $(, fixture_glob_patterns: $fixtures:expr)?
823        $(, virtual_module_prefixes: $virtual:expr)?
824        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
825        $(, package_json_config_key: $pkg_key:expr)?
826        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
827        , resolve_config($cp:ident, $src:ident, $root:ident) $body:block
828        $(,)?
829    ) => {
830        pub struct $name;
831
832        impl Plugin for $name {
833            fn name(&self) -> &'static str {
834                $display
835            }
836
837            fn enablers(&self) -> &'static [&'static str] {
838                $enablers
839            }
840
841            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
842            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
843            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
844            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
845            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
846            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
847            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
848
849            $(
850                fn package_json_config_key(&self) -> Option<&'static str> {
851                    Some($pkg_key)
852                }
853            )?
854
855            $(
856                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
857                    vec![$( ($pat, $exports) ),*]
858                }
859            )?
860
861            fn resolve_config(
862                &self,
863                $cp: &std::path::Path,
864                $src: &str,
865                $root: &std::path::Path,
866            ) -> PluginResult
867            $body
868        }
869    };
870
871    // Base variant: no resolve_config.
872    (
873        struct $name:ident => $display:expr,
874        enablers: $enablers:expr
875        $(, entry_patterns: $entry:expr)?
876        $(, config_patterns: $config:expr)?
877        $(, always_used: $always:expr)?
878        $(, tooling_dependencies: $tooling:expr)?
879        $(, fixture_glob_patterns: $fixtures:expr)?
880        $(, virtual_module_prefixes: $virtual:expr)?
881        $(, virtual_package_suffixes: $virtual_suffixes:expr)?
882        $(, used_exports: [$( ($pat:expr, $exports:expr) ),* $(,)?])?
883        $(,)?
884    ) => {
885        pub struct $name;
886
887        impl Plugin for $name {
888            fn name(&self) -> &'static str {
889                $display
890            }
891
892            fn enablers(&self) -> &'static [&'static str] {
893                $enablers
894            }
895
896            $( fn entry_patterns(&self) -> &'static [&'static str] { $entry } )?
897            $( fn config_patterns(&self) -> &'static [&'static str] { $config } )?
898            $( fn always_used(&self) -> &'static [&'static str] { $always } )?
899            $( fn tooling_dependencies(&self) -> &'static [&'static str] { $tooling } )?
900            $( fn fixture_glob_patterns(&self) -> &'static [&'static str] { $fixtures } )?
901            $( fn virtual_module_prefixes(&self) -> &'static [&'static str] { $virtual } )?
902            $( fn virtual_package_suffixes(&self) -> &'static [&'static str] { $virtual_suffixes } )?
903
904            $(
905                fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
906                    vec![$( ($pat, $exports) ),*]
907                }
908            )?
909        }
910    };
911}
912
913pub mod config_parser;
914pub mod registry;
915mod tooling;
916
917pub use registry::{AggregatedPluginResult, PluginRegistry};
918pub use tooling::is_known_tooling_dependency;
919
920mod angular;
921mod astro;
922mod ava;
923mod babel;
924mod biome;
925mod bun;
926mod c8;
927mod capacitor;
928mod changesets;
929mod commitizen;
930mod commitlint;
931mod convex;
932mod cspell;
933mod cucumber;
934mod cypress;
935mod dependency_cruiser;
936mod docusaurus;
937mod drizzle;
938mod electron;
939mod eslint;
940mod expo;
941mod expo_router;
942mod gatsby;
943mod graphql_codegen;
944mod hardhat;
945mod husky;
946mod i18next;
947mod jest;
948mod karma;
949mod knex;
950mod kysely;
951mod lefthook;
952mod lint_staged;
953mod markdownlint;
954mod mocha;
955mod msw;
956mod nestjs;
957mod next_intl;
958mod nextjs;
959mod nitro;
960mod nodemon;
961mod nuxt;
962mod nx;
963mod nyc;
964mod openapi_ts;
965mod oxlint;
966mod pandacss;
967mod parcel;
968mod playwright;
969mod plop;
970mod pm2;
971mod pnpm;
972mod postcss;
973mod prettier;
974mod prisma;
975mod qwik;
976mod react_native;
977mod react_router;
978mod relay;
979mod remark;
980mod remix;
981mod rolldown;
982mod rollup;
983mod rsbuild;
984mod rspack;
985mod sanity;
986mod semantic_release;
987mod sentry;
988mod simple_git_hooks;
989mod storybook;
990mod stylelint;
991mod sveltekit;
992mod svgo;
993mod svgr;
994mod swc;
995mod syncpack;
996mod tailwind;
997mod tanstack_router;
998mod tsdown;
999mod tsup;
1000mod turborepo;
1001mod typedoc;
1002mod typeorm;
1003mod typescript;
1004mod unocss;
1005mod vite;
1006mod vitepress;
1007mod vitest;
1008mod webdriverio;
1009mod webpack;
1010mod wrangler;
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use std::path::Path;
1016
1017    // ── is_enabled_with_deps edge cases ──────────────────────────
1018
1019    #[test]
1020    fn is_enabled_with_deps_exact_match() {
1021        let plugin = nextjs::NextJsPlugin;
1022        let deps = vec!["next".to_string()];
1023        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1024    }
1025
1026    #[test]
1027    fn is_enabled_with_deps_no_match() {
1028        let plugin = nextjs::NextJsPlugin;
1029        let deps = vec!["react".to_string()];
1030        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1031    }
1032
1033    #[test]
1034    fn is_enabled_with_deps_empty_deps() {
1035        let plugin = nextjs::NextJsPlugin;
1036        let deps: Vec<String> = vec![];
1037        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1038    }
1039
1040    #[test]
1041    fn entry_point_role_defaults_are_centralized() {
1042        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1043        assert_eq!(
1044            vitest::VitestPlugin.entry_point_role(),
1045            EntryPointRole::Test
1046        );
1047        assert_eq!(
1048            storybook::StorybookPlugin.entry_point_role(),
1049            EntryPointRole::Support
1050        );
1051        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1052    }
1053
1054    #[test]
1055    fn plugins_with_entry_patterns_have_explicit_role_intent() {
1056        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1057            TEST_ENTRY_POINT_PLUGINS
1058                .iter()
1059                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1060                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1061                .copied()
1062                .collect();
1063
1064        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1065            if plugin.entry_patterns().is_empty() {
1066                continue;
1067            }
1068            assert!(
1069                runtime_or_test_or_support.contains(plugin.name()),
1070                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1071                plugin.name()
1072            );
1073        }
1074    }
1075
1076    // ── PluginResult::is_empty ───────────────────────────────────
1077
1078    #[test]
1079    fn plugin_result_is_empty_when_default() {
1080        let r = PluginResult::default();
1081        assert!(r.is_empty());
1082    }
1083
1084    #[test]
1085    fn plugin_result_not_empty_with_entry_patterns() {
1086        let r = PluginResult {
1087            entry_patterns: vec!["*.ts".into()],
1088            ..Default::default()
1089        };
1090        assert!(!r.is_empty());
1091    }
1092
1093    #[test]
1094    fn plugin_result_not_empty_with_referenced_deps() {
1095        let r = PluginResult {
1096            referenced_dependencies: vec!["lodash".to_string()],
1097            ..Default::default()
1098        };
1099        assert!(!r.is_empty());
1100    }
1101
1102    #[test]
1103    fn plugin_result_not_empty_with_setup_files() {
1104        let r = PluginResult {
1105            setup_files: vec![PathBuf::from("/setup.ts")],
1106            ..Default::default()
1107        };
1108        assert!(!r.is_empty());
1109    }
1110
1111    #[test]
1112    fn plugin_result_not_empty_with_always_used_files() {
1113        let r = PluginResult {
1114            always_used_files: vec!["**/*.stories.tsx".to_string()],
1115            ..Default::default()
1116        };
1117        assert!(!r.is_empty());
1118    }
1119
1120    #[test]
1121    fn plugin_result_not_empty_with_fixture_patterns() {
1122        let r = PluginResult {
1123            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1124            ..Default::default()
1125        };
1126        assert!(!r.is_empty());
1127    }
1128
1129    // ── is_enabled_with_deps prefix matching ─────────────────────
1130
1131    #[test]
1132    fn is_enabled_with_deps_prefix_match() {
1133        // Storybook plugin uses prefix enabler "@storybook/"
1134        let plugin = storybook::StorybookPlugin;
1135        let deps = vec!["@storybook/react".to_string()];
1136        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1137    }
1138
1139    #[test]
1140    fn is_enabled_with_deps_prefix_no_match_without_slash() {
1141        // "@storybook/" prefix should NOT match "@storybookish" (different package)
1142        let plugin = storybook::StorybookPlugin;
1143        let deps = vec!["@storybookish".to_string()];
1144        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1145    }
1146
1147    #[test]
1148    fn is_enabled_with_deps_multiple_enablers() {
1149        // Vitest plugin has multiple enablers
1150        let plugin = vitest::VitestPlugin;
1151        let deps_vitest = vec!["vitest".to_string()];
1152        let deps_none = vec!["mocha".to_string()];
1153        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1154        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1155    }
1156
1157    // ── Plugin trait default implementations ─────────────────────
1158
1159    #[test]
1160    fn plugin_default_methods_return_empty() {
1161        // Use a simple plugin to test default trait methods
1162        let plugin = commitizen::CommitizenPlugin;
1163        assert!(
1164            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1165        );
1166        assert!(plugin.virtual_module_prefixes().is_empty());
1167        assert!(plugin.virtual_package_suffixes().is_empty());
1168        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1169        assert!(
1170            plugin.package_json_config_key().is_none()
1171                || plugin.package_json_config_key().is_some()
1172        );
1173    }
1174
1175    #[test]
1176    fn plugin_resolve_config_default_returns_empty() {
1177        let plugin = commitizen::CommitizenPlugin;
1178        let result = plugin.resolve_config(
1179            Path::new("/project/config.js"),
1180            "const x = 1;",
1181            Path::new("/project"),
1182        );
1183        assert!(result.is_empty());
1184    }
1185
1186    // ── is_enabled_with_deps exact and prefix ────────────────────
1187
1188    #[test]
1189    fn is_enabled_with_deps_exact_and_prefix_both_work() {
1190        let plugin = storybook::StorybookPlugin;
1191        let deps_exact = vec!["storybook".to_string()];
1192        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1193        let deps_prefix = vec!["@storybook/vue3".to_string()];
1194        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1195    }
1196
1197    #[test]
1198    fn is_enabled_with_deps_multiple_enablers_remix() {
1199        let plugin = remix::RemixPlugin;
1200        let deps_node = vec!["@remix-run/node".to_string()];
1201        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1202        let deps_react = vec!["@remix-run/react".to_string()];
1203        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1204        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1205        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1206    }
1207
1208    // ── Plugin trait default implementations ──────────────────────
1209
1210    struct MinimalPlugin;
1211    impl Plugin for MinimalPlugin {
1212        fn name(&self) -> &'static str {
1213            "minimal"
1214        }
1215    }
1216
1217    #[test]
1218    fn default_enablers_is_empty() {
1219        assert!(MinimalPlugin.enablers().is_empty());
1220    }
1221
1222    #[test]
1223    fn default_entry_patterns_is_empty() {
1224        assert!(MinimalPlugin.entry_patterns().is_empty());
1225    }
1226
1227    #[test]
1228    fn default_config_patterns_is_empty() {
1229        assert!(MinimalPlugin.config_patterns().is_empty());
1230    }
1231
1232    #[test]
1233    fn default_always_used_is_empty() {
1234        assert!(MinimalPlugin.always_used().is_empty());
1235    }
1236
1237    #[test]
1238    fn default_used_exports_is_empty() {
1239        assert!(MinimalPlugin.used_exports().is_empty());
1240    }
1241
1242    #[test]
1243    fn default_tooling_dependencies_is_empty() {
1244        assert!(MinimalPlugin.tooling_dependencies().is_empty());
1245    }
1246
1247    #[test]
1248    fn default_fixture_glob_patterns_is_empty() {
1249        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1250    }
1251
1252    #[test]
1253    fn default_virtual_module_prefixes_is_empty() {
1254        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1255    }
1256
1257    #[test]
1258    fn default_virtual_package_suffixes_is_empty() {
1259        assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1260    }
1261
1262    #[test]
1263    fn default_path_aliases_is_empty() {
1264        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1265    }
1266
1267    #[test]
1268    fn default_resolve_config_returns_empty() {
1269        let r = MinimalPlugin.resolve_config(
1270            Path::new("config.js"),
1271            "export default {}",
1272            Path::new("/"),
1273        );
1274        assert!(r.is_empty());
1275    }
1276
1277    #[test]
1278    fn default_package_json_config_key_is_none() {
1279        assert!(MinimalPlugin.package_json_config_key().is_none());
1280    }
1281
1282    #[test]
1283    fn default_is_enabled_returns_false_when_no_enablers() {
1284        let deps = vec!["anything".to_string()];
1285        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1286    }
1287
1288    // ── All built-in plugins have unique names ───────────────────
1289
1290    #[test]
1291    fn all_builtin_plugin_names_are_unique() {
1292        let plugins = registry::builtin::create_builtin_plugins();
1293        let mut seen = std::collections::BTreeSet::new();
1294        for p in &plugins {
1295            let name = p.name();
1296            assert!(seen.insert(name), "duplicate plugin name: {name}");
1297        }
1298    }
1299
1300    #[test]
1301    fn all_builtin_plugins_have_enablers() {
1302        let plugins = registry::builtin::create_builtin_plugins();
1303        for p in &plugins {
1304            assert!(
1305                !p.enablers().is_empty(),
1306                "plugin '{}' has no enablers",
1307                p.name()
1308            );
1309        }
1310    }
1311
1312    #[test]
1313    fn plugins_with_config_patterns_have_always_used() {
1314        let plugins = registry::builtin::create_builtin_plugins();
1315        for p in &plugins {
1316            if !p.config_patterns().is_empty() {
1317                assert!(
1318                    !p.always_used().is_empty(),
1319                    "plugin '{}' has config_patterns but no always_used",
1320                    p.name()
1321                );
1322            }
1323        }
1324    }
1325
1326    // ── Enabler patterns for all categories ──────────────────────
1327
1328    #[test]
1329    fn framework_plugins_enablers() {
1330        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1331            (&nextjs::NextJsPlugin, &["next"]),
1332            (&nuxt::NuxtPlugin, &["nuxt"]),
1333            (&angular::AngularPlugin, &["@angular/core"]),
1334            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1335            (&gatsby::GatsbyPlugin, &["gatsby"]),
1336        ];
1337        for (plugin, expected_enablers) in cases {
1338            let enablers = plugin.enablers();
1339            for expected in expected_enablers {
1340                assert!(
1341                    enablers.contains(expected),
1342                    "plugin '{}' should have '{}'",
1343                    plugin.name(),
1344                    expected
1345                );
1346            }
1347        }
1348    }
1349
1350    #[test]
1351    fn testing_plugins_enablers() {
1352        let cases: Vec<(&dyn Plugin, &str)> = vec![
1353            (&jest::JestPlugin, "jest"),
1354            (&vitest::VitestPlugin, "vitest"),
1355            (&playwright::PlaywrightPlugin, "@playwright/test"),
1356            (&cypress::CypressPlugin, "cypress"),
1357            (&mocha::MochaPlugin, "mocha"),
1358        ];
1359        for (plugin, enabler) in cases {
1360            assert!(
1361                plugin.enablers().contains(&enabler),
1362                "plugin '{}' should have '{}'",
1363                plugin.name(),
1364                enabler
1365            );
1366        }
1367    }
1368
1369    #[test]
1370    fn bundler_plugins_enablers() {
1371        let cases: Vec<(&dyn Plugin, &str)> = vec![
1372            (&vite::VitePlugin, "vite"),
1373            (&webpack::WebpackPlugin, "webpack"),
1374            (&rollup::RollupPlugin, "rollup"),
1375        ];
1376        for (plugin, enabler) in cases {
1377            assert!(
1378                plugin.enablers().contains(&enabler),
1379                "plugin '{}' should have '{}'",
1380                plugin.name(),
1381                enabler
1382            );
1383        }
1384    }
1385
1386    #[test]
1387    fn test_plugins_have_test_entry_patterns() {
1388        let test_plugins: Vec<&dyn Plugin> = vec![
1389            &jest::JestPlugin,
1390            &vitest::VitestPlugin,
1391            &mocha::MochaPlugin,
1392        ];
1393        for plugin in test_plugins {
1394            let patterns = plugin.entry_patterns();
1395            assert!(
1396                !patterns.is_empty(),
1397                "test plugin '{}' should have entry patterns",
1398                plugin.name()
1399            );
1400            assert!(
1401                patterns
1402                    .iter()
1403                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1404                "test plugin '{}' should have test/spec patterns",
1405                plugin.name()
1406            );
1407        }
1408    }
1409
1410    #[test]
1411    fn framework_plugins_have_entry_patterns() {
1412        let plugins: Vec<&dyn Plugin> = vec![
1413            &nextjs::NextJsPlugin,
1414            &nuxt::NuxtPlugin,
1415            &angular::AngularPlugin,
1416            &sveltekit::SvelteKitPlugin,
1417        ];
1418        for plugin in plugins {
1419            assert!(
1420                !plugin.entry_patterns().is_empty(),
1421                "framework plugin '{}' should have entry patterns",
1422                plugin.name()
1423            );
1424        }
1425    }
1426
1427    #[test]
1428    fn plugins_with_resolve_config_have_config_patterns() {
1429        let plugins: Vec<&dyn Plugin> = vec![
1430            &jest::JestPlugin,
1431            &vitest::VitestPlugin,
1432            &babel::BabelPlugin,
1433            &eslint::EslintPlugin,
1434            &webpack::WebpackPlugin,
1435            &storybook::StorybookPlugin,
1436            &typescript::TypeScriptPlugin,
1437            &postcss::PostCssPlugin,
1438            &nextjs::NextJsPlugin,
1439            &nuxt::NuxtPlugin,
1440            &angular::AngularPlugin,
1441            &nx::NxPlugin,
1442            &rollup::RollupPlugin,
1443            &sveltekit::SvelteKitPlugin,
1444            &prettier::PrettierPlugin,
1445        ];
1446        for plugin in plugins {
1447            assert!(
1448                !plugin.config_patterns().is_empty(),
1449                "plugin '{}' with resolve_config should have config_patterns",
1450                plugin.name()
1451            );
1452        }
1453    }
1454
1455    #[test]
1456    fn plugin_tooling_deps_include_enabler_package() {
1457        let plugins: Vec<&dyn Plugin> = vec![
1458            &jest::JestPlugin,
1459            &vitest::VitestPlugin,
1460            &webpack::WebpackPlugin,
1461            &typescript::TypeScriptPlugin,
1462            &eslint::EslintPlugin,
1463            &prettier::PrettierPlugin,
1464        ];
1465        for plugin in plugins {
1466            let tooling = plugin.tooling_dependencies();
1467            let enablers = plugin.enablers();
1468            assert!(
1469                enablers
1470                    .iter()
1471                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
1472                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1473                plugin.name()
1474            );
1475        }
1476    }
1477
1478    #[test]
1479    fn nextjs_has_used_exports_for_pages() {
1480        let plugin = nextjs::NextJsPlugin;
1481        let exports = plugin.used_exports();
1482        assert!(!exports.is_empty());
1483        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1484    }
1485
1486    #[test]
1487    fn remix_has_used_exports_for_routes() {
1488        let plugin = remix::RemixPlugin;
1489        let exports = plugin.used_exports();
1490        assert!(!exports.is_empty());
1491        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1492        assert!(route_entry.is_some());
1493        let (_, names) = route_entry.unwrap();
1494        assert!(names.contains(&"loader"));
1495        assert!(names.contains(&"action"));
1496        assert!(names.contains(&"default"));
1497    }
1498
1499    #[test]
1500    fn sveltekit_has_used_exports_for_routes() {
1501        let plugin = sveltekit::SvelteKitPlugin;
1502        let exports = plugin.used_exports();
1503        assert!(!exports.is_empty());
1504        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1505    }
1506
1507    #[test]
1508    fn nuxt_has_hash_virtual_prefix() {
1509        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1510    }
1511
1512    #[test]
1513    fn sveltekit_has_dollar_virtual_prefixes() {
1514        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1515        assert!(prefixes.contains(&"$app/"));
1516        assert!(prefixes.contains(&"$env/"));
1517        assert!(prefixes.contains(&"$lib/"));
1518    }
1519
1520    #[test]
1521    fn sveltekit_has_lib_path_alias() {
1522        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1523        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1524    }
1525
1526    #[test]
1527    fn nuxt_has_tilde_path_alias() {
1528        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1529        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1530        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1531    }
1532
1533    #[test]
1534    fn jest_has_package_json_config_key() {
1535        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1536    }
1537
1538    #[test]
1539    fn babel_has_package_json_config_key() {
1540        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1541    }
1542
1543    #[test]
1544    fn eslint_has_package_json_config_key() {
1545        assert_eq!(
1546            eslint::EslintPlugin.package_json_config_key(),
1547            Some("eslintConfig")
1548        );
1549    }
1550
1551    #[test]
1552    fn prettier_has_package_json_config_key() {
1553        assert_eq!(
1554            prettier::PrettierPlugin.package_json_config_key(),
1555            Some("prettier")
1556        );
1557    }
1558
1559    #[test]
1560    fn macro_generated_plugin_basic_properties() {
1561        let plugin = msw::MswPlugin;
1562        assert_eq!(plugin.name(), "msw");
1563        assert!(plugin.enablers().contains(&"msw"));
1564        assert!(!plugin.entry_patterns().is_empty());
1565        assert!(plugin.config_patterns().is_empty());
1566        assert!(!plugin.always_used().is_empty());
1567        assert!(!plugin.tooling_dependencies().is_empty());
1568    }
1569
1570    #[test]
1571    fn macro_generated_plugin_with_used_exports() {
1572        let plugin = remix::RemixPlugin;
1573        assert_eq!(plugin.name(), "remix");
1574        assert!(!plugin.used_exports().is_empty());
1575    }
1576
1577    #[test]
1578    fn macro_passes_through_virtual_package_suffixes() {
1579        // Synthetic smoke check: a plugin defined via define_plugin! that
1580        // declares virtual_package_suffixes returns those suffixes from the
1581        // trait method. Guards against future macro regressions where the
1582        // field name silently drops out of one of the three variants.
1583        define_plugin! {
1584            struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1585            enablers: &["macro-suffix-smoke"],
1586            virtual_package_suffixes: &["/__macro_smoke__"],
1587        }
1588
1589        let plugin = MacroSuffixSmokePlugin;
1590        assert_eq!(
1591            plugin.virtual_package_suffixes(),
1592            &["/__macro_smoke__"],
1593            "macro-declared virtual_package_suffixes must propagate to the trait method"
1594        );
1595    }
1596
1597    #[test]
1598    fn macro_generated_plugin_imports_only_resolve_config() {
1599        let plugin = cypress::CypressPlugin;
1600        let source = r"
1601            import { defineConfig } from 'cypress';
1602            import coveragePlugin from '@cypress/code-coverage';
1603            export default defineConfig({});
1604        ";
1605        let result = plugin.resolve_config(
1606            Path::new("cypress.config.ts"),
1607            source,
1608            Path::new("/project"),
1609        );
1610        assert!(
1611            result
1612                .referenced_dependencies
1613                .contains(&"cypress".to_string())
1614        );
1615        assert!(
1616            result
1617                .referenced_dependencies
1618                .contains(&"@cypress/code-coverage".to_string())
1619        );
1620    }
1621
1622    #[test]
1623    fn builtin_plugin_count_is_expected() {
1624        let plugins = registry::builtin::create_builtin_plugins();
1625        assert!(
1626            plugins.len() >= 80,
1627            "expected at least 80 built-in plugins, got {}",
1628            plugins.len()
1629        );
1630    }
1631}