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