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