1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
10#![cfg_attr(
11 test,
12 allow(
13 clippy::unwrap_used,
14 clippy::expect_used,
15 reason = "tests use unwrap and expect to keep fixture setup concise"
16 )
17)]
18
19use std::fmt;
20#[cfg(test)]
21use std::path::Path;
22
23use rustc_hash::FxHashMap;
24
25pub mod baseline;
26#[path = "changed_files.rs"]
27mod changed_files_impl;
28#[path = "churn.rs"]
29mod churn_impl;
30pub mod codeowners;
31mod core_backend;
32mod cross_reference;
33mod css;
34mod dead_code;
35mod discover;
36mod duplicates;
37mod error;
38mod feature_flags;
39mod flags;
40#[path = "git_env.rs"]
41mod git_env_impl;
42mod health;
43mod module_graph;
44mod plugins;
45mod project_config;
46mod public_api;
47pub mod results;
48mod security;
49mod session;
50mod source;
51mod suppress;
52mod trace;
53mod trace_chain;
54pub mod validate;
55pub mod vital_signs;
56
57pub use changed_files_impl::{
58 ChangedFilesError, changed_files, filter_duplication_by_changed_files,
59 filter_results_by_changed_files, get_changed_files, resolve_git_common_dir,
60 resolve_git_toplevel, set_spawn_hook, try_get_changed_diff, try_get_changed_files,
61 try_get_changed_files_with_toplevel, validate_git_ref,
62};
63pub use churn_impl::{
64 AuthorContribution, ChurnResult, ChurnTrend, FileChurn, SinceDuration, analyze_churn,
65 analyze_churn_cached, is_git_repo, parse_since, set_spawn_hook as set_churn_spawn_hook,
66};
67pub use cross_reference::{CombinedFinding, CrossReferenceResult, DeadCodeKind, cross_reference};
68pub use dead_code::{
69 analyze, analyze_retaining_modules, analyze_with_file_hashes, analyze_with_parse_result,
70 analyze_with_trace, analyze_with_usages, analyze_with_usages_and_complexity,
71 filter_by_changed_files, filter_to_workspaces,
72};
73pub use discover::{
74 AnalysisDiscovery, CategorizedEntryPoints, DiscoveredFile, EntryPoint, EntryPointSource,
75 FileId, HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS,
76 collect_plugin_hidden_dir_scopes, discover_entry_points, discover_files,
77 discover_files_with_additional_hidden_dirs, discover_files_with_plugin_scopes,
78 discover_plugin_entry_points, discover_workspace_entry_points, is_allowed_hidden_dir,
79};
80pub use duplicates::{
81 CloneFingerprintSet, FINGERPRINT_PREFIX, clone_fingerprint, dominant_identifier,
82 find_duplicates, find_duplicates_touching_files_with_defaults, find_duplicates_with_defaults,
83 fingerprint_for_fragment, recompute_stats, refresh_clone_families,
84 source_token_kinds_equivalent,
85};
86pub use error::emit_error;
87use fallow_types::extract::ModuleInfo;
88use fallow_types::results::AnalysisResults;
89pub use flags::{
90 FeatureFlagsAnalysis, analyze_feature_flags, builtin_env_prefixes, builtin_sdk_providers,
91};
92pub use git_env_impl::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
93pub use health::{
94 ComplexityRunOptions, ComplexitySectionOptions, DerivedComplexityOptions,
95 DerivedHealthSections, HealthCoverageInputs, HealthExecutionOptions, HealthGateOptions,
96 HealthGroupResolver, HealthPipelineInputs, HealthRunOptions, HealthRunOptionsInput,
97 HealthScopeInputs, HealthSeams, HealthSectionOptions, HealthSharedParseData, HealthSort,
98 HealthThresholdOverrides, RuntimeCoverageOptions, RuntimeCoverageSeamInput,
99 derive_complexity_sections, derive_health_run_options, derive_health_sections,
100 execute_health_inner, run_ungrouped_health, validate_coverage_root_absolute,
101 validate_health_churn_file,
102};
103pub use health::{ownership as health_ownership, scoring as health_scoring};
104pub use module_graph::{
105 CoordinationGapPaths, DirectImporterSummary, FocusFileFactsPaths, ImpactClosurePaths,
106 ImportedSymbolSummary, ModuleValueExport, PartitionOrderPaths, RetainedModuleGraph,
107 ReviewUnitPaths, export_lines_for_changed_paths, focus_facts_for_changed_paths,
108 impact_closure_for_changed_paths, internal_consumers_for_changed_paths, module_value_exports,
109 partition_order_for_changed_paths,
110};
111pub use plugins::registry::{
112 PluginRegexValidationError, builtin_plugin_names, format_plugin_regex_errors,
113};
114pub use plugins::{AggregatedPluginResult, PluginRegistry};
115pub use project_config::{
116 ProjectConfig, ProjectConfigOptions, config_for_project, config_for_project_analysis,
117 resolve_cache_max_size_bytes,
118};
119pub use public_api::public_api_package_entry_points;
120pub use results::{
121 DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
122 DeadCodeAnalysisWithHashes, DuplicationAnalysis, HealthAnalysisResult, ProjectAnalysisOutput,
123};
124pub use security::{derive_security_severity, security_catalogue_title};
125pub use session::{AnalysisSession, AnalysisSessionArtifacts};
126pub use source::inventory::{
127 InventoryComplexity, InventoryEntry, walk_source, walk_source_with_complexity,
128};
129pub use suppress::{IssueKind, Suppression, is_file_suppressed, is_suppressed};
130pub use trace::{
131 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ImpactClosureGap,
132 ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup, TracedExport,
133 TracedReExport, trace_clone, trace_clone_by_fingerprint, trace_dependency, trace_export,
134 trace_file, trace_impact_closure,
135};
136pub use trace_chain::trace_symbol_chain;
137
138pub type EngineResult<T> = Result<T, EngineError>;
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct EngineError {
144 message: String,
145}
146
147impl EngineError {
148 #[must_use]
150 pub fn new(message: impl Into<String>) -> Self {
151 Self {
152 message: message.into(),
153 }
154 }
155
156 #[must_use]
158 pub fn message(&self) -> &str {
159 &self.message
160 }
161}
162
163impl fmt::Display for EngineError {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str(&self.message)
166 }
167}
168
169impl std::error::Error for EngineError {}
170
171pub(crate) fn engine_error(err: impl fmt::Display) -> EngineError {
172 EngineError::new(err.to_string())
173}
174
175#[must_use]
177pub fn health_shared_parse_data_from_artifacts(
178 results: &AnalysisResults,
179 graph: Option<RetainedModuleGraph>,
180 modules: Option<Vec<ModuleInfo>>,
181 files: Option<Vec<DiscoveredFile>>,
182 script_used_packages: impl IntoIterator<Item = String>,
183) -> Option<HealthSharedParseData> {
184 let (Some(modules), Some(files)) = (modules, files) else {
185 return None;
186 };
187 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
188 results: results.clone(),
189 timings: None,
190 graph: Some(graph),
191 modules: None,
192 files: None,
193 script_used_packages: script_used_packages.into_iter().collect(),
194 file_hashes: FxHashMap::default(),
195 });
196 Some(HealthSharedParseData {
197 files,
198 modules,
199 analysis_output,
200 })
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use fallow_config::ProductionAnalysis;
207 use fallow_types::output_format::OutputFormat;
208
209 #[test]
210 fn engine_error_displays_message() {
211 let err = EngineError::new("config failed");
212
213 assert_eq!(err.message(), "config failed");
214 assert_eq!(err.to_string(), "config failed");
215 }
216
217 #[test]
218 fn analysis_session_loads_config_and_discovered_files() {
219 let temp = tempfile::tempdir().expect("tempdir");
220 let src = temp.path().join("src");
221 std::fs::create_dir(&src).expect("src dir");
222 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
223
224 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
225
226 assert_eq!(session.root(), temp.path());
227 assert!(session.config_path().is_none());
228 assert!(session.files().iter().any(|file| {
229 file.path
230 .strip_prefix(temp.path())
231 .is_ok_and(|path| path == Path::new("src/index.ts"))
232 }));
233 }
234
235 #[test]
236 fn analysis_session_applies_config_adjustment_before_discovery() {
237 let temp = tempfile::tempdir().expect("tempdir");
238 let src = temp.path().join("src");
239 std::fs::create_dir(&src).expect("src dir");
240 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
241 std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
242 .expect("test source file");
243
244 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
245 config.production = true;
246 })
247 .expect("session loads");
248
249 let relative_paths: Vec<_> = session
250 .files()
251 .iter()
252 .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
253 .collect();
254 assert!(relative_paths.contains(&Path::new("src/index.ts")));
255 assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
256 }
257
258 #[test]
259 fn analysis_session_captures_workspace_diagnostics() {
260 let temp = tempfile::tempdir().expect("tempdir");
261 std::fs::write(
262 temp.path().join("package.json"),
263 r#"{"name":"diagnostic-root","workspaces":["packages/*"]}"#,
264 )
265 .expect("package json");
266 std::fs::create_dir_all(temp.path().join("packages/empty")).expect("workspace dir");
267 std::fs::create_dir(temp.path().join("src")).expect("src dir");
268 std::fs::write(
269 temp.path().join("src/index.ts"),
270 "export const value = 1;\n",
271 )
272 .expect("source file");
273
274 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
275
276 assert!(session.workspace_diagnostics().iter().any(|diagnostic| {
277 diagnostic.kind.id() == "glob-matched-no-package-json"
278 && diagnostic.path.ends_with("packages/empty")
279 }));
280 }
281
282 #[test]
283 fn analysis_session_can_be_consumed_into_pipeline_parts() {
284 let temp = tempfile::tempdir().expect("tempdir");
285 let src = temp.path().join("src");
286 std::fs::create_dir(&src).expect("src dir");
287 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
288
289 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
290 let parts = session.into_parts();
291
292 assert_eq!(parts.config.root, temp.path());
293 assert!(parts.config_path.is_none());
294 assert!(parts.files.iter().any(|file| {
295 file.path
296 .strip_prefix(temp.path())
297 .is_ok_and(|path| path == Path::new("src/index.ts"))
298 }));
299 }
300
301 #[test]
302 fn analysis_session_can_be_consumed_into_parsed_pipeline_parts() {
303 let temp = tempfile::tempdir().expect("tempdir");
304 let src = temp.path().join("src");
305 std::fs::create_dir(&src).expect("src dir");
306 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
307
308 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
309 std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
310 let parts = session.into_parsed_parts(false);
311
312 assert_eq!(parts.config.root, temp.path());
313 assert!(parts.config_path.is_none());
314 assert!(parts.modules.iter().any(|module| {
315 parts.files[module.file_id.0 as usize]
316 .path
317 .strip_prefix(temp.path())
318 .is_ok_and(|path| path == Path::new("src/index.ts"))
319 }));
320 assert!(parts.modules.iter().all(|module| {
321 !parts.files[module.file_id.0 as usize]
322 .path
323 .ends_with("late.ts")
324 }));
325 }
326
327 #[test]
328 fn analysis_session_returns_combined_project_analysis() {
329 let temp = tempfile::tempdir().expect("tempdir");
330 let src = temp.path().join("src");
331 std::fs::create_dir(&src).expect("src dir");
332 let repeated =
333 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
334 std::fs::write(src.join("a.ts"), repeated).expect("source file");
335 std::fs::write(src.join("b.ts"), repeated).expect("source file");
336
337 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
338 let mut config = session.config().duplicates.clone();
339 config.min_tokens = 1;
340 config.min_lines = 1;
341
342 let analysis = session
343 .analyze_project_with(&config, true)
344 .expect("project analysis succeeds");
345
346 assert!(analysis.dead_code.modules.is_some());
347 assert!(analysis.dead_code.files.is_some());
348 assert!(!analysis.duplication.clone_groups.is_empty());
349 }
350
351 #[test]
352 fn analysis_session_reuses_discovery_for_dead_code() {
353 let temp = tempfile::tempdir().expect("tempdir");
354 let src = temp.path().join("src");
355 std::fs::create_dir(&src).expect("src dir");
356 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
357
358 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
359 std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
360
361 let analysis = session.analyze_dead_code().expect("analysis succeeds");
362
363 assert!(
364 analysis
365 .results
366 .unused_files
367 .iter()
368 .all(|finding| !finding.file.path.ends_with("late.ts")),
369 "session analysis must not rediscover files added after session load"
370 );
371 }
372
373 #[test]
374 fn analysis_session_returns_retained_artifacts() {
375 let temp = tempfile::tempdir().expect("tempdir");
376 let src = temp.path().join("src");
377 std::fs::create_dir(&src).expect("src dir");
378 std::fs::write(
379 src.join("index.ts"),
380 "export function used() { return 1; }\nused();\n",
381 )
382 .expect("source file");
383
384 let config = config_for_project(temp.path(), None)
385 .expect("config")
386 .config;
387 let session = AnalysisSession::from_resolved_config(config);
388 let artifacts = session
389 .analyze_dead_code_with_artifacts(true, true)
390 .expect("analysis succeeds");
391
392 assert!(artifacts.graph.is_some());
393 assert!(artifacts.modules.is_some_and(|modules| !modules.is_empty()));
394 assert!(artifacts.files.is_some_and(|files| !files.is_empty()));
395 }
396
397 #[test]
398 fn analysis_session_returns_reuse_artifacts_with_fingerprints_and_scope() {
399 let temp = tempfile::tempdir().expect("tempdir");
400 let src = temp.path().join("src");
401 std::fs::create_dir(&src).expect("src dir");
402 let source = src.join("index.ts");
403 std::fs::write(&source, "export const value = 1;\n").expect("source file");
404
405 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
406 let mut changed_files = rustc_hash::FxHashSet::default();
407 changed_files.insert(source.clone());
408 let artifacts = session
409 .analyze_dead_code_with_session_artifacts(false, true, Some(changed_files))
410 .expect("analysis succeeds");
411
412 assert!(artifacts.analysis.graph.is_some());
413 assert!(
414 artifacts
415 .changed_files
416 .as_ref()
417 .is_some_and(|changed| changed.contains(&source))
418 );
419 assert!(
420 artifacts
421 .source_fingerprints
422 .get(&source)
423 .is_some_and(|fingerprint| fingerprint.file_size > 0)
424 );
425 }
426
427 #[test]
428 fn analysis_session_runs_duplication_with_default_skip_metadata() {
429 let temp = tempfile::tempdir().expect("tempdir");
430 let src = temp.path().join("src");
431 let generated = temp.path().join("storybook-static");
432 std::fs::create_dir(&src).expect("src dir");
433 std::fs::create_dir(&generated).expect("generated dir");
434 let repeated =
435 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
436 std::fs::write(src.join("a.ts"), repeated).expect("source file");
437 std::fs::write(src.join("b.ts"), repeated).expect("source file");
438 std::fs::write(generated.join("generated.ts"), repeated).expect("generated file");
439
440 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
441 let mut config = session.config().duplicates.clone();
442 config.min_tokens = 1;
443 config.min_lines = 1;
444
445 let analysis = session.find_duplicates_with_defaults(&config, None);
446
447 assert!(!analysis.report.clone_groups.is_empty());
448 assert!(analysis.default_ignore_skips.total > 0);
449 }
450
451 #[test]
452 fn trace_symbol_chain_uses_retained_engine_analysis() {
453 let temp = tempfile::tempdir().expect("tempdir");
454 let src = temp.path().join("src");
455 std::fs::create_dir(&src).expect("src dir");
456 std::fs::write(
457 src.join("util.ts"),
458 "export function helper() { return 1; }\n",
459 )
460 .expect("util source");
461 std::fs::write(
462 src.join("index.ts"),
463 "import { helper } from './util';\nexport const value = helper();\n",
464 )
465 .expect("index source");
466
467 let project_config = config_for_project_analysis(
468 temp.path(),
469 None,
470 ProjectConfigOptions {
471 output: OutputFormat::Json,
472 no_cache: true,
473 threads: 1,
474 production_override: None,
475 quiet: true,
476 analysis: ProductionAnalysis::DeadCode,
477 },
478 )
479 .expect("project config loads");
480 let trace = crate::trace_symbol_chain(
481 &project_config.config,
482 fallow_types::trace_chain::SymbolChainQuery {
483 file: "src/util.ts",
484 symbol: "helper",
485 depth: 1,
486 directions: fallow_types::trace_chain::TraceDirections {
487 callers: true,
488 callees: false,
489 },
490 },
491 )
492 .expect("trace succeeds")
493 .expect("trace target exists");
494
495 assert!(trace.symbol_found);
496 assert_eq!(trace.file, Path::new("src/util.ts"));
497 assert!(trace.callers.is_some_and(|callers| {
498 callers
499 .iter()
500 .any(|caller| caller.file == Path::new("src/index.ts"))
501 }));
502 }
503}