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