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