Skip to main content

amont_runtime/hooks/
ban_terms.rs

1//! pre-commit-ban-terms — refuse focused/debug leftovers in staged JS/TS.
2//!
3//! Two stages, kept exactly as the JS had them:
4//!   1. `git diff --cached -G<loose>` picks candidate files cheaply, and keeps
5//!      the check scoped to what this commit touches — a pre-existing violation
6//!      in an untouched part of an edited file is not this commit's problem.
7//!   2. Each candidate is re-checked against its STAGED content with comments
8//!      and string literals blanked, which is where correctness lives.
9//!
10//! Stage 1 stays deliberately loose (POSIX regex, flavour varies by platform);
11//! a loose prefilter costs one extra file read, a strict one misses violations.
12
13use crate::check::Outcome;
14use crate::git;
15use crate::ui::{error_sign, highlight, valid_sign};
16
17/// (label, loose `git diff -G` prefilter, precise matcher)
18struct Term {
19    label: &'static str,
20    prefilter: &'static str,
21    matches: fn(&str) -> bool,
22}
23
24fn is_ident(c: char) -> bool {
25    c.is_alphanumeric() || c == '_' || c == '$'
26}
27
28/// `(?<![\w.$])<word>` — not preceded by an identifier char, a dot or a `$`.
29/// The dot is what keeps `foo.fit(` out; without it `layout.fit(` is a false
30/// positive.
31fn preceded_ok(src: &str, at: usize) -> bool {
32    src[..at]
33        .chars()
34        .next_back()
35        .map(|c| !(is_ident(c) || c == '.'))
36        .unwrap_or(true)
37}
38
39/// `<word>\s*\(` — the call form.
40fn call_of(src: &str, word: &str) -> bool {
41    let mut from = 0;
42    while let Some(i) = src[from..].find(word) {
43        let at = from + i;
44        let after = at + word.len();
45        if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
46            return true;
47        }
48        from = at + word.len();
49    }
50    false
51}
52
53/// `(?<![\w.$])debugger(?![\w$])` — the bare statement, so `debuggerish` and
54/// `x.debugger` both pass.
55fn bare_debugger(src: &str) -> bool {
56    let word = "debugger";
57    let mut from = 0;
58    while let Some(i) = src[from..].find(word) {
59        let at = from + i;
60        let after = at + word.len();
61        let next_ok = src[after..]
62            .chars()
63            .next()
64            .map(|c| !is_ident(c))
65            .unwrap_or(true);
66        if preceded_ok(src, at) && next_ok {
67            return true;
68        }
69        from = at + word.len();
70    }
71    false
72}
73
74/// `(?<![\w$])(describe|context|it)\.(skip|only)(?![\w$])`
75///
76/// The trailing guard is the whole point: `describe.skipIf(...)` is vitest's
77/// legitimate conditional API and must pass, while `describe.skip` must not.
78/// Note the LEADING guard here excludes only identifier chars, not `.` — that
79/// matches the JS, so `foo.describe.skip` is still caught.
80fn focused_suite(src: &str) -> bool {
81    for head in ["describe", "context", "it"] {
82        for tail in ["skip", "only"] {
83            let needle = format!("{head}.{tail}");
84            let mut from = 0;
85            while let Some(i) = src[from..].find(&needle) {
86                let at = from + i;
87                let after = at + needle.len();
88                let before_ok = src[..at]
89                    .chars()
90                    .next_back()
91                    .map(|c| !is_ident(c))
92                    .unwrap_or(true);
93                let after_ok = src[after..]
94                    .chars()
95                    .next()
96                    .map(|c| !is_ident(c))
97                    .unwrap_or(true);
98                if before_ok && after_ok {
99                    return true;
100                }
101                from = at + needle.len();
102            }
103        }
104    }
105    false
106}
107
108const TERMS: [Term; 4] = [
109    Term {
110        label: "fit",
111        prefilter: r"\s*fit\(",
112        matches: |s| call_of(s, "fit"),
113    },
114    Term {
115        label: "fdescribe",
116        prefilter: r"\s*fdescribe\(",
117        matches: |s| call_of(s, "fdescribe"),
118    },
119    Term {
120        label: "debugger",
121        prefilter: "debugger;?",
122        matches: bare_debugger,
123    },
124    Term {
125        label: "skipOnly",
126        prefilter: r"(describe|context|it)\.(skip|only)",
127        matches: focused_suite,
128    },
129];
130
131#[derive(Clone, Copy, PartialEq)]
132enum S {
133    Code,
134    Line,
135    Block,
136    Single,
137    Double,
138    Template,
139    Regex,
140}
141
142/// Keywords after which a `/` begins a regex, not a division.
143const REGEX_KEYWORDS: [&str; 13] = [
144    "return",
145    "typeof",
146    "case",
147    "in",
148    "of",
149    "delete",
150    "void",
151    "instanceof",
152    "new",
153    "do",
154    "else",
155    "yield",
156    "await",
157];
158
159/// Can a `/` here start a regex? Decided from the last significant code
160/// character, and the word it belongs to when it is one.
161fn regex_can_start(prev: Option<char>, word: &str) -> bool {
162    match prev {
163        // start of input
164        None => true,
165        Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
166        // an identifier or number ends a value → `/` divides it …
167        Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
168        // … as do `)` and `]`; `}` is ambiguous and treated as allowing a
169        // regex, since a false alarm is the worse error (see the type doc).
170        Some(_) => false,
171    }
172}
173
174/// Blank comments and the insides of string/template/REGEX literals, preserving
175/// length and line count so offsets still line up — blanked, not deleted, for
176/// that reason.
177///
178/// Now handles regex literals. It previously did not, and `/a\\/b/` read as a
179/// line comment: everything after it on that line was blanked, so a real
180/// `debugger;` sharing the line went unreported. Missed warnings rather than
181/// false alarms, but missed all the same.
182///
183/// Telling a regex from division needs the preceding token, which is why this
184/// is a tokenizer and not a set of rules over characters. The two ways to be
185/// wrong are NOT symmetric:
186///   - division mistaken for a regex → blanks to the next `/`, so a violation
187///     there is MISSED. Safe direction.
188///   - a regex mistaken for division → its contents are scanned as code, so
189///     `/it\\.only/` would be reported as a violation that does not exist.
190///     A false alarm blocking a commit — the direction to avoid, and why the
191///     "regex allowed" set below is generous.
192pub fn blank_non_code(src: &str) -> String {
193    let b: Vec<char> = src.chars().collect();
194    let mut out = String::with_capacity(src.len());
195    let mut state = S::Code;
196    let mut i = 0;
197    // The token before the current position, for the regex-vs-division call.
198    let mut prev_significant: Option<char> = None;
199    let mut word = String::new();
200    let mut in_class = false;
201    // Open `${…}` substitutions, innermost last; each entry counts the `{` we
202    // are nested under, so the matching `}` returns us to template text.
203    // A stack, not a flag: substitutions nest — `` `${`${x}`}` ``.
204    let mut subst: Vec<u32> = Vec::new();
205    let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
206
207    while i < b.len() {
208        let ch = b[i];
209        let next = b.get(i + 1).copied();
210        match state {
211            S::Code => {
212                if ch == '/' && next == Some('/') {
213                    state = S::Line;
214                    out.push_str("  ");
215                    i += 2;
216                } else if ch == '/' && next == Some('*') {
217                    state = S::Block;
218                    out.push_str("  ");
219                    i += 2;
220                } else if ch == '/' && regex_can_start(prev_significant, &word) {
221                    state = S::Regex;
222                    in_class = false;
223                    out.push(ch);
224                    i += 1;
225                } else if ch == '\'' || ch == '"' || ch == '`' {
226                    state = match ch {
227                        '\'' => S::Single,
228                        '"' => S::Double,
229                        _ => S::Template,
230                    };
231                    out.push(ch);
232                    i += 1;
233                } else {
234                    if !subst.is_empty() {
235                        if ch == '{' {
236                            *subst.last_mut().expect("non-empty") += 1;
237                        } else if ch == '}' {
238                            let depth = subst.last_mut().expect("non-empty");
239                            if *depth == 0 {
240                                subst.pop();
241                                state = S::Template;
242                                out.push(ch);
243                                i += 1;
244                                continue;
245                            }
246                            *depth -= 1;
247                        }
248                    }
249                    if !ch.is_whitespace() {
250                        prev_significant = Some(ch);
251                        if ch.is_alphanumeric() || ch == '_' || ch == '$' {
252                            word.push(ch);
253                        } else {
254                            word.clear();
255                        }
256                    }
257                    out.push(ch);
258                    i += 1;
259                }
260            }
261            S::Regex => {
262                // `\` escapes anything; `[…]` is a class in which `/` is literal.
263                if ch == '\\' {
264                    out.push_str(if next.is_none() { " " } else { "  " });
265                    i += 2;
266                    continue;
267                }
268                if ch == '[' {
269                    in_class = true;
270                } else if ch == ']' {
271                    in_class = false;
272                } else if ch == '/' && !in_class {
273                    state = S::Code;
274                    prev_significant = Some('/');
275                    word.clear();
276                    out.push(ch);
277                    i += 1;
278                    continue;
279                } else if ch == '\n' {
280                    // An unterminated regex cannot span lines — bail back to
281                    // code rather than blanking the rest of the file.
282                    state = S::Code;
283                }
284                out.push(keep(ch));
285                i += 1;
286            }
287            S::Line => {
288                if ch == '\n' {
289                    state = S::Code;
290                    out.push(ch);
291                } else {
292                    out.push(' ');
293                }
294                i += 1;
295            }
296            S::Block => {
297                if ch == '*' && next == Some('/') {
298                    state = S::Code;
299                    out.push_str("  ");
300                    i += 2;
301                } else {
302                    out.push(keep(ch));
303                    i += 1;
304                }
305            }
306            S::Template => {
307                if ch == '\\' {
308                    out.push_str(if next.is_none() { " " } else { "  " });
309                    i += 2;
310                    continue;
311                }
312                // `${…}` is CODE. Blanking it hid every banned call written
313                // inside a substitution — `` `${it.only(x)}` `` read as string
314                // content. That is a MISSED warning, which is the direction
315                // this hook must never fail in.
316                if ch == '$' && next == Some('{') {
317                    subst.push(0);
318                    state = S::Code;
319                    prev_significant = Some('{');
320                    word.clear();
321                    out.push_str("${");
322                    i += 2;
323                    continue;
324                }
325                if ch == '`' {
326                    state = S::Code;
327                    prev_significant = Some('`');
328                    word.clear();
329                    out.push(ch);
330                    i += 1;
331                    continue;
332                }
333                out.push(keep(ch));
334                i += 1;
335            }
336            _ => {
337                if ch == '\\' {
338                    // Blank the escape AND what it escapes, so \" never closes.
339                    out.push_str(if next.is_none() { " " } else { "  " });
340                    i += 2;
341                    continue;
342                }
343                let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
344                if closes {
345                    state = S::Code;
346                    out.push(ch);
347                } else {
348                    out.push(keep(ch));
349                }
350                i += 1;
351            }
352        }
353    }
354    out
355}
356
357fn is_searchable(file: &str) -> bool {
358    let f = file.rsplit('/').next().unwrap_or(file);
359    [".js", ".jsx", ".ts", ".tsx", ".vue"]
360        .iter()
361        .any(|e| f.ends_with(e))
362}
363
364pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
365    // This file necessarily NAMES every term it bans, so it must never flag
366    // itself. Compare on the file STEM against the hook name we were invoked
367    // as: the hook is checked from two layouts — installed at .git/hooks/<name>
368    // and as source at templates/hooks/<name> — and a path-relative comparison
369    // never matched from the second, which once made this very file
370    // uncommittable.
371    //
372    // NOT argv[0]: that is now the `amont` binary, so deriving the name from
373    // it excluded nothing and the hook flagged its own source. Caught by the
374    // existing suite.
375    let stem_matches_self = |file: &str| {
376        let base = file.rsplit('/').next().unwrap_or(file);
377        let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
378        stem == hook_name
379    };
380
381    let mut found_any = false;
382    for term in &TERMS {
383        let arg = format!("-G{}", term.prefilter);
384        let Some(out) =
385            git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
386        else {
387            continue;
388        };
389        let matches: Vec<&str> = out
390            .iter()
391            .map(String::as_str)
392            .filter(|f| is_searchable(f))
393            .filter(|f| !stem_matches_self(f))
394            .filter(|file| {
395                match git::stdout(&["show", &format!(":{file}")]) {
396                    // Unreadable (binary, or vanished between the two git
397                    // calls): keep the prefilter's verdict rather than
398                    // silently clearing it.
399                    None => true,
400                    Some(content) => (term.matches)(&blank_non_code(&content)),
401                }
402            })
403            .collect();
404
405        if !matches.is_empty() {
406            if !found_any {
407                crate::say!("  {} Unwanted terms found", error_sign().trim());
408            }
409            found_any = true;
410            crate::say!(
411                "    The following files contains '{}' in them:",
412                highlight(term.label)
413            );
414            for m in matches {
415                crate::say!("    - {}", highlight(m));
416            }
417        }
418    }
419    if found_any {
420        return Outcome::Failed;
421    }
422    crate::say!("  {} No unwanted terms were found", valid_sign().trim());
423    Outcome::Passed
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn catches_the_banned_forms() {
432        assert!(call_of("fit('x', () => {})", "fit"));
433        assert!(call_of("  fit (", "fit"));
434        assert!(call_of("fdescribe('x')", "fdescribe"));
435        assert!(bare_debugger("  debugger;"));
436        assert!(bare_debugger("debugger"));
437        assert!(focused_suite("describe.skip('x')"));
438        assert!(focused_suite("it.only('x')"));
439        assert!(focused_suite("context.skip('x')"));
440    }
441
442    /// The false positives that forced the two-stage design.
443    #[test]
444    fn leaves_lookalikes_alone() {
445        assert!(!call_of("profit(", "fit")); // preceded by a word char
446        assert!(!call_of("layout.fit(", "fit")); // preceded by a dot
447        assert!(!bare_debugger("debuggerish")); // trailing guard
448        assert!(!bare_debugger("x.debugger")); // preceded by a dot
449        assert!(!focused_suite("describe.skipIf(cond)")); // vitest's real API
450        assert!(!focused_suite("it.onlyWhen(x)"));
451    }
452
453    #[test]
454    fn blanks_comments_and_strings_keeping_layout() {
455        let src = "a\n// debugger;\nb";
456        let out = blank_non_code(src);
457        assert_eq!(out.len(), src.len(), "length must be preserved");
458        assert_eq!(out.lines().count(), src.lines().count());
459        assert!(!bare_debugger(&out), "a term in a comment is discussion");
460
461        assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
462        assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
463        assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
464    }
465
466    #[test]
467    fn an_escape_never_closes_a_string() {
468        // If \" closed the run, the trailing code would be scanned as code.
469        let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
470        assert!(
471            bare_debugger(&out),
472            "real code after the string must survive"
473        );
474        assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
475    }
476
477    /// The bug the tokenizer exists for, with the input that ACTUALLY triggers
478    /// it: an escaped slash immediately before the closing one puts two `/`
479    /// characters next to each other in the stream, which the old blanker read
480    /// as a line comment — blanking the rest of the line, so the real
481    /// `debugger;` after it went unreported.
482    ///
483    /// `/a\/b/` does NOT trigger it (the slashes are not adjacent); my first
484    /// version of this test used that and passed against both implementations,
485    /// proving nothing. Differentially confirmed: old exits 0 on these, new
486    /// exits 1.
487    #[test]
488    fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
489        for src in [
490            r"const re = /a\//; debugger;",
491            r"const re = /\//; debugger;",
492        ] {
493            assert!(
494                bare_debugger(&blank_non_code(src)),
495                "code after the regex must still be scanned: {src}"
496            );
497        }
498        // and the same shape with nothing to find must stay quiet
499        assert!(!bare_debugger(&blank_non_code(
500            r"const re = /a\//; const ok = 1;"
501        )));
502    }
503
504    /// The dangerous direction: a regex mistaken for division would have its
505    /// contents scanned, reporting a violation that does not exist.
506    #[test]
507    fn terms_inside_a_regex_literal_are_not_violations() {
508        for src in [
509            r"const re = /it\.only/;",
510            r"if (x) { const r = /debugger/; }",
511            r"foo(/fdescribe\(/);",
512            r"return /describe\.skip/;",
513            r"const r = /[/]debugger/;", // `/` inside a class does not end it
514        ] {
515            let b = blank_non_code(src);
516            assert!(!bare_debugger(&b), "false alarm: {src}");
517            assert!(!focused_suite(&b), "false alarm: {src}");
518            assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
519        }
520    }
521
522    /// Division must stay division, or the blanker eats real code.
523    #[test]
524    fn division_is_not_treated_as_a_regex() {
525        let src = "const x = a / b; debugger;";
526        assert!(bare_debugger(&blank_non_code(src)));
527        let src2 = "const x = (a + b) / c; debugger;";
528        assert!(bare_debugger(&blank_non_code(src2)));
529    }
530
531    #[test]
532    fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
533        let src = "const r = /oops
534debugger;";
535        assert!(bare_debugger(&blank_non_code(src)));
536    }
537
538    #[test]
539    fn blanking_still_preserves_length_and_lines() {
540        let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
541        let out = blank_non_code(src);
542        assert_eq!(out.len(), src.len());
543        assert_eq!(out.lines().count(), src.lines().count());
544    }
545
546    #[test]
547    fn only_js_like_files_are_searched() {
548        for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
549            assert!(is_searchable(f), "{f}");
550        }
551        for f in ["a.rs", "a.md", "a.json", "README"] {
552            assert!(!is_searchable(f), "{f}");
553        }
554    }
555}
556
557#[cfg(test)]
558mod template_substitutions {
559    use super::*;
560
561    /// `${…}` inside a template literal is CODE, not string content. Blanking
562    /// it hid every banned call written in a substitution — a MISSED warning,
563    /// which is the direction this hook must never fail in.
564    #[test]
565    fn a_substitution_is_code() {
566        let b = blank_non_code("const s = `${fit(1)}`;");
567        assert!(call_of(&b, "fit"), "blanked to {b:?}");
568    }
569
570    /// A stack, not a flag: substitutions nest. Before the fix this case
571    /// reported correctly by ACCIDENT — the scanner lost track and left the
572    /// inner text unblanked, which happened to expose the call.
573    #[test]
574    fn substitutions_nest() {
575        let b = blank_non_code("const s = `${`${fit(1)}`}`;");
576        assert!(call_of(&b, "fit"), "blanked to {b:?}");
577        assert!(
578            b.contains("${`${fit(1)}`}"),
579            "nesting must be tracked, not merely survived: {b:?}"
580        );
581    }
582
583    /// Braces INSIDE the substitution must not close it early.
584    #[test]
585    fn braces_inside_a_substitution_do_not_close_it() {
586        let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
587        assert!(
588            !call_of(&b, "fit"),
589            "the string literal must stay blanked: {b:?}"
590        );
591        let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
592        assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
593
594        // The discriminating case for DEPTH COUNTING. Treating the first `}` as
595        // the end of the substitution puts the rest of it back into template
596        // text, so the call after the object literal is blanked and missed.
597        // Popping unconditionally passes every other case here.
598        let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
599        assert!(
600            call_of(&b3, "fit"),
601            "a `}}` closing a nested object must not end the substitution: {b3:?}"
602        );
603    }
604
605    /// Template TEXT is still string content, and an escaped `\${` is text too.
606    #[test]
607    fn template_text_is_still_blanked() {
608        assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
609        assert!(
610            !call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
611            "an escaped dollar does not open a substitution"
612        );
613    }
614
615    /// Comments, strings and regexes inside a substitution are handled by the
616    /// ordinary code path, so they must still be blanked there.
617    #[test]
618    fn nested_constructs_inside_a_substitution() {
619        assert!(!call_of(
620            &blank_non_code("const s = `${/* fit(1) */ x}`;"),
621            "fit"
622        ));
623        assert!(!call_of(
624            &blank_non_code(r#"const s = `${"fit("}`;"#),
625            "fit"
626        ));
627        assert!(!call_of(
628            &blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
629            "fit"
630        ));
631    }
632
633    /// An unbalanced `}` in ordinary code must not be mistaken for the end of a
634    /// substitution that was never opened.
635    #[test]
636    fn a_stray_brace_in_code_is_harmless() {
637        let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
638        assert!(call_of(&b, "fit"), "blanked to {b:?}");
639    }
640}