Skip to main content

fallow_core/plugins/
mod.rs

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