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