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