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
23pub mod baseline;
24pub mod changed_files;
25pub mod churn;
26pub mod codeowners;
27mod core_backend;
28pub mod cross_reference;
29mod css;
30pub mod dead_code;
31pub mod discover;
32mod discover_walk;
33pub mod duplicates;
34mod entry_points;
35mod feature_flags;
36pub mod flags;
37pub(crate) mod graph {
38 pub use fallow_graph::graph::*;
39}
40#[path = "git_env.rs"]
41mod git_env;
42pub mod guard;
43pub mod health;
44#[cfg(test)]
45pub(crate) mod extract {
46 pub use fallow_types::extract::*;
47}
48#[cfg(test)]
49pub(crate) mod analyze {
50 pub mod test_support {
51 use fallow_types::discover::FileId;
52 use fallow_types::extract::ModuleInfo;
53
54 pub fn empty_module() -> ModuleInfo {
55 ModuleInfo {
56 file_id: FileId(1),
57 exports: Vec::new(),
58 imports: Vec::new(),
59 re_exports: Vec::new(),
60 dynamic_imports: Vec::new(),
61 dynamic_import_patterns: Vec::new(),
62 require_calls: Vec::new(),
63 package_path_references: Box::default(),
64 member_accesses: Vec::new(),
65 semantic_facts: Box::default(),
66 whole_object_uses: Box::default(),
67 has_cjs_exports: false,
68 has_angular_component_template_url: false,
69 content_hash: 0,
70 suppressions: Vec::new(),
71 unknown_suppression_kinds: Vec::new(),
72 unused_import_bindings: Vec::new(),
73 type_referenced_import_bindings: Vec::new(),
74 value_referenced_import_bindings: Vec::new(),
75 line_offsets: Vec::new(),
76 complexity: Vec::new(),
77 flag_uses: Vec::new(),
78 class_heritage: Vec::new(),
79 exported_factory_returns: Box::default(),
80 type_member_types: Box::default(),
81 injection_tokens: Vec::new(),
82 local_type_declarations: Vec::new(),
83 public_signature_type_references: Vec::new(),
84 namespace_object_aliases: Vec::new(),
85 iconify_prefixes: Vec::new(),
86 iconify_icon_names: Vec::new(),
87 auto_import_candidates: Vec::new(),
88 directives: Vec::new(),
89 client_only_dynamic_import_spans: Vec::new(),
90 security_sinks: Vec::new(),
91 security_sinks_skipped: 0,
92 security_unresolved_callee_sites: Vec::new(),
93 tainted_bindings: Vec::new(),
94 sanitized_sink_args: Vec::new(),
95 security_control_sites: Vec::new(),
96 callee_uses: Vec::new(),
97 misplaced_directives: Vec::new(),
98 inline_server_action_exports: Vec::new(),
99 di_key_sites: Vec::new(),
100 has_dynamic_provide: false,
101 referenced_import_bindings: Vec::new(),
102 component_props: Vec::new(),
103 has_props_attrs_fallthrough: false,
104 has_define_expose: false,
105 has_define_model: false,
106 has_unharvestable_props: false,
107 component_emits: Vec::new(),
108 angular_inputs: Vec::new(),
109 angular_outputs: Vec::new(),
110 has_unharvestable_emits: false,
111 has_dynamic_emit: false,
112 has_emit_whole_object_use: false,
113 load_return_keys: Vec::new(),
114 has_unharvestable_load: false,
115 has_load_data_whole_use: false,
116 has_page_data_store_whole_use: false,
117 has_route_loader_data_whole_use: false,
118 component_functions: Vec::new(),
119 react_props: Vec::new(),
120 hook_uses: Vec::new(),
121 render_edges: Vec::new(),
122 svelte_dispatched_events: Vec::new(),
123 svelte_listened_events: Vec::new(),
124 angular_component_selectors: Vec::new(),
125 registered_custom_elements: Vec::new(),
126 used_custom_element_tags: Vec::new(),
127 angular_used_selectors: Vec::new(),
128 angular_entry_component_refs: Vec::new(),
129 has_dynamic_component_render: false,
130 has_dynamic_dispatch: false,
131 }
132 }
133 }
134}
135pub mod list_inventory;
136pub mod module_graph;
137pub mod plugins;
138pub mod project_analysis;
139pub mod project_config;
140mod public_api;
141pub mod repo_refs;
142#[cfg(test)]
143pub(crate) mod resolve {
144 pub use fallow_graph::resolve::*;
145}
146mod results;
147mod security;
148pub mod session;
149pub mod source;
150mod suppress;
151pub mod trace;
152pub mod trace_chain;
153pub mod validate;
154pub mod vital_signs;
155pub mod workspace_scope;
156
157pub type EngineResult<T> = Result<T, EngineError>;
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct EngineError {
163 message: String,
164}
165
166impl EngineError {
167 #[must_use]
169 pub fn new(message: impl Into<String>) -> Self {
170 Self {
171 message: message.into(),
172 }
173 }
174
175 #[must_use]
177 pub fn message(&self) -> &str {
178 &self.message
179 }
180}
181
182impl fmt::Display for EngineError {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.write_str(&self.message)
185 }
186}
187
188impl std::error::Error for EngineError {}
189
190pub(crate) fn engine_error(err: impl fmt::Display) -> EngineError {
191 EngineError::new(err.to_string())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::{
198 project_analysis::ProjectAnalysisArtifactOptions,
199 project_config::{
200 ProjectConfigOptions, config_for_project, config_for_project_analysis,
201 resolve_cache_max_size_bytes,
202 },
203 session::AnalysisSession,
204 };
205 use fallow_config::ProductionAnalysis;
206 use fallow_types::output_format::OutputFormat;
207 use std::fs;
208 use std::path::PathBuf;
209
210 #[test]
211 fn engine_error_displays_message() {
212 let err = EngineError::new("config failed");
213
214 assert_eq!(err.message(), "config failed");
215 assert_eq!(err.to_string(), "config failed");
216 }
217
218 #[test]
219 fn engine_resolves_parse_cache_size_policy() {
220 let mut config = fallow_config::FallowConfig::default().resolve(
221 PathBuf::from("/repo"),
222 OutputFormat::Json,
223 1,
224 false,
225 true,
226 None,
227 );
228 assert_eq!(
229 resolve_cache_max_size_bytes(&config),
230 fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE
231 );
232
233 config.cache_max_size_mb = Some(3);
234 assert_eq!(resolve_cache_max_size_bytes(&config), 3 * 1024 * 1024);
235
236 config.cache_max_size_mb = Some(u32::MAX);
237 assert_eq!(
238 resolve_cache_max_size_bytes(&config),
239 (u32::MAX as usize).saturating_mul(1024 * 1024)
240 );
241 }
242
243 #[test]
244 fn engine_root_does_not_reexport_broad_surface_modules() {
245 let source = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lib.rs"))
246 .expect("read engine lib");
247 let public_surface = source
248 .split("#[cfg(test)]")
249 .next()
250 .expect("engine lib has public surface before tests");
251 let forbidden_exports = [
252 "pub use error::",
253 "pub use flags::",
254 "pub use git_env::",
255 "pub use public_api::",
256 "pub use results::",
257 "pub use security::",
258 "pub use suppress::",
259 "health_shared_parse_data_from_artifacts",
260 ];
261
262 for forbidden in forbidden_exports {
263 assert!(
264 !public_surface.contains(forbidden),
265 "engine root must expose typed modules, not `{forbidden}`"
266 );
267 }
268 }
269
270 #[test]
271 fn engine_session_owns_dead_code_pipeline_sequence() {
272 let session_source =
273 fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
274 .expect("read engine session");
275 assert!(
276 !session_source.contains("analyze_with_owned_parse_result_from_discovery"),
277 "engine session must not delegate dead-code orchestration to the old core monolith"
278 );
279 for required_phase in [
280 "prepare_dead_code_backend_prelude",
281 "discover_dead_code_entry_points",
282 "try_load_dead_code_graph_cache",
283 "resolve_dead_code_imports",
284 "build_dead_code_graph",
285 "run_dead_code_detectors",
286 ] {
287 assert!(
288 session_source.contains(required_phase),
289 "engine session must explicitly sequence `{required_phase}`"
290 );
291 }
292 }
293
294 #[test]
295 fn engine_session_owns_analysis_discovery() {
296 let session_source =
297 fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/session.rs"))
298 .expect("read engine session");
299 assert!(
300 session_source.contains("crate::discover::prepare_analysis_discovery"),
301 "engine session must build discovery through the engine discovery boundary"
302 );
303 assert!(
304 session_source.contains("prepare_analysis_discovery_with_workspaces"),
305 "engine session must reuse workspace metadata captured during config load"
306 );
307 assert!(
308 session_source.contains("workspace_discovery_ms.is_some()"),
309 "AnalysisSession::from_config must only reuse workspace metadata when ProjectConfig preloaded it"
310 );
311 assert!(
312 !session_source.contains("core_backend::prepare_analysis_discovery"),
313 "engine session must not delegate discovery orchestration to core_backend"
314 );
315 }
316
317 #[test]
318 fn analysis_session_loads_config_and_discovered_files() {
319 let temp = tempfile::tempdir().expect("tempdir");
320 let src = temp.path().join("src");
321 std::fs::create_dir(&src).expect("src dir");
322 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
323
324 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
325
326 assert_eq!(session.root(), temp.path());
327 assert!(session.config_path().is_none());
328 assert!(session.files().iter().any(|file| {
329 file.path
330 .strip_prefix(temp.path())
331 .is_ok_and(|path| path == Path::new("src/index.ts"))
332 }));
333 }
334
335 #[test]
336 fn analysis_session_applies_config_adjustment_before_discovery() {
337 let temp = tempfile::tempdir().expect("tempdir");
338 let src = temp.path().join("src");
339 std::fs::create_dir(&src).expect("src dir");
340 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
341 std::fs::write(src.join("index.test.ts"), "export const testValue = 1;\n")
342 .expect("test source file");
343
344 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
345 config.production = true;
346 })
347 .expect("session loads");
348
349 let relative_paths: Vec<_> = session
350 .files()
351 .iter()
352 .filter_map(|file| file.path.strip_prefix(temp.path()).ok())
353 .collect();
354 assert!(relative_paths.contains(&Path::new("src/index.ts")));
355 assert!(!relative_paths.contains(&Path::new("src/index.test.ts")));
356 }
357
358 #[test]
359 fn analysis_session_config_adjustment_invalidates_preloaded_workspaces() {
360 let temp = tempfile::tempdir().expect("tempdir");
361 std::fs::write(
362 temp.path().join("package.json"),
363 r#"{"name":"root","workspaces":["packages/*"]}"#,
364 )
365 .expect("root package");
366 std::fs::create_dir_all(temp.path().join("packages/a")).expect("workspace dir");
367 std::fs::create_dir_all(temp.path().join("packages/ignored")).expect("ignored dir");
368 std::fs::write(
369 temp.path().join("packages/a/package.json"),
370 r#"{"name":"a","main":"src/index.ts"}"#,
371 )
372 .expect("workspace package");
373
374 let session = AnalysisSession::load_with_config(temp.path(), None, |config| {
375 config.ignore_patterns = globset::GlobSetBuilder::new()
376 .add(globset::Glob::new("packages/ignored").expect("ignore glob"))
377 .build()
378 .expect("ignore set");
379 })
380 .expect("session loads");
381
382 assert!(
383 session
384 .workspaces()
385 .iter()
386 .all(|workspace| workspace.name != "ignored"),
387 "config mutations that affect workspace discovery must not reuse preloaded workspaces"
388 );
389 assert!(
390 !session
391 .workspace_diagnostics()
392 .iter()
393 .any(|diagnostic| diagnostic.path.ends_with("packages/ignored")),
394 "config mutations that affect workspace diagnostics must not reuse stale diagnostics"
395 );
396 }
397
398 #[test]
399 fn analysis_session_captures_workspace_diagnostics() {
400 let temp = tempfile::tempdir().expect("tempdir");
401 std::fs::write(
402 temp.path().join("package.json"),
403 r#"{"name":"diagnostic-root","workspaces":["packages/*"]}"#,
404 )
405 .expect("package json");
406 std::fs::create_dir_all(temp.path().join("packages/empty")).expect("workspace dir");
407 std::fs::create_dir(temp.path().join("src")).expect("src dir");
408 std::fs::write(
409 temp.path().join("src/index.ts"),
410 "export const value = 1;\n",
411 )
412 .expect("source file");
413
414 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
415
416 assert!(session.workspace_diagnostics().iter().any(|diagnostic| {
417 diagnostic.kind.id() == "glob-matched-no-package-json"
418 && diagnostic.path.ends_with("packages/empty")
419 }));
420 }
421
422 #[test]
423 fn analysis_session_from_resolved_config_discovers_workspaces() {
424 let temp = tempfile::tempdir().expect("tempdir");
425 std::fs::write(
426 temp.path().join("package.json"),
427 r#"{"name":"root","workspaces":["packages/*"]}"#,
428 )
429 .expect("root package");
430 std::fs::create_dir_all(temp.path().join("packages/a/src")).expect("workspace dir");
431 std::fs::write(
432 temp.path().join("packages/a/package.json"),
433 r#"{"name":"pkg-a","main":"src/index.ts"}"#,
434 )
435 .expect("workspace package");
436 std::fs::write(
437 temp.path().join("packages/a/src/index.ts"),
438 "export const value = 1;\n",
439 )
440 .expect("workspace source");
441
442 let config = fallow_config::FallowConfig::default().resolve(
443 temp.path().to_path_buf(),
444 OutputFormat::Json,
445 1,
446 false,
447 true,
448 None,
449 );
450 let session = AnalysisSession::from_resolved_config(config);
451
452 assert!(
453 session
454 .workspaces()
455 .iter()
456 .any(|workspace| workspace.name == "pkg-a"),
457 "resolved-config sessions must expose workspaces found during fallback discovery"
458 );
459 }
460
461 #[test]
462 fn analysis_session_can_be_consumed_into_pipeline_parts() {
463 let temp = tempfile::tempdir().expect("tempdir");
464 let src = temp.path().join("src");
465 std::fs::create_dir(&src).expect("src dir");
466 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
467
468 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
469 let parts = session.into_parts();
470
471 assert_eq!(parts.config.root, temp.path());
472 assert!(parts.config_path.is_none());
473 assert!(parts.files.iter().any(|file| {
474 file.path
475 .strip_prefix(temp.path())
476 .is_ok_and(|path| path == Path::new("src/index.ts"))
477 }));
478 }
479
480 #[test]
481 fn analysis_session_can_be_consumed_into_parsed_pipeline_parts() {
482 let temp = tempfile::tempdir().expect("tempdir");
483 let src = temp.path().join("src");
484 std::fs::create_dir(&src).expect("src dir");
485 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
486
487 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
488 std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
489 let parts = session.into_parsed_parts(false);
490
491 assert_eq!(parts.config.root, temp.path());
492 assert!(parts.config_path.is_none());
493 assert!(parts.modules.iter().any(|module| {
494 parts.files[module.file_id.0 as usize]
495 .path
496 .strip_prefix(temp.path())
497 .is_ok_and(|path| path == Path::new("src/index.ts"))
498 }));
499 assert!(parts.modules.iter().all(|module| {
500 !parts.files[module.file_id.0 as usize]
501 .path
502 .ends_with("late.ts")
503 }));
504 }
505
506 #[test]
507 fn analysis_session_reuses_complexity_parse_for_plain_parse() {
508 let temp = tempfile::tempdir().expect("tempdir");
509 let src = temp.path().join("src");
510 std::fs::create_dir(&src).expect("src dir");
511 std::fs::write(
512 src.join("index.ts"),
513 "export function value() { return 1; }\n",
514 )
515 .expect("source file");
516
517 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
518 let first = session.parsed_parts(true);
519 assert!(!first.modules.is_empty());
520
521 let second = session.parsed_parts(false);
522
523 assert!(!second.modules.is_empty());
524 assert!(second.parse_ms.abs() < f64::EPSILON);
525 assert!(second.parse_cpu_ms.abs() < f64::EPSILON);
526 }
527
528 #[test]
529 fn dead_code_reused_parse_path_uses_engine_pipeline() {
530 let temp = tempfile::tempdir().expect("tempdir");
531 let src = temp.path().join("src");
532 std::fs::create_dir(&src).expect("src dir");
533 std::fs::write(src.join("index.ts"), "import './util';\n").expect("entry file");
534 std::fs::write(src.join("util.ts"), "export const value = 1;\n").expect("source file");
535
536 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
537 let parts = session.into_parsed_parts(false);
538 let analysis = crate::dead_code::analyze_with_parse_result(&parts.config, &parts.modules)
539 .expect("reused parse analysis succeeds");
540
541 assert!(analysis.graph.is_some());
542 assert!(analysis.modules.is_none());
543 assert!(analysis.files.is_none());
544 assert!(
545 analysis
546 .file_hashes
547 .keys()
548 .any(|path| path.ends_with("util.ts"))
549 );
550 }
551
552 #[test]
553 fn analysis_session_reparses_when_cached_source_changes() {
554 let temp = tempfile::tempdir().expect("tempdir");
555 let src = temp.path().join("src");
556 std::fs::create_dir(&src).expect("src dir");
557 std::fs::write(
558 src.join("index.ts"),
559 "import { value } from './util';\nconsole.log(value);\n",
560 )
561 .expect("entry file");
562 let util_path = src.join("util.ts");
563 std::fs::write(&util_path, "export const value = 1;\n").expect("source file");
564
565 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
566 let first = session
567 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
568 .expect("first analysis succeeds");
569 assert!(first.dead_code.results.unused_exports.is_empty());
570
571 std::fs::write(
572 &util_path,
573 "export const value = 1;\nexport const addedUnused = 2;\n",
574 )
575 .expect("updated source file");
576
577 let second = session
578 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
579 .expect("second analysis succeeds");
580 assert!(
581 second
582 .dead_code
583 .results
584 .unused_exports
585 .iter()
586 .any(|finding| finding.export.export_name == "addedUnused")
587 );
588 }
589
590 #[test]
591 fn analysis_session_returns_combined_project_analysis() {
592 let temp = tempfile::tempdir().expect("tempdir");
593 let src = temp.path().join("src");
594 std::fs::create_dir(&src).expect("src dir");
595 let repeated =
596 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
597 std::fs::write(src.join("a.ts"), repeated).expect("source file");
598 std::fs::write(src.join("b.ts"), repeated).expect("source file");
599
600 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
601 let mut config = session.config().duplicates.clone();
602 config.min_tokens = 1;
603 config.min_lines = 1;
604
605 let analysis = session
606 .analyze_project_with(&config, true)
607 .expect("project analysis succeeds");
608
609 assert!(analysis.dead_code.modules.is_some());
610 assert!(analysis.dead_code.files.is_some());
611 assert!(!analysis.duplication.clone_groups.is_empty());
612 }
613
614 #[test]
615 fn analysis_session_reuses_discovery_for_dead_code() {
616 let temp = tempfile::tempdir().expect("tempdir");
617 let src = temp.path().join("src");
618 std::fs::create_dir(&src).expect("src dir");
619 std::fs::write(src.join("index.ts"), "export const value = 1;\n").expect("source file");
620
621 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
622 std::fs::write(src.join("late.ts"), "export const late = 1;\n").expect("late source file");
623
624 let analysis = session.analyze_dead_code().expect("analysis succeeds");
625
626 assert!(
627 analysis
628 .results
629 .unused_files
630 .iter()
631 .all(|finding| !finding.file.path.ends_with("late.ts")),
632 "session analysis must not rediscover files added after session load"
633 );
634 }
635
636 #[test]
637 fn analysis_session_returns_retained_artifacts() {
638 let temp = tempfile::tempdir().expect("tempdir");
639 let src = temp.path().join("src");
640 std::fs::create_dir(&src).expect("src dir");
641 std::fs::write(
642 src.join("index.ts"),
643 "export function used() { return 1; }\nused();\n",
644 )
645 .expect("source file");
646
647 let config = config_for_project(temp.path(), None)
648 .expect("config")
649 .config;
650 let session = AnalysisSession::from_resolved_config(config);
651 let artifacts = session
652 .analyze_dead_code_with_artifacts(true, true)
653 .expect("analysis succeeds");
654
655 assert!(artifacts.graph.is_some());
656 assert!(artifacts.modules.is_some_and(|modules| !modules.is_empty()));
657 assert!(artifacts.files.is_some_and(|files| !files.is_empty()));
658 }
659
660 #[test]
661 fn analysis_session_returns_reuse_artifacts_with_fingerprints_and_scope() {
662 let temp = tempfile::tempdir().expect("tempdir");
663 let src = temp.path().join("src");
664 std::fs::create_dir(&src).expect("src dir");
665 let source = src.join("index.ts");
666 std::fs::write(&source, "export const value = 1;\n").expect("source file");
667
668 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
669 let mut changed_files = rustc_hash::FxHashSet::default();
670 changed_files.insert(source.clone());
671 let artifacts = session
672 .analyze_dead_code_with_session_artifacts(false, true, Some(changed_files))
673 .expect("analysis succeeds");
674
675 assert!(artifacts.analysis.graph.is_some());
676 assert!(
677 artifacts
678 .changed_files
679 .as_ref()
680 .is_some_and(|changed| changed.contains(&source))
681 );
682 assert!(
683 artifacts
684 .source_fingerprints
685 .get(&source)
686 .is_some_and(|fingerprint| fingerprint.file_size > 0)
687 );
688 }
689
690 #[test]
691 fn analysis_session_returns_project_artifacts_with_reuse_metadata() {
692 let temp = tempfile::tempdir().expect("tempdir");
693 let src = temp.path().join("src");
694 std::fs::create_dir(&src).expect("src dir");
695 let source = src.join("index.ts");
696 std::fs::write(&source, "export const value = 1;\n").expect("source file");
697
698 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
699 let mut changed_files = rustc_hash::FxHashSet::default();
700 changed_files.insert(source.clone());
701 let artifacts = session
702 .analyze_project_with_artifacts(
703 &session.config().duplicates,
704 ProjectAnalysisArtifactOptions {
705 retain_complexity_artifacts: true,
706 retain_graph: true,
707 changed_files: Some(changed_files),
708 collect_source_fingerprints: true,
709 },
710 )
711 .expect("project analysis succeeds");
712
713 assert!(artifacts.dead_code.graph.is_some());
714 assert!(
715 artifacts
716 .changed_files
717 .as_ref()
718 .is_some_and(|changed| changed.contains(&source))
719 );
720 assert!(
721 artifacts
722 .source_fingerprints
723 .as_ref()
724 .and_then(|fingerprints| fingerprints.get(&source))
725 .is_some_and(|fingerprint| fingerprint.file_size > 0)
726 );
727
728 let lightweight = session
729 .analyze_project_with_artifacts(
730 &session.config().duplicates,
731 ProjectAnalysisArtifactOptions::default(),
732 )
733 .expect("project analysis succeeds");
734 assert!(
735 lightweight.source_fingerprints.is_none(),
736 "source fingerprints should be opt-in for lightweight editor analysis"
737 );
738
739 let output = artifacts.into_output();
740 assert!(output.dead_code.modules.is_some());
741 assert!(output.dead_code.files.is_some());
742 }
743
744 #[test]
745 fn project_artifacts_focus_duplication_to_changed_files() {
746 let temp = tempfile::tempdir().expect("tempdir");
747 let src = temp.path().join("src");
748 std::fs::create_dir(&src).expect("src dir");
749 let repeated =
750 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
751 let a = src.join("a.ts");
752 std::fs::write(&a, repeated).expect("source file");
753 std::fs::write(src.join("b.ts"), repeated).expect("source file");
754
755 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
756 let mut config = session.config().duplicates.clone();
757 config.min_tokens = 1;
758 config.min_lines = 1;
759
760 let full = session
761 .analyze_project_with_artifacts(&config, ProjectAnalysisArtifactOptions::default())
762 .expect("project analysis succeeds");
763 assert!(!full.duplication.clone_groups.is_empty());
764
765 let mut unrelated = rustc_hash::FxHashSet::default();
766 unrelated.insert(src.join("unrelated.ts"));
767 let focused_empty = session
768 .analyze_project_with_artifacts(
769 &config,
770 ProjectAnalysisArtifactOptions {
771 changed_files: Some(unrelated),
772 ..ProjectAnalysisArtifactOptions::default()
773 },
774 )
775 .expect("project analysis succeeds");
776 assert!(focused_empty.duplication.clone_groups.is_empty());
777
778 let mut changed = rustc_hash::FxHashSet::default();
779 changed.insert(a);
780 let focused = session
781 .analyze_project_with_artifacts(
782 &config,
783 ProjectAnalysisArtifactOptions {
784 changed_files: Some(changed),
785 ..ProjectAnalysisArtifactOptions::default()
786 },
787 )
788 .expect("project analysis succeeds");
789 assert!(!focused.duplication.clone_groups.is_empty());
790 }
791
792 #[test]
793 fn analysis_session_runs_duplication_with_default_skip_metadata() {
794 let temp = tempfile::tempdir().expect("tempdir");
795 let src = temp.path().join("src");
796 let generated = temp.path().join("storybook-static");
797 std::fs::create_dir(&src).expect("src dir");
798 std::fs::create_dir(&generated).expect("generated dir");
799 let repeated =
800 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
801 std::fs::write(src.join("a.ts"), repeated).expect("source file");
802 std::fs::write(src.join("b.ts"), repeated).expect("source file");
803 std::fs::write(generated.join("generated.ts"), repeated).expect("generated file");
804
805 let session = AnalysisSession::load(temp.path(), None).expect("session loads");
806 let mut config = session.config().duplicates.clone();
807 config.min_tokens = 1;
808 config.min_lines = 1;
809
810 let analysis = session.find_duplicates_with_defaults(&config, None);
811
812 assert!(!analysis.report.clone_groups.is_empty());
813 assert!(analysis.default_ignore_skips.total > 0);
814 }
815
816 #[test]
817 fn trace_symbol_chain_uses_retained_engine_analysis() {
818 let temp = tempfile::tempdir().expect("tempdir");
819 let src = temp.path().join("src");
820 std::fs::create_dir(&src).expect("src dir");
821 std::fs::write(
822 src.join("util.ts"),
823 "export function helper() { return 1; }\n",
824 )
825 .expect("util source");
826 std::fs::write(
827 src.join("index.ts"),
828 "import { helper } from './util';\nexport const value = helper();\n",
829 )
830 .expect("index source");
831
832 let project_config = config_for_project_analysis(
833 temp.path(),
834 None,
835 ProjectConfigOptions {
836 output: OutputFormat::Json,
837 no_cache: true,
838 threads: 1,
839 production_override: None,
840 quiet: true,
841 analysis: ProductionAnalysis::DeadCode,
842 allow_remote_extends: false,
843 },
844 )
845 .expect("project config loads");
846 let session = AnalysisSession::from_config(project_config);
847 let trace = crate::trace_chain::trace_symbol_chain_with_session(
848 &session,
849 fallow_types::trace_chain::SymbolChainQuery {
850 file: "src/util.ts",
851 symbol: "helper",
852 depth: 1,
853 directions: fallow_types::trace_chain::TraceDirections {
854 callers: true,
855 callees: false,
856 },
857 },
858 )
859 .expect("trace succeeds")
860 .expect("trace target exists");
861
862 assert!(trace.symbol_found);
863 assert_eq!(trace.file, Path::new("src/util.ts"));
864 assert!(trace.callers.is_some_and(|callers| {
865 callers
866 .iter()
867 .any(|caller| caller.file == Path::new("src/index.ts"))
868 }));
869 }
870
871 fn workspace_fixture_path(name: &str) -> std::path::PathBuf {
872 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
873 .join("../../tests/fixtures")
874 .join(name)
875 }
876
877 fn trace_symbol_chain_fixture(
878 fixture: &str,
879 file: &str,
880 symbol: &str,
881 directions: fallow_types::trace_chain::TraceDirections,
882 depth: u32,
883 ) -> fallow_types::trace_chain::SymbolChainTrace {
884 let root = workspace_fixture_path(fixture);
885 let session = AnalysisSession::load(&root, None).expect("session loads");
886 crate::trace_chain::trace_symbol_chain_with_session(
887 &session,
888 fallow_types::trace_chain::SymbolChainQuery {
889 file,
890 symbol,
891 depth,
892 directions,
893 },
894 )
895 .expect("trace succeeds")
896 .expect("trace target exists")
897 }
898
899 fn symbol_chain_hop_files(
900 hops: &[fallow_types::trace_chain::ChainHop],
901 ) -> std::collections::BTreeSet<String> {
902 hops.iter()
903 .map(|hop| hop.file.to_string_lossy().replace('\\', "/"))
904 .collect()
905 }
906
907 #[test]
908 fn trace_symbol_chain_caller_set_matches_import_symbol_callers() {
909 let trace = trace_symbol_chain_fixture(
910 "e8-symbol-chain",
911 "src/format.ts",
912 "formatDate",
913 fallow_types::trace_chain::TraceDirections {
914 callers: true,
915 callees: false,
916 },
917 1,
918 );
919
920 assert!(trace.symbol_found, "formatDate is an export of format.ts");
921 assert!(
922 trace.best_effort,
923 "symbol-level chains are labeled best-effort"
924 );
925
926 let callers = trace.callers.expect("callers were requested");
927 let actual = symbol_chain_hop_files(&callers);
928 let expected: std::collections::BTreeSet<String> =
929 ["src/report.ts".to_string(), "src/middle.ts".to_string()]
930 .into_iter()
931 .collect();
932 assert_eq!(actual, expected);
933
934 for hop in &callers {
935 assert_eq!(hop.imported_as, "formatDate");
936 assert_eq!(hop.local_name, "formatDate");
937 assert_eq!(hop.depth, 1);
938 assert!(!hop.type_only);
939 }
940 }
941
942 #[test]
943 fn trace_symbol_chain_reports_unresolved_callees() {
944 let trace = trace_symbol_chain_fixture(
945 "e8-symbol-chain",
946 "src/report.ts",
947 "buildReport",
948 fallow_types::trace_chain::TraceDirections {
949 callers: false,
950 callees: true,
951 },
952 1,
953 );
954
955 assert!(trace.symbol_found, "buildReport is an export of report.ts");
956
957 let unresolved = trace
958 .unresolved_callees
959 .expect("callees were requested, so unresolved_callees is present");
960 let callees: Vec<&str> = unresolved
961 .iter()
962 .map(|callee| callee.callee.as_str())
963 .collect();
964
965 assert!(
966 callees.contains(&"localHelper"),
967 "the local helper callee must be reported as unresolved, got {callees:?}"
968 );
969 assert!(
970 callees.contains(&"parseInt"),
971 "the global callee must be reported as unresolved, got {callees:?}"
972 );
973 assert!(
974 !callees.contains(&"formatDate"),
975 "an imported callee resolves to an edge and is not unresolved, got {callees:?}"
976 );
977
978 let local_helper = unresolved
979 .iter()
980 .find(|callee| callee.callee == "localHelper")
981 .expect("local helper is unresolved");
982 assert_eq!(
983 local_helper.reason,
984 fallow_types::trace_chain::UnresolvedReason::LocalOrGlobal
985 );
986
987 let callees_hops = trace.callees.expect("callees were requested");
988 let resolved_files = symbol_chain_hop_files(&callees_hops);
989 assert!(
990 resolved_files.contains("src/format.ts"),
991 "the resolved import-symbol callee edge to format.ts must be present, got {resolved_files:?}"
992 );
993 }
994
995 #[test]
996 fn trace_export_uses_retained_engine_analysis_for_star_reexport() {
997 let temp = tempfile::tempdir().expect("tempdir");
998 let src = temp.path().join("src");
999 std::fs::create_dir(&src).expect("src dir");
1000 std::fs::write(
1001 src.join("merged.ts"),
1002 "export const Merged = 1;\nexport const unusedControl = 2;\n",
1003 )
1004 .expect("merged source");
1005 std::fs::write(src.join("barrel.ts"), "export * from './merged';\n")
1006 .expect("barrel source");
1007 std::fs::write(
1008 src.join("index.ts"),
1009 "import { Merged } from './barrel';\nconsole.log(Merged);\n",
1010 )
1011 .expect("index source");
1012
1013 let config = config_for_project(temp.path(), None)
1014 .expect("config")
1015 .config;
1016 let session = AnalysisSession::from_resolved_config(config);
1017 let artifacts = session
1018 .analyze_dead_code_with_artifacts(false, true)
1019 .expect("analysis succeeds");
1020 let graph = artifacts.graph.as_ref().expect("graph is retained");
1021 let trace = crate::trace::trace_export(graph, session.root(), "src/merged.ts", "Merged")
1022 .expect("trace exists");
1023
1024 assert!(trace.is_used, "trace should agree the value export is used");
1025 assert_eq!(
1026 trace.direct_references.len(),
1027 1,
1028 "trace should include the consumer named import"
1029 );
1030 }
1031}