Skip to main content

sdivi_patterns/queries/
testing.rs

1//! Callee-text classification for test-suite structure and assertion patterns.
2//!
3//! Detects test-framework globals and helpers:
4//!
5//! - **TypeScript / JavaScript:** BDD globals (`describe`, `it`, `context`), flat `test`,
6//!   lifecycle hooks (`beforeEach`, `afterEach`, `beforeAll`, `afterAll`), `expect(…)`,
7//!   focused/excluded variants (`xit`, `xdescribe`, `fit`, `fdescribe`), and
8//!   framework-namespaced helpers (`jest.fn`, `jest.mock`, `jest.spyOn`, `vi.fn`, `vi.mock`,
9//!   `vi.spyOn`, etc.). Covers Jest, Vitest, Mocha, and Jasmine globals.
10//! - **Go:** `testing.T` method calls — `t.Run`, `t.Error`, `t.Errorf`, `t.Fatal`,
11//!   `t.Fatalf`, `t.Helper`, `t.Skip`, `t.Skipf`, `t.Log`, `t.Logf`, `t.Cleanup`,
12//!   `t.Parallel`.
13//! - **Python:** `unittest.TestCase` assertion methods — `self.assertEqual`,
14//!   `self.assertTrue`, and the full `self.assert[A-Z]…` family. pytest bare `assert`
15//!   statements are not call nodes and produce no hits in the v0 model.
16//!
17//! ## CALL_DISPATCH slot
18//!
19//! Registered at P2 — just below `async_patterns` (P1) and above `schema_validation`
20//! (P4). Test globals are specific and resolve before any broader category.
21//!
22//! ## Known false positives
23//!
24//! `test(args)`, `it(args)`, `context(args)`, and `expect(x)` can appear as business-logic
25//! function names. The `^` anchor prevents mid-identifier matching but cannot distinguish
26//! intent. Accepted as entropy noise at codebase scale. Exclude test paths via
27//! `patterns.scope_exclude` to suppress them.
28//!
29//! ## `scope_exclude` interaction
30//!
31//! `patterns.scope_exclude` removes files from the pattern catalog only (files remain in the
32//! graph). The `testing` bucket is non-empty only when test files are in the pattern scope.
33//! Repos that exclude test paths via `scope_exclude` will see a zero count — intended
34//! behaviour, not a miss. No auto-detection of test paths is performed.
35//!
36//! ## Seeds forward
37//!
38//! Property-based (`fc.assert`, `hypothesis.given`) and E2E frameworks (`cy.`, `page.`)
39//! are adjacent idioms. Deferred to post-M42.
40
41use std::sync::LazyLock;
42
43use regex::Regex;
44
45/// Tree-sitter node kinds for testing patterns.
46///
47/// Empty — detection is entirely via callee-text inspection in [`matches_callee`].
48/// Classification happens in `classify_hint`'s `CALL_DISPATCH` loop at slot P2.
49pub const NODE_KINDS: &[&str] = &[];
50
51// TypeScript / JavaScript — BDD globals, flat test, lifecycle hooks, expect root.
52// Anchored at `^`; covers Jest/Vitest/Mocha/Jasmine/Qunit test-suite globals.
53static TS_JS_GLOBALS_RE: LazyLock<Regex> = LazyLock::new(|| {
54    Regex::new(
55        r"^(describe|it|test|xit|xdescribe|fdescribe|fit|context|beforeEach|afterEach|beforeAll|afterAll|expect)\(",
56    )
57    .expect("testing TS/JS globals regex is valid")
58});
59
60// TypeScript / JavaScript — Jest and Vitest framework-namespaced helpers.
61static TS_JS_FRAMEWORK_RE: LazyLock<Regex> = LazyLock::new(|| {
62    Regex::new(r"^(jest|vi)\.(fn|mock|spyOn|clearAllMocks|resetAllMocks|useFakeTimers)\(")
63        .expect("testing TS/JS framework regex is valid")
64});
65
66// Go — testing.T method calls.
67// `\bt\.` requires a word boundary before `t`; receiver `st` does not match.
68static GO_RE: LazyLock<Regex> = LazyLock::new(|| {
69    Regex::new(
70        r"\bt\.(Run|Error|Errorf|Fatal|Fatalf|Helper|Skip|Skipf|Log|Logf|Cleanup|Parallel)\(",
71    )
72    .expect("testing Go regex is valid")
73});
74
75// Python — unittest.TestCase assertion methods.
76// `self.assert[A-Z]…(` rules out `self.assert_` snake_case helpers.
77static PYTHON_RE: LazyLock<Regex> = LazyLock::new(|| {
78    Regex::new(r"\bself\.assert[A-Z]\w*\(").expect("testing Python regex is valid")
79});
80
81/// Return `true` when `text` looks like a test-framework call.
82///
83/// Covers BDD globals, flat `test`, lifecycle hooks, `expect` roots (TS/JS),
84/// `testing.T` methods (Go), and `unittest.TestCase` assertion methods (Python).
85/// See module doc for scope_exclude interaction and false-positive policy.
86///
87/// # Examples
88///
89/// ```rust
90/// use sdivi_patterns::queries::testing::matches_callee;
91///
92/// assert!(matches_callee("describe('suite', fn)", "typescript"));
93/// assert!(matches_callee("it('does', fn)", "javascript"));
94/// assert!(matches_callee("expect(x).toBe(1)", "javascript"));
95/// assert!(matches_callee("beforeEach(() => {})", "typescript"));
96/// assert!(matches_callee("jest.mock('./module')", "typescript"));
97/// assert!(matches_callee("vi.fn()", "javascript"));
98/// assert!(matches_callee("t.Run(\"sub\", fn)", "go"));
99/// assert!(matches_callee("t.Fatal(err)", "go"));
100/// assert!(matches_callee("self.assertEqual(a, b)", "python"));
101/// assert!(matches_callee("self.assertTrue(x)", "python"));
102/// assert!(!matches_callee("console.log(x)", "typescript"));
103/// assert!(!matches_callee("db.Query(sql)", "go"));
104/// assert!(!matches_callee("self.method()", "python"));
105/// ```
106pub fn matches_callee(text: &str, language: &str) -> bool {
107    match language {
108        "typescript" | "javascript" => {
109            TS_JS_GLOBALS_RE.is_match(text) || TS_JS_FRAMEWORK_RE.is_match(text)
110        }
111        "go" => GO_RE.is_match(text),
112        "python" => PYTHON_RE.is_match(text),
113        _ => false,
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn describe_matches_ts() {
123        assert!(matches_callee("describe('suite', fn)", "typescript"));
124    }
125
126    #[test]
127    fn it_matches_js() {
128        assert!(matches_callee("it('does something', fn)", "javascript"));
129    }
130
131    #[test]
132    fn test_matches_ts() {
133        assert!(matches_callee("test('is truthy', () => {})", "typescript"));
134    }
135
136    #[test]
137    fn expect_matches_js() {
138        assert!(matches_callee("expect(x).toBe(1)", "javascript"));
139    }
140
141    #[test]
142    fn before_each_matches() {
143        assert!(matches_callee("beforeEach(() => {})", "typescript"));
144    }
145
146    #[test]
147    fn after_all_matches() {
148        assert!(matches_callee("afterAll(() => {})", "javascript"));
149    }
150
151    #[test]
152    fn xit_matches() {
153        assert!(matches_callee("xit('skipped', fn)", "typescript"));
154    }
155
156    #[test]
157    fn context_matches() {
158        assert!(matches_callee("context('ctx', fn)", "javascript"));
159    }
160
161    #[test]
162    fn jest_mock_matches() {
163        assert!(matches_callee("jest.mock('./module')", "typescript"));
164    }
165
166    #[test]
167    fn jest_fn_matches() {
168        assert!(matches_callee("jest.fn()", "typescript"));
169    }
170
171    #[test]
172    fn jest_spy_on_matches() {
173        assert!(matches_callee("jest.spyOn(obj, 'method')", "typescript"));
174    }
175
176    #[test]
177    fn vi_fn_matches() {
178        assert!(matches_callee("vi.fn()", "javascript"));
179    }
180
181    #[test]
182    fn vi_mock_matches() {
183        assert!(matches_callee("vi.mock('./mod')", "typescript"));
184    }
185
186    #[test]
187    fn console_log_does_not_match() {
188        assert!(!matches_callee("console.log(x)", "typescript"));
189    }
190
191    #[test]
192    fn use_effect_does_not_match() {
193        assert!(!matches_callee("useEffect(fn, [])", "typescript"));
194    }
195
196    #[test]
197    fn t_run_matches_go() {
198        assert!(matches_callee("t.Run(\"sub\", fn)", "go"));
199    }
200
201    #[test]
202    fn t_fatal_matches_go() {
203        assert!(matches_callee("t.Fatal(err)", "go"));
204    }
205
206    #[test]
207    fn t_parallel_matches_go() {
208        assert!(matches_callee("t.Parallel()", "go"));
209    }
210
211    #[test]
212    fn go_st_run_does_not_match() {
213        // `st.Run(...)` — `t` preceded by `s` (word char); `\bt\.` does not match.
214        assert!(!matches_callee("st.Run(\"sub\", fn)", "go"));
215    }
216
217    #[test]
218    fn self_assert_equal_matches() {
219        assert!(matches_callee("self.assertEqual(a, b)", "python"));
220    }
221
222    #[test]
223    fn self_assert_true_matches() {
224        assert!(matches_callee("self.assertTrue(x)", "python"));
225    }
226
227    #[test]
228    fn self_method_does_not_match() {
229        assert!(!matches_callee("self.method()", "python"));
230    }
231
232    #[test]
233    fn self_assert_snake_does_not_match() {
234        assert!(!matches_callee("self.assert_something()", "python"));
235    }
236
237    #[test]
238    fn rust_returns_false() {
239        assert!(!matches_callee("describe('s', fn)", "rust"));
240    }
241
242    #[test]
243    fn node_kinds_is_empty() {
244        // NODE_KINDS is intentionally empty: this category is callee-only (classified
245        // via classify_hint). The assertion guards that contract against regressions.
246        #[allow(clippy::const_is_empty)]
247        let empty = NODE_KINDS.is_empty();
248        assert!(empty);
249    }
250}