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