1pub mod clones;
20pub mod text;
21
22use std::path::{Path, PathBuf};
23
24use fallow_config::{ResolvedConfig, RulesConfig, Severity};
25use fallow_engine::changed_files::RenamedFile;
26use fallow_engine::module_graph::relative_key_path;
27use fallow_output::{
28 DiffRiskAction, DiffRiskActionType, DiffRiskFinding, DiffRiskLocation, DiffRiskReport,
29 DiffRiskRule, DiffRiskSeverity, WeakenedTestsKind,
30};
31use fallow_types::duplicates::CloneGroup;
32use fallow_types::suppress::{IssueKind, Suppression, SuppressionTarget};
33use rustc_hash::{FxHashMap, FxHashSet};
34
35use crate::audit_run::{BaseFileReader, BaseRead};
36use clones::{BaseCloneGroup, CloneSpan, CloneTexts};
37
38const SOURCE_EXTENSIONS: &[&str] = &[
41 "js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts", "vue", "svelte", "astro",
42];
43const STYLE_EXTENSIONS: &[&str] = &["css", "scss", "sass", "less", "html"];
46
47#[derive(Clone, Copy)]
49pub enum DiffRiskRules<'a> {
50 Config(&'a ResolvedConfig),
52 Rules(&'a RulesConfig),
54}
55
56impl DiffRiskRules<'_> {
57 fn for_path(self, abs: &Path) -> RulesConfig {
58 match self {
59 Self::Config(config) => config.resolve_rules_for_path(abs),
60 Self::Rules(rules) => rules.clone(),
61 }
62 }
63}
64
65pub struct DiffRiskInput<'a> {
67 pub root: &'a Path,
69 pub base_ref: &'a str,
71 pub changed_files: &'a FxHashSet<PathBuf>,
73 pub renames: &'a [RenamedFile],
75 pub rules: DiffRiskRules<'a>,
77 pub base_clone_groups: Option<&'a [BaseCloneGroup]>,
80 pub head_clone_groups: Vec<Vec<CloneSpan>>,
82}
83
84#[must_use]
86pub fn clone_spans(group: &CloneGroup, root: &Path) -> Vec<CloneSpan> {
87 group
88 .instances
89 .iter()
90 .map(|instance| CloneSpan {
91 path: relative_key_path(&instance.file, root),
92 start_line: u32::try_from(instance.start_line).unwrap_or(u32::MAX),
93 end_line: u32::try_from(instance.end_line).unwrap_or(u32::MAX),
94 })
95 .collect()
96}
97
98struct FileTexts {
100 rel: String,
102 abs: PathBuf,
104 base: Option<String>,
106 head: Option<String>,
108}
109
110struct RawFinding {
112 rule: DiffRiskRule,
113 kind: Option<WeakenedTestsKind>,
114 path: String,
115 line: Option<u32>,
116 message: String,
117 evidence: String,
118 related: Vec<DiffRiskLocation>,
119}
120
121#[must_use]
127pub fn detect(input: &DiffRiskInput<'_>) -> DiffRiskReport {
128 let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(input.root) else {
129 return DiffRiskReport::default();
130 };
131 let Some(reader) = BaseFileReader::spawn(input.root) else {
132 return DiffRiskReport::default();
133 };
134 let mut base = BaseSource {
135 git_root: dunce::canonicalize(&git_root).unwrap_or(git_root),
136 reader,
137 commit: merge_base(input.root, input.base_ref),
138 };
139 let files = read_files(input, &mut base);
140
141 let mut raw = Vec::new();
142 for file in &files {
143 let rules = input.rules.for_path(&file.abs);
144 detect_file(input.root, file, &rules, &mut raw);
145 }
146 detect_removed_tests(input.root, &files, &mut raw);
147 if let Some(base_groups) = input.base_clone_groups {
148 detect_diverged_clones(input, &mut base, base_groups, &files, &mut raw);
149 }
150
151 let findings = finalize(input, &files, raw);
152 DiffRiskReport { findings }
153}
154
155struct BaseSource {
158 git_root: PathBuf,
159 reader: BaseFileReader,
160 commit: String,
161}
162
163impl BaseSource {
164 fn read(&mut self, abs: &Path) -> BaseRead {
167 match abs.strip_prefix(&self.git_root) {
168 Ok(relative) => self.reader.read(&self.commit, relative),
169 Err(_) => BaseRead::Error,
170 }
171 }
172}
173
174pub(crate) fn merge_base(root: &Path, base_ref: &str) -> String {
178 let mut command = fallow_engine::changed_files::git_command_in(root);
179 command
180 .args(["merge-base", "--end-of-options", base_ref, "HEAD"])
181 .stderr(std::process::Stdio::null());
182 command
183 .output()
184 .ok()
185 .filter(|output| output.status.success())
186 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
187 .filter(|sha| !sha.is_empty())
188 .unwrap_or_else(|| base_ref.to_owned())
189}
190
191fn is_rule_relevant(root: &Path, rel: &str) -> bool {
196 fallow_engine::discover::SOURCE_EXTENSIONS.contains(&extension(rel))
197 || takes_suppression_comments(rel)
198 || is_js_test_code(root, rel)
199 || text::is_coverage_config_path(rel)
200 || crate::dependency_deltas::is_manifest_path(rel)
201}
202
203fn read_files(input: &DiffRiskInput<'_>, source: &mut BaseSource) -> Vec<FileTexts> {
204 let rename_sources: FxHashSet<&Path> = input
205 .renames
206 .iter()
207 .map(|rename| rename.from.as_path())
208 .collect();
209 let rename_targets: FxHashMap<&Path, &Path> = input
210 .renames
211 .iter()
212 .map(|rename| (rename.to.as_path(), rename.from.as_path()))
213 .collect();
214 let mut paths: Vec<&PathBuf> = input.changed_files.iter().collect();
215 paths.sort();
216
217 let mut files = Vec::new();
218 for abs in paths {
219 if rename_sources.contains(abs.as_path()) {
222 continue;
223 }
224 if !abs.starts_with(input.root) {
225 continue;
226 }
227 let base_abs = rename_targets
228 .get(abs.as_path())
229 .copied()
230 .unwrap_or(abs.as_path());
231 if !base_abs.starts_with(&source.git_root) {
232 continue;
233 }
234 let rel = relative_key_path(abs, input.root);
235 if !is_rule_relevant(input.root, &rel) {
236 continue;
237 }
238 let head = match std::fs::read(abs) {
239 Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
240 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
241 Err(_) => continue,
242 };
243 let base = match source.read(base_abs) {
244 BaseRead::Content(base) => Some(base),
245 BaseRead::Missing => None,
246 BaseRead::Error => break,
247 };
248 if base.is_none() && head.is_none() {
249 continue;
250 }
251 files.push(FileTexts {
252 rel,
253 abs: abs.clone(),
254 base,
255 head,
256 });
257 }
258 files
259}
260
261fn extension(rel: &str) -> &str {
262 let name = rel.rsplit('/').next().unwrap_or(rel);
263 name.rsplit_once('.').map_or("", |(_, ext)| ext)
264}
265
266fn is_source_file(rel: &str) -> bool {
267 SOURCE_EXTENSIONS.contains(&extension(rel))
268}
269
270fn takes_suppression_comments(rel: &str) -> bool {
271 is_source_file(rel) || STYLE_EXTENSIONS.contains(&extension(rel))
272}
273
274fn is_js_test_code(root: &Path, rel: &str) -> bool {
278 is_source_file(rel) && fallow_engine::test_paths::is_test_code_path_str(root, rel)
279}
280
281fn is_type_or_test_file(root: &Path, rel: &str) -> bool {
284 fallow_engine::test_paths::is_test_path_str(root, rel)
285 || rel.split('/').any(|segment| segment == "test-d")
286 || rel.contains(".test-d.")
287}
288
289const FIXTURE_OR_GENERATED_DIRS: &[&str] = &[
293 "fixture",
294 "fixtures",
295 "__fixtures__",
296 "__snapshots__",
297 "generated",
298 "__generated__",
299];
300
301fn is_fixture_or_generated(rel: &str) -> bool {
302 let mut dirs = rel.split('/').rev().skip(1);
303 dirs.any(|dir| FIXTURE_OR_GENERATED_DIRS.contains(&dir))
304}
305
306const STORY_FILE_MARKERS: &[&str] = &[".stories.", ".story."];
308
309fn is_example_copy(root: &Path, rel: &str) -> bool {
312 if fallow_engine::test_paths::is_test_path_str(root, rel) {
313 return true;
314 }
315 let name = rel.rsplit('/').next().unwrap_or(rel).to_ascii_lowercase();
316 STORY_FILE_MARKERS
317 .iter()
318 .any(|marker| name.contains(marker))
319}
320
321fn is_tool_config(rel: &str) -> bool {
324 let name = rel.rsplit('/').next().unwrap_or(rel);
325 let dot_rc = name.strip_prefix('.').is_some_and(|rest| {
328 rest.split_once('.')
329 .is_some_and(|(stem, _ext)| stem.ends_with("rc"))
330 });
331 name.contains(".config.") || name.contains(".conf.") || dot_rc
332}
333
334fn detect_file(root: &Path, file: &FileTexts, rules: &RulesConfig, raw: &mut Vec<RawFinding>) {
335 let base = file.base.as_deref().unwrap_or("");
336 let Some(head) = file.head.as_deref() else {
337 return;
338 };
339 let rel = file.rel.as_str();
340 let is_test = is_js_test_code(root, rel);
341
342 if rules.weakened_tests != Severity::Off {
343 if is_test {
344 for hit in text::skip_only_hits(base, head) {
345 raw.push(RawFinding {
346 rule: DiffRiskRule::WeakenedTests,
347 kind: Some(WeakenedTestsKind::SkipOnly),
348 path: rel.to_owned(),
349 line: Some(hit.line),
350 message: format!(
351 "The change adds `{}`, so other tests or this test do not run",
352 hit.evidence
353 ),
354 evidence: hit.evidence,
355 related: Vec::new(),
356 });
357 }
358 }
359 if text::is_coverage_config_path(rel) {
360 for hit in text::weakened_threshold_hits(base, head) {
361 raw.push(RawFinding {
362 rule: DiffRiskRule::WeakenedTests,
363 kind: Some(WeakenedTestsKind::CoverageThreshold),
364 path: rel.to_owned(),
365 line: Some(hit.line),
366 message: format!(
367 "The change makes a threshold less strict ({})",
368 hit.evidence
369 ),
370 evidence: hit.evidence,
371 related: Vec::new(),
372 });
373 }
374 }
375 }
376
377 if rules.added_suppression != Severity::Off
378 && takes_suppression_comments(rel)
379 && !is_fixture_or_generated(rel)
380 {
381 let type_test = is_type_or_test_file(root, rel);
382 for hit in text::added_suppression_hits(base, head) {
383 if type_test && hit.evidence == "@ts-expect-error" {
385 continue;
386 }
387 raw.push(RawFinding {
388 rule: DiffRiskRule::AddedSuppression,
389 kind: None,
390 path: rel.to_owned(),
391 line: Some(hit.line),
392 message: format!("The change adds a `{}` suppression comment", hit.evidence),
393 evidence: hit.evidence,
394 related: Vec::new(),
395 });
396 }
397 }
398
399 if rules.test_only_branch != Severity::Off
400 && is_source_file(rel)
401 && !fallow_engine::test_paths::is_test_path_str(root, rel)
402 && !is_tool_config(rel)
403 {
404 for hit in text::test_only_branch_hits(base, head) {
405 raw.push(RawFinding {
406 rule: DiffRiskRule::TestOnlyBranch,
407 kind: None,
408 path: rel.to_owned(),
409 line: Some(hit.line),
410 message: format!(
411 "The change adds a `{}` check to source code, so tests and production can take different paths",
412 hit.evidence
413 ),
414 evidence: hit.evidence,
415 related: Vec::new(),
416 });
417 }
418 }
419
420 if rules.added_dependency != Severity::Off && crate::dependency_deltas::is_manifest_path(rel) {
421 let deltas = crate::dependency_deltas::manifest_dependency_deltas(base, head);
422 for added in deltas.added {
423 let evidence = format!("{}@{} ({})", added.name, added.to, added.section);
424 raw.push(RawFinding {
425 rule: DiffRiskRule::AddedDependency,
426 kind: None,
427 path: rel.to_owned(),
428 line: (added.line > 0).then_some(added.line),
429 message: format!("The change adds the dependency `{}`", added.name),
430 evidence,
431 related: Vec::new(),
432 });
433 }
434 }
435}
436
437struct RemovedNames {
449 identifiers: FxHashSet<String>,
450 segments: FxHashSet<String>,
451}
452
453const MIN_REMOVED_NAME_LEN: usize = 4;
456
457const NOT_REMOVED_NAMES: &[&str] = &[
460 "async",
461 "await",
462 "break",
463 "case",
464 "catch",
465 "class",
466 "console",
467 "const",
468 "continue",
469 "default",
470 "delete",
471 "else",
472 "enum",
473 "export",
474 "exports",
475 "extends",
476 "false",
477 "finally",
478 "from",
479 "function",
480 "import",
481 "instanceof",
482 "interface",
483 "module",
484 "null",
485 "require",
486 "return",
487 "static",
488 "super",
489 "switch",
490 "this",
491 "throw",
492 "true",
493 "type",
494 "typeof",
495 "undefined",
496 "void",
497 "while",
498 "with",
499 "yield",
500];
501
502fn is_removed_name_candidate(name: &str) -> bool {
504 name.len() >= MIN_REMOVED_NAME_LEN && !NOT_REMOVED_NAMES.contains(&name)
505}
506
507fn imported_names(src: &str) -> FxHashSet<String> {
511 let mut statements = String::new();
512 let mut in_import = false;
513 for line in src.lines() {
514 let trimmed = line.trim_start();
515 if !in_import && trimmed.starts_with("import ") {
516 in_import = true;
517 }
518 if !in_import {
519 continue;
520 }
521 statements.push_str(line);
522 statements.push('\n');
523 if trimmed.ends_with(';') || trimmed.contains("from ") || trimmed.ends_with("from") {
524 in_import = false;
525 }
526 }
527 text::code_identifiers(&statements)
528}
529
530impl RemovedNames {
531 fn collect(root: &Path, files: &[FileTexts]) -> Self {
532 let mut identifiers = FxHashSet::default();
533 let mut deleted_identifiers = FxHashSet::default();
534 let mut segments = FxHashSet::default();
535 let mut head_union = FxHashSet::default();
536 for file in files {
537 if file.head.is_none()
539 && file
540 .base
541 .as_deref()
542 .is_some_and(|base| text::test_case_count(base) == 0)
543 {
544 segments.extend(deleted_path_segments(root, &file.rel));
545 }
546 if !is_source_file(&file.rel)
547 || fallow_engine::test_paths::is_test_path_str(root, &file.rel)
548 {
549 continue;
550 }
551 if let Some(head) = file.head.as_deref() {
552 head_union.extend(text::code_identifiers(head));
553 }
554 let Some(base) = file.base.as_deref() else {
555 continue;
556 };
557 match file.head.as_deref() {
558 Some(head) => {
559 let head = text::code_identifiers(head);
560 identifiers.extend(
561 text::code_identifiers(base)
562 .into_iter()
563 .filter(|name| is_removed_name_candidate(name) && !head.contains(name)),
564 );
565 }
566 None => {
567 let imported = imported_names(base);
568 deleted_identifiers.extend(text::code_identifiers(base).into_iter().filter(
569 |name| is_removed_name_candidate(name) && !imported.contains(name),
570 ));
571 }
572 }
573 }
574 identifiers.extend(
578 deleted_identifiers
579 .into_iter()
580 .filter(|name| !head_union.contains(name)),
581 );
582 Self {
583 identifiers,
584 segments,
585 }
586 }
587
588 fn explain(&self, base: &str, head: &str) -> bool {
593 let removed: Vec<&str> = text::added_lines(head, base)
594 .into_iter()
595 .map(|(_, line)| line)
596 .collect();
597 if removed.is_empty() {
598 return false;
599 }
600 let head_identifiers = text::code_identifiers(head);
601 let names_removed_identifier = removed.iter().any(|line| {
602 text::code_identifiers(line)
603 .iter()
604 .any(|name| self.identifiers.contains(name) && !head_identifiers.contains(name))
605 });
606 names_removed_identifier
607 || self.segments.iter().any(|segment| {
608 !head.contains(segment.as_str())
609 && removed
610 .iter()
611 .any(|line| path_tokens(line).any(|token| token == segment))
612 })
613 }
614}
615
616fn deleted_path_segments(root: &Path, rel: &str) -> Vec<String> {
621 let stem_path = rel.rsplit_once('.').map_or(rel, |(stem, _)| stem);
622 let mut found = Vec::new();
623 let mut prefix_len = 0;
624 let parts: Vec<&str> = stem_path.split('/').collect();
625 let last = parts.len().saturating_sub(1);
626 for (index, part) in parts.iter().enumerate() {
627 prefix_len += part.len();
628 let is_stem = index == last;
629 let gone = is_stem || !root.join(&rel[..prefix_len]).exists();
630 if gone && part.len() >= MIN_REMOVED_NAME_LEN {
631 found.push((*part).to_owned());
632 }
633 prefix_len += 1;
634 }
635 found
636}
637
638fn path_tokens(line: &str) -> impl Iterator<Item = &str> {
640 line.split(['/', '.', '\'', '"', '`'])
641}
642
643fn detect_removed_tests(root: &Path, files: &[FileTexts], raw: &mut Vec<RawFinding>) {
652 struct Counts<'a> {
653 rel: &'a str,
654 base_cases: usize,
655 head_cases: usize,
656 base_asserts: usize,
657 head_asserts: usize,
658 explained: bool,
659 assertions_led: bool,
660 }
661 let removed_names = RemovedNames::collect(root, files);
662 let counts: Vec<Counts<'_>> = files
663 .iter()
664 .filter(|file| is_js_test_code(root, &file.rel))
665 .map(|file| {
666 let base = file.base.as_deref().unwrap_or("");
667 let head = file.head.as_deref().unwrap_or("");
668 Counts {
669 rel: &file.rel,
670 base_cases: text::test_case_count(base),
671 head_cases: text::test_case_count(head),
672 base_asserts: text::assertion_count(base),
673 head_asserts: text::assertion_count(head),
674 explained: removed_names.explain(base, head),
675 assertions_led: {
676 let (asserts, code) = text::removed_assertion_lines(base, head);
677 asserts * 2 >= code
678 },
679 }
680 })
681 .collect();
682 let total = |pick: fn(&Counts<'_>) -> usize| counts.iter().map(pick).sum::<usize>();
683 let cases_dropped = total(|c| c.head_cases) < total(|c| c.base_cases);
684 let asserts_dropped = total(|c| c.head_asserts) < total(|c| c.base_asserts);
685 for count in counts.iter().filter(|count| !count.explained) {
686 if cases_dropped && count.head_cases < count.base_cases {
687 let evidence = format!("test cases: {} -> {}", count.base_cases, count.head_cases);
688 raw.push(RawFinding {
689 rule: DiffRiskRule::WeakenedTests,
690 kind: Some(WeakenedTestsKind::RemovedTests),
691 path: count.rel.to_owned(),
692 line: None,
693 message: format!("The change removes test cases ({evidence})"),
694 evidence,
695 related: Vec::new(),
696 });
697 } else if asserts_dropped
698 && count.assertions_led
699 && count.head_cases >= count.base_cases
700 && count.head_asserts < count.base_asserts
701 {
702 let evidence = format!(
703 "assertions: {} -> {}",
704 count.base_asserts, count.head_asserts
705 );
706 raw.push(RawFinding {
707 rule: DiffRiskRule::WeakenedTests,
708 kind: Some(WeakenedTestsKind::AssertionDrop),
709 path: count.rel.to_owned(),
710 line: None,
711 message: format!("The change removes assertions ({evidence})"),
712 evidence,
713 related: Vec::new(),
714 });
715 }
716 }
717}
718
719fn detect_diverged_clones(
720 input: &DiffRiskInput<'_>,
721 source: &mut BaseSource,
722 base_groups: &[BaseCloneGroup],
723 files: &[FileTexts],
724 raw: &mut Vec<RawFinding>,
725) {
726 let changed: FxHashSet<String> = files.iter().map(|file| file.rel.clone()).collect();
727 let renames: FxHashMap<String, String> = input
728 .renames
729 .iter()
730 .map(|rename| {
731 (
732 relative_key_path(&rename.from, input.root),
733 relative_key_path(&rename.to, input.root),
734 )
735 })
736 .collect();
737 let head_by_rel: FxHashMap<&str, Option<&str>> = files
738 .iter()
739 .map(|file| (file.rel.as_str(), file.head.as_deref()))
740 .collect();
741 let root = input.root;
742 let mut base_text = |rel: &str| match source.read(&root.join(rel)) {
743 BaseRead::Content(text) => Some(text),
744 BaseRead::Missing | BaseRead::Error => None,
745 };
746 let mut head_text = |rel: &str| match head_by_rel.get(rel) {
747 Some(head) => head.map(str::to_owned),
748 None => std::fs::read_to_string(root.join(rel)).ok(),
749 };
750 let found = clones::diverged_clone_siblings(
751 base_groups,
752 &input.head_clone_groups,
753 &changed,
754 &renames,
755 &mut CloneTexts {
756 base: &mut base_text,
757 head: &mut head_text,
758 },
759 );
760 for diverged in found {
761 if is_example_copy(root, &diverged.path) {
764 continue;
765 }
766 let rules = input.rules.for_path(&root.join(&diverged.path));
767 if rules.diverged_clone_sibling == Severity::Off {
768 continue;
769 }
770 let related: Vec<DiffRiskLocation> = diverged
771 .siblings
772 .iter()
773 .map(|sibling| DiffRiskLocation {
774 path: sibling.path.clone(),
775 line: sibling.start_line,
776 })
777 .collect();
778 let evidence = related
779 .iter()
780 .map(|location| format!("{}:{}", location.path, location.line))
781 .collect::<Vec<_>>()
782 .join(", ");
783 raw.push(RawFinding {
784 rule: DiffRiskRule::DivergedCloneSibling,
785 kind: None,
786 path: diverged.path,
787 line: diverged.line,
788 message: format!(
789 "The change edits one copy of duplicated code, and its {} unchanged copies no longer match",
790 related.len()
791 ),
792 evidence: format!("unchanged copies: {evidence}"),
793 related,
794 });
795 }
796}
797
798const fn issue_kind(rule: DiffRiskRule) -> IssueKind {
799 match rule {
800 DiffRiskRule::WeakenedTests => IssueKind::WeakenedTests,
801 DiffRiskRule::AddedSuppression => IssueKind::AddedSuppression,
802 DiffRiskRule::TestOnlyBranch => IssueKind::TestOnlyBranch,
803 DiffRiskRule::DivergedCloneSibling => IssueKind::DivergedCloneSibling,
804 DiffRiskRule::AddedDependency => IssueKind::AddedDependency,
805 }
806}
807
808const fn rule_severity(rules: &RulesConfig, rule: DiffRiskRule) -> Severity {
809 rules.severity_for_kind(issue_kind(rule))
810}
811
812fn is_suppressed(suppressions: &[Suppression], rule: DiffRiskRule, line: Option<u32>) -> bool {
816 let kind = issue_kind(rule);
817 suppressions.iter().any(|suppression| {
818 let names_rule = matches!(
819 &suppression.target,
820 Some(SuppressionTarget::Issue(target)) if *target == kind
821 );
822 if rule == DiffRiskRule::AddedSuppression && !names_rule {
823 return false;
824 }
825 let line = line.unwrap_or(0);
826 if line == 0 {
827 suppression.line == 0 && suppression.matches_issue_kind(0, kind)
828 } else {
829 suppression.matches_issue_kind(line, kind)
830 }
831 })
832}
833
834fn finalize(
835 input: &DiffRiskInput<'_>,
836 files: &[FileTexts],
837 raw: Vec<RawFinding>,
838) -> Vec<DiffRiskFinding> {
839 let mut suppressions: FxHashMap<&str, Vec<Suppression>> = FxHashMap::default();
840 let head_exists: FxHashMap<&str, bool> = files
841 .iter()
842 .map(|file| (file.rel.as_str(), file.head.is_some()))
843 .collect();
844 for file in files {
845 if let Some(head) = file.head.as_deref() {
846 suppressions.insert(
847 file.rel.as_str(),
848 fallow_engine::source::suppressions_from_text(head),
849 );
850 }
851 }
852 let mut findings: Vec<DiffRiskFinding> = raw
853 .into_iter()
854 .filter_map(|finding| {
855 let rules = input.rules.for_path(&input.root.join(&finding.path));
856 let severity = match rule_severity(&rules, finding.rule) {
857 Severity::Off => return None,
858 Severity::Warn => DiffRiskSeverity::Warn,
859 Severity::Error => DiffRiskSeverity::Error,
860 };
861 if suppressions
862 .get(finding.path.as_str())
863 .is_some_and(|list| is_suppressed(list, finding.rule, finding.line))
864 {
865 return None;
866 }
867 let exists = head_exists
868 .get(finding.path.as_str())
869 .copied()
870 .unwrap_or(true);
871 let actions = suppression_actions(finding.rule, &finding.path, finding.line, exists);
872 Some(DiffRiskFinding {
873 rule: finding.rule,
874 kind: finding.kind,
875 path: finding.path,
876 line: finding.line,
877 message: finding.message,
878 evidence: finding.evidence,
879 severity,
880 introduced: true,
881 related: finding.related,
882 actions,
883 })
884 })
885 .collect();
886 findings.sort_by(|a, b| {
887 a.path
888 .cmp(&b.path)
889 .then(a.line.cmp(&b.line))
890 .then(a.rule.cmp(&b.rule))
891 .then(a.kind.cmp(&b.kind))
892 .then(a.evidence.cmp(&b.evidence))
893 });
894 findings
895}
896
897fn comment_markers(rel: &str) -> Option<(&'static str, &'static str)> {
900 match extension(rel) {
901 "css" | "scss" | "sass" | "less" => Some(("/* ", " */")),
902 "html" => Some(("<!-- ", " -->")),
903 _ if is_source_file(rel) => Some(("// ", "")),
904 _ => None,
905 }
906}
907
908fn suppression_actions(
909 rule: DiffRiskRule,
910 path: &str,
911 line: Option<u32>,
912 head_exists: bool,
913) -> Vec<DiffRiskAction> {
914 let code = rule.code();
915 let markers = comment_markers(path).filter(|_| head_exists);
916 let Some((open, close)) = markers else {
917 return vec![DiffRiskAction {
918 kind: DiffRiskActionType::SuppressInConfig,
919 auto_fixable: false,
920 description: format!(
921 "Set `{code}` to `off` for `{path}` in an `overrides` entry of the Fallow config"
922 ),
923 comment: None,
924 }];
925 };
926 let mut actions = Vec::new();
927 if line.is_some() {
928 actions.push(DiffRiskAction {
929 kind: DiffRiskActionType::SuppressLine,
930 auto_fixable: false,
931 description: format!("Suppress this `{code}` finding with a comment above the line"),
932 comment: Some(format!("{open}fallow-ignore-next-line {code}{close}")),
933 });
934 }
935 actions.push(DiffRiskAction {
936 kind: DiffRiskActionType::SuppressFile,
937 auto_fixable: false,
938 description: format!("Suppress `{code}` for the whole file"),
939 comment: Some(format!("{open}fallow-ignore-file {code}{close}")),
940 });
941 actions
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 fn suppressions(src: &str) -> Vec<Suppression> {
949 fallow_engine::source::suppressions_from_text(src)
950 }
951
952 fn file(rel: &str, base: &str, head: &str) -> FileTexts {
953 FileTexts {
954 rel: rel.to_owned(),
955 abs: PathBuf::from("/project").join(rel),
956 base: Some(base.to_owned()),
957 head: Some(head.to_owned()),
958 }
959 }
960
961 fn rules_of(raw: &[RawFinding]) -> Vec<(DiffRiskRule, &str)> {
962 raw.iter()
963 .map(|finding| (finding.rule, finding.evidence.as_str()))
964 .collect()
965 }
966
967 #[test]
968 fn only_files_a_rule_reads_are_relevant() {
969 let root = Path::new("/project");
970 for rel in [
971 "src/a.ts",
972 "src/a.test.ts",
973 "src/page.mdx",
974 "src/query.graphql",
975 "styles/app.css",
976 "package.json",
977 "packages/ui/package.json",
978 "vitest.config.ts",
979 ".nycrc",
980 ".fallowrc.json",
981 ] {
982 assert!(is_rule_relevant(root, rel), "{rel} must be read");
983 }
984 for rel in [
985 "pnpm-lock.yaml",
986 "package-lock.json",
987 "yarn.lock",
988 "assets/logo.png",
989 "fonts/inter.woff2",
990 "README.md",
991 ] {
992 assert!(!is_rule_relevant(root, rel), "{rel} must not be read");
993 }
994 }
995
996 fn removed_test_findings(files: &[FileTexts]) -> Vec<(String, String)> {
997 removed_test_findings_in(Path::new("/project"), files)
998 }
999
1000 fn removed_test_findings_in(root: &Path, files: &[FileTexts]) -> Vec<(String, String)> {
1001 let mut raw = Vec::new();
1002 detect_removed_tests(root, files, &mut raw);
1003 let mut found: Vec<(String, String)> = raw
1004 .into_iter()
1005 .map(|finding| (finding.path, finding.evidence))
1006 .collect();
1007 found.sort_unstable();
1008 found
1009 }
1010
1011 fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> {
1012 list.iter()
1013 .map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
1014 .collect()
1015 }
1016
1017 const ERRORS_BASE: &str = "export const ERR_A = 'a';\nexport const ERR_GONE = 'gone';\n";
1018 const ERRORS_HEAD: &str = "export const ERR_A = 'a';\n";
1019 const ERRORS_TEST_BASE: &str = "test('ERR_A', () => {\n expect(ERR_A).toBe('a');\n});\ntest('ERR_GONE', () => {\n expect(ERR_GONE).toBe('gone');\n});\n";
1020 const ERRORS_TEST_HEAD: &str = "test('ERR_A', () => {\n expect(ERR_A).toBe('a');\n});\n";
1021
1022 #[test]
1023 fn tests_removed_with_the_source_name_they_cover_are_not_reported() {
1024 let files = [
1025 file("src/errors.ts", ERRORS_BASE, ERRORS_HEAD),
1026 file("src/errors.test.ts", ERRORS_TEST_BASE, ERRORS_TEST_HEAD),
1027 ];
1028 assert!(removed_test_findings(&files).is_empty());
1029 }
1030
1031 #[test]
1032 fn tests_removed_while_their_source_name_stays_are_reported() {
1033 let files = [
1034 file("src/errors.ts", ERRORS_BASE, ERRORS_BASE),
1035 file("src/errors.test.ts", ERRORS_TEST_BASE, ERRORS_TEST_HEAD),
1036 ];
1037 assert_eq!(
1038 removed_test_findings(&files),
1039 pairs(&[("src/errors.test.ts", "test cases: 2 -> 1")])
1040 );
1041 }
1042
1043 #[test]
1044 fn assertions_removed_with_the_source_name_they_cover_are_not_reported() {
1045 let base = "test('errors', () => {\n expect(ERR_A).toBe('a');\n expect(ERR_GONE).toBe('gone');\n});\n";
1046 let head = "test('errors', () => {\n expect(ERR_A).toBe('a');\n});\n";
1047 let files = [
1048 file("src/errors.ts", ERRORS_BASE, ERRORS_HEAD),
1049 file("src/errors.test.ts", base, head),
1050 ];
1051 assert!(removed_test_findings(&files).is_empty());
1052 let files = [
1053 file("src/errors.ts", ERRORS_BASE, ERRORS_BASE),
1054 file("src/errors.test.ts", base, head),
1055 ];
1056 assert_eq!(
1057 removed_test_findings(&files),
1058 pairs(&[("src/errors.test.ts", "assertions: 2 -> 1")])
1059 );
1060 }
1061
1062 #[test]
1063 fn tests_removed_with_a_deleted_fixture_directory_are_not_reported() {
1064 let base = "test('a', () => run('basic'));\ntest('legacy', () => run('legacy-types'));\n";
1065 let head = "test('a', () => run('basic'));\n";
1066 let files = [
1067 file("test/package.spec.js", base, head),
1068 FileTexts {
1069 rel: "test/fixtures/legacy-types/src/index.ts".to_owned(),
1070 abs: PathBuf::from("/project/test/fixtures/legacy-types/src/index.ts"),
1071 base: Some("export const a = 1;\n".to_owned()),
1072 head: None,
1073 },
1074 ];
1075 assert!(removed_test_findings(&files).is_empty());
1076 }
1077
1078 #[test]
1079 fn assertions_that_leave_with_rewritten_setup_code_are_not_reported() {
1080 let base = "test('creates', async () => {\n const response = await client.post('/api').send({ query: CREATE });\n expect(response.body.errors).toBeUndefined();\n const id = response.body.data.create.id;\n expect(await read(id)).toEqual({ id });\n});\n";
1081 let head = "test('creates', async () => {\n const { id } = await createRecord();\n expect(await read(id)).toEqual({ id });\n});\n";
1082 let files = [file("test/create.spec.ts", base, head)];
1083 assert!(removed_test_findings(&files).is_empty());
1084 let head = "test('creates', async () => {\n const response = await client.post('/api').send({ query: CREATE });\n const id = response.body.data.create.id;\n});\n";
1085 let files = [file("test/create.spec.ts", base, head)];
1086 assert_eq!(
1087 removed_test_findings(&files),
1088 pairs(&[("test/create.spec.ts", "assertions: 2 -> 0")])
1089 );
1090 }
1091
1092 #[test]
1093 fn tests_of_a_call_that_one_source_file_dropped_are_not_reported() {
1094 let hook_base = "export const useCommands = () => {\n const enabled = useIsFeatureEnabled('CORE');\n return enabled ? commands : [];\n};\n";
1095 let hook_head = "export const useCommands = () => commands;\n";
1096 let other = "export const usePage = () => useIsFeatureEnabled('PAGE');\n";
1097 let test_base = "jest.mock('./flags', () => ({ useIsFeatureEnabled: () => flag() }));\nit('shows commands', () => {\n expect(useCommands()).toEqual(commands);\n});\nit('hides commands with the flag off', () => {\n flag.mockReturnValue(false);\n expect(useCommands()).toEqual([]);\n});\n";
1098 let test_head =
1099 "it('shows commands', () => {\n expect(useCommands()).toEqual(commands);\n});\n";
1100 let files = [
1101 file("src/useCommands.ts", hook_base, hook_head),
1102 file("src/usePage.ts", other, other),
1103 file("src/useCommands.test.ts", test_base, test_head),
1104 ];
1105 assert!(removed_test_findings(&files).is_empty());
1106 }
1107
1108 #[test]
1109 fn words_in_comments_and_test_titles_do_not_explain_a_removal() {
1110 let source_base =
1111 "// Hide the items when the page is disabled.\nexport const items = (all) => all;\n";
1112 let source_head = "export const items = (all) => all;\n";
1113 let test_base = "it('keeps items', () => {\n expect(items([1])).toEqual([1]);\n});\nit('keeps items when the page is disabled', () => {\n expect(items([])).toEqual([]);\n});\n";
1114 let test_head = "it('keeps items', () => {\n expect(items([1])).toEqual([1]);\n});\n";
1115 let files = [
1116 file("src/items.ts", source_base, source_head),
1117 file("src/items.test.ts", test_base, test_head),
1118 ];
1119 assert_eq!(
1120 removed_test_findings(&files),
1121 pairs(&[("src/items.test.ts", "test cases: 2 -> 1")])
1122 );
1123 }
1124
1125 #[test]
1126 fn a_deleted_test_file_does_not_explain_its_own_removal() {
1127 let files = [FileTexts {
1128 rel: "src/zero.test.ts".to_owned(),
1129 abs: PathBuf::from("/project/src/zero.test.ts"),
1130 base: Some("import { price } from './zero';\nit('keeps zero', () => {\n expect(price(0)).toBe(0);\n});\n".to_owned()),
1131 head: None,
1132 }];
1133 assert_eq!(
1134 removed_test_findings(&files),
1135 pairs(&[("src/zero.test.ts", "test cases: 1 -> 0")])
1136 );
1137 }
1138
1139 fn deleted(rel: &str, base: &str) -> FileTexts {
1140 FileTexts {
1141 rel: rel.to_owned(),
1142 abs: PathBuf::from("/project").join(rel),
1143 base: Some(base.to_owned()),
1144 head: None,
1145 }
1146 }
1147
1148 #[test]
1149 fn an_unrelated_deleted_source_file_does_not_explain_a_deleted_test_file() {
1150 let files = [
1151 deleted(
1152 "src/legacy.ts",
1153 "import { a } from './a';\nimport { price } from './price';\nexport const legacy = price(a);\n",
1154 ),
1155 deleted(
1156 "src/price.test.ts",
1157 "import { price } from './price';\nit('keeps zero', () => {\n expect(price(0)).toBe(0);\n});\n",
1158 ),
1159 ];
1160 assert_eq!(
1161 removed_test_findings(&files),
1162 pairs(&[("src/price.test.ts", "test cases: 1 -> 0")])
1163 );
1164 }
1165
1166 #[test]
1167 fn a_name_that_a_changed_file_still_holds_does_not_explain_a_removal() {
1168 let files = [
1169 deleted("src/legacy.ts", "export const formatPrice = (n) => n;\n"),
1170 file(
1171 "src/format.ts",
1172 "export const format = (n) => n;\n",
1173 "export const formatPrice = (n) => n;\n",
1174 ),
1175 file(
1176 "src/format.test.ts",
1177 "it('formats', () => {\n expect(formatPrice(1)).toBe(1);\n});\n",
1178 "",
1179 ),
1180 ];
1181 assert_eq!(
1182 removed_test_findings(&files),
1183 pairs(&[("src/format.test.ts", "test cases: 1 -> 0")])
1184 );
1185 }
1186
1187 #[test]
1188 fn a_deleted_directory_name_matches_whole_path_segments_only() {
1189 let root = tempfile::tempdir().expect("tempdir");
1190 let base =
1191 "it('a', () => run());\nit('scores', () => {\n expect(score(1)).toBe(1);\n});\n";
1192 let head = "it('a', () => run());\n";
1193 let files = [
1194 file("src/score.test.ts", base, head),
1195 deleted("src/core/x.ts", "export const x = 1;\n"),
1196 ];
1197 assert_eq!(
1198 removed_test_findings_in(root.path(), &files),
1199 pairs(&[("src/score.test.ts", "test cases: 2 -> 1")])
1200 );
1201 let base =
1202 "import { a } from '../core/a';\nit('a', () => run(a));\nit('b', () => run(b));\n";
1203 let head = "it('a', () => run(a));\n";
1204 let files = [
1205 file("src/run.test.ts", base, head),
1206 deleted("src/core/x.ts", "export const x = 1;\n"),
1207 ];
1208 assert!(removed_test_findings_in(root.path(), &files).is_empty());
1209 }
1210
1211 #[test]
1212 fn a_directory_that_still_exists_does_not_explain_a_removal() {
1213 let root = tempfile::tempdir().expect("tempdir");
1214 std::fs::create_dir_all(root.path().join("src/components")).expect("components dir");
1215 let files = [
1216 deleted(
1217 "src/components/Legacy.tsx",
1218 "export const Legacy = () => null;\n",
1219 ),
1220 deleted(
1221 "src/__tests__/button.test.tsx",
1222 "import { Button } from '../components/Button';\nit('renders', () => {\n expect(Button()).toBeTruthy();\n});\n",
1223 ),
1224 ];
1225 assert_eq!(
1226 removed_test_findings_in(root.path(), &files),
1227 pairs(&[("src/__tests__/button.test.tsx", "test cases: 1 -> 0")])
1228 );
1229 }
1230
1231 #[test]
1232 fn edited_story_copy_is_not_a_diverged_clone() {
1233 for rel in [
1234 "apps/storybook/stories/select.stories.tsx",
1235 "src/button.story.tsx",
1236 ] {
1237 assert!(is_example_copy(Path::new("/project"), rel), "{rel}");
1238 }
1239 assert!(!is_example_copy(Path::new("/project"), "src/store.ts"));
1240 }
1241
1242 #[test]
1243 fn tool_config_names_match_config_and_dot_rc_files_only() {
1244 for name in [
1245 "vite.config.ts",
1246 "pkg/karma.conf.js",
1247 ".eslintrc.js",
1248 "pkg/.mocharc.cjs",
1249 ] {
1250 assert!(is_tool_config(name), "{name}");
1251 }
1252 for name in ["src.ts", "src/arc.ts", "lib/orc.js", "src/index.ts"] {
1253 assert!(!is_tool_config(name), "{name}");
1254 }
1255 }
1256
1257 #[test]
1258 fn suppressions_in_fixture_and_generated_files_are_not_reported() {
1259 let head = "// @ts-nocheck\nexport const a = 1;\n";
1260 let mut raw = Vec::new();
1261 let root = Path::new("/project");
1262 let rules = RulesConfig::default();
1263 for rel in [
1264 "src/__tests__/upstream/fixture/generated/schema.ts",
1265 "test/fixtures/app/src/index.ts",
1266 "src/__generated__/graphql.ts",
1267 "src/generated/client.ts",
1268 ] {
1269 detect_file(root, &file(rel, "", head), &rules, &mut raw);
1270 }
1271 assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1272 detect_file(root, &file("src/schema.ts", "", head), &rules, &mut raw);
1273 assert_eq!(
1274 rules_of(&raw),
1275 vec![(DiffRiskRule::AddedSuppression, "@ts-nocheck")]
1276 );
1277 }
1278
1279 #[test]
1280 fn ts_expect_error_in_type_tests_is_an_assertion_not_a_suppression() {
1281 let head = "// @ts-expect-error\nconst x: string = 1;\n";
1282 let mut raw = Vec::new();
1283 let root = Path::new("/project");
1284 let rules = RulesConfig::default();
1285 detect_file(root, &file("test-d/hooks.ts", "", head), &rules, &mut raw);
1286 detect_file(root, &file("src/a.test-d.ts", "", head), &rules, &mut raw);
1287 detect_file(root, &file("src/a.test.ts", "", head), &rules, &mut raw);
1288 assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1289 detect_file(root, &file("src/a.ts", "", head), &rules, &mut raw);
1290 assert_eq!(
1291 rules_of(&raw),
1292 vec![(DiffRiskRule::AddedSuppression, "@ts-expect-error")]
1293 );
1294 }
1295
1296 #[test]
1300 fn test_rules_read_javascript_test_files_only() {
1301 let root = Path::new("/project");
1302 let rules = RulesConfig::default();
1303 let base = "it('a', f);\nit('b', f);\n";
1304 let head = "it.skip('a', f);\n";
1305 let mut raw = Vec::new();
1306 let rust = file("crates/cli/tests/integration/a_tests.rs", base, head);
1307 detect_file(root, &rust, &rules, &mut raw);
1308 detect_removed_tests(root, std::slice::from_ref(&rust), &mut raw);
1309 assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1310
1311 let js = file("src/a.test.ts", base, head);
1312 detect_file(root, &js, &rules, &mut raw);
1313 detect_removed_tests(root, std::slice::from_ref(&js), &mut raw);
1314 assert_eq!(
1315 rules_of(&raw),
1316 vec![
1317 (DiffRiskRule::WeakenedTests, "it.skip"),
1318 (DiffRiskRule::WeakenedTests, "test cases: 2 -> 1"),
1319 ]
1320 );
1321 }
1322
1323 #[test]
1324 fn named_line_suppression_hides_the_finding() {
1325 let list = suppressions("// fallow-ignore-next-line weakened-tests\nit.skip('a', f);\n");
1326 assert!(is_suppressed(&list, DiffRiskRule::WeakenedTests, Some(2)));
1327 assert!(!is_suppressed(&list, DiffRiskRule::TestOnlyBranch, Some(2)));
1328 }
1329
1330 #[test]
1331 fn file_suppression_hides_a_file_level_finding() {
1332 let list = suppressions("// fallow-ignore-file weakened-tests\n");
1333 assert!(is_suppressed(&list, DiffRiskRule::WeakenedTests, None));
1334 }
1335
1336 #[test]
1337 fn blanket_suppression_does_not_hide_an_added_suppression() {
1338 let list = suppressions("// fallow-ignore-file\nconst a = 1;\n");
1339 assert!(!is_suppressed(
1340 &list,
1341 DiffRiskRule::AddedSuppression,
1342 Some(1)
1343 ));
1344 assert!(is_suppressed(&list, DiffRiskRule::TestOnlyBranch, Some(2)));
1345 }
1346
1347 #[test]
1348 fn json_and_deleted_files_get_a_config_action() {
1349 let actions =
1350 suppression_actions(DiffRiskRule::AddedDependency, "package.json", Some(3), true);
1351 assert_eq!(actions.len(), 1);
1352 assert_eq!(actions[0].kind, DiffRiskActionType::SuppressInConfig);
1353 let actions = suppression_actions(DiffRiskRule::WeakenedTests, "a.test.ts", None, false);
1354 assert_eq!(actions[0].kind, DiffRiskActionType::SuppressInConfig);
1355 }
1356
1357 #[test]
1358 fn source_files_get_line_and_file_comments() {
1359 let actions = suppression_actions(DiffRiskRule::TestOnlyBranch, "src/a.ts", Some(3), true);
1360 let comments: Vec<_> = actions
1361 .iter()
1362 .filter_map(|a| a.comment.as_deref())
1363 .collect();
1364 assert_eq!(
1365 comments,
1366 vec![
1367 "// fallow-ignore-next-line test-only-branch",
1368 "// fallow-ignore-file test-only-branch"
1369 ]
1370 );
1371 let actions =
1372 suppression_actions(DiffRiskRule::AddedSuppression, "src/a.css", Some(3), true);
1373 assert_eq!(
1374 actions[0].comment.as_deref(),
1375 Some("/* fallow-ignore-next-line added-suppression */")
1376 );
1377 }
1378}