Skip to main content

dejavu/exec/
classify.rs

1//! Command classification and passthrough policy (spec §11).
2//!
3//! Default is passthrough. A command is optimized only when the shim is known,
4//! the family is supported, the args match a whitelist, it is not interactive,
5//! Dejavu is not disabled, and config allows it. Anything ambiguous — in
6//! particular any git subcommand not provably read-only — passes through.
7
8use super::command_key::{build_key, command_original, drop_noise};
9use super::interactive::{has_watch_flag, is_known_interactive};
10use super::{ExecMode, Family, PassthroughReason};
11use crate::config::Config;
12
13use ExecMode::{Optimize, Passthrough};
14use PassthroughReason as R;
15
16pub struct Classified {
17    pub mode: ExecMode,
18    pub command_original: String,
19}
20
21/// Read-only git subcommands that may be optimized.
22const GIT_READONLY: &[&str] = &["status", "diff", "log", "show"];
23/// Git subcommands that must always pass through (spec §10.7).
24const GIT_MUTATING: &[&str] = &[
25    "add",
26    "am",
27    "apply",
28    "branch",
29    "checkout",
30    "cherry-pick",
31    "clean",
32    "clone",
33    "commit",
34    "fetch",
35    "merge",
36    "mv",
37    "pull",
38    "push",
39    "rebase",
40    "reset",
41    "restore",
42    "revert",
43    "rm",
44    "stash",
45    "switch",
46    "tag",
47    "worktree",
48];
49/// JS package-manager scripts we optimize.
50const JS_SCRIPTS: &[&str] = &["test", "lint", "typecheck", "build"];
51/// Script-name prefixes we also optimize (`test:unit`, `lint:js`,
52/// `build:prod`, `typecheck:strict`, …).
53const JS_SCRIPT_PREFIXES: &[&str] = &["test:", "lint:", "build:", "typecheck:"];
54/// `find` primaries that cause side effects.
55const FIND_DANGEROUS: &[&str] = &[
56    "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fprint", "-fprintf", "-fls", "-fprint0",
57];
58
59pub fn classify(
60    shim: &str,
61    args: &[String],
62    cfg: &Config,
63    disabled: bool,
64    repo_disabled: bool,
65    _stdin_is_tty: bool,
66) -> Classified {
67    let command_original = command_original(shim, args);
68    let wrap = |mode| Classified {
69        mode,
70        command_original: command_original.clone(),
71    };
72
73    if disabled {
74        return wrap(Passthrough(R::Disabled));
75    }
76    if repo_disabled {
77        return wrap(Passthrough(R::RepoDisabled));
78    }
79    if !cfg.intercept.is_enabled(shim) {
80        return wrap(Passthrough(R::ConfigExcluded));
81    }
82
83    let mode = match shim {
84        "npm" | "pnpm" | "yarn" | "bun" => classify_js(shim, args),
85        "tsc" => classify_tsc(args),
86        "eslint" => classify_eslint(args),
87        "vitest" => classify_vitest(args),
88        "jest" => classify_jest(args),
89        "pytest" => classify_pytest(args),
90        "cargo" => classify_cargo(args),
91        "go" => classify_go(args),
92        "rg" | "grep" => classify_search(shim, args),
93        "find" => classify_find(args),
94        "ls" | "tree" => classify_listing(shim, args),
95        "git" => classify_git(args),
96        "docker" => classify_docker(args),
97        // User-elected `[intercept] extra` commands: generic validation
98        // treatment (builtins above always take precedence).
99        _ if cfg.intercept.is_extra(shim) => classify_extra(shim, args),
100        _ => Passthrough(R::UnknownShim),
101    };
102    wrap(mode)
103}
104
105/// A user-added command from `[intercept] extra`. Reduced generically as a
106/// validation-style command; the standard guards still apply (watch-mode
107/// passthrough here, plus the min-token floor and agent gating downstream).
108fn classify_extra(shim: &str, args: &[String]) -> ExecMode {
109    if has_watch_flag(args) || is_known_interactive(shim, first_positional(args).map(|(_, s)| s)) {
110        return Passthrough(R::Interactive);
111    }
112    Optimize {
113        family: Family::Validation,
114        command_key: build_key(&format!("validation:{shim}"), &drop_noise(args)),
115    }
116}
117
118/// First token not starting with `-` (the subcommand), with its index.
119fn first_positional(args: &[String]) -> Option<(usize, &str)> {
120    args.iter()
121        .enumerate()
122        .find(|(_, a)| !a.starts_with('-'))
123        .map(|(i, a)| (i, a.as_str()))
124}
125
126fn classify_js(shim: &str, args: &[String]) -> ExecMode {
127    if has_watch_flag(args) {
128        return Passthrough(R::Interactive);
129    }
130    let Some((idx, sub)) = first_positional(args) else {
131        return Passthrough(R::UnsupportedSubcommand);
132    };
133
134    let (script, key_tokens): (&str, Vec<String>) = if sub == "run" {
135        // `pnpm run <script>` — the script is the next positional.
136        match args[idx + 1..].iter().find(|a| !a.starts_with('-')) {
137            Some(s) => (s.as_str(), vec!["run".to_string(), s.clone()]),
138            None => return Passthrough(R::UnsupportedSubcommand),
139        }
140    } else {
141        (sub, vec![sub.to_string()])
142    };
143
144    if is_known_interactive(shim, Some(script)) {
145        return Passthrough(R::Interactive);
146    }
147    if js_script_whitelisted(script) {
148        Optimize {
149            family: Family::Validation,
150            command_key: build_key(&format!("validation:{shim}"), &key_tokens),
151        }
152    } else {
153        Passthrough(R::UnsupportedSubcommand)
154    }
155}
156
157/// Exact whitelist names, plus `test:*`/`lint:*`/`build:*`/`typecheck:*`
158/// variants — unless a `:`-segment names a watch/serve/mutating flavor
159/// (`test:watch`, `lint:fix`, `build:dev` stay passthrough; `test:fixtures`
160/// is fine).
161fn js_script_whitelisted(script: &str) -> bool {
162    if JS_SCRIPTS.contains(&script) {
163        return true;
164    }
165    JS_SCRIPT_PREFIXES.iter().any(|p| script.starts_with(p))
166        && !script.split(':').any(|seg| {
167            seg == "fix"
168                || seg == "dev"
169                || seg == "serve"
170                || seg == "start"
171                || seg.starts_with("watch")
172        })
173}
174
175fn classify_tsc(args: &[String]) -> ExecMode {
176    if has_watch_flag(args) || args.iter().any(|a| a == "-w" || a == "--watch") {
177        return Passthrough(R::Interactive);
178    }
179    Optimize {
180        family: Family::Validation,
181        command_key: build_key("validation:tsc", &drop_noise(args)),
182    }
183}
184
185fn classify_eslint(args: &[String]) -> ExecMode {
186    if args.iter().any(|a| a == "--fix" || a == "--fix-dry-run") {
187        return Passthrough(R::SideEffecting);
188    }
189    if has_watch_flag(args) {
190        return Passthrough(R::Interactive);
191    }
192    Optimize {
193        family: Family::Validation,
194        command_key: build_key("validation:eslint", &drop_noise(args)),
195    }
196}
197
198fn classify_pytest(args: &[String]) -> ExecMode {
199    Optimize {
200        family: Family::Validation,
201        command_key: build_key("validation:pytest", &drop_noise(args)),
202    }
203}
204
205/// `vitest` defaults to watch mode in a dev terminal, so only the explicit
206/// single-run forms (`vitest run …`, `--run`) are optimized.
207fn classify_vitest(args: &[String]) -> ExecMode {
208    if has_watch_flag(args) {
209        return Passthrough(R::Interactive);
210    }
211    // Snapshot updates rewrite files in the repo.
212    if args.iter().any(|a| a == "-u" || a == "--update") {
213        return Passthrough(R::SideEffecting);
214    }
215    let explicit_run =
216        matches!(first_positional(args), Some((_, "run"))) || args.iter().any(|a| a == "--run");
217    if explicit_run {
218        Optimize {
219            family: Family::Validation,
220            command_key: build_key("validation:vitest", &drop_noise(args)),
221        }
222    } else {
223        // Bare `vitest` / `vitest watch` / `vitest dev` live-rerun on changes.
224        Passthrough(R::Interactive)
225    }
226}
227
228/// `jest` runs once by default; watch and snapshot-update forms pass through.
229fn classify_jest(args: &[String]) -> ExecMode {
230    if has_watch_flag(args) {
231        return Passthrough(R::Interactive);
232    }
233    if args.iter().any(|a| a == "-u" || a == "--updateSnapshot") {
234        return Passthrough(R::SideEffecting);
235    }
236    Optimize {
237        family: Family::Validation,
238        command_key: build_key("validation:jest", &drop_noise(args)),
239    }
240}
241
242fn classify_cargo(args: &[String]) -> ExecMode {
243    // Skip a leading `+toolchain` selector.
244    let rest: &[String] = match args.first() {
245        Some(first) if first.starts_with('+') => &args[1..],
246        _ => args,
247    };
248    match first_positional(rest) {
249        Some((idx, "test")) => Optimize {
250            family: Family::Validation,
251            command_key: build_key("validation:cargo:test", &drop_noise(&rest[idx + 1..])),
252        },
253        Some((idx, "check")) => Optimize {
254            family: Family::Validation,
255            command_key: build_key("validation:cargo:check", &drop_noise(&rest[idx + 1..])),
256        },
257        Some((idx, "clippy")) => {
258            // `cargo clippy --fix` rewrites source files.
259            if rest.iter().any(|a| a == "--fix") {
260                Passthrough(R::SideEffecting)
261            } else {
262                Optimize {
263                    family: Family::Validation,
264                    command_key: build_key(
265                        "validation:cargo:clippy",
266                        &drop_noise(&rest[idx + 1..]),
267                    ),
268                }
269            }
270        }
271        _ => Passthrough(R::UnsupportedSubcommand),
272    }
273}
274
275fn classify_go(args: &[String]) -> ExecMode {
276    match first_positional(args) {
277        Some((idx, "test")) => Optimize {
278            family: Family::Validation,
279            command_key: build_key("validation:go:test", &drop_noise(&args[idx + 1..])),
280        },
281        _ => Passthrough(R::UnsupportedSubcommand),
282    }
283}
284
285fn classify_search(shim: &str, args: &[String]) -> ExecMode {
286    // Unparseable machine formats degrade to generic later; still safe to capture.
287    Optimize {
288        family: Family::Search,
289        command_key: build_key(&format!("search:{shim}"), &drop_noise(args)),
290    }
291}
292
293fn classify_find(args: &[String]) -> ExecMode {
294    if args.iter().any(|a| FIND_DANGEROUS.contains(&a.as_str())) {
295        return Passthrough(R::SideEffecting);
296    }
297    Optimize {
298        family: Family::Tree,
299        command_key: build_key("tree:find", &drop_noise(args)),
300    }
301}
302
303fn classify_listing(shim: &str, args: &[String]) -> ExecMode {
304    Optimize {
305        family: Family::Tree,
306        command_key: build_key(&format!("tree:{shim}"), &drop_noise(args)),
307    }
308}
309
310/// The git subcommand, skipping global options (`git -C p status`, `git -c k=v
311/// commit`, etc.). Returns `(index, subcommand)`.
312fn git_subcommand(args: &[String]) -> Option<(usize, &str)> {
313    const TAKES_VALUE: &[&str] = &[
314        "-C",
315        "-c",
316        "--git-dir",
317        "--work-tree",
318        "--namespace",
319        "--exec-path",
320        "--super-prefix",
321    ];
322    let mut i = 0;
323    while i < args.len() {
324        let a = &args[i];
325        if a == "--" {
326            i += 1;
327            continue;
328        }
329        if a.starts_with('-') {
330            if TAKES_VALUE.contains(&a.as_str()) {
331                i += 2; // separate-form value follows
332            } else {
333                i += 1; // flag, or `--opt=value` single token
334            }
335            continue;
336        }
337        return Some((i, a.as_str()));
338    }
339    None
340}
341
342fn classify_git(args: &[String]) -> ExecMode {
343    match git_subcommand(args) {
344        Some((idx, sub)) if GIT_READONLY.contains(&sub) => {
345            // Machine-readable forms (`--porcelain`, `-z`, `@{upstream}` ranges,
346            // `--no-ext-diff`) are run by shell prompts and IDE SCM, which PARSE
347            // the output — reducing it would corrupt them. Pass those through and
348            // only optimize the human-readable forms an agent actually reads.
349            if is_machine_readable(&args[idx + 1..]) {
350                Passthrough(R::MachineReadable)
351            } else {
352                Optimize {
353                    family: Family::GitReadonly,
354                    command_key: build_key(&format!("git:{sub}"), &drop_noise(&args[idx + 1..])),
355                }
356            }
357        }
358        Some((_, sub)) if GIT_MUTATING.contains(&sub) => Passthrough(R::MutatingGit),
359        // Unknown or no subcommand → safe passthrough.
360        _ => Passthrough(R::UnsupportedSubcommand),
361    }
362}
363
364/// True when a git read-only invocation emits a stable, machine-parseable
365/// format that a program (shell prompt, IDE SCM, git hook, or an agent's own
366/// `$(...)`/pipe/xargs) consumes. Reducing those corrupts the parser, so they
367/// pass through untouched. This is a block-list and so necessarily incomplete;
368/// we err toward passthrough (a missed optimization is harmless, a corrupted
369/// parse is not), and the min-token floor is the final backstop.
370fn is_machine_readable(args: &[String]) -> bool {
371    args.iter().any(|a| {
372        matches!(
373            a.as_str(),
374            // stable columnar / scripting formats
375            "-z" | "-s"
376                | "--short"
377                | "--name-only"
378                | "--name-status"
379                | "--numstat"
380                | "--raw"
381                | "--no-ext-diff"
382        ) || a.starts_with("--porcelain")      // --porcelain, =v1, =v2
383            || a.starts_with("--format")        // git log/show custom format
384            || a.starts_with("--pretty=format") // --pretty=format:%H (machine)
385            || a.contains("@{u") // @{u} / @{upstream} ahead-behind ranges
386    })
387}
388
389/// First positional for docker, skipping global options that take a value.
390fn docker_positional(args: &[String], from: usize) -> Option<(usize, &str)> {
391    const TAKES_VALUE: &[&str] = &[
392        "-H",
393        "--host",
394        "--context",
395        "--config",
396        "--log-level",
397        "-l",
398        "--tlscacert",
399        "--tlscert",
400        "--tlskey",
401    ];
402    let mut i = from;
403    while i < args.len() {
404        let a = &args[i];
405        if a.starts_with('-') {
406            if TAKES_VALUE.contains(&a.as_str()) {
407                i += 2;
408            } else {
409                i += 1;
410            }
411            continue;
412        }
413        return Some((i, a.as_str()));
414    }
415    None
416}
417
418fn classify_docker(args: &[String]) -> ExecMode {
419    match docker_positional(args, 0) {
420        Some((idx, "logs")) => Optimize {
421            family: Family::Logs,
422            command_key: build_key("logs:docker:logs", &drop_noise(&args[idx + 1..])),
423        },
424        Some((idx, "compose")) => match docker_positional(args, idx + 1) {
425            Some((jdx, "logs")) => Optimize {
426                family: Family::Logs,
427                command_key: build_key("logs:docker-compose:logs", &drop_noise(&args[jdx + 1..])),
428            },
429            _ => Passthrough(R::DangerousDocker),
430        },
431        _ => Passthrough(R::DangerousDocker),
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn cfg() -> Config {
440        Config::default()
441    }
442
443    fn a(parts: &[&str]) -> Vec<String> {
444        parts.iter().map(|s| s.to_string()).collect()
445    }
446
447    fn mode(shim: &str, args: &[&str]) -> ExecMode {
448        classify(shim, &a(args), &cfg(), false, false, false).mode
449    }
450
451    fn is_opt(m: &ExecMode) -> bool {
452        matches!(m, Optimize { .. })
453    }
454
455    fn key(m: &ExecMode) -> String {
456        match m {
457            Optimize { command_key, .. } => command_key.clone(),
458            _ => String::new(),
459        }
460    }
461
462    #[test]
463    fn js_validation_optimized() {
464        assert!(is_opt(&mode("pnpm", &["test"])));
465        assert!(is_opt(&mode("pnpm", &["run", "test"])));
466        assert!(is_opt(&mode("npm", &["run", "lint"])));
467        assert!(is_opt(&mode("yarn", &["build"])));
468        assert!(is_opt(&mode("bun", &["test"])));
469        assert_eq!(key(&mode("pnpm", &["test"])), "validation:pnpm:test");
470        assert_eq!(
471            key(&mode("pnpm", &["run", "typecheck"])),
472            "validation:pnpm:run:typecheck"
473        );
474    }
475
476    #[test]
477    fn js_side_effect_commands_passthrough() {
478        assert!(!is_opt(&mode("pnpm", &["install"])));
479        assert!(!is_opt(&mode("npm", &["publish"])));
480        assert!(!is_opt(&mode("yarn", &["add", "react"])));
481        assert!(!is_opt(&mode("bun", &["add", "zod"])));
482    }
483
484    #[test]
485    fn watch_mode_passthrough() {
486        assert!(matches!(
487            mode("pnpm", &["test", "--watch"]),
488            Passthrough(R::Interactive)
489        ));
490        assert!(matches!(
491            mode("tsc", &["--watch"]),
492            Passthrough(R::Interactive)
493        ));
494    }
495
496    #[test]
497    fn git_readonly_optimized_mutating_passthrough() {
498        assert!(is_opt(&mode("git", &["diff"])));
499        assert!(is_opt(&mode("git", &["status"])));
500        assert!(is_opt(&mode("git", &["log"])));
501        assert!(is_opt(&mode("git", &["show"])));
502        assert_eq!(key(&mode("git", &["diff"])), "git:diff:default");
503
504        for sub in GIT_MUTATING {
505            assert!(
506                matches!(mode("git", &[sub]), Passthrough(R::MutatingGit)),
507                "git {sub} must pass through"
508            );
509        }
510    }
511
512    #[test]
513    fn extra_commands_classify_as_generic_validation() {
514        let mut cfg = Config::default();
515        cfg.intercept.extra = vec!["mytool".to_string()];
516
517        // Optimized with a validation key.
518        let c = classify(
519            "mytool",
520            &["run".to_string(), "--color=always".to_string()],
521            &cfg,
522            false,
523            false,
524            false,
525        );
526        match &c.mode {
527            Optimize {
528                family,
529                command_key,
530            } => {
531                assert_eq!(*family, Family::Validation);
532                assert_eq!(command_key, "validation:mytool:run");
533            }
534            other => panic!("expected Optimize, got {other:?}"),
535        }
536
537        // Watch mode passes through.
538        let c = classify(
539            "mytool",
540            &["--watch".to_string()],
541            &cfg,
542            false,
543            false,
544            false,
545        );
546        assert!(matches!(c.mode, Passthrough(R::Interactive)));
547
548        // Not in extra -> excluded by the config gate (never optimized).
549        let c = classify("randomtool", &[], &cfg, false, false, false);
550        assert!(matches!(
551            c.mode,
552            Passthrough(R::ConfigExcluded | R::UnknownShim)
553        ));
554
555        // A builtin listed in extra keeps its specialized policy.
556        cfg.intercept.extra.push("git".to_string());
557        let c = classify("git", &["commit".to_string()], &cfg, false, false, false);
558        assert!(matches!(c.mode, Passthrough(R::MutatingGit)));
559    }
560
561    #[test]
562    fn git_machine_forms_passthrough_human_forms_optimize() {
563        // Prompt / IDE SCM / scripting forms — parsed by programs, must pass through.
564        for args in [
565            &["status", "--porcelain"][..],
566            &["status", "--porcelain=v2", "-z"][..],
567            &["status", "-s"][..],
568            &["status", "--short"][..],
569            &["diff", "--no-ext-diff", "--ignore-submodules"][..],
570            &["diff", "--name-only"][..],
571            &["diff", "--numstat"][..],
572            &["diff", "--name-status", "--diff-filter=ACM"][..],
573            &["log", "--oneline", "..@{upstream}"][..],
574            &["log", "-1", "--format=%H"][..],
575            &["log", "--pretty=format:%h %s"][..],
576        ] {
577            assert!(
578                matches!(mode("git", args), Passthrough(R::MachineReadable)),
579                "git {args:?} must pass through (machine-readable)"
580            );
581        }
582        // Human/agent forms — still optimized.
583        assert!(is_opt(&mode("git", &["status"])));
584        assert!(is_opt(&mode("git", &["diff"])));
585        assert!(is_opt(&mode("git", &["diff", "HEAD~1"])));
586        assert!(is_opt(&mode("git", &["diff", "--stat"])));
587        assert!(is_opt(&mode("git", &["log", "--oneline", "-5"])));
588        assert!(is_opt(&mode("git", &["log", "--graph"])));
589        assert!(is_opt(&mode("git", &["-C", "/tmp/r", "status"])));
590    }
591
592    #[test]
593    fn git_global_options_before_subcommand() {
594        assert!(is_opt(&mode("git", &["-C", "/tmp/repo", "diff"])));
595        assert!(matches!(
596            mode("git", &["-C", "/tmp/repo", "commit"]),
597            Passthrough(R::MutatingGit)
598        ));
599        assert!(matches!(
600            mode("git", &["-c", "user.name=x", "push"]),
601            Passthrough(R::MutatingGit)
602        ));
603    }
604
605    #[test]
606    fn docker_logs_optimized_run_passthrough() {
607        assert!(is_opt(&mode("docker", &["logs", "api"])));
608        assert!(is_opt(&mode("docker", &["compose", "logs", "api"])));
609        assert!(matches!(
610            mode("docker", &["run", "img"]),
611            Passthrough(R::DangerousDocker)
612        ));
613        assert!(matches!(
614            mode("docker", &["compose", "up"]),
615            Passthrough(R::DangerousDocker)
616        ));
617    }
618
619    #[test]
620    fn find_dangerous_passthrough() {
621        assert!(is_opt(&mode("find", &[".", "-name", "*.ts"])));
622        assert!(matches!(
623            mode("find", &[".", "-delete"]),
624            Passthrough(R::SideEffecting)
625        ));
626        assert!(matches!(
627            mode("find", &[".", "-exec", "rm", "{}", ";"]),
628            Passthrough(R::SideEffecting)
629        ));
630    }
631
632    #[test]
633    fn eslint_fix_passthrough() {
634        assert!(is_opt(&mode("eslint", &["."])));
635        assert!(matches!(
636            mode("eslint", &[".", "--fix"]),
637            Passthrough(R::SideEffecting)
638        ));
639    }
640
641    #[test]
642    fn cargo_go_validation_subcommands() {
643        assert!(is_opt(&mode("cargo", &["test"])));
644        assert!(is_opt(&mode("cargo", &["+nightly", "test"])));
645        assert!(is_opt(&mode("cargo", &["check"])));
646        assert!(is_opt(&mode("cargo", &["check", "--all-targets"])));
647        assert!(is_opt(&mode("cargo", &["clippy"])));
648        assert!(is_opt(&mode("cargo", &["clippy", "--", "-D", "warnings"])));
649        assert_eq!(
650            key(&mode("cargo", &["check"])),
651            "validation:cargo:check:default"
652        );
653        assert!(!is_opt(&mode("cargo", &["build"])));
654        assert!(!is_opt(&mode("cargo", &["publish"])));
655        // `clippy --fix` rewrites source files.
656        assert!(matches!(
657            mode("cargo", &["clippy", "--fix"]),
658            Passthrough(R::SideEffecting)
659        ));
660        assert!(is_opt(&mode("go", &["test", "./..."])));
661        assert!(!is_opt(&mode("go", &["build"])));
662    }
663
664    #[test]
665    fn vitest_only_explicit_run_optimized() {
666        assert!(is_opt(&mode("vitest", &["run"])));
667        assert!(is_opt(&mode("vitest", &["run", "src/session"])));
668        assert!(is_opt(&mode("vitest", &["related", "--run", "src/a.ts"])));
669        assert_eq!(key(&mode("vitest", &["run"])), "validation:vitest:run");
670        // Bare vitest defaults to watch mode in a dev terminal.
671        assert!(matches!(mode("vitest", &[]), Passthrough(R::Interactive)));
672        assert!(matches!(
673            mode("vitest", &["watch"]),
674            Passthrough(R::Interactive)
675        ));
676        assert!(matches!(
677            mode("vitest", &["run", "--watch"]),
678            Passthrough(R::Interactive)
679        ));
680        // Snapshot updates rewrite files.
681        assert!(matches!(
682            mode("vitest", &["run", "-u"]),
683            Passthrough(R::SideEffecting)
684        ));
685    }
686
687    #[test]
688    fn jest_optimized_except_watch_and_snapshot_updates() {
689        assert!(is_opt(&mode("jest", &[])));
690        assert!(is_opt(&mode("jest", &["src/session"])));
691        assert_eq!(key(&mode("jest", &[])), "validation:jest:default");
692        assert!(matches!(
693            mode("jest", &["--watch"]),
694            Passthrough(R::Interactive)
695        ));
696        assert!(matches!(
697            mode("jest", &["--watchAll"]),
698            Passthrough(R::Interactive)
699        ));
700        assert!(matches!(
701            mode("jest", &["-u"]),
702            Passthrough(R::SideEffecting)
703        ));
704        assert!(matches!(
705            mode("jest", &["--updateSnapshot"]),
706            Passthrough(R::SideEffecting)
707        ));
708    }
709
710    #[test]
711    fn js_script_prefixes_optimized_dangerous_variants_passthrough() {
712        // `test:*` / `lint:*` / `build:*` / `typecheck:*` variants optimize.
713        assert!(is_opt(&mode("pnpm", &["test:unit"])));
714        assert!(is_opt(&mode("pnpm", &["run", "test:e2e"])));
715        assert!(is_opt(&mode("npm", &["run", "lint:js"])));
716        assert!(is_opt(&mode("yarn", &["build:prod"])));
717        assert!(is_opt(&mode("pnpm", &["run", "typecheck:strict"])));
718        // Segment equality spares look-alikes…
719        assert!(is_opt(&mode("pnpm", &["run", "test:fixtures"])));
720        // …but watch / fix / dev / serve / start variants stay passthrough.
721        assert!(!is_opt(&mode("pnpm", &["run", "test:watch"])));
722        assert!(!is_opt(&mode("pnpm", &["run", "lint:fix"])));
723        assert!(!is_opt(&mode("pnpm", &["run", "build:dev"])));
724        assert!(!is_opt(&mode("npm", &["run", "build:serve"])));
725        assert!(!is_opt(&mode("pnpm", &["run", "test:watch-unit"])));
726        // Unrelated script names are still unsupported.
727        assert!(!is_opt(&mode("pnpm", &["run", "deploy:prod"])));
728    }
729
730    #[test]
731    fn noise_flags_excluded_from_key() {
732        assert_eq!(
733            key(&mode("rg", &["--color=always", "createSession", "src"])),
734            "search:rg:createSession:src"
735        );
736    }
737
738    #[test]
739    fn disabled_and_config_excluded() {
740        let disabled = classify("pnpm", &a(&["test"]), &cfg(), true, false, false).mode;
741        assert!(matches!(disabled, Passthrough(R::Disabled)));
742
743        let mut c = cfg();
744        c.intercept.git = false;
745        let excluded = classify("git", &a(&["diff"]), &c, false, false, false).mode;
746        assert!(matches!(excluded, Passthrough(R::ConfigExcluded)));
747    }
748}