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