Skip to main content

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