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