#[cfg(feature = "lang-go")]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tree_sitter::Node;
use crate::core::calibration::MetricCalibration;
use crate::core::config::Config;
use crate::core::finding::{Finding, IntoFindings, Location};
use crate::feature::{decorate, Family, Feature, FeatureKind, FeatureMeta, HotspotIndex};
use crate::observer::code::complexity::{parse, ParsedFile};
use crate::observer::shared::lang::Language;
use crate::observer::shared::walk::{walk_supported_files_under, ExcludeMatcher};
const FALLBACK_CALIBRATION: MetricCalibration = MetricCalibration {
p50: 0.0,
p75: 1.0,
p90: 5.0,
p95: 10.0,
floor_critical: Some(20.0),
floor_ok: Some(0.5),
};
#[derive(Debug, Clone, Default)]
pub struct SkipRatioObserver {
pub enabled: bool,
pub test_paths: Vec<String>,
pub excluded: Vec<String>,
}
impl SkipRatioObserver {
#[must_use]
pub fn from_config(cfg: &Config) -> Self {
Self {
enabled: cfg.features.test.enabled,
test_paths: cfg.features.test.test_paths.clone(),
excluded: cfg.exclude_lines(),
}
}
#[must_use]
pub fn scan(&self, root: &Path) -> SkipRatioReport {
if !self.enabled || self.test_paths.is_empty() {
return SkipRatioReport::default();
}
let exclude = ExcludeMatcher::compile(root, &self.excluded)
.expect("exclude patterns validated at config load");
let Ok(include) = ExcludeMatcher::compile(root, &self.test_paths) else {
return SkipRatioReport::default();
};
let mut entries: Vec<SkipRatioEntry> = Vec::new();
for path in walk_supported_files_under(root, &exclude, None) {
let rel = path
.strip_prefix(root)
.map_or_else(|_| path.clone(), Path::to_path_buf);
if !include.is_excluded(&rel, false) {
continue;
}
let Some(lang) = Language::from_path(&path) else {
continue;
};
let Ok(source) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(parsed) = parse(source, lang) else {
continue;
};
let counts = count_skips(&parsed);
if counts.total_tests == 0 {
continue;
}
#[allow(clippy::cast_precision_loss)]
let pct = (f64::from(counts.skipped_tests) / f64::from(counts.total_tests)) * 100.0;
entries.push(SkipRatioEntry {
path: rel,
language: lang.name().to_owned(),
total_tests: counts.total_tests,
skipped_tests: counts.skipped_tests,
skip_pct: pct,
});
}
entries.sort_by(|a, b| {
b.skip_pct
.partial_cmp(&a.skip_pct)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
SkipRatioReport { entries }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SkipRatioReport {
pub entries: Vec<SkipRatioEntry>,
}
impl SkipRatioReport {
#[must_use]
pub fn worst_n(&self, n: usize) -> Vec<&SkipRatioEntry> {
let mut top: Vec<&SkipRatioEntry> = self
.entries
.iter()
.filter(|e| e.skipped_tests > 0)
.collect();
top.sort_by(|a, b| {
b.skip_pct
.partial_cmp(&a.skip_pct)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
top.truncate(n);
top
}
#[must_use]
pub fn skipped_file_count(&self) -> usize {
self.entries.iter().filter(|e| e.skipped_tests > 0).count()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkipRatioEntry {
pub path: PathBuf,
pub language: String,
pub total_tests: u32,
pub skipped_tests: u32,
pub skip_pct: f64,
}
impl Eq for SkipRatioEntry {}
#[derive(Debug, Clone, Copy, Default)]
struct SkipCounts {
total_tests: u32,
skipped_tests: u32,
}
fn count_skips(parsed: &ParsedFile) -> SkipCounts {
match parsed.lang {
#[cfg(feature = "lang-rust")]
Language::Rust => count_rust(parsed),
#[cfg(feature = "lang-python")]
Language::Python => count_python(parsed),
#[cfg(feature = "lang-typescript")]
Language::TypeScript | Language::Tsx => count_jsts(parsed),
#[cfg(feature = "lang-javascript")]
Language::JavaScript | Language::Jsx => count_jsts(parsed),
#[cfg(feature = "lang-go")]
Language::Go => count_go(parsed),
#[cfg(feature = "lang-scala")]
Language::Scala => count_scala(parsed),
}
}
#[cfg(feature = "lang-rust")]
fn count_rust(parsed: &ParsedFile) -> SkipCounts {
let mut counts = SkipCounts::default();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "attribute" {
return;
}
let mut cur = node.walk();
for child in node.named_children(&mut cur) {
if child.kind() == "identifier" {
let text = child.utf8_text(parsed.source.as_bytes()).unwrap_or("");
match text {
"test" => counts.total_tests += 1,
"ignore" => counts.skipped_tests += 1,
_ => {}
}
break;
}
}
});
counts
}
#[cfg(feature = "lang-python")]
fn count_python(parsed: &ParsedFile) -> SkipCounts {
let mut counts = SkipCounts::default();
let src = parsed.source.as_bytes();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "function_definition" {
return;
}
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Ok(name) = name_node.utf8_text(src) else {
return;
};
if !name.starts_with("test_") {
return;
}
counts.total_tests += 1;
let Some(parent) = node.parent() else {
return;
};
if parent.kind() != "decorated_definition" {
return;
}
let mut cur = parent.walk();
let has_skip = parent.children(&mut cur).any(|child| {
if child.kind() != "decorator" {
return false;
}
let text = child.utf8_text(src).unwrap_or("");
text.contains(".skip") || text.contains(".expectedFailure")
});
if has_skip {
counts.skipped_tests += 1;
}
});
counts
}
#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
fn count_jsts(parsed: &ParsedFile) -> SkipCounts {
let mut counts = SkipCounts::default();
let src = parsed.source.as_bytes();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "call_expression" {
return;
}
let Some(callee) = node.child_by_field_name("function") else {
return;
};
match callee.kind() {
"identifier" => {
let text = callee.utf8_text(src).unwrap_or("");
match text {
"it" | "test" | "describe" | "context" | "fit" | "fdescribe" | "ftest" => {
counts.total_tests += 1;
}
"xit" | "xtest" | "xdescribe" => {
counts.total_tests += 1;
counts.skipped_tests += 1;
}
_ => {}
}
}
"member_expression" => {
let Some(obj) = callee.child_by_field_name("object") else {
return;
};
let Some(prop) = callee.child_by_field_name("property") else {
return;
};
if obj.kind() != "identifier" {
return;
}
let obj_text = obj.utf8_text(src).unwrap_or("");
if !matches!(obj_text, "it" | "test" | "describe" | "context") {
return;
}
let prop_text = prop.utf8_text(src).unwrap_or("");
match prop_text {
"skip" => {
counts.total_tests += 1;
counts.skipped_tests += 1;
}
"only" | "todo" => {
counts.total_tests += 1;
}
_ => {}
}
}
_ => {}
}
});
counts
}
#[cfg(feature = "lang-go")]
fn count_go(parsed: &ParsedFile) -> SkipCounts {
let src = parsed.source.as_bytes();
let mut test_fns: Vec<(usize, usize)> = Vec::new();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "function_declaration" {
return;
}
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Ok(name) = name_node.utf8_text(src) else {
return;
};
if name.starts_with("Test") {
test_fns.push((node.start_byte(), node.end_byte()));
}
});
let total = u32::try_from(test_fns.len()).unwrap_or(u32::MAX);
if total == 0 {
return SkipCounts::default();
}
let mut skipped_fn_starts: HashSet<usize> = HashSet::new();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "selector_expression" {
return;
}
let Some(field) = node.child_by_field_name("field") else {
return;
};
let text = field.utf8_text(src).unwrap_or("");
if !matches!(text, "Skip" | "SkipNow" | "Skipf") {
return;
}
let byte = node.start_byte();
for &(start, end) in &test_fns {
if byte >= start && byte < end {
skipped_fn_starts.insert(start);
break;
}
}
});
SkipCounts {
total_tests: total,
skipped_tests: u32::try_from(skipped_fn_starts.len()).unwrap_or(u32::MAX),
}
}
#[cfg(feature = "lang-scala")]
fn count_scala(parsed: &ParsedFile) -> SkipCounts {
let mut counts = SkipCounts::default();
let src = parsed.source.as_bytes();
walk_each_node(parsed.tree.root_node(), &mut |node| {
if node.kind() != "call_expression" {
return;
}
let callee = node
.child_by_field_name("function")
.or_else(|| node.named_child(0));
let Some(callee) = callee else { return };
if callee.kind() != "identifier" {
return;
}
let text = callee.utf8_text(src).unwrap_or("");
match text {
"test" | "it" | "they" | "scenario" => counts.total_tests += 1,
"ignore" | "pending" => {
counts.total_tests += 1;
counts.skipped_tests += 1;
}
_ => {}
}
});
counts
}
fn walk_each_node<F: FnMut(Node<'_>)>(root: Node<'_>, visit: &mut F) {
visit(root);
let mut cursor = root.walk();
for child in root.named_children(&mut cursor) {
walk_each_node(child, visit);
}
}
impl IntoFindings for SkipRatioReport {
fn into_findings(&self) -> Vec<Finding> {
self.entries
.iter()
.filter(|e| e.skipped_tests > 0)
.map(entry_finding)
.collect()
}
}
fn entry_finding(entry: &SkipRatioEntry) -> Finding {
let primary = Location::file(entry.path.clone());
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct_int = entry.skip_pct.round() as u32;
let summary = format!(
"Skip={pct_int}% ({}/{} tests skipped)",
entry.skipped_tests, entry.total_tests,
);
let seed = format!("skip_ratio:{pct_int}");
Finding::new(Finding::METRIC_SKIP_RATIO, primary, summary, &seed)
}
pub struct SkipRatioFeature;
impl Feature for SkipRatioFeature {
fn meta(&self) -> FeatureMeta {
FeatureMeta {
name: "skip_ratio",
version: 1,
kind: FeatureKind::Observer,
}
}
fn enabled(&self, cfg: &Config) -> bool {
cfg.features.test.enabled
}
fn family(&self) -> Family {
Family::Test
}
fn lower(
&self,
reports: &crate::observers::ObserverReports,
_cfg: &Config,
cal: &crate::core::calibration::Calibration,
hotspot: &HotspotIndex,
) -> Vec<Finding> {
let Some(report) = reports.skip_ratio.as_ref() else {
return Vec::new();
};
let calibration = cal
.calibration
.skip_ratio
.as_ref()
.unwrap_or(&FALLBACK_CALIBRATION);
report
.entries
.iter()
.filter(|e| e.skipped_tests > 0)
.map(|entry| {
decorate(
entry_finding(entry),
calibration.classify(entry.skip_pct),
hotspot,
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "lang-rust")]
use crate::core::config::TestConfig;
#[cfg(feature = "lang-rust")]
use std::fs;
#[cfg(feature = "lang-rust")]
use tempfile::TempDir;
#[cfg(feature = "lang-rust")]
fn cfg_enabled() -> Config {
let mut cfg = Config::default();
cfg.features.test = TestConfig {
enabled: true,
..TestConfig::default()
};
cfg
}
#[cfg(feature = "lang-rust")]
#[test]
fn rust_counts_test_and_ignore_attributes() {
let parsed = parse(
"
#[test]
fn ok_test() {}
#[test]
#[ignore]
fn skipped_test() {}
#[ignore]
#[test]
fn skipped_test_2() {}
fn not_a_test() {}
"
.to_owned(),
Language::Rust,
)
.unwrap();
let counts = count_rust(&parsed);
assert_eq!(counts.total_tests, 3);
assert_eq!(counts.skipped_tests, 2);
}
#[cfg(feature = "lang-python")]
#[test]
fn python_counts_test_functions_and_skip_decorators() {
let parsed = parse(
r#"
import pytest, unittest
def test_one(): pass
@pytest.mark.skip("reason")
def test_skipped(): pass
@pytest.mark.skipif(True, reason="x")
def test_conditional(): pass
@unittest.skipUnless(False, "x")
def test_unless(): pass
def helper(): pass
"#
.to_owned(),
Language::Python,
)
.unwrap();
let counts = count_python(&parsed);
assert_eq!(counts.total_tests, 4);
assert_eq!(counts.skipped_tests, 3);
}
#[cfg(feature = "lang-typescript")]
#[test]
fn typescript_counts_it_and_skip_variants() {
let parsed = parse(
r#"
describe("group", () => {
it("normal", () => {});
it.skip("skipped", () => {});
xit("also skipped", () => {});
test.only("focused", () => {});
});
"#
.to_owned(),
Language::TypeScript,
)
.unwrap();
let counts = count_jsts(&parsed);
assert_eq!(counts.total_tests, 5);
assert_eq!(counts.skipped_tests, 2);
}
#[cfg(feature = "lang-go")]
#[test]
fn go_counts_test_funcs_and_dedupes_multiple_skips() {
let parsed = parse(
r#"
package foo
import "testing"
func TestOne(t *testing.T) {}
func TestSkipped(t *testing.T) {
if true {
t.Skip("reason")
}
t.SkipNow()
}
func TestAnotherSkip(t *testing.T) {
t.Skipf("formatted %d", 1)
}
func helper() {}
"#
.to_owned(),
Language::Go,
)
.unwrap();
let counts = count_go(&parsed);
assert_eq!(counts.total_tests, 3);
assert_eq!(counts.skipped_tests, 2);
}
#[test]
fn entry_finding_summary_carries_pct() {
let entry = SkipRatioEntry {
path: PathBuf::from("tests/foo.rs"),
language: "rust".into(),
total_tests: 10,
skipped_tests: 2,
skip_pct: 20.0,
};
let f = entry_finding(&entry);
assert!(f.summary.starts_with("Skip=20%"));
assert_eq!(f.metric, Finding::METRIC_SKIP_RATIO);
}
#[test]
fn into_findings_skips_files_with_zero_skips() {
let report = SkipRatioReport {
entries: vec![
SkipRatioEntry {
path: PathBuf::from("tests/clean.rs"),
language: "rust".into(),
total_tests: 5,
skipped_tests: 0,
skip_pct: 0.0,
},
SkipRatioEntry {
path: PathBuf::from("tests/dirty.rs"),
language: "rust".into(),
total_tests: 5,
skipped_tests: 1,
skip_pct: 20.0,
},
],
};
let findings = report.into_findings();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].location.file, PathBuf::from("tests/dirty.rs"));
}
#[test]
fn fallback_calibration_anchors_at_todo_thresholds() {
use crate::core::severity::Severity;
assert_eq!(FALLBACK_CALIBRATION.classify(25.0), Severity::Critical);
assert_eq!(FALLBACK_CALIBRATION.classify(0.1), Severity::Ok);
assert_eq!(FALLBACK_CALIBRATION.classify(6.0), Severity::High);
assert_eq!(FALLBACK_CALIBRATION.classify(2.0), Severity::Medium);
}
#[cfg(feature = "lang-rust")]
#[test]
fn observer_disabled_returns_empty_report() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("tests")).unwrap();
fs::write(
tmp.path().join("tests/foo.rs"),
"#[test] fn t() {} #[test] #[ignore] fn s() {}",
)
.unwrap();
let cfg = Config::default(); let report = SkipRatioObserver::from_config(&cfg).scan(tmp.path());
assert!(report.entries.is_empty());
}
#[cfg(feature = "lang-rust")]
#[test]
fn observer_emits_entry_for_test_file_with_skips() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("tests")).unwrap();
fs::write(
tmp.path().join("tests/foo.rs"),
"#[test] fn a() {} #[test] fn b() {} #[test] #[ignore] fn c() {}",
)
.unwrap();
let cfg = cfg_enabled();
let report = SkipRatioObserver::from_config(&cfg).scan(tmp.path());
assert_eq!(report.entries.len(), 1);
let entry = &report.entries[0];
assert_eq!(entry.total_tests, 3);
assert_eq!(entry.skipped_tests, 1);
assert!((entry.skip_pct - 100.0 / 3.0).abs() < 1e-6);
}
}