1use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_config::{
8 ResolvedConfig, RulesConfig, Severity, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
9};
10use fallow_types::discover::StableFileKey;
11
12pub use crate::results::{
13 AnalysisResults, DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
14 DeadCodeAnalysisWithHashes, derive_security_severity, enable_security_rules,
15 resolve_security_finding_severity, security_catalogue_title, security_rule_id,
16 security_rules_can_error,
17};
18
19pub use crate::effective_severity::{
20 RuleSeverity, SeveritySource, apply_effective_severities, findings_without_severity,
21 promote_effective_warns,
22};
23
24use crate::{
25 EngineResult, change_scope::ChangeScope,
26 session::analyze_dead_code_with_parse_result_from_config, source::ModuleInfo,
27};
28
29pub(crate) fn analyze_with_parse_result(
35 config: &ResolvedConfig,
36 modules: &[ModuleInfo],
37) -> EngineResult<DeadCodeAnalysisArtifacts> {
38 analyze_dead_code_with_parse_result_from_config(config, modules)
39}
40
41#[must_use]
55pub fn config_pattern_diagnostics(
56 config: &ResolvedConfig,
57 reports_dependencies: bool,
58) -> Vec<WorkspaceDiagnostic> {
59 let dependency_globs = if reports_dependencies && dependency_rules_on(&config.rules) {
60 config.ignore_dependencies.unmatched_globs()
61 } else {
62 Vec::new()
63 };
64 let finding_patterns = config.ignore_findings.unmatched_patterns();
65 finding_patterns
66 .into_iter()
67 .map(
68 |pattern| WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched {
69 pattern: pattern.to_owned(),
70 },
71 )
72 .chain(dependency_globs.into_iter().map(|pattern| {
73 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
74 pattern: pattern.to_owned(),
75 }
76 }))
77 .map(|kind| {
78 WorkspaceDiagnostic::new(&config.root, config.root.clone(), kind)
79 .into_root_relative(&config.root)
80 })
81 .collect()
82}
83
84fn dependency_rules_on(rules: &RulesConfig) -> bool {
87 [
88 rules.unused_dependencies,
89 rules.unused_dev_dependencies,
90 rules.unused_optional_dependencies,
91 rules.unlisted_dependencies,
92 rules.type_only_dependencies,
93 rules.test_only_dependencies,
94 rules.dev_dependencies_in_production,
95 ]
96 .into_iter()
97 .any(|severity| severity != Severity::Off)
98}
99
100pub fn stamp_finding_ids(results: &mut AnalysisResults, root: &Path) {
106 fallow_types::identity::stamp_dead_code_finding_ids(results, root);
107}
108
109pub fn stamp_missing_finding_ids(results: &mut AnalysisResults, root: &Path) {
115 fallow_types::identity::stamp_missing_dead_code_finding_ids(results, root);
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct FindingIdFilter {
126 requested: Vec<String>,
127 set: FxHashSet<String>,
128}
129
130impl FindingIdFilter {
131 pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Option<Self>, String> {
140 if values.is_empty() {
141 return Ok(None);
142 }
143 let mut requested = Vec::with_capacity(values.len());
144 let mut set = FxHashSet::default();
145 for value in values {
146 let id = value.as_ref().trim();
147 if !fallow_types::identity::is_dead_code_finding_id(id) {
148 return Err(format!(
149 "invalid finding id '{id}': expected {}:<rule>:<16 hex digits>, \
150 optionally with a ~<k> suffix, as printed in the finding_id field",
151 fallow_types::identity::DEAD_CODE_ID_SCHEME
152 ));
153 }
154 if set.insert(id.to_owned()) {
155 requested.push(id.to_owned());
156 }
157 }
158 Ok(Some(Self { requested, set }))
159 }
160
161 #[must_use]
163 pub fn present(&self, results: &mut AnalysisResults) -> FxHashSet<String> {
164 fallow_types::identity::present_dead_code_finding_ids(results, &self.set)
165 }
166
167 pub fn apply(
174 &self,
175 results: &mut AnalysisResults,
176 config: &ResolvedConfig,
177 filtered: &FxHashSet<String>,
178 run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
179 ) -> fallow_output::FindingIdQuery {
180 let found = fallow_types::identity::retain_dead_code_findings_by_id(results, &self.set);
181 let rule_off = self
182 .requested
183 .iter()
184 .filter(|id| !found.contains(*id))
185 .any(|id| finding_id_rule_is_off(id, config));
186 fallow_output::FindingIdQuery::new(
187 self.requested.clone(),
188 |id| found.contains(id),
189 |id| filtered.contains(id),
190 run_reasons
191 .into_iter()
192 .chain(rule_off.then_some(fallow_output::FindingIdQueryReason::RuleOff)),
193 analysis_fingerprint(config),
194 )
195 }
196}
197
198#[derive(Debug, Clone)]
206pub struct FindingIdTrace {
207 filter: FindingIdFilter,
208 before_stage: FxHashSet<String>,
209 filtered: FxHashSet<String>,
210}
211
212impl FindingIdTrace {
213 #[must_use]
215 pub fn start(filter: FindingIdFilter, results: &mut AnalysisResults) -> Self {
216 let before_stage = filter.present(results);
217 Self {
218 filter,
219 before_stage,
220 filtered: FxHashSet::default(),
221 }
222 }
223
224 pub fn start_stage(&mut self, results: &mut AnalysisResults) {
226 self.before_stage = self.filter.present(results);
227 }
228
229 pub fn end_stage(&mut self, results: &mut AnalysisResults) {
231 let after = self.filter.present(results);
232 self.filtered
233 .extend(self.before_stage.drain().filter(|id| !after.contains(id)));
234 }
235
236 pub fn finish(
238 self,
239 results: &mut AnalysisResults,
240 config: &ResolvedConfig,
241 run_reasons: impl IntoIterator<Item = fallow_output::FindingIdQueryReason>,
242 ) -> fallow_output::FindingIdQuery {
243 self.filter
244 .apply(results, config, &self.filtered, run_reasons)
245 }
246}
247
248const ANALYSIS_FINGERPRINT_SCHEME: &str = "af1";
251
252const IGNORE_FILE_NAMES: &[&str] = &[".gitignore", ".ignore"];
254
255const RESOLUTION_FILE_GLOBS: &[&str] =
259 &["**/package.json", "**/tsconfig*.json", "**/jsconfig*.json"];
260
261const MAX_EXTENDS_DEPTH: usize = 8;
263
264#[must_use]
284pub fn analysis_fingerprint(config: &ResolvedConfig) -> String {
285 analysis_fingerprint_for_version(config, env!("CARGO_PKG_VERSION"))
286}
287
288#[must_use]
290pub fn analysis_fingerprint_for_version(config: &ResolvedConfig, version: &str) -> String {
291 let rules = serde_json::to_string(&config.rules).unwrap_or_default();
292 let projects: Vec<String> = config
293 .type_aware
294 .projects
295 .iter()
296 .map(|project| root_relative_text(&config.root, project))
297 .collect();
298 let type_aware = format!(
299 "{}:{}:{}",
300 config.type_aware.enabled,
301 serde_json::to_string(&config.type_aware.require).unwrap_or_default(),
302 projects.join("|")
303 );
304 let max_file_size = config
305 .max_file_size_bytes
306 .map_or_else(|| "none".to_owned(), |bytes| bytes.to_string());
307 let input_files = input_files_digest(config);
308 let hash = fallow_types::identity::fnv1a64_parts(&[
309 ANALYSIS_FINGERPRINT_SCHEME,
310 version,
311 &config.detection_config_digest,
312 if config.production {
313 "production"
314 } else {
315 "all"
316 },
317 if config.include_entry_exports {
318 "entry-exports"
319 } else {
320 "no-entry-exports"
321 },
322 if config.show_cascade {
323 "cascade-shown"
324 } else {
325 "cascade-hidden"
326 },
327 &rules,
328 &type_aware,
329 &max_file_size,
330 &input_files,
331 ]);
332 format!("{ANALYSIS_FINGERPRINT_SCHEME}:{hash}")
333}
334
335fn root_relative_text(root: &Path, path: &str) -> String {
338 Path::new(path).strip_prefix(root).map_or_else(
339 |_| path.replace('\\', "/"),
340 |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
341 )
342}
343
344fn normalized_text(content: &[u8]) -> String {
348 String::from_utf8_lossy(content)
349 .replace("\r\n", "\n")
350 .trim_end_matches('\n')
351 .to_owned()
352}
353
354fn fingerprint_walk_builder(root: &Path) -> ignore::WalkBuilder {
361 let mut builder = ignore::WalkBuilder::new(root);
362 builder
363 .hidden(false)
364 .git_ignore(true)
365 .git_global(false)
366 .git_exclude(true)
367 .filter_entry(|entry| {
368 let is_dir = entry.file_type().is_some_and(|kind| kind.is_dir());
369 if !is_dir || entry.depth() == 0 {
370 return true;
371 }
372 let name = entry.file_name().to_string_lossy();
373 !name.starts_with('.') && name != "node_modules"
374 });
375 builder
376}
377
378fn resolution_file_globs(config: &ResolvedConfig) -> globset::GlobSet {
381 let mut builder = globset::GlobSetBuilder::new();
382 let external = config
383 .external_plugins
384 .iter()
385 .flat_map(|plugin| plugin.config_patterns.iter().map(String::as_str));
386 let patterns = crate::core_backend::builtin_config_patterns()
387 .into_iter()
388 .chain(external)
389 .chain(RESOLUTION_FILE_GLOBS.iter().copied());
390 for pattern in patterns {
391 let anywhere = if pattern.starts_with("**/") {
392 pattern.to_owned()
393 } else {
394 format!("**/{pattern}")
395 };
396 for candidate in [pattern.to_owned(), anywhere] {
397 if let Ok(glob) = globset::Glob::new(&candidate) {
398 builder.add(glob);
399 }
400 }
401 }
402 builder
403 .build()
404 .unwrap_or_else(|_| globset::GlobSet::empty())
405}
406
407fn is_project_config_name(name: &str) -> bool {
408 (name.starts_with("tsconfig") || name.starts_with("jsconfig"))
409 && std::path::Path::new(name)
410 .extension()
411 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
412}
413
414fn input_files_digest(config: &ResolvedConfig) -> String {
417 let root = config.root.as_path();
418 let globs = resolution_file_globs(config);
419 let mut files: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
420 if let Ok(content) = std::fs::read(root.join(".git/info/exclude")) {
421 files.insert(".git/info/exclude".to_owned(), normalized_text(&content));
422 }
423 let mut project_configs: Vec<PathBuf> = Vec::new();
424 for entry in fingerprint_walk_builder(root).build().flatten() {
425 if entry.file_type().is_none_or(|kind| kind.is_dir()) {
426 continue;
427 }
428 let Ok(relative) = entry.path().strip_prefix(root) else {
429 continue;
430 };
431 let name = entry.file_name().to_string_lossy();
432 let is_ignore_file = IGNORE_FILE_NAMES.contains(&name.as_ref());
433 if !is_ignore_file && !globs.is_match(relative) {
434 continue;
435 }
436 if config.ignore_patterns.is_match(relative) {
437 continue;
438 }
439 if let Ok(content) = std::fs::read(entry.path()) {
440 let key = StableFileKey::from_relative(relative).as_str().to_owned();
441 files.insert(key, normalized_text(&content));
442 if is_project_config_name(&name) {
443 project_configs.push(entry.path().to_path_buf());
444 }
445 }
446 }
447 for project_config in project_configs {
448 add_extends_chain(root, &project_config, &mut files);
449 }
450 let parts: Vec<&str> = files
451 .iter()
452 .flat_map(|(path, content)| [path.as_str(), content.as_str()])
453 .collect();
454 fallow_types::identity::fnv1a64_parts(&parts)
455}
456
457fn add_extends_chain(
461 root: &Path,
462 project_config: &Path,
463 files: &mut std::collections::BTreeMap<String, String>,
464) {
465 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
466 let mut frontier: Vec<(PathBuf, usize)> = vec![(project_config.to_path_buf(), 0)];
467 while let Some((current, depth)) = frontier.pop() {
468 if depth >= MAX_EXTENDS_DEPTH || !seen.insert(current.clone()) {
469 continue;
470 }
471 for target in read_extends(¤t).unwrap_or_default() {
472 let Some(next) = resolve_extends_target(root, ¤t, &target) else {
473 continue;
474 };
475 if let Ok(content) = std::fs::read(&next) {
476 files.insert(extends_key(root, &next), normalized_text(&content));
477 frontier.push((next, depth + 1));
478 }
479 }
480 }
481}
482
483fn read_extends(path: &Path) -> Option<Vec<String>> {
486 let content = std::fs::read_to_string(path).ok()?;
487 let value: serde_json::Value = fallow_config::jsonc::parse_to_value(&content).ok()?;
488 match value.get("extends")? {
489 serde_json::Value::String(target) => Some(vec![target.clone()]),
490 serde_json::Value::Array(items) => Some(
491 items
492 .iter()
493 .filter_map(|item| item.as_str().map(str::to_owned))
494 .collect(),
495 ),
496 _ => None,
497 }
498}
499
500fn resolve_extends_target(root: &Path, from: &Path, target: &str) -> Option<PathBuf> {
503 let base = if target.starts_with('.') || Path::new(target).is_absolute() {
504 from.parent()?.join(target)
505 } else {
506 root.join("node_modules").join(target)
507 };
508 let candidates = [
509 base.clone(),
510 base.with_extension("json"),
511 base.join("tsconfig.json"),
512 ];
513 candidates.into_iter().find(|candidate| candidate.is_file())
514}
515
516fn extends_key(root: &Path, path: &Path) -> String {
519 path.strip_prefix(root).map_or_else(
520 |_| {
521 format!(
522 "extends:{}",
523 path.file_name()
524 .map(|name| name.to_string_lossy().into_owned())
525 .unwrap_or_default()
526 )
527 },
528 |relative| StableFileKey::from_relative(relative).as_str().to_owned(),
529 )
530}
531
532fn finding_id_rule_is_off(id: &str, config: &ResolvedConfig) -> bool {
536 let Some(kind) = id
537 .split(':')
538 .nth(1)
539 .and_then(fallow_types::suppress::IssueKind::parse)
540 else {
541 return false;
542 };
543 let off = |rules: &RulesConfig| rules.severity_for_kind(kind) == Severity::Off;
544 off(&config.rules)
545 || config.overrides.iter().any(|entry| {
546 let mut rules = config.rules.clone();
547 rules.apply_partial(&entry.rules);
548 off(&rules)
549 })
550}
551
552pub fn filter_to_workspaces(results: &mut AnalysisResults, ws_roots: &[PathBuf]) {
557 let any_under = |path: &Path| ws_roots.iter().any(|root| path.starts_with(root));
558 let pkg_jsons = ws_roots
559 .iter()
560 .map(|root| root.join("package.json"))
561 .collect::<Vec<_>>();
562 let in_pkg_jsons = |path: &Path| pkg_jsons.iter().any(|pkg| path == pkg);
563
564 filter_workspace_source_findings(results, &any_under);
565 filter_workspace_dependency_findings(results, &any_under, &in_pkg_jsons);
566 filter_workspace_graph_findings(results, &any_under);
567 filter_workspace_policy_findings(results, &any_under);
568}
569
570#[derive(Debug, Clone, Copy)]
574pub struct DeadCodeScope<'a> {
575 pub workspace_roots: Option<&'a [PathBuf]>,
578 pub changes: Option<&'a ChangeScope>,
581 pub diff: Option<(&'a fallow_output::DiffIndex, &'a Path)>,
583 pub files: Option<&'a FxHashSet<PathBuf>>,
586}
587
588pub fn apply_scope(
598 results: &mut AnalysisResults,
599 scope: &DeadCodeScope<'_>,
600 config: &ResolvedConfig,
601) {
602 crate::effective_severity::apply_prop_drilling_severities(results, config);
604 if let Some(roots) = scope.workspace_roots {
605 filter_to_workspaces(results, roots);
606 }
607 if let Some(changes) = scope.changes {
608 changes.retain_dead_code(results);
609 }
610 if let Some((diff, root)) = scope.diff {
611 crate::diff_scope::filter_dead_code_by_diff(results, diff, root);
612 }
613 if let Some(files) = scope.files {
614 filter_by_changed_files(results, files);
615 clear_dependency_findings(results);
616 }
617 filter_configured_ignored_findings(results, config);
618}
619
620fn clear_dependency_findings(results: &mut AnalysisResults) {
621 results.unused_dependencies.clear();
622 results.unused_dev_dependencies.clear();
623 results.unused_optional_dependencies.clear();
624 results.type_only_dependencies.clear();
625 results.test_only_dependencies.clear();
626 results.dev_dependencies_in_production.clear();
627}
628
629#[expect(
631 clippy::implicit_hasher,
632 reason = "fallow standardizes on FxHashSet across the workspace"
633)]
634pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
635 crate::changed_files::filter_results_by_changed_files(results, changed_files);
636}
637
638pub fn filter_configured_ignored_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
644 if config.ignore_findings.is_empty() {
645 return;
646 }
647
648 results.remove_ignored_dead_code_findings(|path| {
649 let key = if path.is_absolute() {
650 let Ok(relative) = path.strip_prefix(&config.root) else {
651 return false;
652 };
653 StableFileKey::from_relative(relative)
654 } else {
655 StableFileKey::from_relative(path)
656 };
657 config.ignore_findings.is_ignored(key.as_str())
658 });
659}
660
661fn filter_workspace_source_findings(
662 results: &mut AnalysisResults,
663 any_under: &dyn Fn(&Path) -> bool,
664) {
665 results
666 .unused_files
667 .retain(|finding| any_under(&finding.file.path));
668 results
669 .unused_exports
670 .retain(|finding| any_under(&finding.export.path));
671 results
672 .unused_types
673 .retain(|finding| any_under(&finding.export.path));
674 results
675 .private_type_leaks
676 .retain(|finding| any_under(&finding.leak.path));
677 results
678 .deprecated_exports_in_use
679 .retain(|finding| any_under(&finding.export.path));
680 results
681 .unused_enum_members
682 .retain(|finding| any_under(&finding.member.path));
683 results
684 .unused_class_members
685 .retain(|finding| any_under(&finding.member.path));
686 results
687 .unused_store_members
688 .retain(|finding| any_under(&finding.member.path));
689 results
690 .unprovided_injects
691 .retain(|finding| any_under(&finding.inject.path));
692 results
693 .unrendered_components
694 .retain(|finding| any_under(&finding.component.path));
695 results
696 .unused_component_props
697 .retain(|finding| any_under(&finding.prop.path));
698 results
699 .absent_component_props
700 .retain(|finding| any_under(&finding.prop.path));
701 results
702 .unused_component_emits
703 .retain(|finding| any_under(&finding.emit.path));
704 results
705 .unused_component_inputs
706 .retain(|finding| any_under(&finding.input.path));
707 results
708 .unused_component_outputs
709 .retain(|finding| any_under(&finding.output.path));
710 results
711 .unused_svelte_events
712 .retain(|finding| any_under(&finding.event.path));
713 results
714 .unused_effect_layers
715 .retain(|finding| any_under(&finding.layer.path));
716 results
717 .unused_tagged_errors
718 .retain(|finding| any_under(&finding.error.path));
719 results
720 .unproduced_catch_tags
721 .retain(|finding| any_under(&finding.catch_tag.path));
722 results
723 .unused_server_actions
724 .retain(|finding| any_under(&finding.action.path));
725 results
726 .unused_load_data_keys
727 .retain(|finding| any_under(&finding.key.path));
728 results
729 .unresolved_imports
730 .retain(|finding| any_under(&finding.import.path));
731}
732
733fn filter_workspace_dependency_findings(
734 results: &mut AnalysisResults,
735 any_under: &dyn Fn(&Path) -> bool,
736 in_pkg_jsons: &dyn Fn(&Path) -> bool,
737) {
738 results
739 .unused_dependencies
740 .retain(|finding| in_pkg_jsons(&finding.dep.path));
741 results
742 .unused_dev_dependencies
743 .retain(|finding| in_pkg_jsons(&finding.dep.path));
744 results
745 .unused_optional_dependencies
746 .retain(|finding| in_pkg_jsons(&finding.dep.path));
747 results
748 .type_only_dependencies
749 .retain(|finding| in_pkg_jsons(&finding.dep.path));
750 results
751 .test_only_dependencies
752 .retain(|finding| in_pkg_jsons(&finding.dep.path));
753 results
754 .dev_dependencies_in_production
755 .retain(|finding| in_pkg_jsons(&finding.dep.path));
756
757 results.unlisted_dependencies.retain(|finding| {
758 finding
759 .dep
760 .imported_from
761 .iter()
762 .any(|source| any_under(&source.path))
763 });
764 results.unused_dependency_overrides.clear();
765 results.misconfigured_dependency_overrides.clear();
766}
767
768fn filter_workspace_graph_findings(
769 results: &mut AnalysisResults,
770 any_under: &dyn Fn(&Path) -> bool,
771) {
772 for duplicate in &mut results.duplicate_exports {
773 duplicate
774 .export
775 .locations
776 .retain(|location| any_under(&location.path));
777 }
778 results
779 .duplicate_exports
780 .retain(|duplicate| duplicate.export.locations.len() >= 2);
781
782 results
783 .circular_dependencies
784 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
785
786 results
787 .re_export_cycles
788 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
789
790 results
791 .package_cycles
792 .retain(|cycle| cycle.cycle.edges.iter().any(|edge| any_under(&edge.path)));
793}
794
795fn filter_workspace_policy_findings(
796 results: &mut AnalysisResults,
797 any_under: &dyn Fn(&Path) -> bool,
798) {
799 results
800 .boundary_violations
801 .retain(|finding| any_under(&finding.violation.from_path));
802 results
803 .boundary_coverage_violations
804 .retain(|finding| any_under(&finding.violation.path));
805 results
806 .boundary_call_violations
807 .retain(|finding| any_under(&finding.violation.path));
808 results
809 .policy_violations
810 .retain(|finding| any_under(&finding.violation.path));
811
812 results
813 .stale_suppressions
814 .retain(|finding| any_under(&finding.path));
815
816 results
817 .security_findings
818 .retain(|finding| any_under(&finding.path));
819 results
820 .security_unresolved_callee_diagnostics
821 .retain(|finding| any_under(&finding.path));
822
823 results.unused_catalog_entries.clear();
824 results.empty_catalog_groups.clear();
825 results
826 .unresolved_catalog_references
827 .retain(|finding| any_under(&finding.reference.path));
828
829 results
830 .invalid_client_exports
831 .retain(|finding| any_under(&finding.export.path));
832
833 results
834 .mixed_client_server_barrels
835 .retain(|finding| any_under(&finding.barrel.path));
836
837 results
838 .misplaced_directives
839 .retain(|finding| any_under(&finding.directive_site.path));
840
841 results
842 .route_collisions
843 .retain(|finding| any_under(&finding.collision.path));
844
845 results
846 .dynamic_segment_name_conflicts
847 .retain(|finding| any_under(&finding.conflict.path));
848}
849
850pub fn apply_rule_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
867 let rules = &config.rules;
868 let has_overrides = !config.overrides.is_empty();
869
870 if has_overrides {
871 apply_file_override_rules(results, config);
872 apply_boundary_override_rules(results, config);
873 } else {
874 apply_base_file_rules(results, rules);
875 }
876
877 apply_base_collection_rules(results, rules);
878 hide_cascade_findings(results, config);
879 apply_effective_severities(results, config);
880}
881
882pub fn hide_cascade_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
899 if config.show_cascade {
900 return;
901 }
902 let reported = results.unused_files.iter().map(|f| &f.file.path);
903 let suppressed = results
904 .cascade
905 .suppressed_unused_files
906 .iter()
907 .filter(|path| config.resolve_rules_for_path(path).unused_files != Severity::Off);
908 let covered: FxHashSet<PathBuf> = reported.chain(suppressed).cloned().collect();
909 if covered.is_empty() {
910 return;
911 }
912 results.hide_cascade_findings(&|path| covered.contains(path));
913}
914
915fn apply_base_collection_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
916 if rules.unused_dependencies == Severity::Off {
917 results.unused_dependencies.clear();
918 }
919 if rules.unused_dev_dependencies == Severity::Off {
920 results.unused_dev_dependencies.clear();
921 }
922 if rules.unused_optional_dependencies == Severity::Off {
923 results.unused_optional_dependencies.clear();
924 }
925 if rules.unlisted_dependencies == Severity::Off {
926 results.unlisted_dependencies.clear();
927 }
928 if rules.duplicate_exports == Severity::Off {
929 results.duplicate_exports.clear();
930 }
931 if rules.type_only_dependencies == Severity::Off {
932 results.type_only_dependencies.clear();
933 }
934 if rules.test_only_dependencies == Severity::Off {
935 results.test_only_dependencies.clear();
936 }
937 if rules.dev_dependencies_in_production == Severity::Off {
938 results.dev_dependencies_in_production.clear();
939 }
940 if rules.circular_dependencies == Severity::Off {
941 results.circular_dependencies.clear();
942 }
943 if rules.re_export_cycle == Severity::Off {
944 results.re_export_cycles.clear();
945 }
946 if rules.package_cycle == Severity::Off {
947 results.package_cycles.clear();
948 }
949 if rules.boundary_violation == Severity::Off {
950 results.boundary_violations.clear();
951 results.boundary_coverage_violations.clear();
952 results.boundary_call_violations.clear();
953 }
954 if rules.policy_violation == Severity::Off {
955 results.policy_violations.clear();
956 }
957 if rules.unused_catalog_entries == Severity::Off {
958 results.unused_catalog_entries.clear();
959 }
960 if rules.empty_catalog_groups == Severity::Off {
961 results.empty_catalog_groups.clear();
962 }
963 if rules.unresolved_catalog_references == Severity::Off {
964 results.unresolved_catalog_references.clear();
965 }
966 if rules.unused_dependency_overrides == Severity::Off {
967 results.unused_dependency_overrides.clear();
968 }
969 if rules.misconfigured_dependency_overrides == Severity::Off {
970 results.misconfigured_dependency_overrides.clear();
971 }
972}
973
974fn apply_file_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
975 apply_dead_code_override_rules(results, config);
976 apply_catalog_override_rules(results, config);
977 apply_framework_override_rules(results, config);
978 apply_circular_override_rules(results, config);
979}
980
981fn apply_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
982 apply_core_dead_code_override_rules(results, config);
983 apply_component_dead_code_override_rules(results, config);
984}
985
986fn apply_core_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
988 results
989 .unused_files
990 .retain(|f| config.resolve_rules_for_path(&f.file.path).unused_files != Severity::Off);
991 results
992 .unused_exports
993 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_exports != Severity::Off);
994 results
995 .unused_types
996 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_types != Severity::Off);
997 results.private_type_leaks.retain(|e| {
998 config
999 .resolve_rules_for_path(&e.leak.path)
1000 .private_type_leaks
1001 != Severity::Off
1002 });
1003 results.deprecated_exports_in_use.retain(|e| {
1004 config
1005 .resolve_rules_for_path(&e.export.path)
1006 .deprecated_exports_in_use
1007 != Severity::Off
1008 });
1009 results.unused_enum_members.retain(|m| {
1010 config
1011 .resolve_rules_for_path(&m.member.path)
1012 .unused_enum_members
1013 != Severity::Off
1014 });
1015 results.unused_class_members.retain(|m| {
1016 config
1017 .resolve_rules_for_path(&m.member.path)
1018 .unused_class_members
1019 != Severity::Off
1020 });
1021 results.unused_store_members.retain(|m| {
1022 config
1023 .resolve_rules_for_path(&m.member.path)
1024 .unused_store_members
1025 != Severity::Off
1026 });
1027 results.unprovided_injects.retain(|f| {
1028 config
1029 .resolve_rules_for_path(&f.inject.path)
1030 .unprovided_injects
1031 != Severity::Off
1032 });
1033 results.unresolved_imports.retain(|i| {
1034 config
1035 .resolve_rules_for_path(&i.import.path)
1036 .unresolved_imports
1037 != Severity::Off
1038 });
1039}
1040
1041fn apply_component_dead_code_override_rules(
1043 results: &mut AnalysisResults,
1044 config: &ResolvedConfig,
1045) {
1046 results.unrendered_components.retain(|c| {
1047 config
1048 .resolve_rules_for_path(&c.component.path)
1049 .unrendered_components
1050 != Severity::Off
1051 });
1052 results.unused_component_props.retain(|p| {
1053 config
1054 .resolve_rules_for_path(&p.prop.path)
1055 .unused_component_props
1056 != Severity::Off
1057 });
1058 results.absent_component_props.retain(|p| {
1059 config
1060 .resolve_rules_for_path(&p.prop.path)
1061 .absent_component_props
1062 != Severity::Off
1063 });
1064 results.unused_component_emits.retain(|e| {
1065 config
1066 .resolve_rules_for_path(&e.emit.path)
1067 .unused_component_emits
1068 != Severity::Off
1069 });
1070 results.unused_component_inputs.retain(|i| {
1071 config
1072 .resolve_rules_for_path(&i.input.path)
1073 .unused_component_inputs
1074 != Severity::Off
1075 });
1076 results.unused_component_outputs.retain(|o| {
1077 config
1078 .resolve_rules_for_path(&o.output.path)
1079 .unused_component_outputs
1080 != Severity::Off
1081 });
1082 results.unused_svelte_events.retain(|e| {
1083 config
1084 .resolve_rules_for_path(&e.event.path)
1085 .unused_svelte_events
1086 != Severity::Off
1087 });
1088 results.unused_effect_layers.retain(|e| {
1089 config
1090 .resolve_rules_for_path(&e.layer.path)
1091 .unused_effect_layers
1092 != Severity::Off
1093 });
1094 results.unused_tagged_errors.retain(|e| {
1095 config
1096 .resolve_rules_for_path(&e.error.path)
1097 .unused_tagged_errors
1098 != Severity::Off
1099 });
1100 results.unproduced_catch_tags.retain(|e| {
1101 config
1102 .resolve_rules_for_path(&e.catch_tag.path)
1103 .unproduced_catch_tags
1104 != Severity::Off
1105 });
1106 results.unused_server_actions.retain(|a| {
1107 config
1108 .resolve_rules_for_path(&a.action.path)
1109 .unused_server_actions
1110 != Severity::Off
1111 });
1112 results.unused_load_data_keys.retain(|k| {
1113 config
1114 .resolve_rules_for_path(&k.key.path)
1115 .unused_load_data_keys
1116 != Severity::Off
1117 });
1118}
1119
1120fn apply_catalog_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1121 results.stale_suppressions.retain(|s| {
1122 let rules = config.resolve_rules_for_path(&s.path);
1123 if s.missing_reason {
1124 rules.require_suppression_reason != Severity::Off
1125 } else {
1126 rules.stale_suppressions != Severity::Off
1127 }
1128 });
1129 results.unresolved_catalog_references.retain(|r| {
1130 config
1131 .resolve_rules_for_path(&r.reference.path)
1132 .unresolved_catalog_references
1133 != Severity::Off
1134 });
1135 results.empty_catalog_groups.retain(|g| {
1136 config
1137 .resolve_rules_for_path(&g.group.path)
1138 .empty_catalog_groups
1139 != Severity::Off
1140 });
1141 results.unused_dependency_overrides.retain(|o| {
1142 config
1143 .resolve_rules_for_path(&o.entry.path)
1144 .unused_dependency_overrides
1145 != Severity::Off
1146 });
1147 results.misconfigured_dependency_overrides.retain(|o| {
1148 config
1149 .resolve_rules_for_path(&o.entry.path)
1150 .misconfigured_dependency_overrides
1151 != Severity::Off
1152 });
1153}
1154
1155fn apply_framework_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1156 results.invalid_client_exports.retain(|e| {
1157 config
1158 .resolve_rules_for_path(&e.export.path)
1159 .invalid_client_export
1160 != Severity::Off
1161 });
1162 results.mixed_client_server_barrels.retain(|b| {
1163 config
1164 .resolve_rules_for_path(&b.barrel.path)
1165 .mixed_client_server_barrel
1166 != Severity::Off
1167 });
1168 results.misplaced_directives.retain(|d| {
1169 config
1170 .resolve_rules_for_path(&d.directive_site.path)
1171 .misplaced_directive
1172 != Severity::Off
1173 });
1174 results.route_collisions.retain(|c| {
1175 config
1176 .resolve_rules_for_path(&c.collision.path)
1177 .route_collision
1178 != Severity::Off
1179 });
1180 results.dynamic_segment_name_conflicts.retain(|c| {
1181 config
1182 .resolve_rules_for_path(&c.conflict.path)
1183 .dynamic_segment_name_conflict
1184 != Severity::Off
1185 });
1186}
1187
1188fn apply_circular_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1189 results.circular_dependencies.retain(|c| {
1190 c.cycle
1191 .files
1192 .iter()
1193 .any(|path| config.resolve_rules_for_path(path).circular_dependencies != Severity::Off)
1194 });
1195}
1196
1197fn apply_base_file_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
1198 clear_base_core_dead_code(results, rules);
1199 clear_base_component_dead_code(results, rules);
1200 clear_base_suppression_and_framework(results, rules);
1201}
1202
1203fn clear_base_core_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1205 if rules.unused_files == Severity::Off {
1206 results.unused_files.clear();
1207 }
1208 if rules.unused_exports == Severity::Off {
1209 results.unused_exports.clear();
1210 }
1211 if rules.unused_types == Severity::Off {
1212 results.unused_types.clear();
1213 }
1214 if rules.private_type_leaks == Severity::Off {
1215 results.private_type_leaks.clear();
1216 }
1217 if rules.deprecated_exports_in_use == Severity::Off {
1218 results.deprecated_exports_in_use.clear();
1219 }
1220 if rules.unused_enum_members == Severity::Off {
1221 results.unused_enum_members.clear();
1222 }
1223 if rules.unused_class_members == Severity::Off {
1224 results.unused_class_members.clear();
1225 }
1226 if rules.unused_store_members == Severity::Off {
1227 results.unused_store_members.clear();
1228 }
1229 if rules.unprovided_injects == Severity::Off {
1230 results.unprovided_injects.clear();
1231 }
1232 if rules.unresolved_imports == Severity::Off {
1233 results.unresolved_imports.clear();
1234 }
1235}
1236
1237fn clear_base_component_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1239 if rules.unrendered_components == Severity::Off {
1240 results.unrendered_components.clear();
1241 }
1242 if rules.unused_component_props == Severity::Off {
1243 results.unused_component_props.clear();
1244 }
1245 if rules.absent_component_props == Severity::Off {
1246 results.absent_component_props.clear();
1247 }
1248 if rules.unused_component_emits == Severity::Off {
1249 results.unused_component_emits.clear();
1250 }
1251 if rules.unused_component_inputs == Severity::Off {
1252 results.unused_component_inputs.clear();
1253 }
1254 if rules.unused_component_outputs == Severity::Off {
1255 results.unused_component_outputs.clear();
1256 }
1257 if rules.unused_svelte_events == Severity::Off {
1258 results.unused_svelte_events.clear();
1259 }
1260 if rules.unused_effect_layers == Severity::Off {
1261 results.unused_effect_layers.clear();
1262 }
1263 if rules.unused_tagged_errors == Severity::Off {
1264 results.unused_tagged_errors.clear();
1265 }
1266 if rules.unproduced_catch_tags == Severity::Off {
1267 results.unproduced_catch_tags.clear();
1268 }
1269 if rules.unused_server_actions == Severity::Off {
1270 results.unused_server_actions.clear();
1271 }
1272 if rules.unused_load_data_keys == Severity::Off {
1273 results.unused_load_data_keys.clear();
1274 }
1275}
1276
1277fn clear_base_suppression_and_framework(results: &mut AnalysisResults, rules: &RulesConfig) {
1280 results.stale_suppressions.retain(|s| {
1281 if s.missing_reason {
1282 rules.require_suppression_reason != Severity::Off
1283 } else {
1284 rules.stale_suppressions != Severity::Off
1285 }
1286 });
1287 if rules.invalid_client_export == Severity::Off {
1288 results.invalid_client_exports.clear();
1289 }
1290 if rules.mixed_client_server_barrel == Severity::Off {
1291 results.mixed_client_server_barrels.clear();
1292 }
1293 if rules.misplaced_directive == Severity::Off {
1294 results.misplaced_directives.clear();
1295 }
1296 if rules.route_collision == Severity::Off {
1297 results.route_collisions.clear();
1298 }
1299 if rules.dynamic_segment_name_conflict == Severity::Off {
1300 results.dynamic_segment_name_conflicts.clear();
1301 }
1302}
1303
1304fn apply_boundary_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1305 results.boundary_violations.retain(|v| {
1306 config
1307 .resolve_rules_for_path(&v.violation.from_path)
1308 .boundary_violation
1309 != Severity::Off
1310 });
1311 results.boundary_coverage_violations.retain(|v| {
1312 config
1313 .resolve_rules_for_path(&v.violation.path)
1314 .boundary_violation
1315 != Severity::Off
1316 });
1317 results.boundary_call_violations.retain(|v| {
1318 config
1319 .resolve_rules_for_path(&v.violation.path)
1320 .boundary_violation
1321 != Severity::Off
1322 });
1323 results.policy_violations.retain(|v| {
1324 config
1325 .resolve_rules_for_path(&v.violation.path)
1326 .policy_violation
1327 != Severity::Off
1328 });
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use std::path::PathBuf;
1334
1335 use super::*;
1336 use fallow_types::output_dead_code::{
1337 BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
1338 PrivateTypeLeakFinding, UnlistedDependencyFinding, UnusedClassMemberFinding,
1339 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1340 };
1341 use fallow_types::results::{
1342 BoundaryViolation, CircularDependency, DuplicateExport, DuplicateLocation, ImportSite,
1343 PrivateTypeLeak, UnlistedDependency, UnusedExport, UnusedFile, UnusedMember,
1344 };
1345
1346 #[test]
1347 fn finding_id_filter_keeps_request_order_and_drops_duplicates() {
1348 let a = "dc1:unused-export:0123456789abcdef";
1349 let b = "dc1:unused-file:0123456789abcdef~1";
1350
1351 let filter = FindingIdFilter::parse(&[b, a, b])
1352 .expect("valid ids")
1353 .expect("a filter");
1354
1355 assert_eq!(filter.requested, vec![b.to_owned(), a.to_owned()]);
1356 assert_eq!(FindingIdFilter::parse::<&str>(&[]), Ok(None));
1357 }
1358
1359 #[test]
1360 fn normalized_text_ignores_line_endings_and_trailing_newlines() {
1361 assert_eq!(normalized_text(b"dist\r\nbuild\r\n"), "dist\nbuild");
1362 assert_eq!(normalized_text(b"dist\nbuild\n\n"), "dist\nbuild");
1363 assert_eq!(normalized_text(b"dist\nbuild"), "dist\nbuild");
1364 assert_ne!(
1365 normalized_text(b"dist\nbuild"),
1366 normalized_text(b"dist\nbuilt")
1367 );
1368 }
1369
1370 #[test]
1371 fn a_crlf_checkout_has_the_same_fingerprint_as_an_lf_checkout() {
1372 let write_project = |line_end: &str| {
1373 let dir = tempfile::tempdir().expect("project");
1374 let root = dir.path();
1375 std::fs::create_dir_all(root.join("src")).expect("src");
1376 std::fs::write(
1377 root.join(".gitignore"),
1378 format!("dist{line_end}coverage{line_end}"),
1379 )
1380 .expect("gitignore");
1381 std::fs::write(
1382 root.join("package.json"),
1383 format!("{{{line_end} \"name\": \"crlf\"{line_end}}}{line_end}"),
1384 )
1385 .expect("package.json");
1386 dir
1387 };
1388 let config_at = |root: &std::path::Path| {
1389 fallow_config::FallowConfig::default().resolve(
1390 root.to_path_buf(),
1391 fallow_config::OutputFormat::Json,
1392 1,
1393 true,
1394 true,
1395 None,
1396 )
1397 };
1398 let lf = write_project("\n");
1399 let crlf = write_project("\r\n");
1400
1401 assert_eq!(
1402 analysis_fingerprint_for_version(&config_at(lf.path()), "1.0.0"),
1403 analysis_fingerprint_for_version(&config_at(crlf.path()), "1.0.0")
1404 );
1405 }
1406
1407 #[test]
1408 fn a_tsconfig_extends_target_outside_the_walk_is_an_input() {
1409 let dir = tempfile::tempdir().expect("project");
1410 let root = dir.path();
1411 std::fs::create_dir_all(root.join(".config")).expect("hidden dir");
1412 std::fs::write(
1413 root.join("tsconfig.json"),
1414 r#"{ "extends": "./.config/tsconfig.base.json" }"#,
1415 )
1416 .expect("tsconfig");
1417 let base = root.join(".config/tsconfig.base.json");
1418 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "." } }"#).expect("base");
1419 let config = fallow_config::FallowConfig::default().resolve(
1420 root.to_path_buf(),
1421 fallow_config::OutputFormat::Json,
1422 1,
1423 true,
1424 true,
1425 None,
1426 );
1427 let before = analysis_fingerprint_for_version(&config, "1.0.0");
1428
1429 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "src" } }"#).expect("edit");
1430
1431 assert_ne!(analysis_fingerprint_for_version(&config, "1.0.0"), before);
1432 }
1433
1434 #[test]
1435 fn analysis_fingerprint_depends_on_the_version_and_not_on_the_root() {
1436 let config_at = |root: &std::path::Path| {
1437 fallow_config::FallowConfig::default().resolve(
1438 root.to_path_buf(),
1439 fallow_config::OutputFormat::Json,
1440 1,
1441 true,
1442 true,
1443 None,
1444 )
1445 };
1446 let a = tempfile::tempdir().expect("project a");
1447 let b = tempfile::tempdir().expect("project b");
1448 let config_a = config_at(a.path());
1449 let config_b = config_at(b.path());
1450
1451 let base = analysis_fingerprint_for_version(&config_a, "1.0.0");
1452 assert!(base.starts_with("af1:"), "{base}");
1453 assert_eq!(base, analysis_fingerprint_for_version(&config_a, "1.0.0"));
1454 assert_eq!(base, analysis_fingerprint_for_version(&config_b, "1.0.0"));
1455 assert_ne!(base, analysis_fingerprint_for_version(&config_a, "1.0.1"));
1456 }
1457
1458 #[test]
1459 fn finding_id_filter_refuses_a_malformed_id() {
1460 let error = FindingIdFilter::parse(&["dc1:unused-export:helper"])
1461 .expect_err("malformed id refused");
1462
1463 assert!(error.contains("dc1:unused-export:helper"), "{error}");
1464 }
1465
1466 #[test]
1467 fn finding_id_rule_is_off_reads_rules_and_overrides() {
1468 let id = "dc1:unused-export:0123456789abcdef";
1469 let mut config = fallow_config::FallowConfig::default().resolve(
1470 PathBuf::from("/repo"),
1471 fallow_config::OutputFormat::Json,
1472 1,
1473 true,
1474 true,
1475 None,
1476 );
1477 assert!(!finding_id_rule_is_off(id, &config));
1478
1479 config.rules.unused_exports = Severity::Off;
1480 assert!(finding_id_rule_is_off(id, &config));
1481 assert!(!finding_id_rule_is_off(
1482 "dc1:unused-file:0123456789abcdef",
1483 &config
1484 ));
1485
1486 let with_override = serde_json::from_str::<fallow_config::FallowConfig>(
1487 r#"{"overrides":[{"files":["src/a.ts"],"rules":{"unused-exports":"off"}}]}"#,
1488 )
1489 .expect("config parses")
1490 .resolve(
1491 PathBuf::from("/repo"),
1492 fallow_config::OutputFormat::Json,
1493 1,
1494 true,
1495 true,
1496 None,
1497 );
1498 assert!(finding_id_rule_is_off(id, &with_override));
1499 }
1500
1501 #[test]
1502 fn workspace_filter_keeps_findings_under_workspace_root() {
1503 let root = PathBuf::from("/repo/packages/app");
1504 let mut results = AnalysisResults::default();
1505 results
1506 .unused_files
1507 .push(UnusedFileFinding::with_actions(UnusedFile {
1508 path: root.join("src/unused.ts"),
1509 }));
1510 results
1511 .unused_files
1512 .push(UnusedFileFinding::with_actions(UnusedFile {
1513 path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
1514 }));
1515
1516 filter_to_workspaces(&mut results, std::slice::from_ref(&root));
1517
1518 assert_eq!(results.unused_files.len(), 1);
1519 assert_eq!(
1520 results.unused_files[0].file.path,
1521 root.join("src/unused.ts")
1522 );
1523 }
1524
1525 #[test]
1526 fn configured_filter_removes_findings_added_after_engine_analysis() {
1527 let project = tempfile::tempdir().expect("project");
1528 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1529 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1530 )
1531 .expect("config parses")
1532 .resolve(
1533 project.path().to_path_buf(),
1534 fallow_config::OutputFormat::Human,
1535 1,
1536 true,
1537 true,
1538 None,
1539 );
1540 let mut results = AnalysisResults::default();
1541 results
1542 .private_type_leaks
1543 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1544 path: project.path().join("src/hidden.ts"),
1545 export_name: "publicApi".to_string(),
1546 type_name: "PrivateShape".to_string(),
1547 line: 1,
1548 col: 0,
1549 span_start: 0,
1550 semantic: None,
1551 }));
1552 results
1553 .boundary_violations
1554 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1555 from_path: project.path().join("src/hidden.ts"),
1556 to_path: project.path().join("src/data.ts"),
1557 from_zone: "ui".to_string(),
1558 to_zone: "data".to_string(),
1559 import_specifier: "./data".to_string(),
1560 line: 1,
1561 col: 0,
1562 via_path: None,
1563 }));
1564
1565 filter_configured_ignored_findings(&mut results, &config);
1566
1567 assert!(results.private_type_leaks.is_empty());
1568 assert_eq!(results.boundary_violations.len(), 1);
1569 }
1570
1571 fn unmatched_patterns(diagnostics: &[WorkspaceDiagnostic]) -> Vec<(&'static str, String)> {
1572 diagnostics
1573 .iter()
1574 .filter_map(|diagnostic| match &diagnostic.kind {
1575 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => {
1576 Some(("ignoreFindings", pattern.clone()))
1577 }
1578 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => {
1579 Some(("ignoreDependencies", pattern.clone()))
1580 }
1581 _ => None,
1582 })
1583 .collect()
1584 }
1585
1586 fn write_manifest(root: &Path, dependencies: &str) {
1587 std::fs::write(
1588 root.join("package.json"),
1589 format!(
1590 r#"{{"name":"app","private":true,"main":"src/index.ts","dependencies":{dependencies}}}"#
1591 ),
1592 )
1593 .expect("write package.json");
1594 }
1595
1596 #[test]
1597 fn config_pattern_diagnostics_describe_the_latest_pass_only() {
1598 let project = tempfile::tempdir().expect("project");
1599 let root = project.path();
1600 std::fs::create_dir_all(root.join("src")).expect("create src");
1601 std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("write entry");
1602 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n")
1603 .expect("write orphan");
1604 write_manifest(root, r#"{"@acme/lib":"1.0.0"}"#);
1605 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1606 r#"{"ignoreDependencies":["@acme/*","@typo/*"],"ignoreFindings":["src/legcy/**"]}"#,
1607 )
1608 .expect("config parses")
1609 .resolve(
1610 root.to_path_buf(),
1611 fallow_config::OutputFormat::Human,
1612 1,
1613 true,
1614 true,
1615 None,
1616 );
1617
1618 crate::session::AnalysisSession::from_resolved_config(config.clone())
1619 .expect("session")
1620 .analyze_dead_code()
1621 .expect("first pass");
1622 assert_eq!(
1623 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1624 vec![
1625 ("ignoreFindings", "src/legcy/**".to_owned()),
1626 ("ignoreDependencies", "@typo/*".to_owned()),
1627 ]
1628 );
1629 assert_eq!(
1630 unmatched_patterns(&config_pattern_diagnostics(&config, false)),
1631 vec![("ignoreFindings", "src/legcy/**".to_owned())],
1632 "a run that reports no dependency findings omits the dependency globs"
1633 );
1634
1635 write_manifest(root, r#"{"react":"1.0.0"}"#);
1638 crate::session::AnalysisSession::from_resolved_config(config.clone())
1639 .expect("session")
1640 .analyze_dead_code()
1641 .expect("second pass");
1642 assert_eq!(
1643 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1644 vec![
1645 ("ignoreFindings", "src/legcy/**".to_owned()),
1646 ("ignoreDependencies", "@acme/*".to_owned()),
1647 ("ignoreDependencies", "@typo/*".to_owned()),
1648 ]
1649 );
1650 }
1651
1652 fn config_with_override(
1653 pattern: &str,
1654 configure: impl FnOnce(&mut fallow_config::PartialRulesConfig),
1655 ) -> ResolvedConfig {
1656 let mut partial = fallow_config::PartialRulesConfig::default();
1657 configure(&mut partial);
1658 fallow_config::FallowConfig {
1659 rules: RulesConfig {
1660 private_type_leaks: Severity::Warn,
1661 ..RulesConfig::default()
1662 },
1663 overrides: vec![fallow_config::ConfigOverride {
1664 files: vec![pattern.to_string()],
1665 rules: partial,
1666 }],
1667 ..fallow_config::FallowConfig::default()
1668 }
1669 .resolve(
1670 PathBuf::from("/project"),
1671 fallow_config::OutputFormat::Human,
1672 1,
1673 true,
1674 true,
1675 None,
1676 )
1677 }
1678
1679 fn unused_export(path: &str) -> UnusedExportFinding {
1680 UnusedExportFinding::with_actions(UnusedExport {
1681 path: PathBuf::from(path),
1682 export_name: "Unused".to_string(),
1683 is_type_only: false,
1684 line: 1,
1685 col: 0,
1686 span_start: 0,
1687 is_re_export: false,
1688 deprecated: false,
1689 deprecated_reason: None,
1690 })
1691 }
1692
1693 fn private_type_leak(path: &str) -> PrivateTypeLeakFinding {
1694 PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1695 path: PathBuf::from(path),
1696 export_name: "Unused".to_string(),
1697 type_name: "Props".to_string(),
1698 line: 1,
1699 col: 0,
1700 span_start: 0,
1701 semantic: None,
1702 })
1703 }
1704
1705 fn overridden_fixture() -> AnalysisResults {
1706 let mut results = AnalysisResults::default();
1707 results
1708 .unused_exports
1709 .push(unused_export("/project/src/ui/kit.ts"));
1710 results
1711 .unused_exports
1712 .push(unused_export("/project/src/lib/util.ts"));
1713 results
1714 .private_type_leaks
1715 .push(private_type_leak("/project/src/ui/kit.ts"));
1716 results
1717 .private_type_leaks
1718 .push(private_type_leak("/project/src/lib/util.ts"));
1719 results
1720 }
1721
1722 #[test]
1723 fn rule_severities_drop_findings_only_on_overridden_paths() {
1724 let config = config_with_override("src/ui/**", |rules| {
1725 rules.unused_exports = Some(Severity::Off);
1726 rules.private_type_leaks = Some(Severity::Off);
1727 });
1728 let mut results = overridden_fixture();
1729
1730 apply_rule_severities(&mut results, &config);
1731
1732 assert_eq!(
1733 results
1734 .unused_exports
1735 .iter()
1736 .map(|finding| finding.export.path.clone())
1737 .collect::<Vec<_>>(),
1738 vec![PathBuf::from("/project/src/lib/util.ts")]
1739 );
1740 assert_eq!(
1741 results
1742 .private_type_leaks
1743 .iter()
1744 .map(|finding| finding.leak.path.clone())
1745 .collect::<Vec<_>>(),
1746 vec![PathBuf::from("/project/src/lib/util.ts")]
1747 );
1748 }
1749
1750 #[test]
1751 fn rule_severities_are_idempotent() {
1752 let config = config_with_override("src/ui/**", |rules| {
1756 rules.unused_exports = Some(Severity::Off);
1757 rules.private_type_leaks = Some(Severity::Off);
1758 });
1759
1760 let mut once = overridden_fixture();
1761 apply_rule_severities(&mut once, &config);
1762 let mut twice = overridden_fixture();
1763 apply_rule_severities(&mut twice, &config);
1764 apply_rule_severities(&mut twice, &config);
1765
1766 assert_eq!(
1767 once.unused_exports
1768 .iter()
1769 .map(|finding| finding.export.path.clone())
1770 .collect::<Vec<_>>(),
1771 twice
1772 .unused_exports
1773 .iter()
1774 .map(|finding| finding.export.path.clone())
1775 .collect::<Vec<_>>()
1776 );
1777 assert_eq!(
1778 once.private_type_leaks
1779 .iter()
1780 .map(|finding| finding.leak.path.clone())
1781 .collect::<Vec<_>>(),
1782 twice
1783 .private_type_leaks
1784 .iter()
1785 .map(|finding| finding.leak.path.clone())
1786 .collect::<Vec<_>>()
1787 );
1788 }
1789
1790 #[test]
1791 fn rule_severities_keep_a_cycle_when_any_member_file_stays_enabled() {
1792 let config = config_with_override("src/ui/**", |rules| {
1793 rules.circular_dependencies = Some(Severity::Off);
1794 });
1795 let mut results = AnalysisResults::default();
1796 results
1797 .circular_dependencies
1798 .push(CircularDependencyFinding::with_actions(
1799 CircularDependency {
1800 files: vec![
1801 PathBuf::from("/project/src/ui/a.ts"),
1802 PathBuf::from("/project/src/lib/b.ts"),
1803 ],
1804 length: 2,
1805 line: 1,
1806 col: 0,
1807 edges: Vec::new(),
1808 is_cross_package: false,
1809 },
1810 ));
1811 results
1812 .circular_dependencies
1813 .push(CircularDependencyFinding::with_actions(
1814 CircularDependency {
1815 files: vec![
1816 PathBuf::from("/project/src/ui/c.ts"),
1817 PathBuf::from("/project/src/ui/d.ts"),
1818 ],
1819 length: 2,
1820 line: 1,
1821 col: 0,
1822 edges: Vec::new(),
1823 is_cross_package: false,
1824 },
1825 ));
1826
1827 apply_rule_severities(&mut results, &config);
1828
1829 assert_eq!(results.circular_dependencies.len(), 1);
1830 assert_eq!(
1831 results.circular_dependencies[0].cycle.files[0],
1832 PathBuf::from("/project/src/ui/a.ts")
1833 );
1834 }
1835
1836 #[test]
1837 fn rule_severities_clear_base_rules_without_overrides() {
1838 let config = fallow_config::FallowConfig {
1839 rules: RulesConfig {
1840 unused_exports: Severity::Off,
1841 private_type_leaks: Severity::Warn,
1842 ..RulesConfig::default()
1843 },
1844 ..fallow_config::FallowConfig::default()
1845 }
1846 .resolve(
1847 PathBuf::from("/project"),
1848 fallow_config::OutputFormat::Human,
1849 1,
1850 true,
1851 true,
1852 None,
1853 );
1854 let mut results = overridden_fixture();
1855
1856 apply_rule_severities(&mut results, &config);
1857
1858 assert!(results.unused_exports.is_empty());
1859 assert_eq!(results.private_type_leaks.len(), 2);
1860 }
1861
1862 const DEAD_FILE: &str = "/project/src/dead.ts";
1863 const LIVE_FILE: &str = "/project/src/live.ts";
1864
1865 fn resolve(config: fallow_config::FallowConfig) -> ResolvedConfig {
1866 config.resolve(
1867 PathBuf::from("/project"),
1868 fallow_config::OutputFormat::Human,
1869 1,
1870 true,
1871 true,
1872 None,
1873 )
1874 }
1875
1876 fn member(path: &str, kind: fallow_types::extract::MemberKind) -> UnusedMember {
1877 UnusedMember {
1878 path: PathBuf::from(path),
1879 parent_name: "Parent".to_string(),
1880 member_name: "member".to_string(),
1881 kind,
1882 line: 2,
1883 col: 0,
1884 }
1885 }
1886
1887 fn unused_type(path: &str) -> UnusedTypeFinding {
1888 let mut export = unused_export(path).export;
1889 export.is_type_only = true;
1890 UnusedTypeFinding::with_actions(export)
1891 }
1892
1893 fn cascade_fixture() -> AnalysisResults {
1896 let mut results = AnalysisResults::default();
1897 results
1898 .unused_files
1899 .push(UnusedFileFinding::with_actions(UnusedFile {
1900 path: PathBuf::from(DEAD_FILE),
1901 }));
1902 for path in [DEAD_FILE, LIVE_FILE] {
1903 results.unused_exports.push(unused_export(path));
1904 results.unused_types.push(unused_type(path));
1905 results
1906 .unused_enum_members
1907 .push(UnusedEnumMemberFinding::with_actions(member(
1908 path,
1909 fallow_types::extract::MemberKind::EnumMember,
1910 )));
1911 results
1912 .unused_class_members
1913 .push(UnusedClassMemberFinding::with_actions(member(
1914 path,
1915 fallow_types::extract::MemberKind::ClassMethod,
1916 )));
1917 }
1918 results
1919 .duplicate_exports
1920 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1921 export_name: "Shared".to_string(),
1922 locations: [DEAD_FILE, LIVE_FILE]
1923 .iter()
1924 .map(|path| DuplicateLocation {
1925 path: PathBuf::from(path),
1926 line: 1,
1927 col: 0,
1928 })
1929 .collect(),
1930 }));
1931 results
1932 .circular_dependencies
1933 .push(CircularDependencyFinding::with_actions(
1934 CircularDependency {
1935 files: vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)],
1936 length: 2,
1937 line: 1,
1938 col: 0,
1939 edges: Vec::new(),
1940 is_cross_package: false,
1941 },
1942 ));
1943 results
1944 .unlisted_dependencies
1945 .push(UnlistedDependencyFinding::with_actions(
1946 UnlistedDependency {
1947 package_name: "left-pad".to_string(),
1948 imported_from: vec![ImportSite {
1949 path: PathBuf::from(DEAD_FILE),
1950 line: 1,
1951 col: 0,
1952 }],
1953 },
1954 ));
1955 results
1956 .boundary_violations
1957 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1958 from_path: PathBuf::from(DEAD_FILE),
1959 to_path: PathBuf::from(LIVE_FILE),
1960 from_zone: "ui".to_string(),
1961 to_zone: "data".to_string(),
1962 import_specifier: "./live".to_string(),
1963 line: 1,
1964 col: 0,
1965 via_path: None,
1966 }));
1967 results
1968 }
1969
1970 fn cascade_paths(results: &AnalysisResults) -> Vec<Vec<PathBuf>> {
1971 vec![
1972 results
1973 .unused_exports
1974 .iter()
1975 .map(|f| f.export.path.clone())
1976 .collect(),
1977 results
1978 .unused_types
1979 .iter()
1980 .map(|f| f.export.path.clone())
1981 .collect(),
1982 results
1983 .unused_enum_members
1984 .iter()
1985 .map(|f| f.member.path.clone())
1986 .collect(),
1987 results
1988 .unused_class_members
1989 .iter()
1990 .map(|f| f.member.path.clone())
1991 .collect(),
1992 ]
1993 }
1994
1995 #[test]
1996 fn rule_pass_hides_the_cascade_kinds_of_unused_files() {
1997 let config = resolve(fallow_config::FallowConfig::default());
1998 let mut results = cascade_fixture();
1999
2000 apply_rule_severities(&mut results, &config);
2001
2002 let live = vec![PathBuf::from(LIVE_FILE)];
2003 assert_eq!(cascade_paths(&results), vec![live; 4]);
2004 assert_eq!(results.cascade_hidden, 4);
2005 assert_eq!(results.cascade.hidden.len(), 4);
2006 assert_eq!(results.unused_files.len(), 1, "the unused file stays");
2007 }
2008
2009 #[test]
2010 fn rule_pass_keeps_other_kinds_in_unused_files() {
2011 let config = resolve(fallow_config::FallowConfig::default());
2012 let mut results = cascade_fixture();
2013
2014 apply_rule_severities(&mut results, &config);
2015
2016 assert_eq!(results.duplicate_exports.len(), 1);
2017 assert_eq!(results.circular_dependencies.len(), 1);
2018 assert_eq!(results.unlisted_dependencies.len(), 1);
2019 assert_eq!(results.boundary_violations.len(), 1);
2020 }
2021
2022 #[test]
2023 fn show_cascade_keeps_every_finding() {
2024 let config = resolve(fallow_config::FallowConfig {
2025 show_cascade: true,
2026 ..fallow_config::FallowConfig::default()
2027 });
2028 let mut results = cascade_fixture();
2029
2030 apply_rule_severities(&mut results, &config);
2031
2032 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2033 assert_eq!(cascade_paths(&results), vec![both; 4]);
2034 assert_eq!(results.cascade_hidden, 0);
2035 }
2036
2037 #[test]
2038 fn unused_files_rule_off_for_the_file_keeps_its_findings() {
2039 let config = config_with_override("src/dead.ts", |rules| {
2040 rules.unused_files = Some(Severity::Off);
2041 });
2042 let mut results = cascade_fixture();
2043
2044 apply_rule_severities(&mut results, &config);
2045
2046 assert!(results.unused_files.is_empty());
2047 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2048 assert_eq!(cascade_paths(&results), vec![both; 4]);
2049 assert_eq!(results.cascade_hidden, 0);
2050 }
2051
2052 #[test]
2053 fn a_suppressed_unused_file_still_hides_its_findings() {
2054 let config = resolve(fallow_config::FallowConfig::default());
2057 let mut results = cascade_fixture();
2058 results.unused_files.clear();
2059 results.cascade.suppressed_unused_files = vec![PathBuf::from(DEAD_FILE)];
2060
2061 apply_rule_severities(&mut results, &config);
2062
2063 let live = vec![PathBuf::from(LIVE_FILE)];
2064 assert_eq!(cascade_paths(&results), vec![live; 4]);
2065 assert_eq!(results.cascade_hidden, 4);
2066 }
2067
2068 #[test]
2069 fn a_file_missing_from_the_report_hides_nothing() {
2070 let config = resolve(fallow_config::FallowConfig::default());
2074 let mut results = cascade_fixture();
2075 results.unused_files.clear();
2076
2077 apply_rule_severities(&mut results, &config);
2078
2079 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2080 assert_eq!(cascade_paths(&results), vec![both; 4]);
2081 assert_eq!(results.cascade_hidden, 0);
2082 }
2083
2084 #[test]
2085 fn cascade_hiding_is_idempotent() {
2086 let config = resolve(fallow_config::FallowConfig::default());
2087 let mut results = cascade_fixture();
2088
2089 apply_rule_severities(&mut results, &config);
2090 apply_rule_severities(&mut results, &config);
2091
2092 assert_eq!(results.cascade_hidden, 4);
2093 assert_eq!(results.cascade.hidden.len(), 4);
2094 }
2095
2096 #[test]
2097 fn cascade_hidden_serializes_only_when_not_zero() {
2098 let config = resolve(fallow_config::FallowConfig::default());
2099 let mut results = cascade_fixture();
2100 let before = serde_json::to_value(&results).expect("serializes");
2101 assert!(before.get("cascade_hidden").is_none());
2102
2103 apply_rule_severities(&mut results, &config);
2104
2105 let after = serde_json::to_value(&results).expect("serializes");
2106 assert_eq!(after["cascade_hidden"], 4);
2107 assert!(after.get("cascade").is_none());
2108 }
2109}