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