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