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