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