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