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