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