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 playwright;
1183mod plop;
1184mod pm2;
1185mod pnpm;
1186mod postcss;
1187mod prettier;
1188mod prisma;
1189mod qwik;
1190mod react_native;
1191mod react_router;
1192mod redwoodsdk;
1193mod relay;
1194mod remark;
1195mod remix;
1196mod rolldown;
1197mod rollup;
1198mod rsbuild;
1199mod rspack;
1200mod sanity;
1201mod semantic_release;
1202mod sentry;
1203mod simple_git_hooks;
1204mod storybook;
1205mod stryker;
1206mod stylelint;
1207mod supabase;
1208mod sveltekit;
1209mod svgo;
1210mod svgr;
1211mod swc;
1212mod syncpack;
1213mod tailwind;
1214mod tanstack_router;
1215mod tap;
1216mod test_alias;
1217mod tsd;
1218mod tsdown;
1219mod tsup;
1220mod turborepo;
1221mod typedoc;
1222mod typeorm;
1223mod typescript;
1224mod unocss;
1225mod varlock;
1226mod vite;
1227mod vitepress;
1228mod vitest;
1229mod webdriverio;
1230mod webpack;
1231mod wrangler;
1232mod wuchale;
1233mod wxt;
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use std::path::Path;
1239
1240    // ── is_enabled_with_deps edge cases ──────────────────────────
1241
1242    #[test]
1243    fn is_enabled_with_deps_exact_match() {
1244        let plugin = nextjs::NextJsPlugin;
1245        let deps = vec!["next".to_string()];
1246        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1247    }
1248
1249    #[test]
1250    fn is_enabled_with_deps_no_match() {
1251        let plugin = nextjs::NextJsPlugin;
1252        let deps = vec!["react".to_string()];
1253        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1254    }
1255
1256    #[test]
1257    fn is_enabled_with_deps_empty_deps() {
1258        let plugin = nextjs::NextJsPlugin;
1259        let deps: Vec<String> = vec![];
1260        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1261    }
1262
1263    #[test]
1264    fn entry_point_role_defaults_are_centralized() {
1265        assert_eq!(vite::VitePlugin.entry_point_role(), EntryPointRole::Runtime);
1266        assert_eq!(
1267            vitest::VitestPlugin.entry_point_role(),
1268            EntryPointRole::Test
1269        );
1270        assert_eq!(
1271            storybook::StorybookPlugin.entry_point_role(),
1272            EntryPointRole::Support
1273        );
1274        assert_eq!(
1275            obsidian::ObsidianPlugin.entry_point_role(),
1276            EntryPointRole::Runtime
1277        );
1278        assert_eq!(knex::KnexPlugin.entry_point_role(), EntryPointRole::Support);
1279    }
1280
1281    #[test]
1282    fn plugins_with_entry_patterns_have_explicit_role_intent() {
1283        let runtime_or_test_or_support: rustc_hash::FxHashSet<&'static str> =
1284            TEST_ENTRY_POINT_PLUGINS
1285                .iter()
1286                .chain(RUNTIME_ENTRY_POINT_PLUGINS.iter())
1287                .chain(SUPPORT_ENTRY_POINT_PLUGINS.iter())
1288                .copied()
1289                .collect();
1290
1291        for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
1292            if plugin.entry_patterns().is_empty() {
1293                continue;
1294            }
1295            assert!(
1296                runtime_or_test_or_support.contains(plugin.name()),
1297                "plugin '{}' exposes entry patterns but is missing from the entry-point role map",
1298                plugin.name()
1299            );
1300        }
1301    }
1302
1303    // ── PluginResult::is_empty ───────────────────────────────────
1304
1305    #[test]
1306    fn plugin_result_is_empty_when_default() {
1307        let r = PluginResult::default();
1308        assert!(r.is_empty());
1309    }
1310
1311    #[test]
1312    fn plugin_result_not_empty_with_entry_patterns() {
1313        let r = PluginResult {
1314            entry_patterns: vec!["*.ts".into()],
1315            ..Default::default()
1316        };
1317        assert!(!r.is_empty());
1318    }
1319
1320    #[test]
1321    fn plugin_result_not_empty_with_referenced_deps() {
1322        let r = PluginResult {
1323            referenced_dependencies: vec!["lodash".to_string()],
1324            ..Default::default()
1325        };
1326        assert!(!r.is_empty());
1327    }
1328
1329    #[test]
1330    fn plugin_result_not_empty_with_setup_files() {
1331        let r = PluginResult {
1332            setup_files: vec![PathBuf::from("/setup.ts")],
1333            ..Default::default()
1334        };
1335        assert!(!r.is_empty());
1336    }
1337
1338    #[test]
1339    fn plugin_result_not_empty_with_always_used_files() {
1340        let r = PluginResult {
1341            always_used_files: vec!["**/*.stories.tsx".to_string()],
1342            ..Default::default()
1343        };
1344        assert!(!r.is_empty());
1345    }
1346
1347    #[test]
1348    fn plugin_result_not_empty_with_fixture_patterns() {
1349        let r = PluginResult {
1350            fixture_patterns: vec!["**/__fixtures__/**/*".to_string()],
1351            ..Default::default()
1352        };
1353        assert!(!r.is_empty());
1354    }
1355
1356    // ── is_enabled_with_deps prefix matching ─────────────────────
1357
1358    #[test]
1359    fn is_enabled_with_deps_prefix_match() {
1360        // Storybook plugin uses prefix enabler "@storybook/"
1361        let plugin = storybook::StorybookPlugin;
1362        let deps = vec!["@storybook/react".to_string()];
1363        assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1364    }
1365
1366    #[test]
1367    fn is_enabled_with_deps_prefix_no_match_without_slash() {
1368        // "@storybook/" prefix should NOT match "@storybookish" (different package)
1369        let plugin = storybook::StorybookPlugin;
1370        let deps = vec!["@storybookish".to_string()];
1371        assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
1372    }
1373
1374    #[test]
1375    fn is_enabled_with_deps_multiple_enablers() {
1376        // Vitest plugin has multiple enablers
1377        let plugin = vitest::VitestPlugin;
1378        let deps_vitest = vec!["vitest".to_string()];
1379        let deps_none = vec!["mocha".to_string()];
1380        assert!(plugin.is_enabled_with_deps(&deps_vitest, Path::new("/project")));
1381        assert!(!plugin.is_enabled_with_deps(&deps_none, Path::new("/project")));
1382    }
1383
1384    // ── Plugin trait default implementations ─────────────────────
1385
1386    #[test]
1387    fn plugin_default_methods_return_empty() {
1388        // Use a simple plugin to test default trait methods
1389        let plugin = commitizen::CommitizenPlugin;
1390        assert!(
1391            plugin.tooling_dependencies().is_empty() || !plugin.tooling_dependencies().is_empty()
1392        );
1393        assert!(plugin.virtual_module_prefixes().is_empty());
1394        assert!(plugin.virtual_package_suffixes().is_empty());
1395        assert!(plugin.path_aliases(Path::new("/project")).is_empty());
1396        assert!(
1397            plugin.package_json_config_key().is_none()
1398                || plugin.package_json_config_key().is_some()
1399        );
1400    }
1401
1402    #[test]
1403    fn plugin_resolve_config_default_returns_empty() {
1404        let plugin = commitizen::CommitizenPlugin;
1405        let result = plugin.resolve_config(
1406            Path::new("/project/config.js"),
1407            "const x = 1;",
1408            Path::new("/project"),
1409        );
1410        assert!(result.is_empty());
1411    }
1412
1413    // ── is_enabled_with_deps exact and prefix ────────────────────
1414
1415    #[test]
1416    fn is_enabled_with_deps_exact_and_prefix_both_work() {
1417        let plugin = storybook::StorybookPlugin;
1418        let deps_exact = vec!["storybook".to_string()];
1419        assert!(plugin.is_enabled_with_deps(&deps_exact, Path::new("/project")));
1420        let deps_prefix = vec!["@storybook/vue3".to_string()];
1421        assert!(plugin.is_enabled_with_deps(&deps_prefix, Path::new("/project")));
1422    }
1423
1424    #[test]
1425    fn is_enabled_with_deps_multiple_enablers_remix() {
1426        let plugin = remix::RemixPlugin;
1427        let deps_node = vec!["@remix-run/node".to_string()];
1428        assert!(plugin.is_enabled_with_deps(&deps_node, Path::new("/project")));
1429        let deps_react = vec!["@remix-run/react".to_string()];
1430        assert!(plugin.is_enabled_with_deps(&deps_react, Path::new("/project")));
1431        let deps_cf = vec!["@remix-run/cloudflare".to_string()];
1432        assert!(plugin.is_enabled_with_deps(&deps_cf, Path::new("/project")));
1433    }
1434
1435    // ── Plugin trait default implementations ──────────────────────
1436
1437    struct MinimalPlugin;
1438    impl Plugin for MinimalPlugin {
1439        fn name(&self) -> &'static str {
1440            "minimal"
1441        }
1442    }
1443
1444    #[test]
1445    fn default_enablers_is_empty() {
1446        assert!(MinimalPlugin.enablers().is_empty());
1447    }
1448
1449    #[test]
1450    fn default_entry_patterns_is_empty() {
1451        assert!(MinimalPlugin.entry_patterns().is_empty());
1452    }
1453
1454    #[test]
1455    fn default_config_patterns_is_empty() {
1456        assert!(MinimalPlugin.config_patterns().is_empty());
1457    }
1458
1459    #[test]
1460    fn default_always_used_is_empty() {
1461        assert!(MinimalPlugin.always_used().is_empty());
1462    }
1463
1464    #[test]
1465    fn default_used_exports_is_empty() {
1466        assert!(MinimalPlugin.used_exports().is_empty());
1467    }
1468
1469    #[test]
1470    fn default_tooling_dependencies_is_empty() {
1471        assert!(MinimalPlugin.tooling_dependencies().is_empty());
1472    }
1473
1474    #[test]
1475    fn default_fixture_glob_patterns_is_empty() {
1476        assert!(MinimalPlugin.fixture_glob_patterns().is_empty());
1477    }
1478
1479    #[test]
1480    fn default_virtual_module_prefixes_is_empty() {
1481        assert!(MinimalPlugin.virtual_module_prefixes().is_empty());
1482    }
1483
1484    #[test]
1485    fn default_virtual_package_suffixes_is_empty() {
1486        assert!(MinimalPlugin.virtual_package_suffixes().is_empty());
1487    }
1488
1489    #[test]
1490    fn default_path_aliases_is_empty() {
1491        assert!(MinimalPlugin.path_aliases(Path::new("/")).is_empty());
1492    }
1493
1494    #[test]
1495    fn default_resolve_config_returns_empty() {
1496        let r = MinimalPlugin.resolve_config(
1497            Path::new("config.js"),
1498            "export default {}",
1499            Path::new("/"),
1500        );
1501        assert!(r.is_empty());
1502    }
1503
1504    #[test]
1505    fn default_package_json_config_key_is_none() {
1506        assert!(MinimalPlugin.package_json_config_key().is_none());
1507    }
1508
1509    #[test]
1510    fn default_is_enabled_returns_false_when_no_enablers() {
1511        let deps = vec!["anything".to_string()];
1512        assert!(!MinimalPlugin.is_enabled_with_deps(&deps, Path::new("/")));
1513    }
1514
1515    // ── All built-in plugins have unique names ───────────────────
1516
1517    #[test]
1518    fn all_builtin_plugin_names_are_unique() {
1519        let plugins = registry::builtin::create_builtin_plugins();
1520        let mut seen = std::collections::BTreeSet::new();
1521        for p in &plugins {
1522            let name = p.name();
1523            assert!(seen.insert(name), "duplicate plugin name: {name}");
1524        }
1525    }
1526
1527    #[test]
1528    fn all_builtin_plugins_have_enablers() {
1529        let plugins = registry::builtin::create_builtin_plugins();
1530        for p in &plugins {
1531            assert!(
1532                !p.enablers().is_empty(),
1533                "plugin '{}' has no enablers",
1534                p.name()
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn plugins_with_config_patterns_have_always_used() {
1541        let plugins = registry::builtin::create_builtin_plugins();
1542        for p in &plugins {
1543            if !p.config_patterns().is_empty() {
1544                assert!(
1545                    !p.always_used().is_empty(),
1546                    "plugin '{}' has config_patterns but no always_used",
1547                    p.name()
1548                );
1549            }
1550        }
1551    }
1552
1553    // ── Enabler patterns for all categories ──────────────────────
1554
1555    #[test]
1556    fn framework_plugins_enablers() {
1557        let cases: Vec<(&dyn Plugin, &[&str])> = vec![
1558            (&nextjs::NextJsPlugin, &["next"]),
1559            (&nuxt::NuxtPlugin, &["nuxt"]),
1560            (&angular::AngularPlugin, &["@angular/core"]),
1561            (&sveltekit::SvelteKitPlugin, &["@sveltejs/kit"]),
1562            (&gatsby::GatsbyPlugin, &["gatsby"]),
1563        ];
1564        for (plugin, expected_enablers) in cases {
1565            let enablers = plugin.enablers();
1566            for expected in expected_enablers {
1567                assert!(
1568                    enablers.contains(expected),
1569                    "plugin '{}' should have '{}'",
1570                    plugin.name(),
1571                    expected
1572                );
1573            }
1574        }
1575    }
1576
1577    #[test]
1578    fn testing_plugins_enablers() {
1579        let cases: Vec<(&dyn Plugin, &str)> = vec![
1580            (&jest::JestPlugin, "jest"),
1581            (&vitest::VitestPlugin, "vitest"),
1582            (&playwright::PlaywrightPlugin, "@playwright/test"),
1583            (&cypress::CypressPlugin, "cypress"),
1584            (&mocha::MochaPlugin, "mocha"),
1585            (&stryker::StrykerPlugin, "@stryker-mutator/core"),
1586        ];
1587        for (plugin, enabler) in cases {
1588            assert!(
1589                plugin.enablers().contains(&enabler),
1590                "plugin '{}' should have '{}'",
1591                plugin.name(),
1592                enabler
1593            );
1594        }
1595    }
1596
1597    #[test]
1598    fn bundler_plugins_enablers() {
1599        let cases: Vec<(&dyn Plugin, &str)> = vec![
1600            (&vite::VitePlugin, "vite"),
1601            (&webpack::WebpackPlugin, "webpack"),
1602            (&rollup::RollupPlugin, "rollup"),
1603        ];
1604        for (plugin, enabler) in cases {
1605            assert!(
1606                plugin.enablers().contains(&enabler),
1607                "plugin '{}' should have '{}'",
1608                plugin.name(),
1609                enabler
1610            );
1611        }
1612    }
1613
1614    #[test]
1615    fn test_plugins_have_test_entry_patterns() {
1616        let test_plugins: Vec<&dyn Plugin> = vec![
1617            &jest::JestPlugin,
1618            &vitest::VitestPlugin,
1619            &mocha::MochaPlugin,
1620            &tap::TapPlugin,
1621            &tsd::TsdPlugin,
1622        ];
1623        for plugin in test_plugins {
1624            let patterns = plugin.entry_patterns();
1625            assert!(
1626                !patterns.is_empty(),
1627                "test plugin '{}' should have entry patterns",
1628                plugin.name()
1629            );
1630            assert!(
1631                patterns
1632                    .iter()
1633                    .any(|p| p.contains("test") || p.contains("spec") || p.contains("__tests__")),
1634                "test plugin '{}' should have test/spec patterns",
1635                plugin.name()
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn framework_plugins_have_entry_patterns() {
1642        let plugins: Vec<&dyn Plugin> = vec![
1643            &nextjs::NextJsPlugin,
1644            &nuxt::NuxtPlugin,
1645            &angular::AngularPlugin,
1646            &sveltekit::SvelteKitPlugin,
1647        ];
1648        for plugin in plugins {
1649            assert!(
1650                !plugin.entry_patterns().is_empty(),
1651                "framework plugin '{}' should have entry patterns",
1652                plugin.name()
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn plugins_with_resolve_config_have_config_patterns() {
1659        let plugins: Vec<&dyn Plugin> = vec![
1660            &jest::JestPlugin,
1661            &vitest::VitestPlugin,
1662            &babel::BabelPlugin,
1663            &eslint::EslintPlugin,
1664            &webpack::WebpackPlugin,
1665            &storybook::StorybookPlugin,
1666            &typescript::TypeScriptPlugin,
1667            &postcss::PostCssPlugin,
1668            &nextjs::NextJsPlugin,
1669            &nuxt::NuxtPlugin,
1670            &angular::AngularPlugin,
1671            &nx::NxPlugin,
1672            &stryker::StrykerPlugin,
1673            &wuchale::WuchalePlugin,
1674            &rollup::RollupPlugin,
1675            &sveltekit::SvelteKitPlugin,
1676            &prettier::PrettierPlugin,
1677            &contentlayer::ContentlayerPlugin,
1678        ];
1679        for plugin in plugins {
1680            assert!(
1681                !plugin.config_patterns().is_empty(),
1682                "plugin '{}' with resolve_config should have config_patterns",
1683                plugin.name()
1684            );
1685        }
1686    }
1687
1688    #[test]
1689    fn plugin_tooling_deps_include_enabler_package() {
1690        let plugins: Vec<&dyn Plugin> = vec![
1691            &jest::JestPlugin,
1692            &vitest::VitestPlugin,
1693            &webpack::WebpackPlugin,
1694            &typescript::TypeScriptPlugin,
1695            &eslint::EslintPlugin,
1696            &prettier::PrettierPlugin,
1697            &danger::DangerPlugin,
1698            &stryker::StrykerPlugin,
1699            &wuchale::WuchalePlugin,
1700            &contentlayer::ContentlayerPlugin,
1701        ];
1702        for plugin in plugins {
1703            let tooling = plugin.tooling_dependencies();
1704            let enablers = plugin.enablers();
1705            assert!(
1706                enablers
1707                    .iter()
1708                    .any(|e| !e.ends_with('/') && tooling.contains(e)),
1709                "plugin '{}': at least one non-prefix enabler should be in tooling_dependencies",
1710                plugin.name()
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn nextjs_has_used_exports_for_pages() {
1717        let plugin = nextjs::NextJsPlugin;
1718        let exports = plugin.used_exports();
1719        assert!(!exports.is_empty());
1720        assert!(exports.iter().any(|(_, names)| names.contains(&"default")));
1721    }
1722
1723    #[test]
1724    fn remix_has_used_exports_for_routes() {
1725        let plugin = remix::RemixPlugin;
1726        let exports = plugin.used_exports();
1727        assert!(!exports.is_empty());
1728        let route_entry = exports.iter().find(|(pat, _)| pat.contains("routes"));
1729        assert!(route_entry.is_some());
1730        let (_, names) = route_entry.unwrap();
1731        assert!(names.contains(&"loader"));
1732        assert!(names.contains(&"action"));
1733        assert!(names.contains(&"default"));
1734    }
1735
1736    #[test]
1737    fn sveltekit_has_used_exports_for_routes() {
1738        let plugin = sveltekit::SvelteKitPlugin;
1739        let exports = plugin.used_exports();
1740        assert!(!exports.is_empty());
1741        assert!(exports.iter().any(|(_, names)| names.contains(&"GET")));
1742    }
1743
1744    #[test]
1745    fn nuxt_has_hash_virtual_prefix() {
1746        assert!(nuxt::NuxtPlugin.virtual_module_prefixes().contains(&"#"));
1747    }
1748
1749    #[test]
1750    fn sveltekit_has_dollar_virtual_prefixes() {
1751        let prefixes = sveltekit::SvelteKitPlugin.virtual_module_prefixes();
1752        assert!(prefixes.contains(&"$app/"));
1753        assert!(prefixes.contains(&"$env/"));
1754        assert!(prefixes.contains(&"$lib/"));
1755    }
1756
1757    #[test]
1758    fn sveltekit_has_lib_path_alias() {
1759        let aliases = sveltekit::SvelteKitPlugin.path_aliases(Path::new("/project"));
1760        assert!(aliases.iter().any(|(prefix, _)| *prefix == "$lib/"));
1761    }
1762
1763    #[test]
1764    fn nuxt_has_tilde_path_alias() {
1765        let aliases = nuxt::NuxtPlugin.path_aliases(Path::new("/nonexistent"));
1766        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~/"));
1767        assert!(aliases.iter().any(|(prefix, _)| *prefix == "~~/"));
1768    }
1769
1770    #[test]
1771    fn jest_has_package_json_config_key() {
1772        assert_eq!(jest::JestPlugin.package_json_config_key(), Some("jest"));
1773    }
1774
1775    #[test]
1776    fn tsd_has_package_json_config_key() {
1777        assert_eq!(tsd::TsdPlugin.package_json_config_key(), Some("tsd"));
1778    }
1779
1780    #[test]
1781    fn babel_has_package_json_config_key() {
1782        assert_eq!(babel::BabelPlugin.package_json_config_key(), Some("babel"));
1783    }
1784
1785    #[test]
1786    fn eslint_has_package_json_config_key() {
1787        assert_eq!(
1788            eslint::EslintPlugin.package_json_config_key(),
1789            Some("eslintConfig")
1790        );
1791    }
1792
1793    #[test]
1794    fn prettier_has_package_json_config_key() {
1795        assert_eq!(
1796            prettier::PrettierPlugin.package_json_config_key(),
1797            Some("prettier")
1798        );
1799    }
1800
1801    #[test]
1802    fn macro_generated_plugin_basic_properties() {
1803        let plugin = msw::MswPlugin;
1804        assert_eq!(plugin.name(), "msw");
1805        assert!(plugin.enablers().contains(&"msw"));
1806        assert!(!plugin.entry_patterns().is_empty());
1807        assert!(plugin.config_patterns().is_empty());
1808        assert!(!plugin.always_used().is_empty());
1809        assert!(!plugin.tooling_dependencies().is_empty());
1810    }
1811
1812    #[test]
1813    fn macro_generated_plugin_with_used_exports() {
1814        let plugin = remix::RemixPlugin;
1815        assert_eq!(plugin.name(), "remix");
1816        assert!(!plugin.used_exports().is_empty());
1817    }
1818
1819    #[test]
1820    fn macro_passes_through_virtual_package_suffixes() {
1821        // Synthetic smoke check: a plugin defined via define_plugin! that
1822        // declares virtual_package_suffixes returns those suffixes from the
1823        // trait method. Guards against future macro regressions where the
1824        // field name silently drops out of one of the three variants.
1825        define_plugin! {
1826            struct MacroSuffixSmokePlugin => "macro-suffix-smoke",
1827            enablers: &["macro-suffix-smoke"],
1828            virtual_package_suffixes: &["/__macro_smoke__"],
1829        }
1830
1831        let plugin = MacroSuffixSmokePlugin;
1832        assert_eq!(
1833            plugin.virtual_package_suffixes(),
1834            &["/__macro_smoke__"],
1835            "macro-declared virtual_package_suffixes must propagate to the trait method"
1836        );
1837    }
1838
1839    #[test]
1840    fn macro_generated_plugin_imports_only_resolve_config() {
1841        let plugin = cypress::CypressPlugin;
1842        let source = r"
1843            import { defineConfig } from 'cypress';
1844            import coveragePlugin from '@cypress/code-coverage';
1845            export default defineConfig({});
1846        ";
1847        let result = plugin.resolve_config(
1848            Path::new("cypress.config.ts"),
1849            source,
1850            Path::new("/project"),
1851        );
1852        assert!(
1853            result
1854                .referenced_dependencies
1855                .contains(&"cypress".to_string())
1856        );
1857        assert!(
1858            result
1859                .referenced_dependencies
1860                .contains(&"@cypress/code-coverage".to_string())
1861        );
1862    }
1863
1864    #[test]
1865    fn builtin_plugin_count_is_expected() {
1866        let plugins = registry::builtin::create_builtin_plugins();
1867        assert!(
1868            plugins.len() >= 80,
1869            "expected at least 80 built-in plugins, got {}",
1870            plugins.len()
1871        );
1872    }
1873}