Skip to main content

fallow_engine/
lib.rs

1//! Typed analysis engine boundary for fallow consumers.
2//!
3//! `fallow-core` remains the internal orchestration backend. This crate owns
4//! the typed boundary that editor, API, and embedding surfaces can depend on
5//! without calling deprecated core entry points directly. Public modules should
6//! expose owned engine runners, typed result structs, or narrowly scoped aliases
7//! instead of broad core re-exports.
8
9#![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
138/// Result alias for typed engine operations.
139pub type EngineResult<T> = Result<T, EngineError>;
140
141/// Error type exposed by the typed engine boundary.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct EngineError {
144    message: String,
145}
146
147impl EngineError {
148    /// Create an engine error from a user-facing message.
149    #[must_use]
150    pub fn new(message: impl Into<String>) -> Self {
151        Self {
152            message: message.into(),
153        }
154    }
155
156    /// User-facing error message from the backend.
157    #[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/// Build health shared parse data from retained dead-code artifacts.
176#[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}