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