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 if let Some(roots) = scope.workspace_roots {
603 filter_to_workspaces(results, roots);
604 }
605 if let Some(changes) = scope.changes {
606 changes.retain_dead_code(results);
607 }
608 if let Some((diff, root)) = scope.diff {
609 crate::diff_scope::filter_dead_code_by_diff(results, diff, root);
610 }
611 if let Some(files) = scope.files {
612 filter_by_changed_files(results, files);
613 clear_dependency_findings(results);
614 }
615 filter_configured_ignored_findings(results, config);
616}
617
618fn clear_dependency_findings(results: &mut AnalysisResults) {
619 results.unused_dependencies.clear();
620 results.unused_dev_dependencies.clear();
621 results.unused_optional_dependencies.clear();
622 results.type_only_dependencies.clear();
623 results.test_only_dependencies.clear();
624 results.dev_dependencies_in_production.clear();
625}
626
627#[expect(
629 clippy::implicit_hasher,
630 reason = "fallow standardizes on FxHashSet across the workspace"
631)]
632pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
633 crate::changed_files::filter_results_by_changed_files(results, changed_files);
634}
635
636pub fn filter_configured_ignored_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
642 if config.ignore_findings.is_empty() {
643 return;
644 }
645
646 results.remove_ignored_dead_code_findings(|path| {
647 let key = if path.is_absolute() {
648 let Ok(relative) = path.strip_prefix(&config.root) else {
649 return false;
650 };
651 StableFileKey::from_relative(relative)
652 } else {
653 StableFileKey::from_relative(path)
654 };
655 config.ignore_findings.is_ignored(key.as_str())
656 });
657}
658
659fn filter_workspace_source_findings(
660 results: &mut AnalysisResults,
661 any_under: &dyn Fn(&Path) -> bool,
662) {
663 results
664 .unused_files
665 .retain(|finding| any_under(&finding.file.path));
666 results
667 .unused_exports
668 .retain(|finding| any_under(&finding.export.path));
669 results
670 .unused_types
671 .retain(|finding| any_under(&finding.export.path));
672 results
673 .private_type_leaks
674 .retain(|finding| any_under(&finding.leak.path));
675 results
676 .deprecated_exports_in_use
677 .retain(|finding| any_under(&finding.export.path));
678 results
679 .unused_enum_members
680 .retain(|finding| any_under(&finding.member.path));
681 results
682 .unused_class_members
683 .retain(|finding| any_under(&finding.member.path));
684 results
685 .unused_store_members
686 .retain(|finding| any_under(&finding.member.path));
687 results
688 .unprovided_injects
689 .retain(|finding| any_under(&finding.inject.path));
690 results
691 .unrendered_components
692 .retain(|finding| any_under(&finding.component.path));
693 results
694 .unused_component_props
695 .retain(|finding| any_under(&finding.prop.path));
696 results
697 .absent_component_props
698 .retain(|finding| any_under(&finding.prop.path));
699 results
700 .unused_component_emits
701 .retain(|finding| any_under(&finding.emit.path));
702 results
703 .unused_component_inputs
704 .retain(|finding| any_under(&finding.input.path));
705 results
706 .unused_component_outputs
707 .retain(|finding| any_under(&finding.output.path));
708 results
709 .unused_svelte_events
710 .retain(|finding| any_under(&finding.event.path));
711 results
712 .unused_server_actions
713 .retain(|finding| any_under(&finding.action.path));
714 results
715 .unused_load_data_keys
716 .retain(|finding| any_under(&finding.key.path));
717 results
718 .unresolved_imports
719 .retain(|finding| any_under(&finding.import.path));
720}
721
722fn filter_workspace_dependency_findings(
723 results: &mut AnalysisResults,
724 any_under: &dyn Fn(&Path) -> bool,
725 in_pkg_jsons: &dyn Fn(&Path) -> bool,
726) {
727 results
728 .unused_dependencies
729 .retain(|finding| in_pkg_jsons(&finding.dep.path));
730 results
731 .unused_dev_dependencies
732 .retain(|finding| in_pkg_jsons(&finding.dep.path));
733 results
734 .unused_optional_dependencies
735 .retain(|finding| in_pkg_jsons(&finding.dep.path));
736 results
737 .type_only_dependencies
738 .retain(|finding| in_pkg_jsons(&finding.dep.path));
739 results
740 .test_only_dependencies
741 .retain(|finding| in_pkg_jsons(&finding.dep.path));
742 results
743 .dev_dependencies_in_production
744 .retain(|finding| in_pkg_jsons(&finding.dep.path));
745
746 results.unlisted_dependencies.retain(|finding| {
747 finding
748 .dep
749 .imported_from
750 .iter()
751 .any(|source| any_under(&source.path))
752 });
753 results.unused_dependency_overrides.clear();
754 results.misconfigured_dependency_overrides.clear();
755}
756
757fn filter_workspace_graph_findings(
758 results: &mut AnalysisResults,
759 any_under: &dyn Fn(&Path) -> bool,
760) {
761 for duplicate in &mut results.duplicate_exports {
762 duplicate
763 .export
764 .locations
765 .retain(|location| any_under(&location.path));
766 }
767 results
768 .duplicate_exports
769 .retain(|duplicate| duplicate.export.locations.len() >= 2);
770
771 results
772 .circular_dependencies
773 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
774
775 results
776 .re_export_cycles
777 .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
778
779 results
780 .package_cycles
781 .retain(|cycle| cycle.cycle.edges.iter().any(|edge| any_under(&edge.path)));
782}
783
784fn filter_workspace_policy_findings(
785 results: &mut AnalysisResults,
786 any_under: &dyn Fn(&Path) -> bool,
787) {
788 results
789 .boundary_violations
790 .retain(|finding| any_under(&finding.violation.from_path));
791 results
792 .boundary_coverage_violations
793 .retain(|finding| any_under(&finding.violation.path));
794 results
795 .boundary_call_violations
796 .retain(|finding| any_under(&finding.violation.path));
797 results
798 .policy_violations
799 .retain(|finding| any_under(&finding.violation.path));
800
801 results
802 .stale_suppressions
803 .retain(|finding| any_under(&finding.path));
804
805 results
806 .security_findings
807 .retain(|finding| any_under(&finding.path));
808 results
809 .security_unresolved_callee_diagnostics
810 .retain(|finding| any_under(&finding.path));
811
812 results.unused_catalog_entries.clear();
813 results.empty_catalog_groups.clear();
814 results
815 .unresolved_catalog_references
816 .retain(|finding| any_under(&finding.reference.path));
817
818 results
819 .invalid_client_exports
820 .retain(|finding| any_under(&finding.export.path));
821
822 results
823 .mixed_client_server_barrels
824 .retain(|finding| any_under(&finding.barrel.path));
825
826 results
827 .misplaced_directives
828 .retain(|finding| any_under(&finding.directive_site.path));
829
830 results
831 .route_collisions
832 .retain(|finding| any_under(&finding.collision.path));
833
834 results
835 .dynamic_segment_name_conflicts
836 .retain(|finding| any_under(&finding.conflict.path));
837}
838
839pub fn apply_rule_severities(results: &mut AnalysisResults, config: &ResolvedConfig) {
856 let rules = &config.rules;
857 let has_overrides = !config.overrides.is_empty();
858
859 if has_overrides {
860 apply_file_override_rules(results, config);
861 apply_boundary_override_rules(results, config);
862 } else {
863 apply_base_file_rules(results, rules);
864 }
865
866 apply_base_collection_rules(results, rules);
867 hide_cascade_findings(results, config);
868 apply_effective_severities(results, config);
869}
870
871pub fn hide_cascade_findings(results: &mut AnalysisResults, config: &ResolvedConfig) {
888 if config.show_cascade {
889 return;
890 }
891 let reported = results.unused_files.iter().map(|f| &f.file.path);
892 let suppressed = results
893 .cascade
894 .suppressed_unused_files
895 .iter()
896 .filter(|path| config.resolve_rules_for_path(path).unused_files != Severity::Off);
897 let covered: FxHashSet<PathBuf> = reported.chain(suppressed).cloned().collect();
898 if covered.is_empty() {
899 return;
900 }
901 results.hide_cascade_findings(&|path| covered.contains(path));
902}
903
904fn apply_base_collection_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
905 if rules.unused_dependencies == Severity::Off {
906 results.unused_dependencies.clear();
907 }
908 if rules.unused_dev_dependencies == Severity::Off {
909 results.unused_dev_dependencies.clear();
910 }
911 if rules.unused_optional_dependencies == Severity::Off {
912 results.unused_optional_dependencies.clear();
913 }
914 if rules.unlisted_dependencies == Severity::Off {
915 results.unlisted_dependencies.clear();
916 }
917 if rules.duplicate_exports == Severity::Off {
918 results.duplicate_exports.clear();
919 }
920 if rules.type_only_dependencies == Severity::Off {
921 results.type_only_dependencies.clear();
922 }
923 if rules.test_only_dependencies == Severity::Off {
924 results.test_only_dependencies.clear();
925 }
926 if rules.dev_dependencies_in_production == Severity::Off {
927 results.dev_dependencies_in_production.clear();
928 }
929 if rules.circular_dependencies == Severity::Off {
930 results.circular_dependencies.clear();
931 }
932 if rules.re_export_cycle == Severity::Off {
933 results.re_export_cycles.clear();
934 }
935 if rules.package_cycle == Severity::Off {
936 results.package_cycles.clear();
937 }
938 if rules.boundary_violation == Severity::Off {
939 results.boundary_violations.clear();
940 results.boundary_coverage_violations.clear();
941 results.boundary_call_violations.clear();
942 }
943 if rules.policy_violation == Severity::Off {
944 results.policy_violations.clear();
945 }
946 if rules.unused_catalog_entries == Severity::Off {
947 results.unused_catalog_entries.clear();
948 }
949 if rules.empty_catalog_groups == Severity::Off {
950 results.empty_catalog_groups.clear();
951 }
952 if rules.unresolved_catalog_references == Severity::Off {
953 results.unresolved_catalog_references.clear();
954 }
955 if rules.unused_dependency_overrides == Severity::Off {
956 results.unused_dependency_overrides.clear();
957 }
958 if rules.misconfigured_dependency_overrides == Severity::Off {
959 results.misconfigured_dependency_overrides.clear();
960 }
961}
962
963fn apply_file_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
964 apply_dead_code_override_rules(results, config);
965 apply_catalog_override_rules(results, config);
966 apply_framework_override_rules(results, config);
967 apply_circular_override_rules(results, config);
968}
969
970fn apply_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
971 apply_core_dead_code_override_rules(results, config);
972 apply_component_dead_code_override_rules(results, config);
973}
974
975fn apply_core_dead_code_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
977 results
978 .unused_files
979 .retain(|f| config.resolve_rules_for_path(&f.file.path).unused_files != Severity::Off);
980 results
981 .unused_exports
982 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_exports != Severity::Off);
983 results
984 .unused_types
985 .retain(|e| config.resolve_rules_for_path(&e.export.path).unused_types != Severity::Off);
986 results.private_type_leaks.retain(|e| {
987 config
988 .resolve_rules_for_path(&e.leak.path)
989 .private_type_leaks
990 != Severity::Off
991 });
992 results.deprecated_exports_in_use.retain(|e| {
993 config
994 .resolve_rules_for_path(&e.export.path)
995 .deprecated_exports_in_use
996 != Severity::Off
997 });
998 results.unused_enum_members.retain(|m| {
999 config
1000 .resolve_rules_for_path(&m.member.path)
1001 .unused_enum_members
1002 != Severity::Off
1003 });
1004 results.unused_class_members.retain(|m| {
1005 config
1006 .resolve_rules_for_path(&m.member.path)
1007 .unused_class_members
1008 != Severity::Off
1009 });
1010 results.unused_store_members.retain(|m| {
1011 config
1012 .resolve_rules_for_path(&m.member.path)
1013 .unused_store_members
1014 != Severity::Off
1015 });
1016 results.unprovided_injects.retain(|f| {
1017 config
1018 .resolve_rules_for_path(&f.inject.path)
1019 .unprovided_injects
1020 != Severity::Off
1021 });
1022 results.unresolved_imports.retain(|i| {
1023 config
1024 .resolve_rules_for_path(&i.import.path)
1025 .unresolved_imports
1026 != Severity::Off
1027 });
1028}
1029
1030fn apply_component_dead_code_override_rules(
1032 results: &mut AnalysisResults,
1033 config: &ResolvedConfig,
1034) {
1035 results.unrendered_components.retain(|c| {
1036 config
1037 .resolve_rules_for_path(&c.component.path)
1038 .unrendered_components
1039 != Severity::Off
1040 });
1041 results.unused_component_props.retain(|p| {
1042 config
1043 .resolve_rules_for_path(&p.prop.path)
1044 .unused_component_props
1045 != Severity::Off
1046 });
1047 results.absent_component_props.retain(|p| {
1048 config
1049 .resolve_rules_for_path(&p.prop.path)
1050 .absent_component_props
1051 != Severity::Off
1052 });
1053 results.unused_component_emits.retain(|e| {
1054 config
1055 .resolve_rules_for_path(&e.emit.path)
1056 .unused_component_emits
1057 != Severity::Off
1058 });
1059 results.unused_component_inputs.retain(|i| {
1060 config
1061 .resolve_rules_for_path(&i.input.path)
1062 .unused_component_inputs
1063 != Severity::Off
1064 });
1065 results.unused_component_outputs.retain(|o| {
1066 config
1067 .resolve_rules_for_path(&o.output.path)
1068 .unused_component_outputs
1069 != Severity::Off
1070 });
1071 results.unused_svelte_events.retain(|e| {
1072 config
1073 .resolve_rules_for_path(&e.event.path)
1074 .unused_svelte_events
1075 != Severity::Off
1076 });
1077 results.unused_server_actions.retain(|a| {
1078 config
1079 .resolve_rules_for_path(&a.action.path)
1080 .unused_server_actions
1081 != Severity::Off
1082 });
1083 results.unused_load_data_keys.retain(|k| {
1084 config
1085 .resolve_rules_for_path(&k.key.path)
1086 .unused_load_data_keys
1087 != Severity::Off
1088 });
1089}
1090
1091fn apply_catalog_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1092 results.stale_suppressions.retain(|s| {
1093 let rules = config.resolve_rules_for_path(&s.path);
1094 if s.missing_reason {
1095 rules.require_suppression_reason != Severity::Off
1096 } else {
1097 rules.stale_suppressions != Severity::Off
1098 }
1099 });
1100 results.unresolved_catalog_references.retain(|r| {
1101 config
1102 .resolve_rules_for_path(&r.reference.path)
1103 .unresolved_catalog_references
1104 != Severity::Off
1105 });
1106 results.empty_catalog_groups.retain(|g| {
1107 config
1108 .resolve_rules_for_path(&g.group.path)
1109 .empty_catalog_groups
1110 != Severity::Off
1111 });
1112 results.unused_dependency_overrides.retain(|o| {
1113 config
1114 .resolve_rules_for_path(&o.entry.path)
1115 .unused_dependency_overrides
1116 != Severity::Off
1117 });
1118 results.misconfigured_dependency_overrides.retain(|o| {
1119 config
1120 .resolve_rules_for_path(&o.entry.path)
1121 .misconfigured_dependency_overrides
1122 != Severity::Off
1123 });
1124}
1125
1126fn apply_framework_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1127 results.invalid_client_exports.retain(|e| {
1128 config
1129 .resolve_rules_for_path(&e.export.path)
1130 .invalid_client_export
1131 != Severity::Off
1132 });
1133 results.mixed_client_server_barrels.retain(|b| {
1134 config
1135 .resolve_rules_for_path(&b.barrel.path)
1136 .mixed_client_server_barrel
1137 != Severity::Off
1138 });
1139 results.misplaced_directives.retain(|d| {
1140 config
1141 .resolve_rules_for_path(&d.directive_site.path)
1142 .misplaced_directive
1143 != Severity::Off
1144 });
1145 results.route_collisions.retain(|c| {
1146 config
1147 .resolve_rules_for_path(&c.collision.path)
1148 .route_collision
1149 != Severity::Off
1150 });
1151 results.dynamic_segment_name_conflicts.retain(|c| {
1152 config
1153 .resolve_rules_for_path(&c.conflict.path)
1154 .dynamic_segment_name_conflict
1155 != Severity::Off
1156 });
1157}
1158
1159fn apply_circular_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1160 results.circular_dependencies.retain(|c| {
1161 c.cycle
1162 .files
1163 .iter()
1164 .any(|path| config.resolve_rules_for_path(path).circular_dependencies != Severity::Off)
1165 });
1166}
1167
1168fn apply_base_file_rules(results: &mut AnalysisResults, rules: &RulesConfig) {
1169 clear_base_core_dead_code(results, rules);
1170 clear_base_component_dead_code(results, rules);
1171 clear_base_suppression_and_framework(results, rules);
1172}
1173
1174fn clear_base_core_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1176 if rules.unused_files == Severity::Off {
1177 results.unused_files.clear();
1178 }
1179 if rules.unused_exports == Severity::Off {
1180 results.unused_exports.clear();
1181 }
1182 if rules.unused_types == Severity::Off {
1183 results.unused_types.clear();
1184 }
1185 if rules.private_type_leaks == Severity::Off {
1186 results.private_type_leaks.clear();
1187 }
1188 if rules.deprecated_exports_in_use == Severity::Off {
1189 results.deprecated_exports_in_use.clear();
1190 }
1191 if rules.unused_enum_members == Severity::Off {
1192 results.unused_enum_members.clear();
1193 }
1194 if rules.unused_class_members == Severity::Off {
1195 results.unused_class_members.clear();
1196 }
1197 if rules.unused_store_members == Severity::Off {
1198 results.unused_store_members.clear();
1199 }
1200 if rules.unprovided_injects == Severity::Off {
1201 results.unprovided_injects.clear();
1202 }
1203 if rules.unresolved_imports == Severity::Off {
1204 results.unresolved_imports.clear();
1205 }
1206}
1207
1208fn clear_base_component_dead_code(results: &mut AnalysisResults, rules: &RulesConfig) {
1210 if rules.unrendered_components == Severity::Off {
1211 results.unrendered_components.clear();
1212 }
1213 if rules.unused_component_props == Severity::Off {
1214 results.unused_component_props.clear();
1215 }
1216 if rules.absent_component_props == Severity::Off {
1217 results.absent_component_props.clear();
1218 }
1219 if rules.unused_component_emits == Severity::Off {
1220 results.unused_component_emits.clear();
1221 }
1222 if rules.unused_component_inputs == Severity::Off {
1223 results.unused_component_inputs.clear();
1224 }
1225 if rules.unused_component_outputs == Severity::Off {
1226 results.unused_component_outputs.clear();
1227 }
1228 if rules.unused_svelte_events == Severity::Off {
1229 results.unused_svelte_events.clear();
1230 }
1231 if rules.unused_server_actions == Severity::Off {
1232 results.unused_server_actions.clear();
1233 }
1234 if rules.unused_load_data_keys == Severity::Off {
1235 results.unused_load_data_keys.clear();
1236 }
1237}
1238
1239fn clear_base_suppression_and_framework(results: &mut AnalysisResults, rules: &RulesConfig) {
1242 results.stale_suppressions.retain(|s| {
1243 if s.missing_reason {
1244 rules.require_suppression_reason != Severity::Off
1245 } else {
1246 rules.stale_suppressions != Severity::Off
1247 }
1248 });
1249 if rules.invalid_client_export == Severity::Off {
1250 results.invalid_client_exports.clear();
1251 }
1252 if rules.mixed_client_server_barrel == Severity::Off {
1253 results.mixed_client_server_barrels.clear();
1254 }
1255 if rules.misplaced_directive == Severity::Off {
1256 results.misplaced_directives.clear();
1257 }
1258 if rules.route_collision == Severity::Off {
1259 results.route_collisions.clear();
1260 }
1261 if rules.dynamic_segment_name_conflict == Severity::Off {
1262 results.dynamic_segment_name_conflicts.clear();
1263 }
1264}
1265
1266fn apply_boundary_override_rules(results: &mut AnalysisResults, config: &ResolvedConfig) {
1267 results.boundary_violations.retain(|v| {
1268 config
1269 .resolve_rules_for_path(&v.violation.from_path)
1270 .boundary_violation
1271 != Severity::Off
1272 });
1273 results.boundary_coverage_violations.retain(|v| {
1274 config
1275 .resolve_rules_for_path(&v.violation.path)
1276 .boundary_violation
1277 != Severity::Off
1278 });
1279 results.boundary_call_violations.retain(|v| {
1280 config
1281 .resolve_rules_for_path(&v.violation.path)
1282 .boundary_violation
1283 != Severity::Off
1284 });
1285 results.policy_violations.retain(|v| {
1286 config
1287 .resolve_rules_for_path(&v.violation.path)
1288 .policy_violation
1289 != Severity::Off
1290 });
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use std::path::PathBuf;
1296
1297 use super::*;
1298 use fallow_types::output_dead_code::{
1299 BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
1300 PrivateTypeLeakFinding, UnlistedDependencyFinding, UnusedClassMemberFinding,
1301 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1302 };
1303 use fallow_types::results::{
1304 BoundaryViolation, CircularDependency, DuplicateExport, DuplicateLocation, ImportSite,
1305 PrivateTypeLeak, UnlistedDependency, UnusedExport, UnusedFile, UnusedMember,
1306 };
1307
1308 #[test]
1309 fn finding_id_filter_keeps_request_order_and_drops_duplicates() {
1310 let a = "dc1:unused-export:0123456789abcdef";
1311 let b = "dc1:unused-file:0123456789abcdef~1";
1312
1313 let filter = FindingIdFilter::parse(&[b, a, b])
1314 .expect("valid ids")
1315 .expect("a filter");
1316
1317 assert_eq!(filter.requested, vec![b.to_owned(), a.to_owned()]);
1318 assert_eq!(FindingIdFilter::parse::<&str>(&[]), Ok(None));
1319 }
1320
1321 #[test]
1322 fn normalized_text_ignores_line_endings_and_trailing_newlines() {
1323 assert_eq!(normalized_text(b"dist\r\nbuild\r\n"), "dist\nbuild");
1324 assert_eq!(normalized_text(b"dist\nbuild\n\n"), "dist\nbuild");
1325 assert_eq!(normalized_text(b"dist\nbuild"), "dist\nbuild");
1326 assert_ne!(
1327 normalized_text(b"dist\nbuild"),
1328 normalized_text(b"dist\nbuilt")
1329 );
1330 }
1331
1332 #[test]
1333 fn a_crlf_checkout_has_the_same_fingerprint_as_an_lf_checkout() {
1334 let write_project = |line_end: &str| {
1335 let dir = tempfile::tempdir().expect("project");
1336 let root = dir.path();
1337 std::fs::create_dir_all(root.join("src")).expect("src");
1338 std::fs::write(
1339 root.join(".gitignore"),
1340 format!("dist{line_end}coverage{line_end}"),
1341 )
1342 .expect("gitignore");
1343 std::fs::write(
1344 root.join("package.json"),
1345 format!("{{{line_end} \"name\": \"crlf\"{line_end}}}{line_end}"),
1346 )
1347 .expect("package.json");
1348 dir
1349 };
1350 let config_at = |root: &std::path::Path| {
1351 fallow_config::FallowConfig::default().resolve(
1352 root.to_path_buf(),
1353 fallow_config::OutputFormat::Json,
1354 1,
1355 true,
1356 true,
1357 None,
1358 )
1359 };
1360 let lf = write_project("\n");
1361 let crlf = write_project("\r\n");
1362
1363 assert_eq!(
1364 analysis_fingerprint_for_version(&config_at(lf.path()), "1.0.0"),
1365 analysis_fingerprint_for_version(&config_at(crlf.path()), "1.0.0")
1366 );
1367 }
1368
1369 #[test]
1370 fn a_tsconfig_extends_target_outside_the_walk_is_an_input() {
1371 let dir = tempfile::tempdir().expect("project");
1372 let root = dir.path();
1373 std::fs::create_dir_all(root.join(".config")).expect("hidden dir");
1374 std::fs::write(
1375 root.join("tsconfig.json"),
1376 r#"{ "extends": "./.config/tsconfig.base.json" }"#,
1377 )
1378 .expect("tsconfig");
1379 let base = root.join(".config/tsconfig.base.json");
1380 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "." } }"#).expect("base");
1381 let config = fallow_config::FallowConfig::default().resolve(
1382 root.to_path_buf(),
1383 fallow_config::OutputFormat::Json,
1384 1,
1385 true,
1386 true,
1387 None,
1388 );
1389 let before = analysis_fingerprint_for_version(&config, "1.0.0");
1390
1391 std::fs::write(&base, r#"{ "compilerOptions": { "baseUrl": "src" } }"#).expect("edit");
1392
1393 assert_ne!(analysis_fingerprint_for_version(&config, "1.0.0"), before);
1394 }
1395
1396 #[test]
1397 fn analysis_fingerprint_depends_on_the_version_and_not_on_the_root() {
1398 let config_at = |root: &std::path::Path| {
1399 fallow_config::FallowConfig::default().resolve(
1400 root.to_path_buf(),
1401 fallow_config::OutputFormat::Json,
1402 1,
1403 true,
1404 true,
1405 None,
1406 )
1407 };
1408 let a = tempfile::tempdir().expect("project a");
1409 let b = tempfile::tempdir().expect("project b");
1410 let config_a = config_at(a.path());
1411 let config_b = config_at(b.path());
1412
1413 let base = analysis_fingerprint_for_version(&config_a, "1.0.0");
1414 assert!(base.starts_with("af1:"), "{base}");
1415 assert_eq!(base, analysis_fingerprint_for_version(&config_a, "1.0.0"));
1416 assert_eq!(base, analysis_fingerprint_for_version(&config_b, "1.0.0"));
1417 assert_ne!(base, analysis_fingerprint_for_version(&config_a, "1.0.1"));
1418 }
1419
1420 #[test]
1421 fn finding_id_filter_refuses_a_malformed_id() {
1422 let error = FindingIdFilter::parse(&["dc1:unused-export:helper"])
1423 .expect_err("malformed id refused");
1424
1425 assert!(error.contains("dc1:unused-export:helper"), "{error}");
1426 }
1427
1428 #[test]
1429 fn finding_id_rule_is_off_reads_rules_and_overrides() {
1430 let id = "dc1:unused-export:0123456789abcdef";
1431 let mut config = fallow_config::FallowConfig::default().resolve(
1432 PathBuf::from("/repo"),
1433 fallow_config::OutputFormat::Json,
1434 1,
1435 true,
1436 true,
1437 None,
1438 );
1439 assert!(!finding_id_rule_is_off(id, &config));
1440
1441 config.rules.unused_exports = Severity::Off;
1442 assert!(finding_id_rule_is_off(id, &config));
1443 assert!(!finding_id_rule_is_off(
1444 "dc1:unused-file:0123456789abcdef",
1445 &config
1446 ));
1447
1448 let with_override = serde_json::from_str::<fallow_config::FallowConfig>(
1449 r#"{"overrides":[{"files":["src/a.ts"],"rules":{"unused-exports":"off"}}]}"#,
1450 )
1451 .expect("config parses")
1452 .resolve(
1453 PathBuf::from("/repo"),
1454 fallow_config::OutputFormat::Json,
1455 1,
1456 true,
1457 true,
1458 None,
1459 );
1460 assert!(finding_id_rule_is_off(id, &with_override));
1461 }
1462
1463 #[test]
1464 fn workspace_filter_keeps_findings_under_workspace_root() {
1465 let root = PathBuf::from("/repo/packages/app");
1466 let mut results = AnalysisResults::default();
1467 results
1468 .unused_files
1469 .push(UnusedFileFinding::with_actions(UnusedFile {
1470 path: root.join("src/unused.ts"),
1471 }));
1472 results
1473 .unused_files
1474 .push(UnusedFileFinding::with_actions(UnusedFile {
1475 path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
1476 }));
1477
1478 filter_to_workspaces(&mut results, std::slice::from_ref(&root));
1479
1480 assert_eq!(results.unused_files.len(), 1);
1481 assert_eq!(
1482 results.unused_files[0].file.path,
1483 root.join("src/unused.ts")
1484 );
1485 }
1486
1487 #[test]
1488 fn configured_filter_removes_findings_added_after_engine_analysis() {
1489 let project = tempfile::tempdir().expect("project");
1490 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1491 r#"{"ignoreFindings":["src/hidden.ts"]}"#,
1492 )
1493 .expect("config parses")
1494 .resolve(
1495 project.path().to_path_buf(),
1496 fallow_config::OutputFormat::Human,
1497 1,
1498 true,
1499 true,
1500 None,
1501 );
1502 let mut results = AnalysisResults::default();
1503 results
1504 .private_type_leaks
1505 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1506 path: project.path().join("src/hidden.ts"),
1507 export_name: "publicApi".to_string(),
1508 type_name: "PrivateShape".to_string(),
1509 line: 1,
1510 col: 0,
1511 span_start: 0,
1512 semantic: None,
1513 }));
1514 results
1515 .boundary_violations
1516 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1517 from_path: project.path().join("src/hidden.ts"),
1518 to_path: project.path().join("src/data.ts"),
1519 from_zone: "ui".to_string(),
1520 to_zone: "data".to_string(),
1521 import_specifier: "./data".to_string(),
1522 line: 1,
1523 col: 0,
1524 via_path: None,
1525 }));
1526
1527 filter_configured_ignored_findings(&mut results, &config);
1528
1529 assert!(results.private_type_leaks.is_empty());
1530 assert_eq!(results.boundary_violations.len(), 1);
1531 }
1532
1533 fn unmatched_patterns(diagnostics: &[WorkspaceDiagnostic]) -> Vec<(&'static str, String)> {
1534 diagnostics
1535 .iter()
1536 .filter_map(|diagnostic| match &diagnostic.kind {
1537 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => {
1538 Some(("ignoreFindings", pattern.clone()))
1539 }
1540 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => {
1541 Some(("ignoreDependencies", pattern.clone()))
1542 }
1543 _ => None,
1544 })
1545 .collect()
1546 }
1547
1548 fn write_manifest(root: &Path, dependencies: &str) {
1549 std::fs::write(
1550 root.join("package.json"),
1551 format!(
1552 r#"{{"name":"app","private":true,"main":"src/index.ts","dependencies":{dependencies}}}"#
1553 ),
1554 )
1555 .expect("write package.json");
1556 }
1557
1558 #[test]
1559 fn config_pattern_diagnostics_describe_the_latest_pass_only() {
1560 let project = tempfile::tempdir().expect("project");
1561 let root = project.path();
1562 std::fs::create_dir_all(root.join("src")).expect("create src");
1563 std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("write entry");
1564 std::fs::write(root.join("src/orphan.ts"), "export const orphan = 1;\n")
1565 .expect("write orphan");
1566 write_manifest(root, r#"{"@acme/lib":"1.0.0"}"#);
1567 let config = serde_json::from_str::<fallow_config::FallowConfig>(
1568 r#"{"ignoreDependencies":["@acme/*","@typo/*"],"ignoreFindings":["src/legcy/**"]}"#,
1569 )
1570 .expect("config parses")
1571 .resolve(
1572 root.to_path_buf(),
1573 fallow_config::OutputFormat::Human,
1574 1,
1575 true,
1576 true,
1577 None,
1578 );
1579
1580 crate::session::AnalysisSession::from_resolved_config(config.clone())
1581 .expect("session")
1582 .analyze_dead_code()
1583 .expect("first pass");
1584 assert_eq!(
1585 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1586 vec![
1587 ("ignoreFindings", "src/legcy/**".to_owned()),
1588 ("ignoreDependencies", "@typo/*".to_owned()),
1589 ]
1590 );
1591 assert_eq!(
1592 unmatched_patterns(&config_pattern_diagnostics(&config, false)),
1593 vec![("ignoreFindings", "src/legcy/**".to_owned())],
1594 "a run that reports no dependency findings omits the dependency globs"
1595 );
1596
1597 write_manifest(root, r#"{"react":"1.0.0"}"#);
1600 crate::session::AnalysisSession::from_resolved_config(config.clone())
1601 .expect("session")
1602 .analyze_dead_code()
1603 .expect("second pass");
1604 assert_eq!(
1605 unmatched_patterns(&config_pattern_diagnostics(&config, true)),
1606 vec![
1607 ("ignoreFindings", "src/legcy/**".to_owned()),
1608 ("ignoreDependencies", "@acme/*".to_owned()),
1609 ("ignoreDependencies", "@typo/*".to_owned()),
1610 ]
1611 );
1612 }
1613
1614 fn config_with_override(
1615 pattern: &str,
1616 configure: impl FnOnce(&mut fallow_config::PartialRulesConfig),
1617 ) -> ResolvedConfig {
1618 let mut partial = fallow_config::PartialRulesConfig::default();
1619 configure(&mut partial);
1620 fallow_config::FallowConfig {
1621 rules: RulesConfig {
1622 private_type_leaks: Severity::Warn,
1623 ..RulesConfig::default()
1624 },
1625 overrides: vec![fallow_config::ConfigOverride {
1626 files: vec![pattern.to_string()],
1627 rules: partial,
1628 }],
1629 ..fallow_config::FallowConfig::default()
1630 }
1631 .resolve(
1632 PathBuf::from("/project"),
1633 fallow_config::OutputFormat::Human,
1634 1,
1635 true,
1636 true,
1637 None,
1638 )
1639 }
1640
1641 fn unused_export(path: &str) -> UnusedExportFinding {
1642 UnusedExportFinding::with_actions(UnusedExport {
1643 path: PathBuf::from(path),
1644 export_name: "Unused".to_string(),
1645 is_type_only: false,
1646 line: 1,
1647 col: 0,
1648 span_start: 0,
1649 is_re_export: false,
1650 deprecated: false,
1651 deprecated_reason: None,
1652 })
1653 }
1654
1655 fn private_type_leak(path: &str) -> PrivateTypeLeakFinding {
1656 PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1657 path: PathBuf::from(path),
1658 export_name: "Unused".to_string(),
1659 type_name: "Props".to_string(),
1660 line: 1,
1661 col: 0,
1662 span_start: 0,
1663 semantic: None,
1664 })
1665 }
1666
1667 fn overridden_fixture() -> AnalysisResults {
1668 let mut results = AnalysisResults::default();
1669 results
1670 .unused_exports
1671 .push(unused_export("/project/src/ui/kit.ts"));
1672 results
1673 .unused_exports
1674 .push(unused_export("/project/src/lib/util.ts"));
1675 results
1676 .private_type_leaks
1677 .push(private_type_leak("/project/src/ui/kit.ts"));
1678 results
1679 .private_type_leaks
1680 .push(private_type_leak("/project/src/lib/util.ts"));
1681 results
1682 }
1683
1684 #[test]
1685 fn rule_severities_drop_findings_only_on_overridden_paths() {
1686 let config = config_with_override("src/ui/**", |rules| {
1687 rules.unused_exports = Some(Severity::Off);
1688 rules.private_type_leaks = Some(Severity::Off);
1689 });
1690 let mut results = overridden_fixture();
1691
1692 apply_rule_severities(&mut results, &config);
1693
1694 assert_eq!(
1695 results
1696 .unused_exports
1697 .iter()
1698 .map(|finding| finding.export.path.clone())
1699 .collect::<Vec<_>>(),
1700 vec![PathBuf::from("/project/src/lib/util.ts")]
1701 );
1702 assert_eq!(
1703 results
1704 .private_type_leaks
1705 .iter()
1706 .map(|finding| finding.leak.path.clone())
1707 .collect::<Vec<_>>(),
1708 vec![PathBuf::from("/project/src/lib/util.ts")]
1709 );
1710 }
1711
1712 #[test]
1713 fn rule_severities_are_idempotent() {
1714 let config = config_with_override("src/ui/**", |rules| {
1718 rules.unused_exports = Some(Severity::Off);
1719 rules.private_type_leaks = Some(Severity::Off);
1720 });
1721
1722 let mut once = overridden_fixture();
1723 apply_rule_severities(&mut once, &config);
1724 let mut twice = overridden_fixture();
1725 apply_rule_severities(&mut twice, &config);
1726 apply_rule_severities(&mut twice, &config);
1727
1728 assert_eq!(
1729 once.unused_exports
1730 .iter()
1731 .map(|finding| finding.export.path.clone())
1732 .collect::<Vec<_>>(),
1733 twice
1734 .unused_exports
1735 .iter()
1736 .map(|finding| finding.export.path.clone())
1737 .collect::<Vec<_>>()
1738 );
1739 assert_eq!(
1740 once.private_type_leaks
1741 .iter()
1742 .map(|finding| finding.leak.path.clone())
1743 .collect::<Vec<_>>(),
1744 twice
1745 .private_type_leaks
1746 .iter()
1747 .map(|finding| finding.leak.path.clone())
1748 .collect::<Vec<_>>()
1749 );
1750 }
1751
1752 #[test]
1753 fn rule_severities_keep_a_cycle_when_any_member_file_stays_enabled() {
1754 let config = config_with_override("src/ui/**", |rules| {
1755 rules.circular_dependencies = Some(Severity::Off);
1756 });
1757 let mut results = AnalysisResults::default();
1758 results
1759 .circular_dependencies
1760 .push(CircularDependencyFinding::with_actions(
1761 CircularDependency {
1762 files: vec![
1763 PathBuf::from("/project/src/ui/a.ts"),
1764 PathBuf::from("/project/src/lib/b.ts"),
1765 ],
1766 length: 2,
1767 line: 1,
1768 col: 0,
1769 edges: Vec::new(),
1770 is_cross_package: false,
1771 },
1772 ));
1773 results
1774 .circular_dependencies
1775 .push(CircularDependencyFinding::with_actions(
1776 CircularDependency {
1777 files: vec![
1778 PathBuf::from("/project/src/ui/c.ts"),
1779 PathBuf::from("/project/src/ui/d.ts"),
1780 ],
1781 length: 2,
1782 line: 1,
1783 col: 0,
1784 edges: Vec::new(),
1785 is_cross_package: false,
1786 },
1787 ));
1788
1789 apply_rule_severities(&mut results, &config);
1790
1791 assert_eq!(results.circular_dependencies.len(), 1);
1792 assert_eq!(
1793 results.circular_dependencies[0].cycle.files[0],
1794 PathBuf::from("/project/src/ui/a.ts")
1795 );
1796 }
1797
1798 #[test]
1799 fn rule_severities_clear_base_rules_without_overrides() {
1800 let config = fallow_config::FallowConfig {
1801 rules: RulesConfig {
1802 unused_exports: Severity::Off,
1803 private_type_leaks: Severity::Warn,
1804 ..RulesConfig::default()
1805 },
1806 ..fallow_config::FallowConfig::default()
1807 }
1808 .resolve(
1809 PathBuf::from("/project"),
1810 fallow_config::OutputFormat::Human,
1811 1,
1812 true,
1813 true,
1814 None,
1815 );
1816 let mut results = overridden_fixture();
1817
1818 apply_rule_severities(&mut results, &config);
1819
1820 assert!(results.unused_exports.is_empty());
1821 assert_eq!(results.private_type_leaks.len(), 2);
1822 }
1823
1824 const DEAD_FILE: &str = "/project/src/dead.ts";
1825 const LIVE_FILE: &str = "/project/src/live.ts";
1826
1827 fn resolve(config: fallow_config::FallowConfig) -> ResolvedConfig {
1828 config.resolve(
1829 PathBuf::from("/project"),
1830 fallow_config::OutputFormat::Human,
1831 1,
1832 true,
1833 true,
1834 None,
1835 )
1836 }
1837
1838 fn member(path: &str, kind: fallow_types::extract::MemberKind) -> UnusedMember {
1839 UnusedMember {
1840 path: PathBuf::from(path),
1841 parent_name: "Parent".to_string(),
1842 member_name: "member".to_string(),
1843 kind,
1844 line: 2,
1845 col: 0,
1846 }
1847 }
1848
1849 fn unused_type(path: &str) -> UnusedTypeFinding {
1850 let mut export = unused_export(path).export;
1851 export.is_type_only = true;
1852 UnusedTypeFinding::with_actions(export)
1853 }
1854
1855 fn cascade_fixture() -> AnalysisResults {
1858 let mut results = AnalysisResults::default();
1859 results
1860 .unused_files
1861 .push(UnusedFileFinding::with_actions(UnusedFile {
1862 path: PathBuf::from(DEAD_FILE),
1863 }));
1864 for path in [DEAD_FILE, LIVE_FILE] {
1865 results.unused_exports.push(unused_export(path));
1866 results.unused_types.push(unused_type(path));
1867 results
1868 .unused_enum_members
1869 .push(UnusedEnumMemberFinding::with_actions(member(
1870 path,
1871 fallow_types::extract::MemberKind::EnumMember,
1872 )));
1873 results
1874 .unused_class_members
1875 .push(UnusedClassMemberFinding::with_actions(member(
1876 path,
1877 fallow_types::extract::MemberKind::ClassMethod,
1878 )));
1879 }
1880 results
1881 .duplicate_exports
1882 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1883 export_name: "Shared".to_string(),
1884 locations: [DEAD_FILE, LIVE_FILE]
1885 .iter()
1886 .map(|path| DuplicateLocation {
1887 path: PathBuf::from(path),
1888 line: 1,
1889 col: 0,
1890 })
1891 .collect(),
1892 }));
1893 results
1894 .circular_dependencies
1895 .push(CircularDependencyFinding::with_actions(
1896 CircularDependency {
1897 files: vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)],
1898 length: 2,
1899 line: 1,
1900 col: 0,
1901 edges: Vec::new(),
1902 is_cross_package: false,
1903 },
1904 ));
1905 results
1906 .unlisted_dependencies
1907 .push(UnlistedDependencyFinding::with_actions(
1908 UnlistedDependency {
1909 package_name: "left-pad".to_string(),
1910 imported_from: vec![ImportSite {
1911 path: PathBuf::from(DEAD_FILE),
1912 line: 1,
1913 col: 0,
1914 }],
1915 },
1916 ));
1917 results
1918 .boundary_violations
1919 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1920 from_path: PathBuf::from(DEAD_FILE),
1921 to_path: PathBuf::from(LIVE_FILE),
1922 from_zone: "ui".to_string(),
1923 to_zone: "data".to_string(),
1924 import_specifier: "./live".to_string(),
1925 line: 1,
1926 col: 0,
1927 via_path: None,
1928 }));
1929 results
1930 }
1931
1932 fn cascade_paths(results: &AnalysisResults) -> Vec<Vec<PathBuf>> {
1933 vec![
1934 results
1935 .unused_exports
1936 .iter()
1937 .map(|f| f.export.path.clone())
1938 .collect(),
1939 results
1940 .unused_types
1941 .iter()
1942 .map(|f| f.export.path.clone())
1943 .collect(),
1944 results
1945 .unused_enum_members
1946 .iter()
1947 .map(|f| f.member.path.clone())
1948 .collect(),
1949 results
1950 .unused_class_members
1951 .iter()
1952 .map(|f| f.member.path.clone())
1953 .collect(),
1954 ]
1955 }
1956
1957 #[test]
1958 fn rule_pass_hides_the_cascade_kinds_of_unused_files() {
1959 let config = resolve(fallow_config::FallowConfig::default());
1960 let mut results = cascade_fixture();
1961
1962 apply_rule_severities(&mut results, &config);
1963
1964 let live = vec![PathBuf::from(LIVE_FILE)];
1965 assert_eq!(cascade_paths(&results), vec![live; 4]);
1966 assert_eq!(results.cascade_hidden, 4);
1967 assert_eq!(results.cascade.hidden.len(), 4);
1968 assert_eq!(results.unused_files.len(), 1, "the unused file stays");
1969 }
1970
1971 #[test]
1972 fn rule_pass_keeps_other_kinds_in_unused_files() {
1973 let config = resolve(fallow_config::FallowConfig::default());
1974 let mut results = cascade_fixture();
1975
1976 apply_rule_severities(&mut results, &config);
1977
1978 assert_eq!(results.duplicate_exports.len(), 1);
1979 assert_eq!(results.circular_dependencies.len(), 1);
1980 assert_eq!(results.unlisted_dependencies.len(), 1);
1981 assert_eq!(results.boundary_violations.len(), 1);
1982 }
1983
1984 #[test]
1985 fn show_cascade_keeps_every_finding() {
1986 let config = resolve(fallow_config::FallowConfig {
1987 show_cascade: true,
1988 ..fallow_config::FallowConfig::default()
1989 });
1990 let mut results = cascade_fixture();
1991
1992 apply_rule_severities(&mut results, &config);
1993
1994 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
1995 assert_eq!(cascade_paths(&results), vec![both; 4]);
1996 assert_eq!(results.cascade_hidden, 0);
1997 }
1998
1999 #[test]
2000 fn unused_files_rule_off_for_the_file_keeps_its_findings() {
2001 let config = config_with_override("src/dead.ts", |rules| {
2002 rules.unused_files = Some(Severity::Off);
2003 });
2004 let mut results = cascade_fixture();
2005
2006 apply_rule_severities(&mut results, &config);
2007
2008 assert!(results.unused_files.is_empty());
2009 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2010 assert_eq!(cascade_paths(&results), vec![both; 4]);
2011 assert_eq!(results.cascade_hidden, 0);
2012 }
2013
2014 #[test]
2015 fn a_suppressed_unused_file_still_hides_its_findings() {
2016 let config = resolve(fallow_config::FallowConfig::default());
2019 let mut results = cascade_fixture();
2020 results.unused_files.clear();
2021 results.cascade.suppressed_unused_files = vec![PathBuf::from(DEAD_FILE)];
2022
2023 apply_rule_severities(&mut results, &config);
2024
2025 let live = vec![PathBuf::from(LIVE_FILE)];
2026 assert_eq!(cascade_paths(&results), vec![live; 4]);
2027 assert_eq!(results.cascade_hidden, 4);
2028 }
2029
2030 #[test]
2031 fn a_file_missing_from_the_report_hides_nothing() {
2032 let config = resolve(fallow_config::FallowConfig::default());
2036 let mut results = cascade_fixture();
2037 results.unused_files.clear();
2038
2039 apply_rule_severities(&mut results, &config);
2040
2041 let both = vec![PathBuf::from(DEAD_FILE), PathBuf::from(LIVE_FILE)];
2042 assert_eq!(cascade_paths(&results), vec![both; 4]);
2043 assert_eq!(results.cascade_hidden, 0);
2044 }
2045
2046 #[test]
2047 fn cascade_hiding_is_idempotent() {
2048 let config = resolve(fallow_config::FallowConfig::default());
2049 let mut results = cascade_fixture();
2050
2051 apply_rule_severities(&mut results, &config);
2052 apply_rule_severities(&mut results, &config);
2053
2054 assert_eq!(results.cascade_hidden, 4);
2055 assert_eq!(results.cascade.hidden.len(), 4);
2056 }
2057
2058 #[test]
2059 fn cascade_hidden_serializes_only_when_not_zero() {
2060 let config = resolve(fallow_config::FallowConfig::default());
2061 let mut results = cascade_fixture();
2062 let before = serde_json::to_value(&results).expect("serializes");
2063 assert!(before.get("cascade_hidden").is_none());
2064
2065 apply_rule_severities(&mut results, &config);
2066
2067 let after = serde_json::to_value(&results).expect("serializes");
2068 assert_eq!(after["cascade_hidden"], 4);
2069 assert!(after.get("cascade").is_none());
2070 }
2071}