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}