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