pub use fallow_output::{WeakeningKind, WeakeningSignal};
#[must_use]
pub fn detect_test_weakening(base: &str, head: &str) -> Vec<String> {
const SKIP_TOKENS: &[&str] = &[
"it.skip",
"test.skip",
"describe.skip",
"xit(",
"xdescribe(",
"it.only",
"test.only",
"describe.only",
];
let mut signals = Vec::new();
for token in SKIP_TOKENS {
let head_count = count_token(head, token);
let base_count = count_token(base, token);
if head_count > base_count {
signals.push((*token).to_string());
}
}
signals
}
#[must_use]
pub fn detect_removed_tests(base: &str, head: &str) -> Vec<String> {
const TEST_TOKENS: &[&str] = &["it(", "test(", "describe("];
let mut signals = Vec::new();
for token in TEST_TOKENS {
let base_count = count_token(base, token);
let head_count = count_token(head, token);
if base_count > head_count {
signals.push(format!("{token} removed ({base_count} -> {head_count})"));
}
}
signals
}
#[must_use]
pub fn detect_added_suppressions(base: &str, head: &str) -> Vec<String> {
const SUPPRESS_TOKENS: &[&str] = &[
"fallow-ignore",
"eslint-disable",
"@ts-ignore",
"@ts-expect-error",
"biome-ignore",
"prettier-ignore",
];
let mut signals = Vec::new();
for token in SUPPRESS_TOKENS {
let base_count = count_token_in_comments(base, token);
let head_count = count_token_in_comments(head, token);
if head_count > base_count {
signals.push(format!("{token} added ({base_count} -> {head_count})"));
}
}
signals
}
#[must_use]
pub fn detect_lowered_thresholds(base: &str, head: &str) -> Vec<String> {
const THRESHOLD_KEYS: &[&str] = &[
"branches",
"functions",
"lines",
"statements",
"minScore",
"min-score",
"maxCrap",
"max-crap",
"minCoverage",
"min-coverage",
"coverageThreshold",
"threshold",
];
let mut signals = Vec::new();
for key in THRESHOLD_KEYS {
let (Some(base_val), Some(head_val)) = (
first_numeric_for_key(base, key),
first_numeric_for_key(head, key),
) else {
continue;
};
if head_val < base_val {
signals.push(format!("{key}: {base_val} -> {head_val}"));
}
}
signals
}
#[must_use]
pub fn detect_removed_security_steps(base: &str, head: &str) -> Vec<String> {
const SECURITY_TOKENS: &[&str] = &[
"npm audit",
"yarn audit",
"pnpm audit",
"fallow security",
"codeql",
"snyk",
"trivy",
"semgrep",
"gitleaks",
"dependency-review",
"osv-scanner",
];
let mut signals = Vec::new();
for token in SECURITY_TOKENS {
let base_count = count_token(base, token);
let head_count = count_token(head, token);
if base_count > head_count {
signals.push(format!("{token} step removed"));
}
}
signals
}
#[must_use]
pub fn is_ci_file(rel_path: &str) -> bool {
rel_path.contains(".github/workflows/")
|| rel_path.ends_with(".gitlab-ci.yml")
|| rel_path.ends_with(".gitlab-ci.yaml")
}
#[must_use]
pub fn is_test_file(rel_path: &str) -> bool {
let lower = rel_path.to_ascii_lowercase();
lower.contains(".test.")
|| lower.contains(".spec.")
|| lower.contains("__tests__/")
|| lower.contains("/tests/")
|| lower.contains("/test/")
|| lower.contains(".cy.")
}
fn count_token(src: &str, token: &str) -> usize {
src.matches(token).count()
}
fn count_token_in_comments(src: &str, token: &str) -> usize {
src.lines()
.filter(|line| line_is_comment(line))
.map(|line| line.matches(token).count())
.sum()
}
fn line_is_comment(line: &str) -> bool {
let trimmed = line.trim_start();
line.contains("//")
|| line.contains("/*")
|| line.contains("<!--")
|| trimmed.starts_with('#')
|| trimmed.starts_with('*')
}
fn first_numeric_for_key(src: &str, key: &str) -> Option<f64> {
let mut search_from = 0;
while let Some(rel) = src[search_from..].find(key) {
let start = search_from + rel;
search_from = start + key.len();
let preceded_ok = start == 0
|| src[..start]
.chars()
.next_back()
.is_some_and(|c| matches!(c, '"' | '\'' | ' ' | '\t' | '{' | ',' | '\n'));
if !preceded_ok {
continue;
}
let after = src[search_from..]
.trim_start_matches(['"', '\''])
.trim_start()
.trim_start_matches([':', '='])
.trim_start()
.trim_start_matches(['"', '\'']);
let num: String = after
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();
if let Ok(value) = num.parse::<f64>() {
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn injected_it_skip_is_flagged() {
let base = "it('works', () => { expect(x).toBe(1); });";
let head = "it.skip('works', () => { expect(x).toBe(1); });";
let signals = detect_test_weakening(base, head);
assert!(
signals.iter().any(|s| s == "it.skip"),
"it.skip must be flagged: {signals:?}"
);
}
#[test]
fn only_narrowing_is_flagged() {
let base = "describe('a', () => {});";
let head = "describe.only('a', () => {});";
let signals = detect_test_weakening(base, head);
assert!(signals.iter().any(|s| s == "describe.only"));
}
#[test]
fn unchanged_tests_produce_no_signal() {
let src = "it('a', () => {}); it('b', () => {});";
assert!(detect_test_weakening(src, src).is_empty());
assert!(detect_removed_tests(src, src).is_empty());
}
#[test]
fn removed_test_is_flagged() {
let base = "it('a', () => {}); it('b', () => {});";
let head = "it('a', () => {});";
let signals = detect_removed_tests(base, head);
assert_eq!(signals.len(), 1);
assert!(signals[0].contains("it("));
}
#[test]
fn added_suppression_is_flagged() {
let base = "const x = 1;";
let head = "// eslint-disable-next-line\nconst x = 1;";
let signals = detect_added_suppressions(base, head);
assert!(signals.iter().any(|s| s.starts_with("eslint-disable")));
}
#[test]
fn lowered_threshold_is_flagged() {
let base = r#"{ "branches": 90, "lines": 85 }"#;
let head = r#"{ "branches": 70, "lines": 85 }"#;
let signals = detect_lowered_thresholds(base, head);
assert_eq!(signals, vec!["branches: 90 -> 70".to_string()]);
}
#[test]
fn lowered_min_score_is_flagged() {
let base = "minScore: 80";
let head = "minScore: 50";
let signals = detect_lowered_thresholds(base, head);
assert_eq!(signals, vec!["minScore: 80 -> 50".to_string()]);
}
#[test]
fn raised_threshold_is_not_flagged() {
let base = r#"{ "branches": 70 }"#;
let head = r#"{ "branches": 90 }"#;
assert!(detect_lowered_thresholds(base, head).is_empty());
}
#[test]
fn removed_security_step_is_flagged() {
let base = " - run: npm audit --audit-level=high\n - run: build";
let head = " - run: build";
let signals = detect_removed_security_steps(base, head);
assert!(signals.iter().any(|s| s.contains("npm audit")));
}
#[test]
fn file_classifiers() {
assert!(is_ci_file(".github/workflows/ci.yml"));
assert!(is_ci_file(".gitlab-ci.yml"));
assert!(!is_ci_file("src/app.ts"));
assert!(is_test_file("src/app.test.ts"));
assert!(is_test_file("__tests__/app.ts"));
assert!(!is_test_file("src/app.ts"));
}
}