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