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