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)]
315#[expect(clippy::disallowed_types)]
316mod tests {
317    use super::*;
318
319    // --- parse_script tests ---
320
321    #[test]
322    fn simple_binary() {
323        let cmds = parse_script("webpack");
324        assert_eq!(cmds.len(), 1);
325        assert_eq!(cmds[0].binary, "webpack");
326    }
327
328    #[test]
329    fn binary_with_args() {
330        let cmds = parse_script("eslint src --ext .ts,.tsx");
331        assert_eq!(cmds.len(), 1);
332        assert_eq!(cmds[0].binary, "eslint");
333    }
334
335    #[test]
336    fn chained_commands() {
337        let cmds = parse_script("tsc --noEmit && eslint src");
338        assert_eq!(cmds.len(), 2);
339        assert_eq!(cmds[0].binary, "tsc");
340        assert_eq!(cmds[1].binary, "eslint");
341    }
342
343    #[test]
344    fn semicolon_separator() {
345        let cmds = parse_script("tsc; eslint src");
346        assert_eq!(cmds.len(), 2);
347        assert_eq!(cmds[0].binary, "tsc");
348        assert_eq!(cmds[1].binary, "eslint");
349    }
350
351    #[test]
352    fn or_chain() {
353        let cmds = parse_script("tsc --noEmit || echo failed");
354        assert_eq!(cmds.len(), 2);
355        assert_eq!(cmds[0].binary, "tsc");
356        assert_eq!(cmds[1].binary, "echo");
357    }
358
359    #[test]
360    fn pipe_operator() {
361        let cmds = parse_script("jest --json | tee results.json");
362        assert_eq!(cmds.len(), 2);
363        assert_eq!(cmds[0].binary, "jest");
364        assert_eq!(cmds[1].binary, "tee");
365    }
366
367    #[test]
368    fn npx_prefix() {
369        let cmds = parse_script("npx eslint src");
370        assert_eq!(cmds.len(), 1);
371        assert_eq!(cmds[0].binary, "eslint");
372    }
373
374    #[test]
375    fn pnpx_prefix() {
376        let cmds = parse_script("pnpx vitest run");
377        assert_eq!(cmds.len(), 1);
378        assert_eq!(cmds[0].binary, "vitest");
379    }
380
381    #[test]
382    fn npx_with_flags() {
383        let cmds = parse_script("npx --yes --package @scope/tool eslint src");
384        assert_eq!(cmds.len(), 1);
385        assert_eq!(cmds[0].binary, "eslint");
386    }
387
388    #[test]
389    fn yarn_exec() {
390        let cmds = parse_script("yarn exec jest");
391        assert_eq!(cmds.len(), 1);
392        assert_eq!(cmds[0].binary, "jest");
393    }
394
395    #[test]
396    fn pnpm_exec() {
397        let cmds = parse_script("pnpm exec vitest run");
398        assert_eq!(cmds.len(), 1);
399        assert_eq!(cmds[0].binary, "vitest");
400    }
401
402    #[test]
403    fn pnpm_dlx() {
404        let cmds = parse_script("pnpm dlx create-react-app my-app");
405        assert_eq!(cmds.len(), 1);
406        assert_eq!(cmds[0].binary, "create-react-app");
407    }
408
409    #[test]
410    fn npm_run_skipped() {
411        let cmds = parse_script("npm run build");
412        assert!(cmds.is_empty());
413    }
414
415    #[test]
416    fn yarn_run_skipped() {
417        let cmds = parse_script("yarn run test");
418        assert!(cmds.is_empty());
419    }
420
421    #[test]
422    fn bare_yarn_skipped() {
423        // `yarn build` runs the "build" script
424        let cmds = parse_script("yarn build");
425        assert!(cmds.is_empty());
426    }
427
428    // --- env wrappers ---
429
430    #[test]
431    fn cross_env_prefix() {
432        let cmds = parse_script("cross-env NODE_ENV=production webpack");
433        assert_eq!(cmds.len(), 1);
434        assert_eq!(cmds[0].binary, "webpack");
435    }
436
437    #[test]
438    fn dotenv_prefix() {
439        let cmds = parse_script("dotenv -- next build");
440        assert_eq!(cmds.len(), 1);
441        assert_eq!(cmds[0].binary, "next");
442    }
443
444    #[test]
445    fn env_var_assignment_prefix() {
446        let cmds = parse_script("NODE_ENV=production webpack --mode production");
447        assert_eq!(cmds.len(), 1);
448        assert_eq!(cmds[0].binary, "webpack");
449    }
450
451    #[test]
452    fn multiple_env_vars() {
453        let cmds = parse_script("NODE_ENV=test CI=true jest");
454        assert_eq!(cmds.len(), 1);
455        assert_eq!(cmds[0].binary, "jest");
456    }
457
458    // --- node runners ---
459
460    #[test]
461    fn node_runner_file_args() {
462        let cmds = parse_script("node scripts/build.js");
463        assert_eq!(cmds.len(), 1);
464        assert_eq!(cmds[0].binary, "node");
465        assert_eq!(cmds[0].file_args, vec!["scripts/build.js"]);
466    }
467
468    #[test]
469    fn tsx_runner_file_args() {
470        let cmds = parse_script("tsx scripts/migrate.ts");
471        assert_eq!(cmds.len(), 1);
472        assert_eq!(cmds[0].binary, "tsx");
473        assert_eq!(cmds[0].file_args, vec!["scripts/migrate.ts"]);
474    }
475
476    #[test]
477    fn node_with_flags() {
478        let cmds = parse_script("node --experimental-specifier-resolution=node scripts/run.mjs");
479        assert_eq!(cmds.len(), 1);
480        assert_eq!(cmds[0].file_args, vec!["scripts/run.mjs"]);
481    }
482
483    #[test]
484    fn node_eval_no_file() {
485        let cmds = parse_script("node -e \"console.log('hi')\"");
486        assert_eq!(cmds.len(), 1);
487        assert_eq!(cmds[0].binary, "node");
488        assert!(cmds[0].file_args.is_empty());
489    }
490
491    #[test]
492    fn node_multiple_files() {
493        let cmds = parse_script("node --test file1.mjs file2.mjs");
494        assert_eq!(cmds.len(), 1);
495        assert_eq!(cmds[0].file_args, vec!["file1.mjs", "file2.mjs"]);
496    }
497
498    // --- config args ---
499
500    #[test]
501    fn config_equals() {
502        let cmds = parse_script("webpack --config=webpack.prod.js");
503        assert_eq!(cmds.len(), 1);
504        assert_eq!(cmds[0].binary, "webpack");
505        assert_eq!(cmds[0].config_args, vec!["webpack.prod.js"]);
506    }
507
508    #[test]
509    fn config_space() {
510        let cmds = parse_script("jest --config jest.config.ts");
511        assert_eq!(cmds.len(), 1);
512        assert_eq!(cmds[0].binary, "jest");
513        assert_eq!(cmds[0].config_args, vec!["jest.config.ts"]);
514    }
515
516    #[test]
517    fn config_short_flag() {
518        let cmds = parse_script("eslint -c .eslintrc.json src");
519        assert_eq!(cmds.len(), 1);
520        assert_eq!(cmds[0].binary, "eslint");
521        assert_eq!(cmds[0].config_args, vec![".eslintrc.json"]);
522    }
523
524    // --- binary -> package mapping ---
525
526    #[test]
527    fn tsc_maps_to_typescript() {
528        let pkg = resolve_binary_to_package("tsc", Path::new("/nonexistent"));
529        assert_eq!(pkg, "typescript");
530    }
531
532    #[test]
533    fn ng_maps_to_angular_cli() {
534        let pkg = resolve_binary_to_package("ng", Path::new("/nonexistent"));
535        assert_eq!(pkg, "@angular/cli");
536    }
537
538    #[test]
539    fn biome_maps_to_biomejs() {
540        let pkg = resolve_binary_to_package("biome", Path::new("/nonexistent"));
541        assert_eq!(pkg, "@biomejs/biome");
542    }
543
544    #[test]
545    fn unknown_binary_is_identity() {
546        let pkg = resolve_binary_to_package("my-custom-tool", Path::new("/nonexistent"));
547        assert_eq!(pkg, "my-custom-tool");
548    }
549
550    #[test]
551    fn run_s_maps_to_npm_run_all() {
552        let pkg = resolve_binary_to_package("run-s", Path::new("/nonexistent"));
553        assert_eq!(pkg, "npm-run-all");
554    }
555
556    // --- extract_package_from_bin_path ---
557
558    #[test]
559    fn bin_path_regular_package() {
560        let path = std::path::Path::new("../webpack/bin/webpack.js");
561        assert_eq!(
562            resolve::extract_package_from_bin_path(path),
563            Some("webpack".to_string())
564        );
565    }
566
567    #[test]
568    fn bin_path_scoped_package() {
569        let path = std::path::Path::new("../@babel/cli/bin/babel.js");
570        assert_eq!(
571            resolve::extract_package_from_bin_path(path),
572            Some("@babel/cli".to_string())
573        );
574    }
575
576    // --- builtin commands ---
577
578    #[test]
579    fn builtin_commands_not_tracked() {
580        let scripts: HashMap<String, String> =
581            std::iter::once(("postinstall".to_string(), "echo done".to_string())).collect();
582        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
583        assert!(result.used_packages.is_empty());
584    }
585
586    // --- analyze_scripts integration ---
587
588    #[test]
589    fn analyze_extracts_binaries() {
590        let scripts: HashMap<String, String> = [
591            ("build".to_string(), "tsc --noEmit && webpack".to_string()),
592            ("lint".to_string(), "eslint src".to_string()),
593            ("test".to_string(), "jest".to_string()),
594        ]
595        .into_iter()
596        .collect();
597        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
598        assert!(result.used_packages.contains("typescript"));
599        assert!(result.used_packages.contains("webpack"));
600        assert!(result.used_packages.contains("eslint"));
601        assert!(result.used_packages.contains("jest"));
602    }
603
604    #[test]
605    fn analyze_extracts_config_files() {
606        let scripts: HashMap<String, String> = std::iter::once((
607            "build".to_string(),
608            "webpack --config webpack.prod.js".to_string(),
609        ))
610        .collect();
611        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
612        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
613    }
614
615    #[test]
616    fn analyze_extracts_entry_files() {
617        let scripts: HashMap<String, String> =
618            std::iter::once(("seed".to_string(), "ts-node scripts/seed.ts".to_string())).collect();
619        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
620        assert!(result.entry_files.contains(&"scripts/seed.ts".to_string()));
621        // ts-node should be tracked as a used package
622        assert!(result.used_packages.contains("ts-node"));
623    }
624
625    #[test]
626    fn analyze_cross_env_with_config() {
627        let scripts: HashMap<String, String> = std::iter::once((
628            "build".to_string(),
629            "cross-env NODE_ENV=production webpack --config webpack.prod.js".to_string(),
630        ))
631        .collect();
632        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
633        assert!(result.used_packages.contains("cross-env"));
634        assert!(result.used_packages.contains("webpack"));
635        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
636    }
637
638    #[test]
639    fn analyze_complex_script() {
640        let scripts: HashMap<String, String> = std::iter::once((
641            "ci".to_string(),
642            "cross-env CI=true npm run build && jest --config jest.ci.js --coverage".to_string(),
643        ))
644        .collect();
645        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
646        // cross-env is tracked, npm run is skipped, jest is tracked
647        assert!(result.used_packages.contains("cross-env"));
648        assert!(result.used_packages.contains("jest"));
649        assert!(!result.used_packages.contains("npm"));
650        assert!(result.config_files.contains(&"jest.ci.js".to_string()));
651    }
652
653    // --- is_env_assignment ---
654
655    #[test]
656    fn env_assignment_valid() {
657        assert!(is_env_assignment("NODE_ENV=production"));
658        assert!(is_env_assignment("CI=true"));
659        assert!(is_env_assignment("PORT=3000"));
660    }
661
662    #[test]
663    fn env_assignment_invalid() {
664        assert!(!is_env_assignment("--config"));
665        assert!(!is_env_assignment("webpack"));
666        assert!(!is_env_assignment("./scripts/build.js"));
667    }
668
669    // --- split_shell_operators ---
670
671    #[test]
672    fn split_respects_quotes() {
673        let segments = shell::split_shell_operators("echo 'a && b' && jest");
674        assert_eq!(segments.len(), 2);
675        assert!(segments[1].trim() == "jest");
676    }
677
678    #[test]
679    fn split_double_quotes() {
680        let segments = shell::split_shell_operators("echo \"a || b\" || jest");
681        assert_eq!(segments.len(), 2);
682        assert!(segments[1].trim() == "jest");
683    }
684
685    #[test]
686    fn background_operator_splits_commands() {
687        let cmds = parse_script("tsc --watch & webpack --watch");
688        assert_eq!(cmds.len(), 2);
689        assert_eq!(cmds[0].binary, "tsc");
690        assert_eq!(cmds[1].binary, "webpack");
691    }
692
693    #[test]
694    fn double_ampersand_still_works() {
695        let cmds = parse_script("tsc --watch && webpack --watch");
696        assert_eq!(cmds.len(), 2);
697        assert_eq!(cmds[0].binary, "tsc");
698        assert_eq!(cmds[1].binary, "webpack");
699    }
700
701    #[test]
702    fn multiple_background_operators() {
703        let cmds = parse_script("server & client & proxy");
704        assert_eq!(cmds.len(), 3);
705        assert_eq!(cmds[0].binary, "server");
706        assert_eq!(cmds[1].binary, "client");
707        assert_eq!(cmds[2].binary, "proxy");
708    }
709
710    // --- is_production_script ---
711
712    #[test]
713    fn production_script_start() {
714        assert!(super::is_production_script("start"));
715        assert!(super::is_production_script("prestart"));
716        assert!(super::is_production_script("poststart"));
717    }
718
719    #[test]
720    fn production_script_build() {
721        assert!(super::is_production_script("build"));
722        assert!(super::is_production_script("prebuild"));
723        assert!(super::is_production_script("postbuild"));
724        assert!(super::is_production_script("build:prod"));
725        assert!(super::is_production_script("build:esm"));
726    }
727
728    #[test]
729    fn production_script_serve_preview() {
730        assert!(super::is_production_script("serve"));
731        assert!(super::is_production_script("preview"));
732        assert!(super::is_production_script("prepare"));
733    }
734
735    #[test]
736    fn non_production_scripts() {
737        assert!(!super::is_production_script("test"));
738        assert!(!super::is_production_script("lint"));
739        assert!(!super::is_production_script("dev"));
740        assert!(!super::is_production_script("storybook"));
741        assert!(!super::is_production_script("typecheck"));
742        assert!(!super::is_production_script("format"));
743        assert!(!super::is_production_script("e2e"));
744    }
745
746    // --- mixed operator parsing ---
747
748    #[test]
749    fn mixed_operators_all_binaries_detected() {
750        let cmds = parse_script("build && serve & watch || fallback");
751        assert_eq!(cmds.len(), 4);
752        assert_eq!(cmds[0].binary, "build");
753        assert_eq!(cmds[1].binary, "serve");
754        assert_eq!(cmds[2].binary, "watch");
755        assert_eq!(cmds[3].binary, "fallback");
756    }
757
758    #[test]
759    fn background_with_env_vars() {
760        let cmds = parse_script("NODE_ENV=production server &");
761        assert_eq!(cmds.len(), 1);
762        assert_eq!(cmds[0].binary, "server");
763    }
764
765    #[test]
766    fn trailing_background_operator() {
767        let cmds = parse_script("webpack --watch &");
768        assert_eq!(cmds.len(), 1);
769        assert_eq!(cmds[0].binary, "webpack");
770    }
771
772    // --- filter_production_scripts ---
773
774    #[test]
775    fn filter_keeps_production_scripts() {
776        let scripts: HashMap<String, String> = [
777            ("build".to_string(), "webpack".to_string()),
778            ("start".to_string(), "node server.js".to_string()),
779            ("test".to_string(), "jest".to_string()),
780            ("lint".to_string(), "eslint src".to_string()),
781            ("dev".to_string(), "next dev".to_string()),
782        ]
783        .into_iter()
784        .collect();
785
786        let filtered = filter_production_scripts(&scripts);
787        assert!(filtered.contains_key("build"));
788        assert!(filtered.contains_key("start"));
789        assert!(!filtered.contains_key("test"));
790        assert!(!filtered.contains_key("lint"));
791        assert!(!filtered.contains_key("dev"));
792    }
793
794    mod proptests {
795        use super::*;
796        use proptest::prelude::*;
797
798        proptest! {
799            /// parse_script should never panic on arbitrary input.
800            #[test]
801            fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
802                let _ = parse_script(&s);
803            }
804
805            /// split_shell_operators should never panic on arbitrary input.
806            #[test]
807            fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
808                let _ = shell::split_shell_operators(&s);
809            }
810
811            /// When parse_script returns commands, binary names should be non-empty.
812            #[test]
813            fn parsed_binaries_are_non_empty(
814                binary in "[a-z][a-z0-9-]{0,20}",
815                args in "[a-zA-Z0-9 _./=-]{0,50}",
816            ) {
817                let script = format!("{binary} {args}");
818                let commands = parse_script(&script);
819                for cmd in &commands {
820                    prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
821                }
822            }
823
824            /// analyze_scripts should never panic on arbitrary script values.
825            #[test]
826            fn analyze_scripts_no_panic(
827                name in "[a-z]{1,10}",
828                value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
829            ) {
830                let scripts: HashMap<String, String> = std::iter::once((name, value)).collect();
831                let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
832            }
833
834            /// is_env_assignment should never panic on arbitrary input.
835            #[test]
836            fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
837                let _ = is_env_assignment(&s);
838            }
839
840            /// resolve_binary_to_package should always return a non-empty string.
841            #[test]
842            fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
843                let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
844                prop_assert!(!result.is_empty(), "Package name should never be empty");
845            }
846
847            /// Chained scripts should produce at least as many commands as operators + 1
848            /// when each segment is a valid binary (excluding package managers and builtins).
849            #[test]
850            fn chained_binaries_produce_multiple_commands(
851                bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
852            ) {
853                let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
854                    "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
855                prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
856                let script = bins.join(" && ");
857                let commands = parse_script(&script);
858                prop_assert!(
859                    commands.len() >= 2,
860                    "Chained commands should produce multiple parsed commands, got {}",
861                    commands.len()
862                );
863            }
864        }
865    }
866}