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