fallow_core/plugins/registry/mod.rs
1//! Plugin registry: discovers active plugins, collects patterns, parses configs.
2
3use rustc_hash::FxHashSet;
4use std::path::{Path, PathBuf};
5
6use fallow_config::{
7 AutoImportRule, EntryPointRole, ExternalPluginDef, PackageJson, UsedClassMemberRule,
8};
9
10use crate::scripts;
11
12use super::{PathRule, Plugin, PluginUsedExportRule, ProvidedDependencyRule};
13
14pub(crate) mod builtin;
15mod helpers;
16
17use helpers::{
18 check_has_config_file, discover_config_files, is_external_plugin_active,
19 prepare_config_pattern, process_config_result, process_external_plugins,
20 process_static_patterns,
21};
22
23// ESLint is included because each workspace owns its own eslint.config.{mjs,js,...}
24// that may import a shared workspace eslint-config package. Those transitive deps
25// (e.g. eslint-config-next, eslint-plugin-react) are declared in the workspace's
26// devDependencies and will be flagged as unused if we skip config parsing here.
27fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
28 matches!(
29 plugin_name,
30 "eslint" | "docusaurus" | "jest" | "tanstack-router" | "vitest"
31 )
32}
33
34/// Registry of all available plugins (built-in + external).
35pub struct PluginRegistry {
36 plugins: Vec<Box<dyn Plugin>>,
37 external_plugins: Vec<ExternalPluginDef>,
38}
39
40/// Aggregated results from all active plugins for a project.
41#[derive(Debug, Clone, Default)]
42pub struct AggregatedPluginResult {
43 /// All entry point patterns from active plugins: (rule, plugin_name).
44 pub entry_patterns: Vec<(PathRule, String)>,
45 /// Coverage role for each plugin contributing entry point patterns.
46 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
47 /// All config file patterns from active plugins.
48 pub config_patterns: Vec<String>,
49 /// All always-used file patterns from active plugins: (pattern, plugin_name).
50 pub always_used: Vec<(String, String)>,
51 /// All used export rules from active plugins.
52 pub used_exports: Vec<PluginUsedExportRule>,
53 /// Class member rules contributed by active plugins that should never be
54 /// flagged as unused. Extends the built-in Angular/React lifecycle allowlist
55 /// with framework-invoked method names, optionally scoped by class heritage.
56 pub used_class_members: Vec<UsedClassMemberRule>,
57 /// Dependencies referenced in config files (should not be flagged unused).
58 pub referenced_dependencies: Vec<String>,
59 /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
60 pub discovered_always_used: Vec<(String, String)>,
61 /// Setup files discovered from config parsing: (path, plugin_name).
62 pub setup_files: Vec<(PathBuf, String)>,
63 /// Tooling dependencies (should not be flagged as unused devDeps).
64 pub tooling_dependencies: Vec<String>,
65 /// Package names discovered as used in package.json scripts (binary invocations).
66 pub script_used_packages: FxHashSet<String>,
67 /// Import prefixes for virtual modules provided by active frameworks.
68 /// Imports matching these prefixes should not be flagged as unlisted dependencies.
69 pub virtual_module_prefixes: Vec<String>,
70 /// Package name suffixes that identify virtual or convention-based specifiers.
71 /// Extracted package names ending with any of these suffixes are not flagged as unlisted.
72 pub virtual_package_suffixes: Vec<String>,
73 /// Import suffixes for build-time generated relative imports.
74 /// Unresolved imports ending with these suffixes are suppressed.
75 pub generated_import_patterns: Vec<String>,
76 /// Import prefixes for build-time generated type-only relative imports.
77 /// Unresolved type-only imports starting with these prefixes are suppressed.
78 pub generated_type_import_prefixes: Vec<String>,
79 /// Path alias mappings from active plugins (prefix → replacement directory).
80 /// Used by the resolver to substitute import prefixes before re-resolving.
81 pub path_aliases: Vec<(String, String)>,
82 /// Convention-based auto-import rules from active plugins (Nuxt components).
83 /// The resolver matches each file's captured `auto_import_candidates` against
84 /// these and synthesizes a graph edge to the rule's source. See issue #704.
85 pub auto_imports: Vec<AutoImportRule>,
86 /// Names of active plugins.
87 pub active_plugins: Vec<String>,
88 /// Test fixture glob patterns from active plugins: (pattern, plugin_name).
89 pub fixture_patterns: Vec<(String, String)>,
90 /// Absolute directories contributed by plugins that should be searched
91 /// when resolving SCSS/Sass `@import`/`@use` specifiers. Populated from
92 /// Angular's `stylePreprocessorOptions.includePaths` and equivalent
93 /// framework settings. See issue #103.
94 pub scss_include_paths: Vec<PathBuf>,
95 /// Static directory mappings contributed by plugins.
96 pub static_dir_mappings: Vec<(PathBuf, String)>,
97 /// File-scoped dependency provider rules from active plugins.
98 pub provided_dependencies: Vec<ProvidedDependencyRule>,
99}
100
101/// Append `incoming` string items to `target`, skipping values already present
102/// in `target` or earlier in `incoming`. Matches the deduplication the
103/// workspace merge applied via per-field `seen` sets before #444 centralized
104/// it on [`AggregatedPluginResult::merge_into`].
105fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
106 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
107 for item in incoming {
108 if seen.insert(item.clone()) {
109 target.push(item);
110 }
111 }
112}
113
114/// Prefix a workspace-relative pattern so it matches from the monorepo root,
115/// unless it is already workspace-prefixed or project-root-relative (leading
116/// `/`, e.g. an angular.json path). Mirrors the pre-#444 inline closure.
117fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
118 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
119 pat.to_string()
120 } else {
121 format!("{ws_prefix}/{pat}")
122 }
123}
124
125impl AggregatedPluginResult {
126 /// Apply a workspace prefix to every path-bearing field in place.
127 ///
128 /// Workspace-package results are collected with patterns relative to the
129 /// package root; to be matchable from the monorepo root they need the
130 /// package's prefix applied. This transform is call-site-specific (it
131 /// depends on `ws_prefix`), so it stays separate from [`Self::merge_into`],
132 /// which is a prefix-agnostic union. The root project's own result is
133 /// never prefixed.
134 ///
135 /// Fields that carry package names, absolute paths, or import-specifier
136 /// boundaries (referenced/tooling deps, setup files, static dir mappings,
137 /// auto-imports, virtual prefixes/suffixes, generated patterns) are left
138 /// untouched, matching the pre-#444 merge loop.
139 pub fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
140 for (rule, _) in &mut self.entry_patterns {
141 *rule = rule.prefixed(ws_prefix);
142 }
143 for (pat, _) in &mut self.always_used {
144 *pat = prefix_if_needed(pat, ws_prefix);
145 }
146 for (pat, _) in &mut self.discovered_always_used {
147 *pat = prefix_if_needed(pat, ws_prefix);
148 }
149 for (pat, _) in &mut self.fixture_patterns {
150 *pat = prefix_if_needed(pat, ws_prefix);
151 }
152 for rule in &mut self.used_exports {
153 *rule = rule.prefixed(ws_prefix);
154 }
155 for rule in &mut self.provided_dependencies {
156 *rule = rule.prefixed(ws_prefix);
157 }
158 // Path aliases: the alias key (prefix) is unchanged, but the
159 // replacement directory is prefixed so it resolves from the root.
160 for (_, replacement) in &mut self.path_aliases {
161 *replacement = format!("{ws_prefix}/{replacement}");
162 }
163 }
164
165 /// Merge `other` into `self`, taking the union of every field.
166 ///
167 /// Exhaustively destructures `Self` so adding a field to
168 /// `AggregatedPluginResult` becomes a `missing field in pattern` compile
169 /// error here instead of a silently-dropped field. See issue #444.
170 ///
171 /// Callers that need the workspace prefix applied must call
172 /// [`Self::apply_workspace_prefix`] on `other` first; this method does not
173 /// transform any path. Dedup-bearing fields (`active_plugins`, the virtual
174 /// prefix/suffix and generated-pattern lists) deduplicate the incoming
175 /// values against the contents already in `self`, matching the pre-#444
176 /// `seen`-set behavior. `entry_point_roles` is first-writer-wins.
177 pub fn merge_into(&mut self, other: Self) {
178 let Self {
179 entry_patterns,
180 entry_point_roles,
181 config_patterns,
182 always_used,
183 used_exports,
184 used_class_members,
185 referenced_dependencies,
186 discovered_always_used,
187 setup_files,
188 tooling_dependencies,
189 script_used_packages,
190 virtual_module_prefixes,
191 virtual_package_suffixes,
192 generated_import_patterns,
193 generated_type_import_prefixes,
194 path_aliases,
195 auto_imports,
196 active_plugins,
197 fixture_patterns,
198 scss_include_paths,
199 static_dir_mappings,
200 provided_dependencies,
201 } = other;
202
203 self.entry_patterns.extend(entry_patterns);
204 for (plugin_name, role) in entry_point_roles {
205 self.entry_point_roles.entry(plugin_name).or_insert(role);
206 }
207 self.config_patterns.extend(config_patterns);
208 self.always_used.extend(always_used);
209 self.used_exports.extend(used_exports);
210 self.used_class_members.extend(used_class_members);
211 self.referenced_dependencies.extend(referenced_dependencies);
212 self.discovered_always_used.extend(discovered_always_used);
213 self.setup_files.extend(setup_files);
214 self.tooling_dependencies.extend(tooling_dependencies);
215 self.script_used_packages.extend(script_used_packages);
216 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
217 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
218 extend_unique(
219 &mut self.generated_import_patterns,
220 generated_import_patterns,
221 );
222 extend_unique(
223 &mut self.generated_type_import_prefixes,
224 generated_type_import_prefixes,
225 );
226 self.path_aliases.extend(path_aliases);
227 self.auto_imports.extend(auto_imports);
228 extend_unique(&mut self.active_plugins, active_plugins);
229 self.fixture_patterns.extend(fixture_patterns);
230 self.scss_include_paths.extend(scss_include_paths);
231 self.static_dir_mappings.extend(static_dir_mappings);
232 self.provided_dependencies.extend(provided_dependencies);
233 }
234}
235
236impl PluginRegistry {
237 /// Create a registry with all built-in plugins and optional external plugins.
238 #[must_use]
239 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
240 Self {
241 plugins: builtin::create_builtin_plugins(),
242 external_plugins: external,
243 }
244 }
245
246 /// Hidden directory names that should be traversed before full plugin execution.
247 ///
248 /// Source discovery runs before plugin config parsing, so this helper only uses
249 /// package-activation checks and static plugin metadata.
250 #[must_use]
251 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
252 let all_deps = pkg.all_dependency_names();
253 let mut seen = FxHashSet::default();
254 let mut dirs = Vec::new();
255
256 for plugin in &self.plugins {
257 if !plugin.is_enabled_with_deps(&all_deps, root) {
258 continue;
259 }
260 for dir in plugin.discovery_hidden_dirs() {
261 if seen.insert(*dir) {
262 dirs.push((*dir).to_string());
263 }
264 }
265 }
266
267 dirs
268 }
269
270 /// Run all plugins against a project, returning aggregated results.
271 ///
272 /// This discovers which plugins are active, collects their static patterns,
273 /// then parses any config files to extract dynamic information.
274 pub fn run(
275 &self,
276 pkg: &PackageJson,
277 root: &Path,
278 discovered_files: &[PathBuf],
279 ) -> AggregatedPluginResult {
280 self.run_with_search_roots(pkg, root, discovered_files, &[root], false)
281 }
282
283 /// Run all plugins against a project with explicit config-file search roots.
284 ///
285 /// `config_search_roots` should stay narrowly focused to directories that are
286 /// already known to matter for this project. Broad recursive scans are
287 /// intentionally avoided because they become prohibitively expensive on
288 /// large monorepos with populated `node_modules` trees.
289 ///
290 /// `production_mode` controls the FS fallback for source-extension config
291 /// patterns. In production mode the source walker excludes `*.config.*` so
292 /// the FS walk is required; otherwise Phase 3a's in-memory matcher covers
293 /// them and the walk is skipped.
294 pub fn run_with_search_roots(
295 &self,
296 pkg: &PackageJson,
297 root: &Path,
298 discovered_files: &[PathBuf],
299 config_search_roots: &[&Path],
300 production_mode: bool,
301 ) -> AggregatedPluginResult {
302 let _span = tracing::info_span!("run_plugins").entered();
303 let mut result = AggregatedPluginResult::default();
304
305 // Phase 1: Determine which plugins are active
306 // Compute deps once to avoid repeated Vec<String> allocation per plugin
307 let all_deps = pkg.all_dependency_names();
308 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
309 let active: Vec<&dyn Plugin> = self
310 .plugins
311 .iter()
312 .filter(|p| {
313 p.is_enabled_with_files(&all_deps, root, discovered_files)
314 || p.is_enabled_with_scripts(&script_packages, root)
315 })
316 .map(AsRef::as_ref)
317 .collect();
318
319 tracing::info!(
320 plugins = active
321 .iter()
322 .map(|p| p.name())
323 .collect::<Vec<_>>()
324 .join(", "),
325 "active plugins"
326 );
327
328 // Warn when meta-frameworks are active but their generated configs are missing.
329 // Without these, tsconfig extends chains break and import resolution fails.
330 check_meta_framework_prerequisites(&active, root);
331
332 // Silent-fail diagnostics for the plugin system (#479).
333 self.emit_silent_fail_diagnostics(&active, &all_deps, root, discovered_files);
334
335 // Phase 2: Collect static patterns from active plugins
336 for plugin in &active {
337 process_static_patterns(*plugin, root, &mut result);
338 }
339
340 // Phase 2b: Process external plugins (includes inline framework definitions)
341 process_external_plugins(
342 &self.external_plugins,
343 &all_deps,
344 root,
345 discovered_files,
346 &mut result,
347 );
348
349 // Phase 3: Find and parse config files for dynamic resolution
350 // Pre-compile all config patterns. Source-extension root-anchored
351 // patterns are wrapped with `**/` so they match nested files via the
352 // discovered file set (Phase 3a), letting Phase 3b skip those plugins
353 // and avoid a per-directory stat storm on large monorepos.
354 let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
355 .iter()
356 .filter(|p| !p.config_patterns().is_empty())
357 .map(|p| {
358 let matchers: Vec<globset::GlobMatcher> = p
359 .config_patterns()
360 .iter()
361 .filter_map(|pat| {
362 let prepared = prepare_config_pattern(pat);
363 globset::Glob::new(&prepared)
364 .ok()
365 .map(|g| g.compile_matcher())
366 })
367 .collect();
368 (*p, matchers)
369 })
370 .collect();
371
372 use rayon::prelude::*;
373 // Build relative paths lazily: only needed when config matchers exist
374 // or plugins have package_json_config_key. Skip entirely for projects
375 // with no config-parsing plugins (e.g., only React), avoiding O(files)
376 // String allocations.
377 let needs_relative_files = !config_matchers.is_empty()
378 || active.iter().any(|p| p.package_json_config_key().is_some());
379 let relative_files: Vec<(PathBuf, String)> = if needs_relative_files {
380 discovered_files
381 .par_iter()
382 .map(|f| {
383 let rel = f
384 .strip_prefix(root)
385 .unwrap_or(f)
386 .to_string_lossy()
387 .into_owned();
388 (f.clone(), rel)
389 })
390 .collect()
391 } else {
392 Vec::new()
393 };
394
395 if !config_matchers.is_empty() {
396 // Phase 3a: Match config files from discovered source files. Per-file
397 // glob matching is parallelized: on monorepos with tens of thousands
398 // of source files, the file-scan cost dominates the plugins phase.
399 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
400
401 for (plugin, matchers) in &config_matchers {
402 let plugin_hits: Vec<&PathBuf> = relative_files
403 .par_iter()
404 .filter_map(|(abs_path, rel_path)| {
405 matchers
406 .iter()
407 .any(|m| m.is_match(rel_path.as_str()))
408 .then_some(abs_path)
409 })
410 .collect();
411 for abs_path in plugin_hits {
412 if let Ok(source) = std::fs::read_to_string(abs_path) {
413 let plugin_result = plugin.resolve_config(abs_path, &source, root);
414 if !plugin_result.is_empty() {
415 resolved_plugins.insert(plugin.name());
416 tracing::debug!(
417 plugin = plugin.name(),
418 config = %abs_path.display(),
419 entries = plugin_result.entry_patterns.len(),
420 deps = plugin_result.referenced_dependencies.len(),
421 "resolved config"
422 );
423 process_config_result(
424 plugin.name(),
425 plugin_result,
426 &mut result,
427 Some(abs_path),
428 );
429 }
430 }
431 }
432 }
433
434 // Phase 3b: Filesystem fallback for JSON config files.
435 // JSON files (angular.json, project.json) are not in the discovered file set
436 // because fallow only discovers JS/TS/CSS/Vue/etc. files. In production
437 // mode, source-extension configs (`*.config.*`, dotfiles) are also
438 // excluded from the walker, so the FS walk runs for those patterns too.
439 let json_configs = discover_config_files(
440 &config_matchers,
441 &resolved_plugins,
442 config_search_roots,
443 production_mode,
444 );
445 for (abs_path, plugin) in &json_configs {
446 if let Ok(source) = std::fs::read_to_string(abs_path) {
447 let plugin_result = plugin.resolve_config(abs_path, &source, root);
448 if !plugin_result.is_empty() {
449 let rel = abs_path
450 .strip_prefix(root)
451 .map(|p| p.to_string_lossy())
452 .unwrap_or_default();
453 tracing::debug!(
454 plugin = plugin.name(),
455 config = %rel,
456 entries = plugin_result.entry_patterns.len(),
457 deps = plugin_result.referenced_dependencies.len(),
458 "resolved config (filesystem fallback)"
459 );
460 process_config_result(
461 plugin.name(),
462 plugin_result,
463 &mut result,
464 Some(abs_path),
465 );
466 }
467 }
468 }
469 }
470
471 // Phase 4: Package.json inline config fallback.
472 process_package_json_inline_configs(
473 &active,
474 &config_matchers,
475 &relative_files,
476 root,
477 &mut result,
478 );
479
480 result
481 }
482
483 /// Fast variant of `run()` for workspace packages.
484 ///
485 /// Reuses pre-compiled config matchers and pre-computed relative files from the root
486 /// project run, avoiding repeated glob compilation and path computation per workspace.
487 /// Skips package.json inline config (workspace packages rarely have inline configs).
488 #[expect(
489 clippy::too_many_arguments,
490 reason = "Each parameter is a distinct, small value with no natural grouping; \
491 bundling them into a struct hurts call-site readability."
492 )]
493 pub fn run_workspace_fast(
494 &self,
495 pkg: &PackageJson,
496 root: &Path,
497 project_root: &Path,
498 precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
499 relative_files: &[(PathBuf, String)],
500 skip_config_plugins: &FxHashSet<&str>,
501 production_mode: bool,
502 ) -> AggregatedPluginResult {
503 let _span = tracing::info_span!("run_plugins").entered();
504 let mut result = AggregatedPluginResult::default();
505
506 // Phase 1: Determine which plugins are active (with pre-computed deps)
507 let all_deps = pkg.all_dependency_names();
508 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
509 let workspace_files: Vec<PathBuf> = relative_files
510 .iter()
511 .map(|(abs_path, _)| abs_path.clone())
512 .collect();
513
514 let active: Vec<&dyn Plugin> = self
515 .plugins
516 .iter()
517 .filter(|p| {
518 p.is_enabled_with_files(&all_deps, root, &workspace_files)
519 || p.is_enabled_with_scripts(&script_packages, root)
520 })
521 .map(AsRef::as_ref)
522 .collect();
523
524 tracing::info!(
525 plugins = active
526 .iter()
527 .map(|p| p.name())
528 .collect::<Vec<_>>()
529 .join(", "),
530 "active plugins"
531 );
532
533 // Silent-fail diagnostics (#479); the shared dedupe set means the
534 // same external plugin's enabler typo or pattern collision only warns
535 // once per process even when this fast path runs per workspace.
536 self.emit_silent_fail_diagnostics(&active, &all_deps, root, &workspace_files);
537
538 process_external_plugins(
539 &self.external_plugins,
540 &all_deps,
541 root,
542 &workspace_files,
543 &mut result,
544 );
545
546 // Early exit if no plugins are active (common for leaf workspace packages)
547 if active.is_empty() && result.active_plugins.is_empty() {
548 return result;
549 }
550
551 // Phase 2: Collect static patterns from active plugins
552 for plugin in &active {
553 process_static_patterns(*plugin, root, &mut result);
554 }
555
556 // Phase 3: Find and parse config files using pre-compiled matchers
557 // Only check matchers for plugins that are active in this workspace
558 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
559 let workspace_matchers: Vec<_> = precompiled_config_matchers
560 .iter()
561 .filter(|(p, _)| {
562 active_names.contains(p.name())
563 && (!skip_config_plugins.contains(p.name())
564 || must_parse_workspace_config_when_root_active(p.name()))
565 })
566 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
567 .collect();
568
569 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
570 if !workspace_matchers.is_empty() {
571 use rayon::prelude::*;
572 for (plugin, matchers) in &workspace_matchers {
573 let plugin_hits: Vec<&PathBuf> = relative_files
574 .par_iter()
575 .filter_map(|(abs_path, rel_path)| {
576 matchers
577 .iter()
578 .any(|m| m.is_match(rel_path.as_str()))
579 .then_some(abs_path)
580 })
581 .collect();
582 for abs_path in plugin_hits {
583 if let Ok(source) = std::fs::read_to_string(abs_path) {
584 let plugin_result = plugin.resolve_config(abs_path, &source, root);
585 if !plugin_result.is_empty() {
586 resolved_ws_plugins.insert(plugin.name());
587 tracing::debug!(
588 plugin = plugin.name(),
589 config = %abs_path.display(),
590 entries = plugin_result.entry_patterns.len(),
591 deps = plugin_result.referenced_dependencies.len(),
592 "resolved config"
593 );
594 process_config_result(
595 plugin.name(),
596 plugin_result,
597 &mut result,
598 Some(abs_path),
599 );
600 }
601 }
602 }
603 }
604 }
605
606 // Phase 3b: Filesystem fallback for JSON config files at the project root.
607 // Config files like angular.json live at the monorepo root, but Angular is
608 // only active in workspace packages. Check the project root for unresolved
609 // config patterns.
610 let ws_json_configs = if root == project_root {
611 discover_config_files(
612 &workspace_matchers,
613 &resolved_ws_plugins,
614 &[root],
615 production_mode,
616 )
617 } else {
618 discover_config_files(
619 &workspace_matchers,
620 &resolved_ws_plugins,
621 &[root, project_root],
622 production_mode,
623 )
624 };
625 // Parse discovered JSON config files
626 for (abs_path, plugin) in &ws_json_configs {
627 if let Ok(source) = std::fs::read_to_string(abs_path) {
628 let plugin_result = plugin.resolve_config(abs_path, &source, root);
629 if !plugin_result.is_empty() {
630 let rel = abs_path
631 .strip_prefix(project_root)
632 .map(|p| p.to_string_lossy())
633 .unwrap_or_default();
634 tracing::debug!(
635 plugin = plugin.name(),
636 config = %rel,
637 entries = plugin_result.entry_patterns.len(),
638 deps = plugin_result.referenced_dependencies.len(),
639 "resolved config (workspace filesystem fallback)"
640 );
641 process_config_result(
642 plugin.name(),
643 plugin_result,
644 &mut result,
645 Some(abs_path),
646 );
647 }
648 }
649 }
650
651 result
652 }
653
654 /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
655 /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
656 #[must_use]
657 pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
658 self.plugins
659 .iter()
660 .filter(|p| !p.config_patterns().is_empty())
661 .map(|p| {
662 let matchers: Vec<globset::GlobMatcher> = p
663 .config_patterns()
664 .iter()
665 .filter_map(|pat| {
666 let prepared = prepare_config_pattern(pat);
667 globset::Glob::new(&prepared)
668 .ok()
669 .map(|g| g.compile_matcher())
670 })
671 .collect();
672 (p.as_ref(), matchers)
673 })
674 .collect()
675 }
676}
677
678impl Default for PluginRegistry {
679 fn default() -> Self {
680 Self::new(vec![])
681 }
682}
683
684impl PluginRegistry {
685 /// Collect the active subset of external plugins, run the silent-fail
686 /// diagnostics (#479), and emit one `tracing::warn!` per finding (dedup'd
687 /// across analysis passes via [`plugin_warn_dedupe`]).
688 ///
689 /// Called from both `run_with_search_roots` (top-level) and
690 /// `run_workspace_fast` (per-workspace) so a typo'd enabler or pattern
691 /// collision surfaces regardless of which entry point dispatched the
692 /// analysis.
693 fn emit_silent_fail_diagnostics(
694 &self,
695 active: &[&dyn Plugin],
696 all_deps: &[String],
697 root: &Path,
698 discovered_files: &[PathBuf],
699 ) {
700 let active_external: Vec<&ExternalPluginDef> = self
701 .external_plugins
702 .iter()
703 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
704 .collect();
705 let mut diagnostics = detect_pattern_collisions(active, &active_external);
706 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
707 emit_plugin_diagnostics(&diagnostics);
708 }
709}
710
711/// Process-wide dedupe key cache for plugin-system diagnostic warnings.
712///
713/// Combined-mode runs `PluginRegistry::run_with_search_roots` three times
714/// (check + dupes + health) per analysis, so a naive warn would triple-emit
715/// every diagnostic. Each warn helper builds a unique key, inserts it here,
716/// and only emits when the key was previously absent.
717fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
718 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
719 std::sync::OnceLock::new();
720 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
721}
722
723/// Insert `key` into the dedupe set and return `true` when it was newly
724/// inserted (caller should emit). Returns `true` on a poisoned mutex so
725/// over-warning beats swallowing.
726fn should_warn(key: String) -> bool {
727 plugin_warn_dedupe()
728 .lock()
729 .map_or(true, |mut set| set.insert(key))
730}
731
732/// Structured diagnostic surfaced by the silent-fail plugin checks (#479).
733///
734/// Returned by [`detect_pattern_collisions`] and [`detect_enabler_typos`] so
735/// unit tests can assert on the findings without standing up a tracing
736/// subscriber. The runtime path calls [`emit_plugin_diagnostics`] to convert
737/// each variant into one `tracing::warn!` line.
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub(crate) enum PluginDiagnostic {
740 /// Two or more plugins declared an identical `config_patterns` entry.
741 PatternCollision {
742 pattern: String,
743 owners: Vec<String>,
744 },
745 /// An external plugin enabler does not match any project dependency, but
746 /// at least one Levenshtein-close dep name exists.
747 EnablerTypo {
748 plugin: String,
749 enabler: String,
750 suggestion: String,
751 },
752}
753
754/// Detect plugins whose `config_patterns` collide byte-for-byte.
755///
756/// Detection is byte-equal on the pattern string. Overlapping but non-identical
757/// globs (e.g. `vite.config.{ts,js}` vs `vite.config.ts`) require pattern
758/// intersection logic and are intentionally out of scope; there are no known
759/// collisions in the built-in plugin set. The warning's purpose is to surface
760/// USER-AUTHORED collisions between external plugins or between an external
761/// plugin and a built-in, so the user can disambiguate by editing one side.
762///
763/// Precedence rule when two plugins claim the same pattern: the one registered
764/// first wins. For built-in plugins, registration order is defined in
765/// [`builtin::create_builtin_plugins`]. External plugins (file-loaded plus
766/// inline `framework[]`) run AFTER built-ins, so they cannot displace a
767/// built-in's `resolve_config` result for the same file.
768pub(crate) fn detect_pattern_collisions(
769 builtin_active: &[&dyn Plugin],
770 external_active: &[&ExternalPluginDef],
771) -> Vec<PluginDiagnostic> {
772 use rustc_hash::FxHashMap;
773
774 // Owners are stored as a Vec to preserve REGISTRATION ORDER: owners[0]
775 // is the plugin that wins Phase 3a config matching, and the warning text
776 // names it as the winner. A `FxHashSet` is held alongside to dedupe a
777 // single plugin that legitimately lists the same pattern twice in its
778 // own `config_patterns` (rare but legal) so it does not look like a
779 // self-vs-self collision.
780 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
781 FxHashMap::default();
782
783 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
784 pattern: String,
785 name: String| {
786 let (list, seen) = pattern_owners.entry(pattern).or_default();
787 if seen.insert(name.clone()) {
788 list.push(name);
789 }
790 };
791
792 for plugin in builtin_active {
793 for pat in plugin.config_patterns() {
794 record(
795 &mut pattern_owners,
796 (*pat).to_string(),
797 plugin.name().to_string(),
798 );
799 }
800 }
801 for ext in external_active {
802 for pat in &ext.config_patterns {
803 record(&mut pattern_owners, pat.clone(), ext.name.clone());
804 }
805 }
806
807 let mut findings: Vec<PluginDiagnostic> = pattern_owners
808 .into_iter()
809 .filter_map(|(pattern, (owners, _seen))| {
810 if owners.len() < 2 {
811 None
812 } else {
813 Some(PluginDiagnostic::PatternCollision { pattern, owners })
814 }
815 })
816 .collect();
817 findings.sort_unstable_by(|a, b| match (a, b) {
818 (
819 PluginDiagnostic::PatternCollision { pattern: ap, .. },
820 PluginDiagnostic::PatternCollision { pattern: bp, .. },
821 ) => ap.cmp(bp),
822 _ => std::cmp::Ordering::Equal,
823 });
824 findings
825}
826
827/// Detect external plugins whose enablers do not match any project dependency
828/// AND at least one enabler is a plausible typo of a real dep.
829///
830/// Scope:
831/// - Only external plugins (file-loaded plus inline `framework[]`). Built-in
832/// plugins' enablers are hard-coded so cannot be misspelled.
833/// - Skip plugins with a `detection` block: detection is the rich-logic path
834/// and false negatives there are not enabler typos.
835/// - Skip plugins with empty `enablers` (no signal to validate against).
836/// - Stay silent when no Levenshtein-close dep exists: the plugin may
837/// legitimately not apply to this project.
838///
839/// Matches the established #467 / #510 pattern: tracing-warn with a `did you
840/// mean` suggestion at the call site. No exit non-zero, no new CLI flag.
841pub(crate) fn detect_enabler_typos(
842 external_plugins: &[ExternalPluginDef],
843 all_deps: &[String],
844) -> Vec<PluginDiagnostic> {
845 let mut findings = Vec::new();
846
847 for ext in external_plugins {
848 if ext.detection.is_some() || ext.enablers.is_empty() {
849 continue;
850 }
851
852 let any_match = ext.enablers.iter().any(|enabler| {
853 if enabler.ends_with('/') {
854 all_deps.iter().any(|d| d.starts_with(enabler))
855 } else {
856 all_deps.iter().any(|d| d == enabler)
857 }
858 });
859 if any_match {
860 continue;
861 }
862
863 for enabler in &ext.enablers {
864 let candidates = all_deps.iter().map(String::as_str);
865 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
866 else {
867 continue;
868 };
869
870 findings.push(PluginDiagnostic::EnablerTypo {
871 plugin: ext.name.clone(),
872 enabler: enabler.clone(),
873 suggestion: suggestion.to_string(),
874 });
875 }
876 }
877
878 findings
879}
880
881/// Emit one `tracing::warn!` per finding, dedup'd against the process-wide
882/// `plugin_warn_dedupe` set so combined-mode does not triple-warn.
883fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
884 for finding in findings {
885 match finding {
886 PluginDiagnostic::PatternCollision { pattern, owners } => {
887 let key = format!("collision::{pattern}::{owners:?}");
888 if !should_warn(key) {
889 continue;
890 }
891 let winner = &owners[0];
892 let others = owners[1..].join(", ");
893 tracing::warn!(
894 "plugin config_patterns collision: identical pattern \
895 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
896 runs first (registration order), others ({others}) \
897 follow. Rename one of the patterns or remove the \
898 duplicate plugin to make resolution explicit. A future \
899 release may reject identical-pattern collisions.",
900 joined = owners.join(", "),
901 );
902 }
903 PluginDiagnostic::EnablerTypo {
904 plugin,
905 enabler,
906 suggestion,
907 } => {
908 let key = format!("enabler::{plugin}::{enabler}");
909 if !should_warn(key) {
910 continue;
911 }
912 tracing::warn!(
913 "plugin '{plugin}' enabler '{enabler}' does not match any \
914 dependency in package.json; did you mean '{suggestion}'? \
915 The plugin will not activate. A future release may reject \
916 unmatched enablers.",
917 );
918 }
919 }
920 }
921}
922
923/// Phase 4 of `PluginRegistry::run_with_search_roots`: for any active plugin
924/// that supports inline package.json configuration via
925/// [`Plugin::package_json_config_key`], read the root `package.json`, extract
926/// the relevant key, and feed the result through `resolve_config`.
927fn process_package_json_inline_configs(
928 active: &[&dyn Plugin],
929 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
930 relative_files: &[(PathBuf, String)],
931 root: &Path,
932 result: &mut AggregatedPluginResult,
933) {
934 for plugin in active {
935 let Some(key) = plugin.package_json_config_key() else {
936 continue;
937 };
938 if check_has_config_file(*plugin, config_matchers, relative_files) {
939 continue;
940 }
941 let pkg_path = root.join("package.json");
942 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
943 continue;
944 };
945 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
946 continue;
947 };
948 let Some(config_value) = json.get(key) else {
949 continue;
950 };
951 let config_json = serde_json::to_string(config_value).unwrap_or_default();
952 let fake_path = root.join(format!("{key}.config.json"));
953 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
954 if plugin_result.is_empty() {
955 continue;
956 }
957 tracing::debug!(
958 plugin = plugin.name(),
959 key = key,
960 "resolved inline package.json config"
961 );
962 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path));
963 }
964}
965
966/// A missing meta-framework prerequisite: the per-process dedupe key and the
967/// warning message to emit.
968#[derive(Debug)]
969struct MetaFrameworkWarning {
970 dedupe_key: &'static str,
971 message: &'static str,
972}
973
974/// Pure detection: which active meta-frameworks are missing their generated
975/// config/types directory under `root`. Separated from emission so the
976/// detection logic is unit-testable without a tracing subscriber or the
977/// process-wide dedupe set.
978///
979/// When adding a framework here, also extend `MATERIALIZED_CONTEXT_DIRS` in
980/// `fallow-cli`'s `audit.rs` with its generated dir, otherwise `fallow audit`'s
981/// base worktree will not symlink that dir and the broken-tsconfig-chain bug
982/// resurfaces on the base pass for the new framework.
983fn missing_meta_framework_prerequisites(
984 active_plugins: &[&dyn Plugin],
985 root: &Path,
986) -> Vec<MetaFrameworkWarning> {
987 active_plugins
988 .iter()
989 .filter_map(|plugin| match plugin.name() {
990 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
991 dedupe_key: "meta-prereq::nuxt",
992 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
993 before fallow for accurate analysis",
994 }),
995 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
996 dedupe_key: "meta-prereq::astro",
997 message: "Astro project missing .astro/ types: run `astro sync` \
998 before fallow for accurate analysis",
999 }),
1000 _ => None,
1001 })
1002 .collect()
1003}
1004
1005/// Warn when meta-frameworks are active but their generated configs are missing.
1006///
1007/// Meta-frameworks like Nuxt and Astro generate tsconfig/types files during a
1008/// "prepare" step. Without these, the tsconfig extends chain breaks and
1009/// extensionless imports fail wholesale (e.g. 2000+ unresolved imports).
1010///
1011/// Deduped per framework so combined-mode (check + dupes + health through one
1012/// loader) does not re-warn. The advice is generic and does not name the root,
1013/// so one line per process per framework is the right bound (issue #637).
1014fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1015 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1016 if should_warn(warning.dedupe_key.to_owned()) {
1017 tracing::warn!("{}", warning.message);
1018 }
1019 }
1020}
1021
1022fn script_activation_packages(
1023 pkg: &PackageJson,
1024 root: &Path,
1025 all_deps: &[String],
1026 production_mode: bool,
1027) -> FxHashSet<String> {
1028 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1029 return FxHashSet::default();
1030 };
1031
1032 let scripts_to_analyze = if production_mode {
1033 scripts::filter_production_scripts(pkg_scripts)
1034 } else {
1035 pkg_scripts.clone()
1036 };
1037
1038 let mut nm_roots = Vec::new();
1039 if root.join("node_modules").is_dir() {
1040 nm_roots.push(root);
1041 }
1042 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1043
1044 scripts::analyze_scripts(&scripts_to_analyze, root, &bin_map).used_packages
1045}
1046
1047#[cfg(test)]
1048mod tests;