Skip to main content

mana_core/
verify_lint.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4/// Severity of a verify command lint finding.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum VerifyLintLevel {
7    Error,
8    Warning,
9}
10
11/// A lint finding for a verify command.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VerifyLintResult {
14    pub level: VerifyLintLevel,
15    pub message: String,
16}
17
18impl VerifyLintResult {
19    fn error(message: impl Into<String>) -> Self {
20        Self {
21            level: VerifyLintLevel::Error,
22            message: message.into(),
23        }
24    }
25
26    fn warning(message: impl Into<String>) -> Self {
27        Self {
28            level: VerifyLintLevel::Warning,
29            message: message.into(),
30        }
31    }
32}
33
34/// Lint a verify command for known anti-patterns.
35#[must_use]
36pub fn lint_verify(cmd: &str) -> Vec<VerifyLintResult> {
37    let trimmed = cmd.trim();
38    if trimmed.is_empty() {
39        return vec![VerifyLintResult::error(
40            "Verify command is empty. Use a command that can fail when the unit is incomplete.",
41        )];
42    }
43
44    let segments: Vec<&str> = shell_segment_splitter()
45        .split(trimmed)
46        .map(str::trim)
47        .filter(|segment| !segment.is_empty())
48        .collect();
49    let meaningful_segments: Vec<&str> = segments
50        .iter()
51        .copied()
52        .filter(|segment| !is_setup_segment(segment))
53        .collect();
54    let target_segment = meaningful_segments
55        .last()
56        .copied()
57        .or_else(|| segments.last().copied())
58        .unwrap_or(trimmed);
59
60    let mut findings = Vec::new();
61
62    if meaningful_segments.len() <= 1 && is_always_pass_command(target_segment) {
63        findings.push(VerifyLintResult::error(
64            "Verify command always exits successfully (`true`, `echo ...`, or `exit 0`). Replace it with a focused check that can fail.",
65        ));
66    }
67
68    if is_bare_cargo_test(target_segment) {
69        findings.push(VerifyLintResult::warning(
70            "Bare `cargo test` is too broad and may pass without proving this unit. Prefer a focused filter like `cargo test auth::login`.",
71        ));
72    }
73
74    if is_npm_test_without_filter(target_segment) {
75        findings.push(VerifyLintResult::warning(
76            "Bare `npm test` is too broad. Prefer `npm test -- --grep login` or `npm test -- -t login`.",
77        ));
78    }
79
80    if is_pytest_without_filter(target_segment) {
81        findings.push(VerifyLintResult::warning(
82            "Bare `pytest` is too broad. Prefer `pytest -k login` and pair it with a grep existence check.",
83        ));
84    }
85
86    if is_go_test_without_run(target_segment) {
87        findings.push(VerifyLintResult::warning(
88            "Bare `go test` without `-run` is too broad. Prefer `go test ./... -run TestLogin`.",
89        ));
90    }
91
92    if cargo_test_filter_arg(target_segment).is_some() && !contains_grep(trimmed) {
93        findings.push(VerifyLintResult::warning(
94            "`cargo test <filter>` exits 0 when the filter matches no tests. Prepend an existence check like `grep -rq '<filter>' tests && cargo test <filter>`.",
95        ));
96    }
97
98    if has_pytest_k_filter(target_segment) && !contains_grep(trimmed) {
99        findings.push(VerifyLintResult::warning(
100            "`pytest -k <filter>` exits 0 when the filter matches no tests. Prepend an existence check like `grep -rq 'test_login' tests && pytest -k login`.",
101        ));
102    }
103
104    if is_existence_only_check(trimmed) {
105        findings.push(VerifyLintResult::warning(
106            "A file existence check does not verify correctness. Chain it with a real assertion, for example `test -f file && grep -q 'expected text' file`.",
107        ));
108    }
109
110    findings
111}
112
113fn shell_segment_splitter() -> &'static Regex {
114    static SPLITTER: OnceLock<Regex> = OnceLock::new();
115    SPLITTER
116        .get_or_init(|| Regex::new(r"\s*(?:&&|\|\||;|\n)\s*").expect("valid shell splitter regex"))
117}
118
119fn is_setup_segment(segment: &str) -> bool {
120    let trimmed = segment.trim();
121    trimmed == "cd" || trimmed.starts_with("cd ")
122}
123
124fn is_always_pass_command(segment: &str) -> bool {
125    let trimmed = segment.trim();
126    trimmed == "true" || trimmed == "exit 0" || trimmed == "echo" || trimmed.starts_with("echo ")
127}
128
129fn tokens(segment: &str) -> Vec<&str> {
130    segment.split_whitespace().collect()
131}
132
133fn is_bare_cargo_test(segment: &str) -> bool {
134    matches!(tokens(segment).as_slice(), ["cargo", "test"])
135}
136
137fn is_npm_test_without_filter(segment: &str) -> bool {
138    let tokens = tokens(segment);
139    tokens.len() >= 2
140        && tokens[0] == "npm"
141        && tokens[1] == "test"
142        && !segment.contains("-- --grep")
143        && !segment.contains("-- -t")
144}
145
146fn is_pytest_without_filter(segment: &str) -> bool {
147    let tokens = tokens(segment);
148    tokens.first() == Some(&"pytest") && !has_pytest_k_filter(segment)
149}
150
151fn is_go_test_without_run(segment: &str) -> bool {
152    let tokens = tokens(segment);
153    tokens.len() >= 2
154        && tokens[0] == "go"
155        && tokens[1] == "test"
156        && !tokens
157            .iter()
158            .any(|token| *token == "-run" || token.starts_with("-run="))
159}
160
161fn cargo_test_filter_arg(segment: &str) -> Option<&str> {
162    let tokens = tokens(segment);
163    if tokens.first() != Some(&"cargo") || tokens.get(1) != Some(&"test") {
164        return None;
165    }
166
167    let mut skip_next = false;
168    for token in tokens.iter().skip(2) {
169        if skip_next {
170            skip_next = false;
171            continue;
172        }
173
174        if *token == "--" {
175            break;
176        }
177
178        if takes_cargo_test_value(token) {
179            skip_next = true;
180            continue;
181        }
182
183        if has_inline_cargo_test_value(token) || token.starts_with('-') {
184            continue;
185        }
186
187        return Some(token);
188    }
189
190    None
191}
192
193fn takes_cargo_test_value(token: &str) -> bool {
194    matches!(
195        token,
196        "-p" | "--package"
197            | "--manifest-path"
198            | "--message-format"
199            | "--target"
200            | "--target-dir"
201            | "--color"
202            | "-j"
203            | "--jobs"
204            | "--profile"
205            | "--config"
206            | "--test"
207            | "--bench"
208            | "--example"
209            | "--bin"
210            | "--features"
211    )
212}
213
214fn has_inline_cargo_test_value(token: &str) -> bool {
215    [
216        "--package=",
217        "--manifest-path=",
218        "--message-format=",
219        "--target=",
220        "--target-dir=",
221        "--color=",
222        "--jobs=",
223        "--profile=",
224        "--config=",
225        "--test=",
226        "--bench=",
227        "--example=",
228        "--bin=",
229        "--features=",
230    ]
231    .iter()
232    .any(|prefix| token.starts_with(prefix))
233}
234
235fn has_pytest_k_filter(segment: &str) -> bool {
236    let tokens = tokens(segment);
237    tokens.windows(2).any(|window| matches!(window, ["-k", _]))
238        || tokens.iter().any(|token| token.starts_with("-k="))
239}
240
241fn contains_grep(command: &str) -> bool {
242    command.split_whitespace().any(|token| token == "grep")
243}
244
245fn is_existence_only_check(command: &str) -> bool {
246    let tokens = tokens(command);
247    matches!(tokens.as_slice(), ["test", "-f", _] | ["[", "-f", _, "]"])
248}
249
250#[cfg(test)]
251mod tests {
252    use super::{lint_verify, VerifyLintLevel};
253
254    #[test]
255    fn verify_lint_rejects_empty_commands() {
256        let findings = lint_verify("   ");
257        assert_eq!(findings.len(), 1);
258        assert_eq!(findings[0].level, VerifyLintLevel::Error);
259    }
260
261    #[test]
262    fn verify_lint_rejects_always_pass_commands() {
263        let findings = lint_verify("cd mana && exit 0");
264        assert!(findings.iter().any(|finding| {
265            finding.level == VerifyLintLevel::Error && finding.message.contains("always exits")
266        }));
267    }
268
269    #[test]
270    fn verify_lint_warns_on_bare_test_runners() {
271        let cargo = lint_verify("cargo test");
272        assert!(cargo.iter().any(|finding| {
273            finding.level == VerifyLintLevel::Warning && finding.message.contains("cargo test")
274        }));
275
276        let pytest = lint_verify("pytest");
277        assert!(pytest.iter().any(|finding| {
278            finding.level == VerifyLintLevel::Warning && finding.message.contains("pytest")
279        }));
280
281        let go = lint_verify("go test ./...");
282        assert!(go.iter().any(|finding| {
283            finding.level == VerifyLintLevel::Warning && finding.message.contains("go test")
284        }));
285    }
286
287    #[test]
288    fn verify_lint_warns_on_filtered_commands_without_grep() {
289        let cargo = lint_verify("cargo test create::tests::lint");
290        assert!(cargo.iter().any(|finding| {
291            finding.level == VerifyLintLevel::Warning
292                && finding.message.contains("matches no tests")
293        }));
294
295        let pytest = lint_verify("pytest -k login");
296        assert!(pytest.iter().any(|finding| {
297            finding.level == VerifyLintLevel::Warning
298                && finding.message.contains("matches no tests")
299        }));
300    }
301
302    #[test]
303    fn verify_lint_accepts_filtered_commands_with_grep_guard() {
304        let findings = lint_verify("grep -rq 'test_login' tests && pytest -k login");
305        assert!(findings.is_empty());
306    }
307
308    #[test]
309    fn verify_lint_warns_on_existence_only_checks() {
310        let findings = lint_verify("test -f README.md");
311        assert!(findings.iter().any(|finding| {
312            finding.level == VerifyLintLevel::Warning && finding.message.contains("existence check")
313        }));
314    }
315}