Skip to main content

fallow_core/
analyze.rs

1use std::collections::{HashMap, HashSet};
2
3use fallow_config::{PackageJson, ResolvedConfig};
4
5use crate::discover::FileId;
6use crate::extract::{MemberKind, ModuleInfo};
7use crate::graph::ModuleGraph;
8use crate::resolve::ResolvedModule;
9use crate::results::*;
10use crate::suppress::{self, IssueKind, Suppression};
11
12/// Convert a byte offset in source text to a 1-based line and 0-based column (byte offset from
13/// start of the line). Uses byte counting to stay consistent with Oxc's byte-offset spans.
14fn byte_offset_to_line_col(source: &str, byte_offset: u32) -> (u32, u32) {
15    let byte_offset = byte_offset as usize;
16    let prefix = &source[..byte_offset.min(source.len())];
17    let line = prefix.bytes().filter(|&b| b == b'\n').count() as u32 + 1;
18    let col = prefix
19        .rfind('\n')
20        .map(|pos| byte_offset - pos - 1)
21        .unwrap_or(byte_offset) as u32;
22    (line, col)
23}
24
25/// Read source content from disk, returning empty string on failure.
26fn read_source(path: &std::path::Path) -> String {
27    std::fs::read_to_string(path).unwrap_or_default()
28}
29
30/// Find all dead code in the project.
31pub fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
32    find_dead_code_with_resolved(graph, config, &[], None)
33}
34
35/// Find all dead code, with optional resolved module data and plugin context.
36pub fn find_dead_code_with_resolved(
37    graph: &ModuleGraph,
38    config: &ResolvedConfig,
39    resolved_modules: &[ResolvedModule],
40    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
41) -> AnalysisResults {
42    find_dead_code_full(graph, config, resolved_modules, plugin_result, &[], &[])
43}
44
45/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
46pub fn find_dead_code_full(
47    graph: &ModuleGraph,
48    config: &ResolvedConfig,
49    resolved_modules: &[ResolvedModule],
50    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
51    workspaces: &[fallow_config::WorkspaceInfo],
52    modules: &[ModuleInfo],
53) -> AnalysisResults {
54    let _span = tracing::info_span!("find_dead_code").entered();
55
56    // Build suppression index: FileId -> suppressions
57    let suppressions_by_file: HashMap<FileId, &[Suppression]> = modules
58        .iter()
59        .filter(|m| !m.suppressions.is_empty())
60        .map(|m| (m.file_id, m.suppressions.as_slice()))
61        .collect();
62
63    let mut results = AnalysisResults::default();
64
65    if config.detect.unused_files {
66        results.unused_files = find_unused_files(graph, &suppressions_by_file);
67    }
68
69    if config.detect.unused_exports || config.detect.unused_types {
70        let (exports, types) =
71            find_unused_exports(graph, config, plugin_result, &suppressions_by_file);
72        if config.detect.unused_exports {
73            results.unused_exports = exports;
74        }
75        if config.detect.unused_types {
76            results.unused_types = types;
77        }
78    }
79
80    if config.detect.unused_enum_members || config.detect.unused_class_members {
81        let (enum_members, class_members) =
82            find_unused_members(graph, config, resolved_modules, &suppressions_by_file);
83        if config.detect.unused_enum_members {
84            results.unused_enum_members = enum_members;
85        }
86        if config.detect.unused_class_members {
87            results.unused_class_members = class_members;
88        }
89    }
90
91    // Build merged dependency set from root + all workspace package.json files
92    let pkg_path = config.root.join("package.json");
93    if let Ok(pkg) = PackageJson::load(&pkg_path) {
94        if config.detect.unused_dependencies || config.detect.unused_dev_dependencies {
95            let (deps, dev_deps) =
96                find_unused_dependencies(graph, &pkg, config, plugin_result, workspaces);
97            if config.detect.unused_dependencies {
98                results.unused_dependencies = deps;
99            }
100            if config.detect.unused_dev_dependencies {
101                results.unused_dev_dependencies = dev_deps;
102            }
103        }
104
105        if config.detect.unlisted_dependencies {
106            results.unlisted_dependencies =
107                find_unlisted_dependencies(graph, &pkg, config, workspaces);
108        }
109    }
110
111    if config.detect.unresolved_imports && !resolved_modules.is_empty() {
112        results.unresolved_imports =
113            find_unresolved_imports(resolved_modules, config, &suppressions_by_file);
114    }
115
116    if config.detect.duplicate_exports {
117        results.duplicate_exports = find_duplicate_exports(graph, config, &suppressions_by_file);
118    }
119
120    // In production mode, detect dependencies that are only used via type-only imports
121    if config.production {
122        let pkg_path = config.root.join("package.json");
123        if let Ok(pkg) = PackageJson::load(&pkg_path) {
124            results.type_only_dependencies =
125                find_type_only_dependencies(graph, &pkg, config, workspaces);
126        }
127    }
128
129    results
130}
131
132/// Find files that are not reachable from any entry point.
133///
134/// TypeScript declaration files (`.d.ts`) are excluded because they are consumed
135/// by the TypeScript compiler via `tsconfig.json` includes, not via explicit
136/// import statements. Flagging them as unused is a false positive.
137///
138/// Configuration files (e.g., `babel.config.js`, `.eslintrc.js`, `knip.config.ts`)
139/// are also excluded because they are consumed by tools, not via imports.
140///
141/// Barrel files (index.ts that only re-export) are excluded when their re-export
142/// sources are reachable — they serve an organizational purpose even if consumers
143/// import directly from the source files rather than through the barrel.
144fn find_unused_files(
145    graph: &ModuleGraph,
146    suppressions_by_file: &HashMap<FileId, &[Suppression]>,
147) -> Vec<UnusedFile> {
148    graph
149        .modules
150        .iter()
151        .filter(|m| !m.is_reachable && !m.is_entry_point)
152        .filter(|m| !is_declaration_file(&m.path))
153        .filter(|m| !is_config_file(&m.path))
154        .filter(|m| !is_barrel_with_reachable_sources(m, graph))
155        .filter(|m| {
156            !suppressions_by_file
157                .get(&m.file_id)
158                .is_some_and(|supps| suppress::is_file_suppressed(supps, IssueKind::UnusedFile))
159        })
160        .map(|m| UnusedFile {
161            path: m.path.clone(),
162        })
163        .collect()
164}
165
166/// Check if a path is a TypeScript declaration file (`.d.ts`, `.d.mts`, `.d.cts`).
167fn is_declaration_file(path: &std::path::Path) -> bool {
168    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
169    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
170}
171
172/// Check if a module is a barrel file (only re-exports) whose sources are reachable.
173///
174/// A barrel file like `index.ts` that only contains `export { Foo } from './source'`
175/// lines serves an organizational purpose. If the source modules are reachable,
176/// the barrel file should not be reported as unused — consumers may have bypassed
177/// it with direct imports, but the barrel still provides valid re-exports.
178fn is_barrel_with_reachable_sources(
179    module: &crate::graph::ModuleNode,
180    graph: &ModuleGraph,
181) -> bool {
182    // Must have re-exports
183    if module.re_exports.is_empty() {
184        return false;
185    }
186
187    // Must be a pure barrel: no local exports with real spans (only re-export-generated
188    // exports have span 0..0) and no CJS exports
189    let has_local_exports = module
190        .exports
191        .iter()
192        .any(|e| e.span.start != 0 || e.span.end != 0);
193    if has_local_exports || module.has_cjs_exports {
194        return false;
195    }
196
197    // At least one re-export source must be reachable
198    module.re_exports.iter().any(|re| {
199        let source_idx = re.source_file.0 as usize;
200        graph
201            .modules
202            .get(source_idx)
203            .is_some_and(|m| m.is_reachable)
204    })
205}
206
207/// Check if a file is a configuration file consumed by tooling, not via imports.
208///
209/// These files should never be reported as unused because they are loaded by
210/// their respective tools (e.g., Babel reads `babel.config.js`, ESLint reads
211/// `eslint.config.ts`, etc.) rather than being imported by application code.
212fn is_config_file(path: &std::path::Path) -> bool {
213    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
214
215    // Dotfiles with "rc" suffix pattern (e.g., .secretlintrc.cjs, .commitlintrc.js, .prettierrc.js)
216    // Only match files with "rc." before the extension — avoids false matches on arbitrary dotfiles.
217    if name.starts_with('.') && !name.starts_with("..") {
218        let lower = name.to_ascii_lowercase();
219        // .foorc.{ext} pattern — standard for tool configs
220        if lower.contains("rc.") {
221            return true;
222        }
223    }
224
225    // Files matching common config naming patterns.
226    // Each pattern is a prefix — the file must start with it.
227    let config_patterns = [
228        // Build tools
229        "babel.config.",
230        "rollup.config.",
231        "webpack.config.",
232        "postcss.config.",
233        "stencil.config.",
234        "remotion.config.",
235        "metro.config.",
236        "tsup.config.",
237        "unbuild.config.",
238        "esbuild.config.",
239        "swc.config.",
240        "turbo.",
241        // Testing
242        "jest.config.",
243        "jest.setup.",
244        "vitest.config.",
245        "vitest.ci.config.",
246        "vitest.setup.",
247        "vitest.workspace.",
248        "playwright.config.",
249        "cypress.config.",
250        // Linting & formatting
251        "eslint.config.",
252        "prettier.config.",
253        "stylelint.config.",
254        "lint-staged.config.",
255        "commitlint.config.",
256        // Frameworks / CMS
257        "next.config.",
258        "next-sitemap.config.",
259        "nuxt.config.",
260        "astro.config.",
261        "sanity.config.",
262        "vite.config.",
263        "tailwind.config.",
264        "drizzle.config.",
265        "knexfile.",
266        "sentry.client.config.",
267        "sentry.server.config.",
268        "sentry.edge.config.",
269        "react-router.config.",
270        // Documentation
271        "typedoc.",
272        // Analysis & misc
273        "knip.config.",
274        "fallow.config.",
275        "i18next-parser.config.",
276        "codegen.config.",
277        "graphql.config.",
278        "npmpackagejsonlint.config.",
279        "release-it.",
280        "release.config.",
281        "contentlayer.config.",
282        // Environment declarations
283        "next-env.d.",
284        "env.d.",
285        "vite-env.d.",
286    ];
287
288    config_patterns.iter().any(|p| name.starts_with(p))
289}
290
291/// Find exports that are never imported by other files.
292fn find_unused_exports(
293    graph: &ModuleGraph,
294    config: &ResolvedConfig,
295    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
296    suppressions_by_file: &HashMap<FileId, &[Suppression]>,
297) -> (Vec<UnusedExport>, Vec<UnusedExport>) {
298    let mut unused_exports = Vec::new();
299    let mut unused_types = Vec::new();
300
301    // Pre-compile glob matchers for ignore rules and framework rules
302    let ignore_matchers: Vec<(globset::GlobMatcher, &[String])> = config
303        .ignore_export_rules
304        .iter()
305        .filter_map(|rule| {
306            globset::Glob::new(&rule.file)
307                .ok()
308                .map(|g| (g.compile_matcher(), rule.exports.as_slice()))
309        })
310        .collect();
311
312    let framework_matchers: Vec<(globset::GlobMatcher, &[String])> = config
313        .framework_rules
314        .iter()
315        .flat_map(|rule| &rule.used_exports)
316        .filter_map(|used| {
317            globset::Glob::new(&used.file_pattern)
318                .ok()
319                .map(|g| (g.compile_matcher(), used.exports.as_slice()))
320        })
321        .collect();
322
323    // Also compile plugin-discovered used_exports rules
324    let plugin_matchers: Vec<(globset::GlobMatcher, Vec<&str>)> = plugin_result
325        .map(|pr| {
326            pr.used_exports
327                .iter()
328                .filter_map(|(file_pat, exports)| {
329                    globset::Glob::new(file_pat).ok().map(|g| {
330                        (
331                            g.compile_matcher(),
332                            exports.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
333                        )
334                    })
335                })
336                .collect()
337        })
338        .unwrap_or_default();
339
340    for module in &graph.modules {
341        // Skip unreachable modules (already reported as unused files)
342        if !module.is_reachable {
343            continue;
344        }
345
346        // Skip entry points (their exports are consumed externally)
347        if module.is_entry_point {
348            continue;
349        }
350
351        // Skip CJS modules with module.exports (hard to track individual exports)
352        if module.has_cjs_exports && module.exports.is_empty() {
353            continue;
354        }
355
356        // Namespace imports are now handled with member-access narrowing in graph.rs:
357        // only specific accessed members get references populated. No blanket skip needed.
358
359        // Svelte files use `export let`/`export const` for component props, which are
360        // consumed by the Svelte runtime rather than imported by other modules. Since we
361        // can't distinguish props from utility exports in the `<script>` block without
362        // Svelte compiler semantics, we skip export analysis entirely for reachable
363        // .svelte files. Unreachable Svelte files are still caught by `find_unused_files`.
364        if module.path.extension().is_some_and(|ext| ext == "svelte") {
365            continue;
366        }
367
368        // Check ignore rules — compute relative path and string once per module
369        let relative_path = module
370            .path
371            .strip_prefix(&config.root)
372            .unwrap_or(&module.path);
373        let file_str = relative_path.to_string_lossy();
374
375        // Pre-check which ignore/framework matchers match this file
376        let matching_ignore: Vec<&[String]> = ignore_matchers
377            .iter()
378            .filter(|(m, _)| m.is_match(file_str.as_ref()))
379            .map(|(_, exports)| *exports)
380            .collect();
381
382        let matching_framework: Vec<&[String]> = framework_matchers
383            .iter()
384            .filter(|(m, _)| m.is_match(file_str.as_ref()))
385            .map(|(_, exports)| *exports)
386            .collect();
387
388        // Check plugin-discovered used_exports rules
389        let matching_plugin: Vec<&Vec<&str>> = plugin_matchers
390            .iter()
391            .filter(|(m, _)| m.is_match(file_str.as_ref()))
392            .map(|(_, exports)| exports)
393            .collect();
394
395        // Lazily load source content for line/col computation
396        let mut source_content: Option<String> = None;
397
398        for export in &module.exports {
399            if export.references.is_empty() {
400                let export_str = export.name.to_string();
401
402                // Check if this export is ignored by config
403                if matching_ignore
404                    .iter()
405                    .any(|exports| exports.iter().any(|e| e == "*" || e == &export_str))
406                {
407                    continue;
408                }
409
410                // Check if this export is considered "used" by a framework rule
411                if matching_framework
412                    .iter()
413                    .any(|exports| exports.iter().any(|e| e == &export_str))
414                {
415                    continue;
416                }
417
418                // Check if this export is considered "used" by a plugin rule
419                if matching_plugin
420                    .iter()
421                    .any(|exports| exports.iter().any(|e| *e == export_str))
422                {
423                    continue;
424                }
425
426                let source = source_content.get_or_insert_with(|| read_source(&module.path));
427                let (line, col) = byte_offset_to_line_col(source, export.span.start);
428
429                // Barrel re-exports are synthesized in graph.rs with Span::new(0, 0) as a sentinel.
430                let is_re_export = export.span.start == 0 && export.span.end == 0;
431
432                // Check inline suppression
433                let issue_kind = if export.is_type_only {
434                    IssueKind::UnusedType
435                } else {
436                    IssueKind::UnusedExport
437                };
438                if let Some(supps) = suppressions_by_file.get(&module.file_id)
439                    && suppress::is_suppressed(supps, line, issue_kind)
440                {
441                    continue;
442                }
443
444                let unused = UnusedExport {
445                    path: module.path.clone(),
446                    export_name: export_str,
447                    is_type_only: export.is_type_only,
448                    line,
449                    col,
450                    span_start: export.span.start,
451                    is_re_export,
452                };
453
454                if export.is_type_only {
455                    unused_types.push(unused);
456                } else {
457                    unused_exports.push(unused);
458                }
459            }
460        }
461    }
462
463    (unused_exports, unused_types)
464}
465
466/// Find dependencies in package.json that are never imported.
467///
468/// Checks both the root package.json and each workspace's package.json.
469/// For workspace deps, only files within that workspace are considered when
470/// determining whether a dependency is used (mirroring `find_unlisted_dependencies`).
471fn find_unused_dependencies(
472    graph: &ModuleGraph,
473    pkg: &PackageJson,
474    config: &ResolvedConfig,
475    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
476    workspaces: &[fallow_config::WorkspaceInfo],
477) -> (Vec<UnusedDependency>, Vec<UnusedDependency>) {
478    // Collect deps referenced in config files (discovered by plugins)
479    let plugin_referenced: HashSet<&str> = plugin_result
480        .map(|pr| {
481            pr.referenced_dependencies
482                .iter()
483                .map(|s| s.as_str())
484                .collect()
485        })
486        .unwrap_or_default();
487
488    // Collect tooling deps from plugins
489    let plugin_tooling: HashSet<&str> = plugin_result
490        .map(|pr| pr.tooling_dependencies.iter().map(|s| s.as_str()).collect())
491        .unwrap_or_default();
492
493    // Collect packages used as binaries in package.json scripts
494    let script_used: HashSet<&str> = plugin_result
495        .map(|pr| pr.script_used_packages.iter().map(|s| s.as_str()).collect())
496        .unwrap_or_default();
497
498    // Collect workspace package names — these are internal deps, not npm packages
499    let workspace_names: HashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
500
501    // Build per-package set of files that use it (globally)
502    let used_packages: HashSet<&str> = graph.package_usage.keys().map(|s| s.as_str()).collect();
503
504    let root_pkg_path = config.root.join("package.json");
505
506    // --- Root package.json check (existing behavior: any file can satisfy usage) ---
507    let mut unused_deps: Vec<UnusedDependency> = pkg
508        .production_dependency_names()
509        .into_iter()
510        .filter(|dep| !used_packages.contains(dep.as_str()))
511        .filter(|dep| !script_used.contains(dep.as_str()))
512        .filter(|dep| !is_implicit_dependency(dep))
513        .filter(|dep| !plugin_referenced.contains(dep.as_str()))
514        .filter(|dep| !config.ignore_dependencies.iter().any(|d| d == dep))
515        .filter(|dep| !workspace_names.contains(dep.as_str()))
516        .map(|dep| UnusedDependency {
517            package_name: dep,
518            location: DependencyLocation::Dependencies,
519            path: root_pkg_path.clone(),
520        })
521        .collect();
522
523    let mut unused_dev_deps: Vec<UnusedDependency> = pkg
524        .dev_dependency_names()
525        .into_iter()
526        .filter(|dep| !used_packages.contains(dep.as_str()))
527        .filter(|dep| !script_used.contains(dep.as_str()))
528        .filter(|dep| !is_tooling_dependency(dep))
529        .filter(|dep| !plugin_tooling.contains(dep.as_str()))
530        .filter(|dep| !plugin_referenced.contains(dep.as_str()))
531        .filter(|dep| !config.ignore_dependencies.iter().any(|d| d == dep))
532        .filter(|dep| !workspace_names.contains(dep.as_str()))
533        .map(|dep| UnusedDependency {
534            package_name: dep,
535            location: DependencyLocation::DevDependencies,
536            path: root_pkg_path.clone(),
537        })
538        .collect();
539
540    // --- Workspace package.json checks: scope usage to files within each workspace ---
541    // Track which deps are already flagged from root to avoid double-reporting
542    let root_flagged: HashSet<String> = unused_deps
543        .iter()
544        .chain(unused_dev_deps.iter())
545        .map(|d| d.package_name.clone())
546        .collect();
547
548    for ws in workspaces {
549        let ws_pkg_path = ws.root.join("package.json");
550        let ws_pkg = match PackageJson::load(&ws_pkg_path) {
551            Ok(p) => p,
552            Err(_) => continue,
553        };
554
555        let canonical_ws = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
556
557        // Helper: check if a dependency is used by any file within this workspace
558        let is_used_in_workspace = |dep: &str| -> bool {
559            if let Some(file_ids) = graph.package_usage.get(dep) {
560                file_ids.iter().any(|id| {
561                    if let Some(module) = graph.modules.get(id.0 as usize) {
562                        let file_canonical = module
563                            .path
564                            .canonicalize()
565                            .unwrap_or_else(|_| module.path.clone());
566                        file_canonical.starts_with(&canonical_ws)
567                    } else {
568                        false
569                    }
570                })
571            } else {
572                false
573            }
574        };
575
576        // Check workspace production dependencies
577        for dep in ws_pkg.production_dependency_names() {
578            if root_flagged.contains(&dep) {
579                continue; // Already flagged from root
580            }
581            if is_implicit_dependency(&dep) {
582                continue;
583            }
584            if script_used.contains(dep.as_str()) {
585                continue;
586            }
587            if plugin_referenced.contains(dep.as_str()) {
588                continue;
589            }
590            if config.ignore_dependencies.iter().any(|d| d == &dep) {
591                continue;
592            }
593            if workspace_names.contains(dep.as_str()) {
594                continue;
595            }
596            if is_used_in_workspace(&dep) {
597                continue;
598            }
599            unused_deps.push(UnusedDependency {
600                package_name: dep,
601                location: DependencyLocation::Dependencies,
602                path: ws_pkg_path.clone(),
603            });
604        }
605
606        // Check workspace dev dependencies
607        for dep in ws_pkg.dev_dependency_names() {
608            if root_flagged.contains(&dep) {
609                continue;
610            }
611            if is_tooling_dependency(&dep) {
612                continue;
613            }
614            if script_used.contains(dep.as_str()) {
615                continue;
616            }
617            if plugin_tooling.contains(dep.as_str()) {
618                continue;
619            }
620            if plugin_referenced.contains(dep.as_str()) {
621                continue;
622            }
623            if config.ignore_dependencies.iter().any(|d| d == &dep) {
624                continue;
625            }
626            if workspace_names.contains(dep.as_str()) {
627                continue;
628            }
629            if is_used_in_workspace(&dep) {
630                continue;
631            }
632            unused_dev_deps.push(UnusedDependency {
633                package_name: dep,
634                location: DependencyLocation::DevDependencies,
635                path: ws_pkg_path.clone(),
636            });
637        }
638    }
639
640    (unused_deps, unused_dev_deps)
641}
642
643/// Find unused enum and class members in exported symbols.
644///
645/// Collects all `Identifier.member` static member accesses from all modules,
646/// maps them to their imported names, and filters out members that are accessed.
647fn find_unused_members(
648    graph: &ModuleGraph,
649    _config: &ResolvedConfig,
650    resolved_modules: &[ResolvedModule],
651    suppressions_by_file: &HashMap<FileId, &[Suppression]>,
652) -> (Vec<UnusedMember>, Vec<UnusedMember>) {
653    let mut unused_enum_members = Vec::new();
654    let mut unused_class_members = Vec::new();
655
656    // Build a set of (export_name, member_name) pairs that are accessed across all modules.
657    // We map local import names back to the original imported names.
658    let mut accessed_members: HashSet<(String, String)> = HashSet::new();
659
660    // Also build a per-file set of `this.member` accesses. These indicate internal usage
661    // within a class body — class members accessed via `this.foo` are used internally
662    // even if no external code accesses them via `ClassName.foo`.
663    let mut self_accessed_members: HashMap<crate::discover::FileId, HashSet<String>> =
664        HashMap::new();
665
666    // Build a set of export names that are used as whole objects (Object.values, for..in, etc.).
667    // All members of these exports should be considered used.
668    let mut whole_object_used_exports: HashSet<String> = HashSet::new();
669
670    for resolved in resolved_modules {
671        // Build a map from local name -> imported name for this module's imports
672        let local_to_imported: HashMap<&str, &str> = resolved
673            .resolved_imports
674            .iter()
675            .filter_map(|imp| match &imp.info.imported_name {
676                crate::extract::ImportedName::Named(name) => {
677                    Some((imp.info.local_name.as_str(), name.as_str()))
678                }
679                crate::extract::ImportedName::Default => {
680                    Some((imp.info.local_name.as_str(), "default"))
681                }
682                _ => None,
683            })
684            .collect();
685
686        for access in &resolved.member_accesses {
687            // Track `this.member` accesses per-file for internal class usage
688            if access.object == "this" {
689                self_accessed_members
690                    .entry(resolved.file_id)
691                    .or_default()
692                    .insert(access.member.clone());
693                continue;
694            }
695            // If the object is a local name for an import, map it to the original export name
696            let export_name = local_to_imported
697                .get(access.object.as_str())
698                .copied()
699                .unwrap_or(access.object.as_str());
700            accessed_members.insert((export_name.to_string(), access.member.clone()));
701        }
702
703        // Map whole-object uses from local names to imported names
704        for local_name in &resolved.whole_object_uses {
705            let export_name = local_to_imported
706                .get(local_name.as_str())
707                .copied()
708                .unwrap_or(local_name.as_str());
709            whole_object_used_exports.insert(export_name.to_string());
710        }
711    }
712
713    for module in &graph.modules {
714        if !module.is_reachable || module.is_entry_point {
715            continue;
716        }
717
718        // Lazily load source content for line/col computation
719        let mut source_content: Option<String> = None;
720
721        for export in &module.exports {
722            if export.members.is_empty() {
723                continue;
724            }
725
726            // If the export itself is unused, skip member analysis (whole export is dead)
727            if export.references.is_empty() && !graph.has_namespace_import(module.file_id) {
728                continue;
729            }
730
731            let export_name = export.name.to_string();
732
733            // If this export is used as a whole object (Object.values, for..in, etc.),
734            // all members are considered used — skip individual member analysis.
735            if whole_object_used_exports.contains(&export_name) {
736                continue;
737            }
738
739            // Get `this.member` accesses from this file (internal class usage)
740            let file_self_accesses = self_accessed_members.get(&module.file_id);
741
742            for member in &export.members {
743                // Check if this member is accessed anywhere via external import
744                if accessed_members.contains(&(export_name.clone(), member.name.clone())) {
745                    continue;
746                }
747
748                // Check if this member is accessed via `this.member` within the same file
749                // (internal class usage — e.g., constructor sets this.label, methods use this.label)
750                if matches!(
751                    member.kind,
752                    MemberKind::ClassMethod | MemberKind::ClassProperty
753                ) && file_self_accesses.is_some_and(|accesses| accesses.contains(&member.name))
754                {
755                    continue;
756                }
757
758                // Skip React class component lifecycle methods — they are called by the
759                // React runtime, not user code, so they should never be flagged as unused.
760                // Also skip Angular lifecycle hooks (OnInit, OnDestroy, etc.).
761                if matches!(
762                    member.kind,
763                    MemberKind::ClassMethod | MemberKind::ClassProperty
764                ) && (is_react_lifecycle_method(&member.name)
765                    || is_angular_lifecycle_method(&member.name))
766                {
767                    continue;
768                }
769
770                let source = source_content.get_or_insert_with(|| read_source(&module.path));
771                let (line, col) = byte_offset_to_line_col(source, member.span.start);
772
773                // Check inline suppression
774                let issue_kind = match member.kind {
775                    MemberKind::EnumMember => IssueKind::UnusedEnumMember,
776                    MemberKind::ClassMethod | MemberKind::ClassProperty => {
777                        IssueKind::UnusedClassMember
778                    }
779                };
780                if let Some(supps) = suppressions_by_file.get(&module.file_id)
781                    && suppress::is_suppressed(supps, line, issue_kind)
782                {
783                    continue;
784                }
785
786                let unused = UnusedMember {
787                    path: module.path.clone(),
788                    parent_name: export_name.clone(),
789                    member_name: member.name.clone(),
790                    kind: member.kind.clone(),
791                    line,
792                    col,
793                };
794
795                match member.kind {
796                    MemberKind::EnumMember => unused_enum_members.push(unused),
797                    MemberKind::ClassMethod | MemberKind::ClassProperty => {
798                        unused_class_members.push(unused);
799                    }
800                }
801            }
802        }
803    }
804
805    (unused_enum_members, unused_class_members)
806}
807
808/// Find production dependencies that are only imported via type-only imports.
809///
810/// In production mode, `import type { Foo } from 'pkg'` is erased at compile time,
811/// meaning the dependency is not needed at runtime. Such dependencies should be
812/// moved to devDependencies.
813fn find_type_only_dependencies(
814    graph: &ModuleGraph,
815    pkg: &PackageJson,
816    config: &ResolvedConfig,
817    workspaces: &[fallow_config::WorkspaceInfo],
818) -> Vec<TypeOnlyDependency> {
819    let root_pkg_path = config.root.join("package.json");
820    let workspace_names: HashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
821
822    let mut type_only_deps = Vec::new();
823
824    // Check root production dependencies
825    for dep in pkg.production_dependency_names() {
826        // Skip internal workspace packages
827        if workspace_names.contains(dep.as_str()) {
828            continue;
829        }
830        // Skip ignored dependencies
831        if config.ignore_dependencies.iter().any(|d| d == &dep) {
832            continue;
833        }
834
835        let has_any_usage = graph.package_usage.contains_key(dep.as_str());
836        let has_type_only_usage = graph.type_only_package_usage.contains_key(dep.as_str());
837
838        if !has_any_usage {
839            // Not used at all — this will be caught by unused_dependencies
840            continue;
841        }
842
843        // Check if ALL usages are type-only: the number of type-only usages must equal
844        // the total number of usages for this package
845        let total_count = graph
846            .package_usage
847            .get(dep.as_str())
848            .map(|v| v.len())
849            .unwrap_or(0);
850        let type_only_count = graph
851            .type_only_package_usage
852            .get(dep.as_str())
853            .map(|v| v.len())
854            .unwrap_or(0);
855
856        if has_type_only_usage && type_only_count == total_count {
857            type_only_deps.push(TypeOnlyDependency {
858                package_name: dep,
859                path: root_pkg_path.clone(),
860            });
861        }
862    }
863
864    type_only_deps
865}
866
867/// Find dependencies used in imports but not listed in package.json.
868fn find_unlisted_dependencies(
869    graph: &ModuleGraph,
870    pkg: &PackageJson,
871    config: &ResolvedConfig,
872    workspaces: &[fallow_config::WorkspaceInfo],
873) -> Vec<UnlistedDependency> {
874    let all_deps: HashSet<String> = pkg.all_dependency_names().into_iter().collect();
875
876    // Build a set of all deps across all workspace package.json files.
877    // In monorepos, imports in workspace files reference deps from that workspace's package.json.
878    let mut all_workspace_deps: HashSet<String> = all_deps.clone();
879    // Also collect workspace package names — internal workspace deps should not be flagged
880    let mut workspace_names: HashSet<String> = HashSet::new();
881    // Map: canonical workspace root -> set of dep names (for per-file checks)
882    let mut ws_dep_map: Vec<(std::path::PathBuf, HashSet<String>)> = Vec::new();
883
884    for ws in workspaces {
885        workspace_names.insert(ws.name.clone());
886        let ws_pkg_path = ws.root.join("package.json");
887        if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path) {
888            let ws_deps: HashSet<String> = ws_pkg.all_dependency_names().into_iter().collect();
889            all_workspace_deps.extend(ws_deps.iter().cloned());
890            let canonical_ws = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
891            ws_dep_map.push((canonical_ws, ws_deps));
892        }
893    }
894
895    let mut unlisted: HashMap<String, Vec<std::path::PathBuf>> = HashMap::new();
896
897    for (package_name, file_ids) in &graph.package_usage {
898        if is_builtin_module(package_name) || is_path_alias(package_name) {
899            continue;
900        }
901        // Skip internal workspace package names
902        if workspace_names.contains(package_name) {
903            continue;
904        }
905        // Quick check: if listed in any root or workspace deps, skip
906        if all_workspace_deps.contains(package_name) {
907            continue;
908        }
909
910        // Slower fallback: check if each importing file belongs to a workspace that lists this dep
911        let mut unlisted_paths: Vec<std::path::PathBuf> = Vec::new();
912        for id in file_ids {
913            if let Some(module) = graph.modules.get(id.0 as usize) {
914                let file_canonical = module.path.canonicalize().unwrap_or(module.path.clone());
915                let listed_in_ws = ws_dep_map.iter().any(|(ws_root, ws_deps)| {
916                    file_canonical.starts_with(ws_root) && ws_deps.contains(package_name)
917                });
918                // Also check root deps
919                let listed_in_root = all_deps.contains(package_name);
920                if !listed_in_ws && !listed_in_root {
921                    unlisted_paths.push(module.path.clone());
922                }
923            }
924        }
925
926        if !unlisted_paths.is_empty() {
927            unlisted_paths.sort();
928            unlisted_paths.dedup();
929            unlisted.insert(package_name.clone(), unlisted_paths);
930        }
931    }
932
933    let _ = config; // future use
934    unlisted
935        .into_iter()
936        .map(|(name, paths)| UnlistedDependency {
937            package_name: name,
938            imported_from: paths,
939        })
940        .collect()
941}
942
943/// Check if a package name looks like a TypeScript path alias rather than an npm package.
944///
945/// Common patterns: `@/components`, `@app/utils`, `~/lib`, `#internal/module`,
946/// `@Components/Button` (PascalCase tsconfig paths).
947/// These are typically defined in tsconfig.json `paths` or package.json `imports`.
948fn is_path_alias(name: &str) -> bool {
949    // `#` prefix is Node.js imports maps (package.json "imports" field)
950    if name.starts_with('#') {
951        return true;
952    }
953    // `~/` prefix is a common alias convention (e.g., Nuxt, custom tsconfig)
954    if name.starts_with("~/") {
955        return true;
956    }
957    // `@/` is a very common path alias (e.g., `@/components/Foo`)
958    if name.starts_with("@/") {
959        return true;
960    }
961    // npm scoped packages MUST be lowercase (npm registry requirement).
962    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
963    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
964    if name.starts_with('@') {
965        let scope = name.split('/').next().unwrap_or(name);
966        if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
967            return true;
968        }
969    }
970
971    false
972}
973
974/// Find imports that could not be resolved.
975fn find_unresolved_imports(
976    resolved_modules: &[ResolvedModule],
977    _config: &ResolvedConfig,
978    suppressions_by_file: &HashMap<FileId, &[Suppression]>,
979) -> Vec<UnresolvedImport> {
980    let mut unresolved = Vec::new();
981
982    for module in resolved_modules {
983        // Lazily load source content for line/col computation
984        let mut source_content: Option<String> = None;
985
986        for import in &module.resolved_imports {
987            if let crate::resolve::ResolveResult::Unresolvable(spec) = &import.target {
988                let source = source_content.get_or_insert_with(|| read_source(&module.path));
989                let (line, col) = byte_offset_to_line_col(source, import.info.span.start);
990
991                // Check inline suppression
992                if let Some(supps) = suppressions_by_file.get(&module.file_id)
993                    && suppress::is_suppressed(supps, line, IssueKind::UnresolvedImport)
994                {
995                    continue;
996                }
997
998                unresolved.push(UnresolvedImport {
999                    path: module.path.clone(),
1000                    specifier: spec.clone(),
1001                    line,
1002                    col,
1003                });
1004            }
1005        }
1006    }
1007
1008    unresolved
1009}
1010
1011/// Find exports that appear with the same name in multiple files (potential duplicates).
1012///
1013/// Barrel re-exports (files that only re-export from other modules via `export { X } from './source'`)
1014/// are excluded — having an index.ts re-export the same name as the source module is the normal
1015/// barrel file pattern, not a true duplicate.
1016fn find_duplicate_exports(
1017    graph: &ModuleGraph,
1018    config: &ResolvedConfig,
1019    suppressions_by_file: &HashMap<FileId, &[Suppression]>,
1020) -> Vec<DuplicateExport> {
1021    // Build a set of re-export relationships: (re-exporting module idx) -> set of (source module idx)
1022    let mut re_export_sources: HashMap<usize, HashSet<usize>> = HashMap::new();
1023    for (idx, module) in graph.modules.iter().enumerate() {
1024        for re in &module.re_exports {
1025            re_export_sources
1026                .entry(idx)
1027                .or_default()
1028                .insert(re.source_file.0 as usize);
1029        }
1030    }
1031
1032    let mut export_locations: HashMap<String, Vec<(usize, std::path::PathBuf)>> = HashMap::new();
1033
1034    for (idx, module) in graph.modules.iter().enumerate() {
1035        if !module.is_reachable || module.is_entry_point {
1036            continue;
1037        }
1038
1039        // Skip files with file-wide duplicate-export suppression
1040        if suppressions_by_file
1041            .get(&module.file_id)
1042            .is_some_and(|supps| suppress::is_file_suppressed(supps, IssueKind::DuplicateExport))
1043        {
1044            continue;
1045        }
1046
1047        for export in &module.exports {
1048            if matches!(export.name, crate::extract::ExportName::Default) {
1049                continue; // Skip default exports
1050            }
1051            // Skip synthetic re-export entries (span 0..0) — these are generated by
1052            // graph construction for re-exports, not real local declarations
1053            if export.span.start == 0 && export.span.end == 0 {
1054                continue;
1055            }
1056            let name = export.name.to_string();
1057            export_locations
1058                .entry(name)
1059                .or_default()
1060                .push((idx, module.path.clone()));
1061        }
1062    }
1063
1064    // Filter: only keep truly independent duplicates (not re-export chains)
1065    let _ = config; // used for consistency
1066    export_locations
1067        .into_iter()
1068        .filter_map(|(name, locations)| {
1069            if locations.len() <= 1 {
1070                return None;
1071            }
1072            // Remove entries where one module re-exports from another in the set.
1073            // For each pair (A, B), if A re-exports from B or B re-exports from A,
1074            // they are part of the same export chain, not true duplicates.
1075            let module_indices: HashSet<usize> = locations.iter().map(|(idx, _)| *idx).collect();
1076            let independent: Vec<std::path::PathBuf> = locations
1077                .into_iter()
1078                .filter(|(idx, _)| {
1079                    // Keep this module only if it doesn't re-export from another module in the set
1080                    // AND no other module in the set re-exports from it (unless both are sources)
1081                    let sources = re_export_sources.get(idx);
1082                    let has_source_in_set = sources
1083                        .map(|s| s.iter().any(|src| module_indices.contains(src)))
1084                        .unwrap_or(false);
1085                    !has_source_in_set
1086                })
1087                .map(|(_, path)| path)
1088                .collect();
1089
1090            if independent.len() > 1 {
1091                Some(DuplicateExport {
1092                    export_name: name,
1093                    locations: independent,
1094                })
1095            } else {
1096                None
1097            }
1098        })
1099        .collect()
1100}
1101
1102/// Check if a package name is a Node.js built-in module.
1103fn is_builtin_module(name: &str) -> bool {
1104    let builtins = [
1105        "assert",
1106        "assert/strict",
1107        "async_hooks",
1108        "buffer",
1109        "child_process",
1110        "cluster",
1111        "console",
1112        "constants",
1113        "crypto",
1114        "dgram",
1115        "diagnostics_channel",
1116        "dns",
1117        "dns/promises",
1118        "domain",
1119        "events",
1120        "fs",
1121        "fs/promises",
1122        "http",
1123        "http2",
1124        "https",
1125        "inspector",
1126        "inspector/promises",
1127        "module",
1128        "net",
1129        "os",
1130        "path",
1131        "path/posix",
1132        "path/win32",
1133        "perf_hooks",
1134        "process",
1135        "punycode",
1136        "querystring",
1137        "readline",
1138        "readline/promises",
1139        "repl",
1140        "stream",
1141        "stream/consumers",
1142        "stream/promises",
1143        "stream/web",
1144        "string_decoder",
1145        "sys",
1146        "test",
1147        "test/reporters",
1148        "timers",
1149        "timers/promises",
1150        "tls",
1151        "trace_events",
1152        "tty",
1153        "url",
1154        "util",
1155        "util/types",
1156        "v8",
1157        "vm",
1158        "wasi",
1159        "worker_threads",
1160        "zlib",
1161    ];
1162    let stripped = name.strip_prefix("node:").unwrap_or(name);
1163    // Check exact match or subpath (e.g., "fs/promises" matches "fs/promises",
1164    // "assert/strict" matches "assert/strict")
1165    builtins.contains(&stripped) || {
1166        // Handle deep subpaths like "stream/consumers" or "test/reporters"
1167        stripped
1168            .split('/')
1169            .next()
1170            .is_some_and(|root| builtins.contains(&root))
1171    }
1172}
1173
1174/// Dependencies that are used implicitly (not via imports).
1175fn is_implicit_dependency(name: &str) -> bool {
1176    if name.starts_with("@types/") {
1177        return true;
1178    }
1179
1180    // Framework runtime dependencies that are used implicitly (e.g., JSX runtime,
1181    // bundler injection) and never appear as explicit imports in source code.
1182    let implicit_deps = [
1183        "react-dom",
1184        "react-dom/client",
1185        "react-native",
1186        "@next/font",
1187        "@next/mdx",
1188        "@next/bundle-analyzer",
1189        "@next/env",
1190        // WebSocket optional native addons (peer deps of ws)
1191        "utf-8-validate",
1192        "bufferutil",
1193    ];
1194    implicit_deps.contains(&name)
1195}
1196
1197/// Dev dependencies that are tooling (used by CLI, not imported in code).
1198fn is_tooling_dependency(name: &str) -> bool {
1199    let tooling_prefixes = [
1200        "@types/",
1201        "eslint",
1202        "@typescript-eslint",
1203        "husky",
1204        "lint-staged",
1205        "commitlint",
1206        "@commitlint",
1207        "stylelint",
1208        "postcss",
1209        "autoprefixer",
1210        "tailwindcss",
1211        "@tailwindcss",
1212        "@vitest/",
1213        "@jest/",
1214        "@testing-library/",
1215        "@playwright/",
1216        "@storybook/",
1217        "storybook",
1218        "@babel/",
1219        "babel-",
1220        "@react-native-community/cli",
1221        "@react-native/",
1222        "secretlint",
1223        "@secretlint/",
1224        "oxlint",
1225        // Release & publishing tooling
1226        "@semantic-release/",
1227        "semantic-release",
1228        "@release-it/",
1229        "@lerna-lite/",
1230        "@changesets/",
1231        // Build tool plugins (used in config)
1232        "@graphql-codegen/",
1233        "@rollup/",
1234        // Biome tooling
1235        "@biomejs/",
1236    ];
1237
1238    let exact_matches = [
1239        "typescript",
1240        "prettier",
1241        "turbo",
1242        "concurrently",
1243        "cross-env",
1244        "rimraf",
1245        "npm-run-all",
1246        "npm-run-all2",
1247        "nodemon",
1248        "ts-node",
1249        "tsx",
1250        "knip",
1251        "fallow",
1252        "jest",
1253        "vitest",
1254        "happy-dom",
1255        "jsdom",
1256        "vite",
1257        "sass",
1258        "sass-embedded",
1259        "webpack",
1260        "webpack-cli",
1261        "webpack-dev-server",
1262        "esbuild",
1263        "rollup",
1264        "swc",
1265        "@swc/core",
1266        "@swc/jest",
1267        "terser",
1268        "cssnano",
1269        "sharp",
1270        // Release & publishing
1271        "release-it",
1272        "lerna",
1273        // Dotenv CLI tools
1274        "dotenv-cli",
1275        "dotenv-flow",
1276        // Code quality & analysis
1277        "oxfmt",
1278        "jscpd",
1279        "npm-check-updates",
1280        "markdownlint-cli",
1281        "npm-package-json-lint",
1282        "synp",
1283        "flow-bin",
1284        // i18n tooling
1285        "i18next-parser",
1286        "i18next-conv",
1287        // Bundle analysis & build tooling
1288        "webpack-bundle-analyzer",
1289        // Vite plugins (used in config, not imported)
1290        "vite-plugin-svgr",
1291        "vite-plugin-eslint",
1292        "@vitejs/plugin-vue",
1293        "@vitejs/plugin-react",
1294        // Site generation / SEO
1295        "next-sitemap",
1296        // Build tools (used by CLI, not imported in code)
1297        "tsup",
1298        "unbuild",
1299        "typedoc",
1300        // Monorepo tools
1301        "nx",
1302        "@manypkg/cli",
1303        // Vue tooling
1304        "vue-tsc",
1305        "@vue/tsconfig",
1306        "@tsconfig/node20",
1307        "@tsconfig/react-native",
1308        // TypeScript experimental
1309        "@typescript/native-preview",
1310        // CSS-only deps (not imported in JS)
1311        "tw-animate-css",
1312        // Formatting
1313        "@ianvs/prettier-plugin-sort-imports",
1314        "prettier-plugin-tailwindcss",
1315        "prettier-plugin-organize-imports",
1316        // Additional build tooling
1317        "@vitejs/plugin-react-swc",
1318        "@vitejs/plugin-legacy",
1319    ];
1320
1321    tooling_prefixes.iter().any(|p| name.starts_with(p)) || exact_matches.contains(&name)
1322}
1323
1324/// Angular lifecycle hooks and framework-invoked methods.
1325///
1326/// These should never be flagged as unused class members because they are
1327/// called by the Angular framework, not user code.
1328fn is_angular_lifecycle_method(name: &str) -> bool {
1329    matches!(
1330        name,
1331        "ngOnInit"
1332            | "ngOnDestroy"
1333            | "ngOnChanges"
1334            | "ngDoCheck"
1335            | "ngAfterContentInit"
1336            | "ngAfterContentChecked"
1337            | "ngAfterViewInit"
1338            | "ngAfterViewChecked"
1339            | "ngAcceptInputType"
1340            // Angular guard/resolver/interceptor methods
1341            | "canActivate"
1342            | "canDeactivate"
1343            | "canActivateChild"
1344            | "canMatch"
1345            | "resolve"
1346            | "intercept"
1347            | "transform"
1348            // Angular form-related methods
1349            | "validate"
1350            | "registerOnChange"
1351            | "registerOnTouched"
1352            | "writeValue"
1353            | "setDisabledState"
1354    )
1355}
1356
1357fn is_react_lifecycle_method(name: &str) -> bool {
1358    matches!(
1359        name,
1360        "render"
1361            | "componentDidMount"
1362            | "componentDidUpdate"
1363            | "componentWillUnmount"
1364            | "shouldComponentUpdate"
1365            | "getSnapshotBeforeUpdate"
1366            | "getDerivedStateFromProps"
1367            | "getDerivedStateFromError"
1368            | "componentDidCatch"
1369            | "componentWillMount"
1370            | "componentWillReceiveProps"
1371            | "componentWillUpdate"
1372            | "UNSAFE_componentWillMount"
1373            | "UNSAFE_componentWillReceiveProps"
1374            | "UNSAFE_componentWillUpdate"
1375            | "getChildContext"
1376            | "contextType"
1377    )
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383
1384    // is_builtin_module tests
1385    #[test]
1386    fn builtin_module_fs() {
1387        assert!(is_builtin_module("fs"));
1388    }
1389
1390    #[test]
1391    fn builtin_module_path() {
1392        assert!(is_builtin_module("path"));
1393    }
1394
1395    #[test]
1396    fn builtin_module_with_node_prefix() {
1397        assert!(is_builtin_module("node:fs"));
1398        assert!(is_builtin_module("node:path"));
1399        assert!(is_builtin_module("node:crypto"));
1400    }
1401
1402    #[test]
1403    fn builtin_module_all_known() {
1404        let known = [
1405            "assert",
1406            "buffer",
1407            "child_process",
1408            "cluster",
1409            "console",
1410            "constants",
1411            "crypto",
1412            "dgram",
1413            "dns",
1414            "domain",
1415            "events",
1416            "fs",
1417            "http",
1418            "http2",
1419            "https",
1420            "module",
1421            "net",
1422            "os",
1423            "path",
1424            "perf_hooks",
1425            "process",
1426            "punycode",
1427            "querystring",
1428            "readline",
1429            "repl",
1430            "stream",
1431            "string_decoder",
1432            "sys",
1433            "timers",
1434            "tls",
1435            "tty",
1436            "url",
1437            "util",
1438            "v8",
1439            "vm",
1440            "wasi",
1441            "worker_threads",
1442            "zlib",
1443        ];
1444        for name in &known {
1445            assert!(is_builtin_module(name), "{name} should be a builtin module");
1446        }
1447    }
1448
1449    #[test]
1450    fn not_builtin_module() {
1451        assert!(!is_builtin_module("react"));
1452        assert!(!is_builtin_module("lodash"));
1453        assert!(!is_builtin_module("express"));
1454        assert!(!is_builtin_module("@scope/pkg"));
1455    }
1456
1457    #[test]
1458    fn not_builtin_similar_names() {
1459        assert!(!is_builtin_module("filesystem"));
1460        assert!(!is_builtin_module("pathlib"));
1461        assert!(!is_builtin_module("node:react"));
1462    }
1463
1464    // is_implicit_dependency tests
1465    #[test]
1466    fn implicit_dep_types_packages() {
1467        assert!(is_implicit_dependency("@types/node"));
1468        assert!(is_implicit_dependency("@types/react"));
1469        assert!(is_implicit_dependency("@types/jest"));
1470    }
1471
1472    #[test]
1473    fn not_implicit_dep() {
1474        assert!(!is_implicit_dependency("react"));
1475        assert!(!is_implicit_dependency("@scope/types"));
1476        assert!(!is_implicit_dependency("types"));
1477        assert!(!is_implicit_dependency("typescript"));
1478        assert!(!is_implicit_dependency("prettier"));
1479        assert!(!is_implicit_dependency("eslint"));
1480    }
1481
1482    // is_tooling_dependency tests
1483    #[test]
1484    fn tooling_dep_prefixes() {
1485        assert!(is_tooling_dependency("@types/node"));
1486        assert!(is_tooling_dependency("eslint"));
1487        assert!(is_tooling_dependency("eslint-plugin-react"));
1488        assert!(is_tooling_dependency("prettier"));
1489        assert!(is_tooling_dependency("@typescript-eslint/parser"));
1490        assert!(is_tooling_dependency("husky"));
1491        assert!(is_tooling_dependency("lint-staged"));
1492        assert!(is_tooling_dependency("commitlint"));
1493        assert!(is_tooling_dependency("@commitlint/config-conventional"));
1494        assert!(is_tooling_dependency("stylelint"));
1495        assert!(is_tooling_dependency("postcss"));
1496        assert!(is_tooling_dependency("autoprefixer"));
1497        assert!(is_tooling_dependency("tailwindcss"));
1498        assert!(is_tooling_dependency("@tailwindcss/forms"));
1499    }
1500
1501    #[test]
1502    fn tooling_dep_exact_matches() {
1503        assert!(is_tooling_dependency("typescript"));
1504        assert!(is_tooling_dependency("prettier"));
1505        assert!(is_tooling_dependency("turbo"));
1506        assert!(is_tooling_dependency("concurrently"));
1507        assert!(is_tooling_dependency("cross-env"));
1508        assert!(is_tooling_dependency("rimraf"));
1509        assert!(is_tooling_dependency("npm-run-all"));
1510        assert!(is_tooling_dependency("nodemon"));
1511        assert!(is_tooling_dependency("ts-node"));
1512        assert!(is_tooling_dependency("tsx"));
1513    }
1514
1515    #[test]
1516    fn not_tooling_dep() {
1517        assert!(!is_tooling_dependency("react"));
1518        assert!(!is_tooling_dependency("next"));
1519        assert!(!is_tooling_dependency("lodash"));
1520        assert!(!is_tooling_dependency("express"));
1521        assert!(!is_tooling_dependency("@emotion/react"));
1522    }
1523
1524    // New tooling dependency tests (Issue 2)
1525    #[test]
1526    fn tooling_dep_testing_frameworks() {
1527        assert!(is_tooling_dependency("jest"));
1528        assert!(is_tooling_dependency("vitest"));
1529        assert!(is_tooling_dependency("@jest/globals"));
1530        assert!(is_tooling_dependency("@vitest/coverage-v8"));
1531        assert!(is_tooling_dependency("@testing-library/react"));
1532        assert!(is_tooling_dependency("@testing-library/jest-dom"));
1533        assert!(is_tooling_dependency("@playwright/test"));
1534    }
1535
1536    #[test]
1537    fn tooling_dep_environments_and_cli() {
1538        assert!(is_tooling_dependency("happy-dom"));
1539        assert!(is_tooling_dependency("jsdom"));
1540        assert!(is_tooling_dependency("knip"));
1541    }
1542
1543    // React lifecycle method tests (Issue 1)
1544    #[test]
1545    fn react_lifecycle_standard_methods() {
1546        assert!(is_react_lifecycle_method("render"));
1547        assert!(is_react_lifecycle_method("componentDidMount"));
1548        assert!(is_react_lifecycle_method("componentDidUpdate"));
1549        assert!(is_react_lifecycle_method("componentWillUnmount"));
1550        assert!(is_react_lifecycle_method("shouldComponentUpdate"));
1551        assert!(is_react_lifecycle_method("getSnapshotBeforeUpdate"));
1552    }
1553
1554    #[test]
1555    fn react_lifecycle_static_methods() {
1556        assert!(is_react_lifecycle_method("getDerivedStateFromProps"));
1557        assert!(is_react_lifecycle_method("getDerivedStateFromError"));
1558    }
1559
1560    #[test]
1561    fn react_lifecycle_error_boundary() {
1562        assert!(is_react_lifecycle_method("componentDidCatch"));
1563    }
1564
1565    #[test]
1566    fn react_lifecycle_deprecated_and_unsafe() {
1567        assert!(is_react_lifecycle_method("componentWillMount"));
1568        assert!(is_react_lifecycle_method("componentWillReceiveProps"));
1569        assert!(is_react_lifecycle_method("componentWillUpdate"));
1570        assert!(is_react_lifecycle_method("UNSAFE_componentWillMount"));
1571        assert!(is_react_lifecycle_method(
1572            "UNSAFE_componentWillReceiveProps"
1573        ));
1574        assert!(is_react_lifecycle_method("UNSAFE_componentWillUpdate"));
1575    }
1576
1577    #[test]
1578    fn react_lifecycle_context_methods() {
1579        assert!(is_react_lifecycle_method("getChildContext"));
1580        assert!(is_react_lifecycle_method("contextType"));
1581    }
1582
1583    #[test]
1584    fn not_react_lifecycle_method() {
1585        assert!(!is_react_lifecycle_method("handleClick"));
1586        assert!(!is_react_lifecycle_method("fetchData"));
1587        assert!(!is_react_lifecycle_method("constructor"));
1588        assert!(!is_react_lifecycle_method("setState"));
1589        assert!(!is_react_lifecycle_method("forceUpdate"));
1590        assert!(!is_react_lifecycle_method("customMethod"));
1591    }
1592
1593    // Declaration file tests (Issue 4)
1594    #[test]
1595    fn declaration_file_dts() {
1596        assert!(is_declaration_file(std::path::Path::new("styled.d.ts")));
1597        assert!(is_declaration_file(std::path::Path::new(
1598            "src/types/styled.d.ts"
1599        )));
1600        assert!(is_declaration_file(std::path::Path::new("env.d.ts")));
1601    }
1602
1603    #[test]
1604    fn declaration_file_dmts_dcts() {
1605        assert!(is_declaration_file(std::path::Path::new("module.d.mts")));
1606        assert!(is_declaration_file(std::path::Path::new("module.d.cts")));
1607    }
1608
1609    #[test]
1610    fn not_declaration_file() {
1611        assert!(!is_declaration_file(std::path::Path::new("index.ts")));
1612        assert!(!is_declaration_file(std::path::Path::new("component.tsx")));
1613        assert!(!is_declaration_file(std::path::Path::new("utils.js")));
1614        assert!(!is_declaration_file(std::path::Path::new("styles.d.css")));
1615    }
1616
1617    // byte_offset_to_line_col tests
1618    #[test]
1619    fn byte_offset_empty_source() {
1620        assert_eq!(byte_offset_to_line_col("", 0), (1, 0));
1621    }
1622
1623    #[test]
1624    fn byte_offset_single_line_start() {
1625        assert_eq!(byte_offset_to_line_col("hello", 0), (1, 0));
1626    }
1627
1628    #[test]
1629    fn byte_offset_single_line_middle() {
1630        assert_eq!(byte_offset_to_line_col("hello", 4), (1, 4));
1631    }
1632
1633    #[test]
1634    fn byte_offset_multiline_start_of_line2() {
1635        // "line1\nline2\nline3"
1636        //  01234 5 678901 2
1637        // offset 6 = start of "line2"
1638        let source = "line1\nline2\nline3";
1639        assert_eq!(byte_offset_to_line_col(source, 6), (2, 0));
1640    }
1641
1642    #[test]
1643    fn byte_offset_multiline_middle_of_line3() {
1644        // "line1\nline2\nline3"
1645        //  01234 5 67890 1 23456
1646        //                1 12345
1647        // offset 14 = 'n' in "line3" (col 2)
1648        let source = "line1\nline2\nline3";
1649        assert_eq!(byte_offset_to_line_col(source, 14), (3, 2));
1650    }
1651
1652    #[test]
1653    fn byte_offset_at_newline_boundary() {
1654        // "line1\nline2"
1655        // offset 5 = the '\n' character itself
1656        let source = "line1\nline2";
1657        assert_eq!(byte_offset_to_line_col(source, 5), (1, 5));
1658    }
1659
1660    #[test]
1661    fn byte_offset_beyond_source_length() {
1662        // Line count is clamped (prefix is sliced to source.len()), but the
1663        // byte-offset column is passed through unclamped because the function
1664        // uses the raw byte_offset for the column fallback.
1665        let source = "hello";
1666        assert_eq!(byte_offset_to_line_col(source, 100), (1, 100));
1667    }
1668
1669    #[test]
1670    fn byte_offset_multibyte_utf8() {
1671        // Emoji is 4 bytes: "hi\n" (3 bytes) + emoji (4 bytes) + "x" (1 byte)
1672        let source = "hi\n\u{1F600}x";
1673        // offset 3 = start of line 2, col 0
1674        assert_eq!(byte_offset_to_line_col(source, 3), (2, 0));
1675        // offset 7 = 'x' (after 4-byte emoji), col 4 (byte-based)
1676        assert_eq!(byte_offset_to_line_col(source, 7), (2, 4));
1677    }
1678
1679    #[test]
1680    fn byte_offset_multibyte_accented_chars() {
1681        // 'e' with accent (U+00E9) is 2 bytes in UTF-8
1682        let source = "caf\u{00E9}\nbar";
1683        // "caf\u{00E9}" = 3 + 2 = 5 bytes, then '\n' at offset 5
1684        // 'b' at offset 6 → line 2, col 0
1685        assert_eq!(byte_offset_to_line_col(source, 6), (2, 0));
1686        // '\u{00E9}' starts at offset 3, col 3 (byte-based)
1687        assert_eq!(byte_offset_to_line_col(source, 3), (1, 3));
1688    }
1689
1690    // is_path_alias tests
1691    #[test]
1692    fn path_alias_at_slash() {
1693        assert!(is_path_alias("@/components"));
1694    }
1695
1696    #[test]
1697    fn path_alias_tilde() {
1698        assert!(is_path_alias("~/lib"));
1699    }
1700
1701    #[test]
1702    fn path_alias_hash_imports_map() {
1703        assert!(is_path_alias("#internal/module"));
1704    }
1705
1706    #[test]
1707    fn path_alias_pascal_case_scope() {
1708        assert!(is_path_alias("@Components/Button"));
1709    }
1710
1711    #[test]
1712    fn not_path_alias_regular_package() {
1713        assert!(!is_path_alias("react"));
1714    }
1715
1716    #[test]
1717    fn not_path_alias_scoped_npm_package() {
1718        assert!(!is_path_alias("@scope/pkg"));
1719    }
1720
1721    #[test]
1722    fn not_path_alias_emotion_react() {
1723        assert!(!is_path_alias("@emotion/react"));
1724    }
1725
1726    #[test]
1727    fn not_path_alias_lodash() {
1728        assert!(!is_path_alias("lodash"));
1729    }
1730
1731    #[test]
1732    fn not_path_alias_lowercase_short_scope() {
1733        assert!(!is_path_alias("@s/lowercase"));
1734    }
1735
1736    // is_angular_lifecycle_method tests
1737    #[test]
1738    fn angular_lifecycle_core_hooks() {
1739        assert!(is_angular_lifecycle_method("ngOnInit"));
1740        assert!(is_angular_lifecycle_method("ngOnDestroy"));
1741        assert!(is_angular_lifecycle_method("ngOnChanges"));
1742        assert!(is_angular_lifecycle_method("ngAfterViewInit"));
1743    }
1744
1745    #[test]
1746    fn angular_lifecycle_check_hooks() {
1747        assert!(is_angular_lifecycle_method("ngDoCheck"));
1748        assert!(is_angular_lifecycle_method("ngAfterContentChecked"));
1749        assert!(is_angular_lifecycle_method("ngAfterViewChecked"));
1750    }
1751
1752    #[test]
1753    fn angular_lifecycle_content_hooks() {
1754        assert!(is_angular_lifecycle_method("ngAfterContentInit"));
1755        assert!(is_angular_lifecycle_method("ngAcceptInputType"));
1756    }
1757
1758    #[test]
1759    fn angular_lifecycle_guard_resolver_methods() {
1760        assert!(is_angular_lifecycle_method("canActivate"));
1761        assert!(is_angular_lifecycle_method("canDeactivate"));
1762        assert!(is_angular_lifecycle_method("canActivateChild"));
1763        assert!(is_angular_lifecycle_method("canMatch"));
1764        assert!(is_angular_lifecycle_method("resolve"));
1765        assert!(is_angular_lifecycle_method("intercept"));
1766        assert!(is_angular_lifecycle_method("transform"));
1767    }
1768
1769    #[test]
1770    fn angular_lifecycle_form_methods() {
1771        assert!(is_angular_lifecycle_method("validate"));
1772        assert!(is_angular_lifecycle_method("registerOnChange"));
1773        assert!(is_angular_lifecycle_method("registerOnTouched"));
1774        assert!(is_angular_lifecycle_method("writeValue"));
1775        assert!(is_angular_lifecycle_method("setDisabledState"));
1776    }
1777
1778    #[test]
1779    fn not_angular_lifecycle_method() {
1780        assert!(!is_angular_lifecycle_method("onClick"));
1781        assert!(!is_angular_lifecycle_method("handleSubmit"));
1782        assert!(!is_angular_lifecycle_method("render"));
1783    }
1784}