Skip to main content

fallow_core/plugins/
tooling.rs

1//! General tooling dependency detection.
2//!
3//! Known dev dependencies that are tooling (used by CLI/config, not imported in
4//! application code). These complement the per-plugin `tooling_dependencies()`
5//! lists with dependencies that aren't tied to any single plugin.
6//!
7//! The catalogue is community-maintainable: the prefix and exact lists live in
8//! `crates/core/data/tooling.toml`, embedded via `include_str!` and parsed once
9//! at startup. There is no regeneration step. To add a tool, edit one entry in
10//! the TOML and open a PR. See `CONTRIBUTING.md`.
11
12use rustc_hash::FxHashSet;
13
14/// Embedded catalogue source. Because it is `include_str!`-embedded at compile
15/// time, a green `catalogue_parses` test guarantees the released binary parses.
16const CATALOGUE_TOML: &str = include_str!("../../data/tooling.toml");
17
18/// Framework-plugin name markers. A package whose bare name OR whose
19/// `@scope/`-stripped tail starts with one of these is a framework plugin and
20/// must NOT be listed in the catalogue (its config-parsing plugin credits it
21/// only when it actually appears in the config, avoiding a false negative). The
22/// tail check catches scoped community forms like
23/// `@ianvs/prettier-plugin-sort-imports`. Enforced by
24/// `catalogue_rejects_framework_plugin_exact_entries`.
25#[cfg(test)]
26const FRAMEWORK_PLUGIN_FAMILY_PREFIXES: &[&str] = &[
27    "vite-plugin-",
28    "prettier-plugin-",
29    "eslint-plugin-",
30    "rollup-plugin-",
31];
32
33/// Official scoped framework-plugin namespaces, checked against the FULL name.
34/// `@rollup/plugin-*` is Rollup's official plugin scope. This is deliberately
35/// NOT generalized to `@scope/plugin-*`, because `@vitejs/plugin-react` and
36/// peers are legitimately-kept tooling exacts.
37#[cfg(test)]
38const FRAMEWORK_PLUGIN_SCOPED_PREFIXES: &[&str] = &["@rollup/plugin-"];
39
40#[derive(serde::Deserialize)]
41struct ToolingCatalogue {
42    #[serde(default)]
43    prefix: Vec<PrefixEntry>,
44    #[serde(default)]
45    exact: Vec<ExactEntry>,
46}
47
48#[derive(serde::Deserialize)]
49struct PrefixEntry {
50    /// Match when `name.starts_with(pattern)`. Required and must be non-empty
51    /// (an empty pattern would match every package, disabling unused-dep
52    /// detection entirely).
53    pattern: String,
54    /// Optional human context; does not affect matching.
55    #[expect(
56        dead_code,
57        reason = "documentation field, surfaced via the catalogue source"
58    )]
59    #[serde(default)]
60    notes: Option<String>,
61}
62
63#[derive(serde::Deserialize)]
64struct ExactEntry {
65    /// Exact package name to credit as tooling.
66    name: String,
67    /// Optional grouping label; does not affect matching.
68    #[expect(
69        dead_code,
70        reason = "documentation field, surfaced via the catalogue source"
71    )]
72    #[serde(default)]
73    ecosystem: Option<String>,
74}
75
76/// Parsed catalogue: ordered prefix patterns + an exact-match set.
77struct Catalogue {
78    prefixes: Vec<String>,
79    exact: FxHashSet<String>,
80}
81
82/// Parse and cache the embedded catalogue once. Panics with a clear message if
83/// the embedded TOML is malformed; this is unreachable in a released binary
84/// because the bytes are compile-time-embedded and gated by `catalogue_parses`.
85fn catalogue() -> &'static Catalogue {
86    static CATALOGUE: std::sync::OnceLock<Catalogue> = std::sync::OnceLock::new();
87    CATALOGUE.get_or_init(|| {
88        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).expect(
89            "embedded crates/core/data/tooling.toml must parse; run \
90             `cargo test -p fallow-core catalogue_parses` to see the error",
91        );
92        Catalogue {
93            prefixes: parsed.prefix.into_iter().map(|p| p.pattern).collect(),
94            exact: parsed.exact.into_iter().map(|e| e.name).collect(),
95        }
96    })
97}
98
99/// Check whether a package is a known tooling/dev dependency by name.
100///
101/// This is the single source of truth for general tooling detection.
102/// Per-plugin tooling dependencies are declared via `Plugin::tooling_dependencies()`
103/// and aggregated separately in `AggregatedPluginResult`.
104#[must_use]
105pub fn is_known_tooling_dependency(name: &str) -> bool {
106    let catalogue = catalogue();
107    catalogue
108        .prefixes
109        .iter()
110        .any(|p| name.starts_with(p.as_str()))
111        || catalogue.exact.contains(name)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    // ── Prefix matching ──────────────────────────────────────────
119
120    #[test]
121    fn types_prefix_matches_scoped() {
122        assert!(is_known_tooling_dependency("@types/node"));
123        assert!(is_known_tooling_dependency("@types/react"));
124        assert!(is_known_tooling_dependency("@types/express"));
125    }
126
127    #[test]
128    fn types_prefix_does_not_match_similar_names() {
129        // "type-fest" should NOT match "@types/" prefix
130        assert!(!is_known_tooling_dependency("type-fest"));
131        assert!(!is_known_tooling_dependency("typesafe-actions"));
132    }
133
134    #[test]
135    fn storybook_not_blanket_matched() {
136        // @storybook/ and storybook prefixes removed — handled by StorybookPlugin config parsing
137        assert!(!is_known_tooling_dependency("@storybook/react"));
138        assert!(!is_known_tooling_dependency("@storybook/addon-essentials"));
139        assert!(!is_known_tooling_dependency("storybook"));
140    }
141
142    #[test]
143    fn testing_library_prefix_matches() {
144        assert!(is_known_tooling_dependency("@testing-library/react"));
145        assert!(is_known_tooling_dependency("@testing-library/jest-dom"));
146    }
147
148    #[test]
149    fn babel_not_blanket_matched() {
150        // @babel/ and babel- prefixes removed — handled by BabelPlugin config parsing
151        assert!(!is_known_tooling_dependency("@babel/core"));
152        assert!(!is_known_tooling_dependency("@babel/preset-env"));
153        assert!(!is_known_tooling_dependency("babel-loader"));
154        assert!(!is_known_tooling_dependency("babel-jest"));
155    }
156
157    #[test]
158    fn vitest_prefix_matches() {
159        assert!(is_known_tooling_dependency("@vitest/coverage-v8"));
160        assert!(is_known_tooling_dependency("@vitest/ui"));
161    }
162
163    #[test]
164    fn eslint_not_blanket_matched() {
165        // eslint and @typescript-eslint prefixes removed — handled by EslintPlugin config parsing
166        assert!(!is_known_tooling_dependency("eslint"));
167        assert!(!is_known_tooling_dependency("eslint-plugin-react"));
168        assert!(!is_known_tooling_dependency("eslint-config-next"));
169        assert!(!is_known_tooling_dependency("@typescript-eslint/parser"));
170    }
171
172    #[test]
173    fn biomejs_prefix_matches() {
174        assert!(is_known_tooling_dependency("@biomejs/biome"));
175    }
176
177    // ── Exact matching ───────────────────────────────────────────
178
179    #[test]
180    fn exact_typescript_matches() {
181        assert!(is_known_tooling_dependency("typescript"));
182    }
183
184    #[test]
185    fn exact_prettier_matches() {
186        assert!(is_known_tooling_dependency("prettier"));
187    }
188
189    #[test]
190    fn exact_vitest_matches() {
191        assert!(is_known_tooling_dependency("vitest"));
192    }
193
194    #[test]
195    fn exact_jest_matches() {
196        assert!(is_known_tooling_dependency("jest"));
197    }
198
199    #[test]
200    fn exact_vite_matches() {
201        assert!(is_known_tooling_dependency("vite"));
202    }
203
204    #[test]
205    fn exact_esbuild_matches() {
206        assert!(is_known_tooling_dependency("esbuild"));
207    }
208
209    #[test]
210    fn exact_tsup_matches() {
211        assert!(is_known_tooling_dependency("tsup"));
212    }
213
214    #[test]
215    fn exact_turbo_matches() {
216        assert!(is_known_tooling_dependency("turbo"));
217    }
218
219    // ── Non-tooling dependencies ─────────────────────────────────
220
221    #[test]
222    fn common_runtime_deps_not_tooling() {
223        assert!(!is_known_tooling_dependency("react"));
224        assert!(!is_known_tooling_dependency("react-dom"));
225        assert!(!is_known_tooling_dependency("express"));
226        assert!(!is_known_tooling_dependency("lodash"));
227        assert!(!is_known_tooling_dependency("next"));
228        assert!(!is_known_tooling_dependency("vue"));
229        assert!(!is_known_tooling_dependency("axios"));
230    }
231
232    #[test]
233    fn empty_string_not_tooling() {
234        assert!(!is_known_tooling_dependency(""));
235    }
236
237    #[test]
238    fn near_miss_not_tooling() {
239        // These look similar to tooling but should NOT match
240        assert!(!is_known_tooling_dependency("type-fest"));
241        assert!(!is_known_tooling_dependency("typestyle"));
242        assert!(!is_known_tooling_dependency("prettier-bytes")); // not the exact "prettier"
243        // "prettier" is an [[exact]] entry in tooling.toml, not a [[prefix]],
244        // so "prettier-bytes" does not match.
245    }
246
247    #[test]
248    fn sass_variants_are_tooling() {
249        assert!(is_known_tooling_dependency("sass"));
250        assert!(is_known_tooling_dependency("sass-embedded"));
251    }
252
253    #[test]
254    fn framework_plugin_packages_no_longer_exact_matched() {
255        // Issue #462: these 5 framework-plugin packages were removed from the
256        // catalogue. They are credited only when they actually appear in the
257        // parsed Vite / Prettier config (via the plugins' config parsers), so a
258        // declared-but-unused plugin now correctly surfaces as unused instead of
259        // being silently treated as used by an exact-name shadow match.
260        assert!(!is_known_tooling_dependency("vite-plugin-svgr"));
261        assert!(!is_known_tooling_dependency("vite-plugin-eslint"));
262        assert!(!is_known_tooling_dependency("prettier-plugin-tailwindcss"));
263        assert!(!is_known_tooling_dependency(
264            "prettier-plugin-organize-imports"
265        ));
266        assert!(!is_known_tooling_dependency(
267            "@ianvs/prettier-plugin-sort-imports"
268        ));
269    }
270
271    // ── Additional prefix matching ────────────────────────────────
272
273    #[test]
274    fn electron_forge_prefix_matches() {
275        assert!(is_known_tooling_dependency("@electron-forge/cli"));
276        assert!(is_known_tooling_dependency(
277            "@electron-forge/maker-squirrel"
278        ));
279    }
280
281    #[test]
282    fn electron_prefix_matches() {
283        assert!(is_known_tooling_dependency("@electron/rebuild"));
284        assert!(is_known_tooling_dependency("@electron/notarize"));
285    }
286
287    #[test]
288    fn formatjs_prefix_matches() {
289        assert!(is_known_tooling_dependency("@formatjs/cli"));
290        assert!(is_known_tooling_dependency("@formatjs/intl"));
291    }
292
293    #[test]
294    fn rollup_not_blanket_matched() {
295        // @rollup/ prefix removed — handled by RollupPlugin config parsing
296        assert!(!is_known_tooling_dependency("@rollup/plugin-commonjs"));
297        assert!(!is_known_tooling_dependency("@rollup/plugin-node-resolve"));
298        assert!(!is_known_tooling_dependency("@rollup/plugin-typescript"));
299    }
300
301    #[test]
302    fn semantic_release_prefix_matches() {
303        assert!(is_known_tooling_dependency("@semantic-release/github"));
304        assert!(is_known_tooling_dependency("@semantic-release/npm"));
305        assert!(is_known_tooling_dependency("semantic-release"));
306    }
307
308    #[test]
309    fn release_it_prefix_matches() {
310        assert!(is_known_tooling_dependency(
311            "@release-it/conventional-changelog"
312        ));
313    }
314
315    #[test]
316    fn lerna_lite_prefix_matches() {
317        assert!(is_known_tooling_dependency("@lerna-lite/cli"));
318        assert!(is_known_tooling_dependency("@lerna-lite/publish"));
319    }
320
321    #[test]
322    fn changesets_prefix_matches() {
323        assert!(is_known_tooling_dependency("@changesets/cli"));
324        assert!(is_known_tooling_dependency("@changesets/changelog-github"));
325    }
326
327    #[test]
328    fn graphql_codegen_prefix_matches() {
329        assert!(is_known_tooling_dependency("@graphql-codegen/cli"));
330        assert!(is_known_tooling_dependency(
331            "@graphql-codegen/typescript-operations"
332        ));
333    }
334
335    #[test]
336    fn secretlint_prefix_matches() {
337        assert!(is_known_tooling_dependency("secretlint"));
338        assert!(is_known_tooling_dependency(
339            "@secretlint/secretlint-rule-preset-recommend"
340        ));
341    }
342
343    #[test]
344    fn oxlint_prefix_matches() {
345        assert!(is_known_tooling_dependency("oxlint"));
346    }
347
348    #[test]
349    fn react_native_community_prefix_matches() {
350        assert!(is_known_tooling_dependency("@react-native-community/cli"));
351        assert!(is_known_tooling_dependency(
352            "@react-native-community/cli-platform-android"
353        ));
354    }
355
356    #[test]
357    fn react_native_prefix_matches() {
358        assert!(is_known_tooling_dependency("@react-native/metro-config"));
359        assert!(is_known_tooling_dependency(
360            "@react-native/typescript-config"
361        ));
362    }
363
364    #[test]
365    fn jest_prefix_matches() {
366        assert!(is_known_tooling_dependency("@jest/globals"));
367        assert!(is_known_tooling_dependency("@jest/types"));
368    }
369
370    #[test]
371    fn playwright_prefix_matches() {
372        assert!(is_known_tooling_dependency("@playwright/test"));
373        assert!(is_known_tooling_dependency("playwright"));
374    }
375
376    #[test]
377    fn tapjs_prefix_matches() {
378        assert!(is_known_tooling_dependency("@tapjs/test"));
379        assert!(is_known_tooling_dependency("@tapjs/snapshot"));
380    }
381
382    // ── Additional exact matching ─────────────────────────────────
383
384    #[test]
385    fn exact_tap_matches() {
386        assert!(is_known_tooling_dependency("tap"));
387    }
388
389    #[test]
390    fn exact_rolldown_matches() {
391        assert!(is_known_tooling_dependency("rolldown"));
392        assert!(is_known_tooling_dependency("rolldown-vite"));
393    }
394
395    #[test]
396    fn exact_electron_matches() {
397        assert!(is_known_tooling_dependency("electron"));
398        assert!(is_known_tooling_dependency("electron-builder"));
399        assert!(is_known_tooling_dependency("electron-vite"));
400    }
401
402    #[test]
403    fn exact_sharp_matches() {
404        assert!(is_known_tooling_dependency("sharp"));
405    }
406
407    #[test]
408    fn exact_puppeteer_matches() {
409        assert!(is_known_tooling_dependency("puppeteer"));
410    }
411
412    #[test]
413    fn exact_madge_matches() {
414        assert!(is_known_tooling_dependency("madge"));
415    }
416
417    #[test]
418    fn exact_patch_package_matches() {
419        assert!(is_known_tooling_dependency("patch-package"));
420    }
421
422    #[test]
423    fn exact_nx_matches() {
424        assert!(is_known_tooling_dependency("nx"));
425    }
426
427    #[test]
428    fn exact_vue_tsc_matches() {
429        assert!(is_known_tooling_dependency("vue-tsc"));
430    }
431
432    #[test]
433    fn exact_tsconfig_packages_match() {
434        assert!(is_known_tooling_dependency("@tsconfig/node20"));
435        assert!(is_known_tooling_dependency("@tsconfig/react-native"));
436        assert!(is_known_tooling_dependency("@vue/tsconfig"));
437    }
438
439    #[test]
440    fn exact_vitejs_plugins_match() {
441        assert!(is_known_tooling_dependency("@vitejs/plugin-vue"));
442        assert!(is_known_tooling_dependency("@vitejs/plugin-react"));
443        assert!(is_known_tooling_dependency("@vitejs/plugin-react-swc"));
444        assert!(is_known_tooling_dependency("@vitejs/plugin-legacy"));
445    }
446
447    #[test]
448    fn exact_oxc_transform_matches() {
449        assert!(is_known_tooling_dependency("oxc-transform"));
450    }
451
452    #[test]
453    fn exact_typescript_native_preview_matches() {
454        assert!(is_known_tooling_dependency("@typescript/native-preview"));
455    }
456
457    #[test]
458    fn exact_tw_animate_css_matches() {
459        assert!(is_known_tooling_dependency("tw-animate-css"));
460    }
461
462    #[test]
463    fn exact_manypkg_cli_matches() {
464        assert!(is_known_tooling_dependency("@manypkg/cli"));
465    }
466
467    #[test]
468    fn exact_swc_variants_match() {
469        assert!(is_known_tooling_dependency("@swc/core"));
470        assert!(is_known_tooling_dependency("@swc/jest"));
471    }
472
473    // ── Negative tests for near-misses ────────────────────────────
474
475    #[test]
476    fn runtime_deps_with_similar_names_not_tooling() {
477        // These are NOT tooling — they don't match any prefix or exact entry
478        assert!(!is_known_tooling_dependency("react-scripts"));
479        assert!(!is_known_tooling_dependency("express-validator"));
480        assert!(!is_known_tooling_dependency("sass-loader")); // "sass" is exact, not prefix
481    }
482
483    #[test]
484    fn postcss_not_blanket_matched() {
485        // postcss, autoprefixer, tailwindcss, @tailwindcss prefixes removed —
486        // handled by PostCssPlugin and TailwindPlugin config parsing
487        assert!(!is_known_tooling_dependency("postcss-modules"));
488        assert!(!is_known_tooling_dependency("postcss-import"));
489        assert!(!is_known_tooling_dependency("autoprefixer"));
490        assert!(!is_known_tooling_dependency("tailwindcss"));
491        assert!(!is_known_tooling_dependency("@tailwindcss/typography"));
492    }
493
494    #[test]
495    fn catalogue_is_deterministic() {
496        // The OnceLock-cached catalogue returns identical results across calls.
497        assert_eq!(
498            is_known_tooling_dependency("typescript"),
499            is_known_tooling_dependency("typescript")
500        );
501        assert!(is_known_tooling_dependency("typescript"));
502    }
503
504    // ── Catalogue parse + schema guards (issue #462) ──────────────
505
506    #[test]
507    fn catalogue_parses() {
508        // The embedded TOML must parse and carry the canonical entries. Because
509        // the bytes are include_str!-embedded at compile time, this CI test
510        // passing == the released binary parses (no startup panic reachable).
511        let cat = catalogue();
512        assert!(!cat.prefixes.is_empty(), "catalogue must have prefixes");
513        assert!(!cat.exact.is_empty(), "catalogue must have exact entries");
514        assert!(cat.exact.contains("typescript"));
515        assert!(cat.prefixes.iter().any(|p| p == "@types/"));
516    }
517
518    #[test]
519    fn catalogue_has_no_empty_or_whitespace_prefixes() {
520        // An empty / whitespace prefix would make `name.starts_with(p)` match
521        // EVERY package, silently disabling unused-dependency detection.
522        for prefix in &catalogue().prefixes {
523            assert!(
524                !prefix.trim().is_empty(),
525                "catalogue prefix must be non-empty / non-whitespace; got {prefix:?}"
526            );
527        }
528    }
529
530    #[test]
531    fn catalogue_has_no_duplicate_entries() {
532        // Re-parse from source so we can detect duplicates the FxHashSet would
533        // otherwise silently collapse.
534        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
535
536        let mut seen_exact = FxHashSet::default();
537        for entry in &parsed.exact {
538            assert!(
539                seen_exact.insert(entry.name.as_str()),
540                "duplicate exact catalogue entry: {:?}",
541                entry.name
542            );
543        }
544
545        let mut seen_prefix = FxHashSet::default();
546        for entry in &parsed.prefix {
547            assert!(
548                seen_prefix.insert(entry.pattern.as_str()),
549                "duplicate prefix catalogue entry: {:?}",
550                entry.pattern
551            );
552        }
553    }
554
555    #[test]
556    fn catalogue_rejects_framework_plugin_exact_entries() {
557        // Framework-plugin packages (vite-plugin-*, prettier-plugin-*,
558        // eslint-plugin-*, @rollup/plugin-*) must NOT be exact catalogue
559        // entries: their config-parsing plugin credits them only when present
560        // in the config, so listing them here re-introduces the false negative
561        // issue #462 removed. Keep them out (use the plugin's config parser).
562        let parsed: ToolingCatalogue = toml::from_str(CATALOGUE_TOML).unwrap();
563        for entry in &parsed.exact {
564            // Bare name, plus the segment after a leading `@scope/`, so a scoped
565            // community plugin like `@ianvs/prettier-plugin-sort-imports` is
566            // caught by its `prettier-plugin-` tail.
567            let tail = entry
568                .name
569                .strip_prefix('@')
570                .and_then(|rest| rest.split_once('/'))
571                .map(|(_scope, tail)| tail);
572            for bad in FRAMEWORK_PLUGIN_FAMILY_PREFIXES {
573                assert!(
574                    !entry.name.starts_with(bad) && !tail.is_some_and(|t| t.starts_with(bad)),
575                    "exact catalogue entry {:?} is a framework plugin ({bad}); \
576                     credit it in the relevant plugin's config parser instead of the catalogue",
577                    entry.name,
578                );
579            }
580            for bad in FRAMEWORK_PLUGIN_SCOPED_PREFIXES {
581                assert!(
582                    !entry.name.starts_with(bad),
583                    "exact catalogue entry {:?} is a framework plugin ({bad}); \
584                     credit it in the relevant plugin's config parser instead of the catalogue",
585                    entry.name,
586                );
587            }
588        }
589    }
590}