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