Skip to main content

fallow_core/plugins/
mod.rs

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