Skip to main content

fallow_core/scripts/
mod.rs

1//! Lightweight shell command parser for package.json scripts.
2//!
3//! Extracts:
4//! - **Binary names** → mapped to npm package names for dependency usage detection
5//! - **`--config` arguments** → file paths for entry point discovery
6//! - **Positional file arguments** → file paths for entry point discovery
7//!
8//! Handles env var prefixes (`cross-env`, `dotenv`, `KEY=value`), package manager
9//! runners (`npx`, `pnpm exec`, `yarn dlx`), and Node.js runners (`node`, `tsx`,
10//! `ts-node`). Shell operators (`&&`, `||`, `;`, `|`, `&`) are split correctly.
11
12mod resolve;
13mod shell;
14
15#[expect(clippy::disallowed_types)]
16use std::collections::HashMap;
17use std::path::Path;
18
19use rustc_hash::FxHashSet;
20
21pub use resolve::resolve_binary_to_package;
22
23/// Environment variable wrapper commands to strip before the actual binary.
24const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
25
26/// Node.js runners whose first non-flag argument is a file path, not a binary name.
27const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
28
29/// Result of analyzing all package.json scripts.
30#[derive(Debug, Default)]
31pub struct ScriptAnalysis {
32    /// Package names used as binaries in scripts (mapped from binary → package name).
33    pub used_packages: FxHashSet<String>,
34    /// Config file paths extracted from `--config` / `-c` arguments.
35    pub config_files: Vec<String>,
36    /// File paths extracted as positional arguments (entry point candidates).
37    pub entry_files: Vec<String>,
38}
39
40/// A parsed command segment from a script value.
41#[derive(Debug, PartialEq, Eq)]
42pub struct ScriptCommand {
43    /// The binary/command name (e.g., "webpack", "eslint", "tsc").
44    pub binary: String,
45    /// Config file arguments (from `--config`, `-c`).
46    pub config_args: Vec<String>,
47    /// File path arguments (positional args that look like file paths).
48    pub file_args: Vec<String>,
49}
50
51/// Filter scripts to only production-relevant ones (start, build, and their pre/post hooks).
52///
53/// In production mode, dev/test/lint scripts are excluded since they only affect
54/// devDependency usage, not the production dependency graph.
55#[expect(clippy::implicit_hasher, clippy::disallowed_types)]
56pub fn filter_production_scripts(scripts: &HashMap<String, String>) -> HashMap<String, String> {
57    scripts
58        .iter()
59        .filter(|(name, _)| is_production_script(name))
60        .map(|(k, v)| (k.clone(), v.clone()))
61        .collect()
62}
63
64/// Check if a script name is production-relevant.
65///
66/// Production scripts: `start`, `build`, `serve`, `preview`, `prepare`, `prepublishOnly`,
67/// and their `pre`/`post` lifecycle hooks, plus namespaced variants like `build:prod`.
68fn is_production_script(name: &str) -> bool {
69    // Check the root name (before any `:` namespace separator)
70    let root_name = name.split(':').next().unwrap_or(name);
71
72    // Direct match (including scripts that happen to start with pre/post like preview, prepare)
73    if matches!(
74        root_name,
75        "start" | "build" | "serve" | "preview" | "prepare" | "prepublishOnly" | "postinstall"
76    ) {
77        return true;
78    }
79
80    // Check lifecycle hooks: pre/post + production script name
81    let base = root_name
82        .strip_prefix("pre")
83        .or_else(|| root_name.strip_prefix("post"));
84
85    base.is_some_and(|base| matches!(base, "start" | "build" | "serve" | "install"))
86}
87
88/// Analyze all scripts from a package.json `scripts` field.
89///
90/// For each script value, parses shell commands, extracts binary names (mapped to
91/// package names), `--config` file paths, and positional file path arguments.
92#[expect(clippy::implicit_hasher, clippy::disallowed_types)]
93pub fn analyze_scripts(scripts: &HashMap<String, String>, root: &Path) -> ScriptAnalysis {
94    let mut result = ScriptAnalysis::default();
95
96    for script_value in scripts.values() {
97        // Track env wrapper packages (cross-env, dotenv) as used before parsing
98        for wrapper in ENV_WRAPPERS {
99            if script_value
100                .split_whitespace()
101                .any(|token| token == *wrapper)
102            {
103                let pkg = resolve_binary_to_package(wrapper, root);
104                if !is_builtin_command(wrapper) {
105                    result.used_packages.insert(pkg);
106                }
107            }
108        }
109
110        let commands = parse_script(script_value);
111
112        for cmd in commands {
113            // Map binary to package name and track as used
114            if !cmd.binary.is_empty() && !is_builtin_command(&cmd.binary) {
115                if NODE_RUNNERS.contains(&cmd.binary.as_str()) {
116                    // Node runners themselves are packages (node excluded)
117                    if cmd.binary != "node" && cmd.binary != "bun" {
118                        let pkg = resolve_binary_to_package(&cmd.binary, root);
119                        result.used_packages.insert(pkg);
120                    }
121                } else {
122                    let pkg = resolve_binary_to_package(&cmd.binary, root);
123                    result.used_packages.insert(pkg);
124                }
125            }
126
127            result.config_files.extend(cmd.config_args);
128            result.entry_files.extend(cmd.file_args);
129        }
130    }
131
132    result
133}
134
135/// Parse a single script value into one or more commands.
136///
137/// Splits on shell operators (`&&`, `||`, `;`, `|`, `&`) and parses each segment.
138pub fn parse_script(script: &str) -> Vec<ScriptCommand> {
139    let mut commands = Vec::new();
140
141    for segment in shell::split_shell_operators(script) {
142        let segment = segment.trim();
143        if segment.is_empty() {
144            continue;
145        }
146        if let Some(cmd) = parse_command_segment(segment) {
147            commands.push(cmd);
148        }
149    }
150
151    commands
152}
153
154/// Extract file path arguments and `--config`/`-c` arguments from the remaining tokens.
155/// When `is_node_runner` is true, flags like `-e`/`--eval`/`-r`/`--require` that consume
156/// the next argument are skipped.
157fn extract_args_for_binary(
158    tokens: &[&str],
159    mut idx: usize,
160    is_node_runner: bool,
161) -> (Vec<String>, Vec<String>) {
162    let mut file_args = Vec::new();
163    let mut config_args = Vec::new();
164
165    while idx < tokens.len() {
166        let token = tokens[idx];
167
168        // Node runners have flags that consume the next argument
169        if is_node_runner
170            && matches!(
171                token,
172                "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
173            )
174        {
175            idx += 2;
176            continue;
177        }
178
179        if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
180            config_args.push(config);
181            if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
182                idx += 1;
183            } else {
184                idx += 2;
185            }
186            continue;
187        }
188
189        if token.starts_with('-') {
190            idx += 1;
191            continue;
192        }
193
194        if looks_like_file_path(token) {
195            file_args.push(token.to_string());
196        }
197        idx += 1;
198    }
199
200    (file_args, config_args)
201}
202
203/// Parse a single command segment (after splitting on shell operators).
204fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
205    let tokens: Vec<&str> = segment.split_whitespace().collect();
206    if tokens.is_empty() {
207        return None;
208    }
209
210    let idx = shell::skip_initial_wrappers(&tokens, 0)?;
211    let idx = shell::advance_past_package_manager(&tokens, idx)?;
212
213    let binary = tokens[idx].to_string();
214    let is_node_runner = NODE_RUNNERS.contains(&binary.as_str());
215    let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
216
217    Some(ScriptCommand {
218        binary,
219        config_args,
220        file_args,
221    })
222}
223
224/// Extract a config file path from a `--config` or `-c` flag.
225fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
226    // --config=path/to/config.js
227    if let Some(value) = token.strip_prefix("--config=")
228        && !value.is_empty()
229    {
230        return Some(value.to_string());
231    }
232    // -c=path
233    if let Some(value) = token.strip_prefix("-c=")
234        && !value.is_empty()
235    {
236        return Some(value.to_string());
237    }
238    // --config path or -c path
239    if matches!(token, "--config" | "-c")
240        && let Some(next_token) = next
241        && !next_token.starts_with('-')
242    {
243        return Some(next_token.to_string());
244    }
245    None
246}
247
248/// Check if a token is an environment variable assignment (`KEY=value`).
249fn is_env_assignment(token: &str) -> bool {
250    token.find('=').is_some_and(|eq_pos| {
251        let name = &token[..eq_pos];
252        !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
253    })
254}
255
256/// Check if a token looks like a file path (has a known extension or path separator).
257fn looks_like_file_path(token: &str) -> bool {
258    const EXTENSIONS: &[&str] = &[
259        ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
260        ".toml",
261    ];
262    if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
263        return true;
264    }
265    token.starts_with("./")
266        || token.starts_with("../")
267        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
268}
269
270/// Check if a command is a shell built-in (not an npm package).
271fn is_builtin_command(cmd: &str) -> bool {
272    matches!(
273        cmd,
274        "echo"
275            | "cat"
276            | "cp"
277            | "mv"
278            | "rm"
279            | "mkdir"
280            | "rmdir"
281            | "ls"
282            | "cd"
283            | "pwd"
284            | "test"
285            | "true"
286            | "false"
287            | "exit"
288            | "export"
289            | "source"
290            | "which"
291            | "chmod"
292            | "chown"
293            | "touch"
294            | "find"
295            | "grep"
296            | "sed"
297            | "awk"
298            | "xargs"
299            | "tee"
300            | "sort"
301            | "uniq"
302            | "wc"
303            | "head"
304            | "tail"
305            | "sleep"
306            | "wait"
307            | "kill"
308            | "sh"
309            | "bash"
310            | "zsh"
311    )
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    // --- parse_script tests ---
319
320    #[test]
321    fn simple_binary() {
322        let cmds = parse_script("webpack");
323        assert_eq!(cmds.len(), 1);
324        assert_eq!(cmds[0].binary, "webpack");
325    }
326
327    #[test]
328    fn binary_with_args() {
329        let cmds = parse_script("eslint src --ext .ts,.tsx");
330        assert_eq!(cmds.len(), 1);
331        assert_eq!(cmds[0].binary, "eslint");
332    }
333
334    #[test]
335    fn chained_commands() {
336        let cmds = parse_script("tsc --noEmit && eslint src");
337        assert_eq!(cmds.len(), 2);
338        assert_eq!(cmds[0].binary, "tsc");
339        assert_eq!(cmds[1].binary, "eslint");
340    }
341
342    #[test]
343    fn semicolon_separator() {
344        let cmds = parse_script("tsc; eslint src");
345        assert_eq!(cmds.len(), 2);
346        assert_eq!(cmds[0].binary, "tsc");
347        assert_eq!(cmds[1].binary, "eslint");
348    }
349
350    #[test]
351    fn or_chain() {
352        let cmds = parse_script("tsc --noEmit || echo failed");
353        assert_eq!(cmds.len(), 2);
354        assert_eq!(cmds[0].binary, "tsc");
355        assert_eq!(cmds[1].binary, "echo");
356    }
357
358    #[test]
359    fn pipe_operator() {
360        let cmds = parse_script("jest --json | tee results.json");
361        assert_eq!(cmds.len(), 2);
362        assert_eq!(cmds[0].binary, "jest");
363        assert_eq!(cmds[1].binary, "tee");
364    }
365
366    #[test]
367    fn npx_prefix() {
368        let cmds = parse_script("npx eslint src");
369        assert_eq!(cmds.len(), 1);
370        assert_eq!(cmds[0].binary, "eslint");
371    }
372
373    #[test]
374    fn pnpx_prefix() {
375        let cmds = parse_script("pnpx vitest run");
376        assert_eq!(cmds.len(), 1);
377        assert_eq!(cmds[0].binary, "vitest");
378    }
379
380    #[test]
381    fn npx_with_flags() {
382        let cmds = parse_script("npx --yes --package @scope/tool eslint src");
383        assert_eq!(cmds.len(), 1);
384        assert_eq!(cmds[0].binary, "eslint");
385    }
386
387    #[test]
388    fn yarn_exec() {
389        let cmds = parse_script("yarn exec jest");
390        assert_eq!(cmds.len(), 1);
391        assert_eq!(cmds[0].binary, "jest");
392    }
393
394    #[test]
395    fn pnpm_exec() {
396        let cmds = parse_script("pnpm exec vitest run");
397        assert_eq!(cmds.len(), 1);
398        assert_eq!(cmds[0].binary, "vitest");
399    }
400
401    #[test]
402    fn pnpm_dlx() {
403        let cmds = parse_script("pnpm dlx create-react-app my-app");
404        assert_eq!(cmds.len(), 1);
405        assert_eq!(cmds[0].binary, "create-react-app");
406    }
407
408    #[test]
409    fn npm_run_skipped() {
410        let cmds = parse_script("npm run build");
411        assert!(cmds.is_empty());
412    }
413
414    #[test]
415    fn yarn_run_skipped() {
416        let cmds = parse_script("yarn run test");
417        assert!(cmds.is_empty());
418    }
419
420    #[test]
421    fn bare_yarn_skipped() {
422        // `yarn build` runs the "build" script
423        let cmds = parse_script("yarn build");
424        assert!(cmds.is_empty());
425    }
426
427    // --- env wrappers ---
428
429    #[test]
430    fn cross_env_prefix() {
431        let cmds = parse_script("cross-env NODE_ENV=production webpack");
432        assert_eq!(cmds.len(), 1);
433        assert_eq!(cmds[0].binary, "webpack");
434    }
435
436    #[test]
437    fn dotenv_prefix() {
438        let cmds = parse_script("dotenv -- next build");
439        assert_eq!(cmds.len(), 1);
440        assert_eq!(cmds[0].binary, "next");
441    }
442
443    #[test]
444    fn env_var_assignment_prefix() {
445        let cmds = parse_script("NODE_ENV=production webpack --mode production");
446        assert_eq!(cmds.len(), 1);
447        assert_eq!(cmds[0].binary, "webpack");
448    }
449
450    #[test]
451    fn multiple_env_vars() {
452        let cmds = parse_script("NODE_ENV=test CI=true jest");
453        assert_eq!(cmds.len(), 1);
454        assert_eq!(cmds[0].binary, "jest");
455    }
456
457    // --- node runners ---
458
459    #[test]
460    fn node_runner_file_args() {
461        let cmds = parse_script("node scripts/build.js");
462        assert_eq!(cmds.len(), 1);
463        assert_eq!(cmds[0].binary, "node");
464        assert_eq!(cmds[0].file_args, vec!["scripts/build.js"]);
465    }
466
467    #[test]
468    fn tsx_runner_file_args() {
469        let cmds = parse_script("tsx scripts/migrate.ts");
470        assert_eq!(cmds.len(), 1);
471        assert_eq!(cmds[0].binary, "tsx");
472        assert_eq!(cmds[0].file_args, vec!["scripts/migrate.ts"]);
473    }
474
475    #[test]
476    fn node_with_flags() {
477        let cmds = parse_script("node --experimental-specifier-resolution=node scripts/run.mjs");
478        assert_eq!(cmds.len(), 1);
479        assert_eq!(cmds[0].file_args, vec!["scripts/run.mjs"]);
480    }
481
482    #[test]
483    fn node_eval_no_file() {
484        let cmds = parse_script("node -e \"console.log('hi')\"");
485        assert_eq!(cmds.len(), 1);
486        assert_eq!(cmds[0].binary, "node");
487        assert!(cmds[0].file_args.is_empty());
488    }
489
490    #[test]
491    fn node_multiple_files() {
492        let cmds = parse_script("node --test file1.mjs file2.mjs");
493        assert_eq!(cmds.len(), 1);
494        assert_eq!(cmds[0].file_args, vec!["file1.mjs", "file2.mjs"]);
495    }
496
497    // --- config args ---
498
499    #[test]
500    fn config_equals() {
501        let cmds = parse_script("webpack --config=webpack.prod.js");
502        assert_eq!(cmds.len(), 1);
503        assert_eq!(cmds[0].binary, "webpack");
504        assert_eq!(cmds[0].config_args, vec!["webpack.prod.js"]);
505    }
506
507    #[test]
508    fn config_space() {
509        let cmds = parse_script("jest --config jest.config.ts");
510        assert_eq!(cmds.len(), 1);
511        assert_eq!(cmds[0].binary, "jest");
512        assert_eq!(cmds[0].config_args, vec!["jest.config.ts"]);
513    }
514
515    #[test]
516    fn config_short_flag() {
517        let cmds = parse_script("eslint -c .eslintrc.json src");
518        assert_eq!(cmds.len(), 1);
519        assert_eq!(cmds[0].binary, "eslint");
520        assert_eq!(cmds[0].config_args, vec![".eslintrc.json"]);
521    }
522
523    // --- binary -> package mapping ---
524
525    #[test]
526    fn tsc_maps_to_typescript() {
527        let pkg = resolve_binary_to_package("tsc", Path::new("/nonexistent"));
528        assert_eq!(pkg, "typescript");
529    }
530
531    #[test]
532    fn ng_maps_to_angular_cli() {
533        let pkg = resolve_binary_to_package("ng", Path::new("/nonexistent"));
534        assert_eq!(pkg, "@angular/cli");
535    }
536
537    #[test]
538    fn biome_maps_to_biomejs() {
539        let pkg = resolve_binary_to_package("biome", Path::new("/nonexistent"));
540        assert_eq!(pkg, "@biomejs/biome");
541    }
542
543    #[test]
544    fn unknown_binary_is_identity() {
545        let pkg = resolve_binary_to_package("my-custom-tool", Path::new("/nonexistent"));
546        assert_eq!(pkg, "my-custom-tool");
547    }
548
549    #[test]
550    fn run_s_maps_to_npm_run_all() {
551        let pkg = resolve_binary_to_package("run-s", Path::new("/nonexistent"));
552        assert_eq!(pkg, "npm-run-all");
553    }
554
555    // --- extract_package_from_bin_path ---
556
557    #[test]
558    fn bin_path_regular_package() {
559        let path = std::path::Path::new("../webpack/bin/webpack.js");
560        assert_eq!(
561            resolve::extract_package_from_bin_path(path),
562            Some("webpack".to_string())
563        );
564    }
565
566    #[test]
567    fn bin_path_scoped_package() {
568        let path = std::path::Path::new("../@babel/cli/bin/babel.js");
569        assert_eq!(
570            resolve::extract_package_from_bin_path(path),
571            Some("@babel/cli".to_string())
572        );
573    }
574
575    // --- builtin commands ---
576
577    #[test]
578    fn builtin_commands_not_tracked() {
579        let scripts: HashMap<String, String> =
580            [("postinstall".to_string(), "echo done".to_string())]
581                .into_iter()
582                .collect();
583        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
584        assert!(result.used_packages.is_empty());
585    }
586
587    // --- analyze_scripts integration ---
588
589    #[test]
590    fn analyze_extracts_binaries() {
591        let scripts: HashMap<String, String> = [
592            ("build".to_string(), "tsc --noEmit && webpack".to_string()),
593            ("lint".to_string(), "eslint src".to_string()),
594            ("test".to_string(), "jest".to_string()),
595        ]
596        .into_iter()
597        .collect();
598        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
599        assert!(result.used_packages.contains("typescript"));
600        assert!(result.used_packages.contains("webpack"));
601        assert!(result.used_packages.contains("eslint"));
602        assert!(result.used_packages.contains("jest"));
603    }
604
605    #[test]
606    fn analyze_extracts_config_files() {
607        let scripts: HashMap<String, String> = [(
608            "build".to_string(),
609            "webpack --config webpack.prod.js".to_string(),
610        )]
611        .into_iter()
612        .collect();
613        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
614        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
615    }
616
617    #[test]
618    fn analyze_extracts_entry_files() {
619        let scripts: HashMap<String, String> =
620            [("seed".to_string(), "ts-node scripts/seed.ts".to_string())]
621                .into_iter()
622                .collect();
623        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
624        assert!(result.entry_files.contains(&"scripts/seed.ts".to_string()));
625        // ts-node should be tracked as a used package
626        assert!(result.used_packages.contains("ts-node"));
627    }
628
629    #[test]
630    fn analyze_cross_env_with_config() {
631        let scripts: HashMap<String, String> = [(
632            "build".to_string(),
633            "cross-env NODE_ENV=production webpack --config webpack.prod.js".to_string(),
634        )]
635        .into_iter()
636        .collect();
637        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
638        assert!(result.used_packages.contains("cross-env"));
639        assert!(result.used_packages.contains("webpack"));
640        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
641    }
642
643    #[test]
644    fn analyze_complex_script() {
645        let scripts: HashMap<String, String> = [(
646            "ci".to_string(),
647            "cross-env CI=true npm run build && jest --config jest.ci.js --coverage".to_string(),
648        )]
649        .into_iter()
650        .collect();
651        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
652        // cross-env is tracked, npm run is skipped, jest is tracked
653        assert!(result.used_packages.contains("cross-env"));
654        assert!(result.used_packages.contains("jest"));
655        assert!(!result.used_packages.contains("npm"));
656        assert!(result.config_files.contains(&"jest.ci.js".to_string()));
657    }
658
659    // --- is_env_assignment ---
660
661    #[test]
662    fn env_assignment_valid() {
663        assert!(is_env_assignment("NODE_ENV=production"));
664        assert!(is_env_assignment("CI=true"));
665        assert!(is_env_assignment("PORT=3000"));
666    }
667
668    #[test]
669    fn env_assignment_invalid() {
670        assert!(!is_env_assignment("--config"));
671        assert!(!is_env_assignment("webpack"));
672        assert!(!is_env_assignment("./scripts/build.js"));
673    }
674
675    // --- split_shell_operators ---
676
677    #[test]
678    fn split_respects_quotes() {
679        let segments = shell::split_shell_operators("echo 'a && b' && jest");
680        assert_eq!(segments.len(), 2);
681        assert!(segments[1].trim() == "jest");
682    }
683
684    #[test]
685    fn split_double_quotes() {
686        let segments = shell::split_shell_operators("echo \"a || b\" || jest");
687        assert_eq!(segments.len(), 2);
688        assert!(segments[1].trim() == "jest");
689    }
690
691    #[test]
692    fn background_operator_splits_commands() {
693        let cmds = parse_script("tsc --watch & webpack --watch");
694        assert_eq!(cmds.len(), 2);
695        assert_eq!(cmds[0].binary, "tsc");
696        assert_eq!(cmds[1].binary, "webpack");
697    }
698
699    #[test]
700    fn double_ampersand_still_works() {
701        let cmds = parse_script("tsc --watch && webpack --watch");
702        assert_eq!(cmds.len(), 2);
703        assert_eq!(cmds[0].binary, "tsc");
704        assert_eq!(cmds[1].binary, "webpack");
705    }
706
707    #[test]
708    fn multiple_background_operators() {
709        let cmds = parse_script("server & client & proxy");
710        assert_eq!(cmds.len(), 3);
711        assert_eq!(cmds[0].binary, "server");
712        assert_eq!(cmds[1].binary, "client");
713        assert_eq!(cmds[2].binary, "proxy");
714    }
715
716    // --- is_production_script ---
717
718    #[test]
719    fn production_script_start() {
720        assert!(super::is_production_script("start"));
721        assert!(super::is_production_script("prestart"));
722        assert!(super::is_production_script("poststart"));
723    }
724
725    #[test]
726    fn production_script_build() {
727        assert!(super::is_production_script("build"));
728        assert!(super::is_production_script("prebuild"));
729        assert!(super::is_production_script("postbuild"));
730        assert!(super::is_production_script("build:prod"));
731        assert!(super::is_production_script("build:esm"));
732    }
733
734    #[test]
735    fn production_script_serve_preview() {
736        assert!(super::is_production_script("serve"));
737        assert!(super::is_production_script("preview"));
738        assert!(super::is_production_script("prepare"));
739    }
740
741    #[test]
742    fn non_production_scripts() {
743        assert!(!super::is_production_script("test"));
744        assert!(!super::is_production_script("lint"));
745        assert!(!super::is_production_script("dev"));
746        assert!(!super::is_production_script("storybook"));
747        assert!(!super::is_production_script("typecheck"));
748        assert!(!super::is_production_script("format"));
749        assert!(!super::is_production_script("e2e"));
750    }
751
752    // --- mixed operator parsing ---
753
754    #[test]
755    fn mixed_operators_all_binaries_detected() {
756        let cmds = parse_script("build && serve & watch || fallback");
757        assert_eq!(cmds.len(), 4);
758        assert_eq!(cmds[0].binary, "build");
759        assert_eq!(cmds[1].binary, "serve");
760        assert_eq!(cmds[2].binary, "watch");
761        assert_eq!(cmds[3].binary, "fallback");
762    }
763
764    #[test]
765    fn background_with_env_vars() {
766        let cmds = parse_script("NODE_ENV=production server &");
767        assert_eq!(cmds.len(), 1);
768        assert_eq!(cmds[0].binary, "server");
769    }
770
771    #[test]
772    fn trailing_background_operator() {
773        let cmds = parse_script("webpack --watch &");
774        assert_eq!(cmds.len(), 1);
775        assert_eq!(cmds[0].binary, "webpack");
776    }
777
778    // --- filter_production_scripts ---
779
780    #[test]
781    fn filter_keeps_production_scripts() {
782        let scripts: HashMap<String, String> = [
783            ("build".to_string(), "webpack".to_string()),
784            ("start".to_string(), "node server.js".to_string()),
785            ("test".to_string(), "jest".to_string()),
786            ("lint".to_string(), "eslint src".to_string()),
787            ("dev".to_string(), "next dev".to_string()),
788        ]
789        .into_iter()
790        .collect();
791
792        let filtered = filter_production_scripts(&scripts);
793        assert!(filtered.contains_key("build"));
794        assert!(filtered.contains_key("start"));
795        assert!(!filtered.contains_key("test"));
796        assert!(!filtered.contains_key("lint"));
797        assert!(!filtered.contains_key("dev"));
798    }
799
800    mod proptests {
801        use super::*;
802        use proptest::prelude::*;
803
804        proptest! {
805            /// parse_script should never panic on arbitrary input.
806            #[test]
807            fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
808                let _ = parse_script(&s);
809            }
810
811            /// split_shell_operators should never panic on arbitrary input.
812            #[test]
813            fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
814                let _ = shell::split_shell_operators(&s);
815            }
816
817            /// When parse_script returns commands, binary names should be non-empty.
818            #[test]
819            fn parsed_binaries_are_non_empty(
820                binary in "[a-z][a-z0-9-]{0,20}",
821                args in "[a-zA-Z0-9 _./=-]{0,50}",
822            ) {
823                let script = format!("{binary} {args}");
824                let commands = parse_script(&script);
825                for cmd in &commands {
826                    prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
827                }
828            }
829
830            /// analyze_scripts should never panic on arbitrary script values.
831            #[test]
832            fn analyze_scripts_no_panic(
833                name in "[a-z]{1,10}",
834                value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
835            ) {
836                let scripts: HashMap<String, String> =
837                    [(name, value)].into_iter().collect();
838                let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
839            }
840
841            /// is_env_assignment should never panic on arbitrary input.
842            #[test]
843            fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
844                let _ = is_env_assignment(&s);
845            }
846
847            /// resolve_binary_to_package should always return a non-empty string.
848            #[test]
849            fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
850                let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
851                prop_assert!(!result.is_empty(), "Package name should never be empty");
852            }
853
854            /// Chained scripts should produce at least as many commands as operators + 1
855            /// when each segment is a valid binary (excluding package managers and builtins).
856            #[test]
857            fn chained_binaries_produce_multiple_commands(
858                bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
859            ) {
860                let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
861                    "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
862                prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
863                let script = bins.join(" && ");
864                let commands = parse_script(&script);
865                prop_assert!(
866                    commands.len() >= 2,
867                    "Chained commands should produce multiple parsed commands, got {}",
868                    commands.len()
869                );
870            }
871        }
872    }
873}