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