1pub mod analyze;
2pub mod cache;
3pub mod churn;
4pub mod cross_reference;
5pub mod discover;
6pub mod duplicates;
7pub mod errors;
8pub mod extract;
9pub mod plugins;
10pub mod progress;
11pub mod results;
12pub mod scripts;
13pub mod suppress;
14pub mod trace;
15
16pub use fallow_graph::graph;
18pub use fallow_graph::project;
19pub use fallow_graph::resolve;
20
21use std::path::Path;
22use std::time::Instant;
23
24use errors::FallowError;
25use fallow_config::{PackageJson, ResolvedConfig, discover_workspaces};
26use rayon::prelude::*;
27use results::AnalysisResults;
28use trace::PipelineTimings;
29
30pub struct AnalysisOutput {
32 pub results: AnalysisResults,
33 pub timings: Option<PipelineTimings>,
34 pub graph: Option<graph::ModuleGraph>,
35}
36
37fn update_cache(
39 store: &mut cache::CacheStore,
40 modules: &[extract::ModuleInfo],
41 files: &[discover::DiscoveredFile],
42) {
43 for module in modules {
44 if let Some(file) = files.get(module.file_id.0 as usize) {
45 let (mt, sz) = file_mtime_and_size(&file.path);
46 if let Some(cached) = store.get_by_path_only(&file.path)
48 && cached.content_hash == module.content_hash
49 {
50 if cached.mtime_secs != mt || cached.file_size != sz {
51 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
52 }
53 continue;
54 }
55 store.insert(&file.path, cache::module_to_cached(module, mt, sz));
56 }
57 }
58 store.retain_paths(files);
59}
60
61fn file_mtime_and_size(path: &std::path::Path) -> (u64, u64) {
63 std::fs::metadata(path)
64 .map(|m| {
65 let mt = m
66 .modified()
67 .ok()
68 .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
69 .map_or(0, |d| d.as_secs());
70 (mt, m.len())
71 })
72 .unwrap_or((0, 0))
73}
74
75pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
77 let output = analyze_full(config, false, false)?;
78 Ok(output.results)
79}
80
81pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
83 let output = analyze_full(config, false, true)?;
84 Ok(output.results)
85}
86
87pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
89 analyze_full(config, true, false)
90}
91
92#[expect(clippy::unnecessary_wraps)] fn analyze_full(
94 config: &ResolvedConfig,
95 retain: bool,
96 collect_usages: bool,
97) -> Result<AnalysisOutput, FallowError> {
98 let _span = tracing::info_span!("fallow_analyze").entered();
99 let pipeline_start = Instant::now();
100
101 let show_progress = !config.quiet
105 && std::io::IsTerminal::is_terminal(&std::io::stderr())
106 && matches!(
107 config.output,
108 fallow_config::OutputFormat::Human
109 | fallow_config::OutputFormat::Compact
110 | fallow_config::OutputFormat::Markdown
111 );
112 let progress = progress::AnalysisProgress::new(show_progress);
113
114 if !config.root.join("node_modules").is_dir() {
116 tracing::warn!(
117 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
118 );
119 }
120
121 let t = Instant::now();
123 let workspaces_vec = discover_workspaces(&config.root);
124 let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
125 if !workspaces_vec.is_empty() {
126 tracing::info!(count = workspaces_vec.len(), "workspaces discovered");
127 }
128
129 let t = Instant::now();
131 let pb = progress.stage_spinner("Discovering files...");
132 let discovered_files = discover::discover_files(config);
133 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
134 pb.finish_and_clear();
135
136 let project = project::ProjectState::new(discovered_files, workspaces_vec);
139 let files = project.files();
140 let workspaces = project.workspaces();
141
142 let t = Instant::now();
144 let pb = progress.stage_spinner("Detecting plugins...");
145 let mut plugin_result = run_plugins(config, files, workspaces);
146 let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
147 pb.finish_and_clear();
148
149 let t = Instant::now();
151 let pkg_path = config.root.join("package.json");
152 if let Ok(pkg) = PackageJson::load(&pkg_path)
153 && let Some(ref pkg_scripts) = pkg.scripts
154 {
155 let scripts_to_analyze = if config.production {
157 scripts::filter_production_scripts(pkg_scripts)
158 } else {
159 pkg_scripts.clone()
160 };
161 let script_analysis = scripts::analyze_scripts(&scripts_to_analyze, &config.root);
162 plugin_result.script_used_packages = script_analysis.used_packages;
163
164 for config_file in &script_analysis.config_files {
166 plugin_result
167 .entry_patterns
168 .push((config_file.clone(), "scripts".to_string()));
169 }
170 }
171 for ws in workspaces {
173 let ws_pkg_path = ws.root.join("package.json");
174 if let Ok(ws_pkg) = PackageJson::load(&ws_pkg_path)
175 && let Some(ref ws_scripts) = ws_pkg.scripts
176 {
177 let scripts_to_analyze = if config.production {
178 scripts::filter_production_scripts(ws_scripts)
179 } else {
180 ws_scripts.clone()
181 };
182 let ws_analysis = scripts::analyze_scripts(&scripts_to_analyze, &ws.root);
183 plugin_result
184 .script_used_packages
185 .extend(ws_analysis.used_packages);
186
187 let ws_prefix = ws
188 .root
189 .strip_prefix(&config.root)
190 .unwrap_or(&ws.root)
191 .to_string_lossy();
192 for config_file in &ws_analysis.config_files {
193 plugin_result
194 .entry_patterns
195 .push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
196 }
197 }
198 }
199 let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
200
201 let t = Instant::now();
203 let pb = progress.stage_spinner(&format!("Parsing {} files...", files.len()));
204 let mut cache_store = if config.no_cache {
205 None
206 } else {
207 cache::CacheStore::load(&config.cache_dir)
208 };
209
210 let parse_result = extract::parse_all_files(files, cache_store.as_ref());
211 let modules = parse_result.modules;
212 let cache_hits = parse_result.cache_hits;
213 let cache_misses = parse_result.cache_misses;
214 let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
215 pb.finish_and_clear();
216
217 let t = Instant::now();
219 if !config.no_cache {
220 let store = cache_store.get_or_insert_with(cache::CacheStore::new);
221 update_cache(store, &modules, files);
222 if let Err(e) = store.save(&config.cache_dir) {
223 tracing::warn!("Failed to save cache: {e}");
224 }
225 }
226 let cache_ms = t.elapsed().as_secs_f64() * 1000.0;
227
228 let t = Instant::now();
230 let mut entry_points = discover::discover_entry_points(config, files);
231 let ws_entries: Vec<_> = workspaces
232 .par_iter()
233 .flat_map(|ws| discover::discover_workspace_entry_points(&ws.root, config, files))
234 .collect();
235 entry_points.extend(ws_entries);
236 let plugin_entries = discover::discover_plugin_entry_points(&plugin_result, config, files);
237 entry_points.extend(plugin_entries);
238 let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
239 entry_points.extend(infra_entries);
240 let entry_points_ms = t.elapsed().as_secs_f64() * 1000.0;
241
242 let t = Instant::now();
244 let pb = progress.stage_spinner("Resolving imports...");
245 let resolved = resolve::resolve_all_imports(
246 &modules,
247 files,
248 workspaces,
249 &plugin_result.active_plugins,
250 &plugin_result.path_aliases,
251 &config.root,
252 );
253 let resolve_ms = t.elapsed().as_secs_f64() * 1000.0;
254 pb.finish_and_clear();
255
256 let t = Instant::now();
258 let pb = progress.stage_spinner("Building module graph...");
259 let graph = graph::ModuleGraph::build(&resolved, &entry_points, files);
260 let graph_ms = t.elapsed().as_secs_f64() * 1000.0;
261 pb.finish_and_clear();
262
263 let t = Instant::now();
265 let pb = progress.stage_spinner("Analyzing...");
266 let result = analyze::find_dead_code_full(
267 &graph,
268 config,
269 &resolved,
270 Some(&plugin_result),
271 workspaces,
272 &modules,
273 collect_usages,
274 );
275 let analyze_ms = t.elapsed().as_secs_f64() * 1000.0;
276 pb.finish_and_clear();
277 progress.finish();
278
279 let total_ms = pipeline_start.elapsed().as_secs_f64() * 1000.0;
280
281 let cache_summary = if cache_hits > 0 {
282 format!(" ({cache_hits} cached, {cache_misses} parsed)")
283 } else {
284 String::new()
285 };
286
287 tracing::debug!(
288 "\n┌─ Pipeline Profile ─────────────────────────────\n\
289 │ discover files: {:>8.1}ms ({} files)\n\
290 │ workspaces: {:>8.1}ms\n\
291 │ plugins: {:>8.1}ms\n\
292 │ script analysis: {:>8.1}ms\n\
293 │ parse/extract: {:>8.1}ms ({} modules{})\n\
294 │ cache update: {:>8.1}ms\n\
295 │ entry points: {:>8.1}ms ({} entries)\n\
296 │ resolve imports: {:>8.1}ms\n\
297 │ build graph: {:>8.1}ms\n\
298 │ analyze: {:>8.1}ms\n\
299 │ ────────────────────────────────────────────\n\
300 │ TOTAL: {:>8.1}ms\n\
301 └─────────────────────────────────────────────────",
302 discover_ms,
303 files.len(),
304 workspaces_ms,
305 plugins_ms,
306 scripts_ms,
307 parse_ms,
308 modules.len(),
309 cache_summary,
310 cache_ms,
311 entry_points_ms,
312 entry_points.len(),
313 resolve_ms,
314 graph_ms,
315 analyze_ms,
316 total_ms,
317 );
318
319 let timings = if retain {
320 Some(PipelineTimings {
321 discover_files_ms: discover_ms,
322 file_count: files.len(),
323 workspaces_ms,
324 workspace_count: workspaces.len(),
325 plugins_ms,
326 script_analysis_ms: scripts_ms,
327 parse_extract_ms: parse_ms,
328 module_count: modules.len(),
329 cache_hits,
330 cache_misses,
331 cache_update_ms: cache_ms,
332 entry_points_ms,
333 entry_point_count: entry_points.len(),
334 resolve_imports_ms: resolve_ms,
335 build_graph_ms: graph_ms,
336 analyze_ms,
337 total_ms,
338 })
339 } else {
340 None
341 };
342
343 Ok(AnalysisOutput {
344 results: result,
345 timings,
346 graph: if retain { Some(graph) } else { None },
347 })
348}
349
350fn run_plugins(
352 config: &ResolvedConfig,
353 files: &[discover::DiscoveredFile],
354 workspaces: &[fallow_config::WorkspaceInfo],
355) -> plugins::AggregatedPluginResult {
356 let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
357 let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
358
359 let pkg_path = config.root.join("package.json");
361 let mut result = PackageJson::load(&pkg_path).map_or_else(
362 |_| plugins::AggregatedPluginResult::default(),
363 |pkg| registry.run(&pkg, &config.root, &file_paths),
364 );
365
366 if workspaces.is_empty() {
367 return result;
368 }
369
370 let precompiled_matchers = registry.precompile_config_matchers();
374 let relative_files: Vec<(&std::path::PathBuf, String)> = file_paths
375 .iter()
376 .map(|f| {
377 let rel = f
378 .strip_prefix(&config.root)
379 .unwrap_or(f)
380 .to_string_lossy()
381 .into_owned();
382 (f, rel)
383 })
384 .collect();
385
386 let ws_results: Vec<_> = workspaces
388 .par_iter()
389 .filter_map(|ws| {
390 let ws_pkg_path = ws.root.join("package.json");
391 let ws_pkg = PackageJson::load(&ws_pkg_path).ok()?;
392 let ws_result = registry.run_workspace_fast(
393 &ws_pkg,
394 &ws.root,
395 &config.root,
396 &precompiled_matchers,
397 &relative_files,
398 );
399 if ws_result.active_plugins.is_empty() {
400 return None;
401 }
402 let ws_prefix = ws
403 .root
404 .strip_prefix(&config.root)
405 .unwrap_or(&ws.root)
406 .to_string_lossy()
407 .into_owned();
408 Some((ws_result, ws_prefix))
409 })
410 .collect();
411
412 let mut seen_plugins: rustc_hash::FxHashSet<String> =
415 result.active_plugins.iter().cloned().collect();
416 let mut seen_prefixes: rustc_hash::FxHashSet<String> =
417 result.virtual_module_prefixes.iter().cloned().collect();
418 for (ws_result, ws_prefix) in ws_results {
419 let prefix_if_needed = |pat: &str| -> String {
424 if pat.starts_with(ws_prefix.as_str()) || pat.starts_with('/') {
425 pat.to_string()
426 } else {
427 format!("{ws_prefix}/{pat}")
428 }
429 };
430
431 for (pat, pname) in &ws_result.entry_patterns {
432 result
433 .entry_patterns
434 .push((prefix_if_needed(pat), pname.clone()));
435 }
436 for (pat, pname) in &ws_result.always_used {
437 result
438 .always_used
439 .push((prefix_if_needed(pat), pname.clone()));
440 }
441 for (pat, pname) in &ws_result.discovered_always_used {
442 result
443 .discovered_always_used
444 .push((prefix_if_needed(pat), pname.clone()));
445 }
446 for (file_pat, exports) in &ws_result.used_exports {
447 result
448 .used_exports
449 .push((prefix_if_needed(file_pat), exports.clone()));
450 }
451 for plugin_name in ws_result.active_plugins {
453 if !seen_plugins.contains(&plugin_name) {
454 seen_plugins.insert(plugin_name.clone());
455 result.active_plugins.push(plugin_name);
456 }
457 }
458 result
460 .referenced_dependencies
461 .extend(ws_result.referenced_dependencies);
462 result.setup_files.extend(ws_result.setup_files);
463 result
464 .tooling_dependencies
465 .extend(ws_result.tooling_dependencies);
466 for prefix in ws_result.virtual_module_prefixes {
469 if !seen_prefixes.contains(&prefix) {
470 seen_prefixes.insert(prefix.clone());
471 result.virtual_module_prefixes.push(prefix);
472 }
473 }
474 }
475
476 result
477}
478
479pub fn analyze_project(root: &Path) -> Result<AnalysisResults, FallowError> {
481 let config = default_config(root);
482 analyze_with_usages(&config)
483}
484
485pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
487 let user_config = fallow_config::FallowConfig::find_and_load(root)
488 .ok()
489 .flatten();
490 match user_config {
491 Some((config, _path)) => config.resolve(
492 root.to_path_buf(),
493 fallow_config::OutputFormat::Human,
494 num_cpus(),
495 false,
496 true, ),
498 None => fallow_config::FallowConfig {
499 schema: None,
500 extends: vec![],
501 entry: vec![],
502 ignore_patterns: vec![],
503 framework: vec![],
504 workspaces: None,
505 ignore_dependencies: vec![],
506 ignore_exports: vec![],
507 duplicates: fallow_config::DuplicatesConfig::default(),
508 health: fallow_config::HealthConfig::default(),
509 rules: fallow_config::RulesConfig::default(),
510 production: false,
511 plugins: vec![],
512 overrides: vec![],
513 }
514 .resolve(
515 root.to_path_buf(),
516 fallow_config::OutputFormat::Human,
517 num_cpus(),
518 false,
519 true,
520 ),
521 }
522}
523
524fn num_cpus() -> usize {
525 std::thread::available_parallelism()
526 .map(|n| n.get())
527 .unwrap_or(4)
528}