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/// Skip env var assignments (`KEY=value`) and env wrapper commands (`cross-env`, `dotenv`, `env`)
225/// at the start of a token list. Returns the index of the first real command token, or `None`
226/// if all tokens were consumed.
227fn skip_initial_wrappers(tokens: &[&str], mut idx: usize) -> Option<usize> {
228    // Skip env var assignments (KEY=value pairs)
229    while idx < tokens.len() && is_env_assignment(tokens[idx]) {
230        idx += 1;
231    }
232    if idx >= tokens.len() {
233        return None;
234    }
235
236    // Skip env wrapper commands (cross-env, dotenv, env)
237    while idx < tokens.len() && ENV_WRAPPERS.contains(&tokens[idx]) {
238        idx += 1;
239        // Skip env var assignments after the wrapper
240        while idx < tokens.len() && is_env_assignment(tokens[idx]) {
241            idx += 1;
242        }
243        // dotenv uses -- as separator
244        if idx < tokens.len() && tokens[idx] == "--" {
245            idx += 1;
246        }
247    }
248    if idx >= tokens.len() {
249        return None;
250    }
251
252    Some(idx)
253}
254
255/// Advance past package manager prefixes (`npx`, `pnpx`, `bunx`, `yarn exec`, `pnpm dlx`, etc.).
256/// Returns the index of the actual binary token, or `None` if the command delegates to a named
257/// script (e.g., `npm run build`, `yarn build`).
258fn advance_past_package_manager(tokens: &[&str], mut idx: usize) -> Option<usize> {
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    Some(idx)
292}
293
294/// Extract file path arguments and `--config`/`-c` arguments from the remaining tokens.
295/// When `is_node_runner` is true, flags like `-e`/`--eval`/`-r`/`--require` that consume
296/// the next argument are skipped.
297fn extract_args_for_binary(
298    tokens: &[&str],
299    mut idx: usize,
300    is_node_runner: bool,
301) -> (Vec<String>, Vec<String>) {
302    let mut file_args = Vec::new();
303    let mut config_args = Vec::new();
304
305    while idx < tokens.len() {
306        let token = tokens[idx];
307
308        // Node runners have flags that consume the next argument
309        if is_node_runner
310            && matches!(
311                token,
312                "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
313            )
314        {
315            idx += 2;
316            continue;
317        }
318
319        if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
320            config_args.push(config);
321            if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
322                idx += 1;
323            } else {
324                idx += 2;
325            }
326            continue;
327        }
328
329        if token.starts_with('-') {
330            idx += 1;
331            continue;
332        }
333
334        if looks_like_file_path(token) {
335            file_args.push(token.to_string());
336        }
337        idx += 1;
338    }
339
340    (file_args, config_args)
341}
342
343/// Parse a single command segment (after splitting on shell operators).
344fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
345    let tokens: Vec<&str> = segment.split_whitespace().collect();
346    if tokens.is_empty() {
347        return None;
348    }
349
350    let idx = skip_initial_wrappers(&tokens, 0)?;
351    let idx = advance_past_package_manager(&tokens, idx)?;
352
353    let binary = tokens[idx].to_string();
354    let is_node_runner = NODE_RUNNERS.contains(&binary.as_str());
355    let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
356
357    Some(ScriptCommand {
358        binary,
359        config_args,
360        file_args,
361    })
362}
363
364/// Extract a config file path from a `--config` or `-c` flag.
365fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
366    // --config=path/to/config.js
367    if let Some(value) = token.strip_prefix("--config=")
368        && !value.is_empty()
369    {
370        return Some(value.to_string());
371    }
372    // -c=path
373    if let Some(value) = token.strip_prefix("-c=")
374        && !value.is_empty()
375    {
376        return Some(value.to_string());
377    }
378    // --config path or -c path
379    if matches!(token, "--config" | "-c")
380        && let Some(next_token) = next
381        && !next_token.starts_with('-')
382    {
383        return Some(next_token.to_string());
384    }
385    None
386}
387
388/// Check if a token is an environment variable assignment (`KEY=value`).
389fn is_env_assignment(token: &str) -> bool {
390    token.find('=').is_some_and(|eq_pos| {
391        let name = &token[..eq_pos];
392        !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
393    })
394}
395
396/// Check if a token looks like a file path (has a known extension or path separator).
397fn looks_like_file_path(token: &str) -> bool {
398    const EXTENSIONS: &[&str] = &[
399        ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
400        ".toml",
401    ];
402    if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
403        return true;
404    }
405    token.starts_with("./")
406        || token.starts_with("../")
407        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
408}
409
410/// Check if a command is a shell built-in (not an npm package).
411fn is_builtin_command(cmd: &str) -> bool {
412    matches!(
413        cmd,
414        "echo"
415            | "cat"
416            | "cp"
417            | "mv"
418            | "rm"
419            | "mkdir"
420            | "rmdir"
421            | "ls"
422            | "cd"
423            | "pwd"
424            | "test"
425            | "true"
426            | "false"
427            | "exit"
428            | "export"
429            | "source"
430            | "which"
431            | "chmod"
432            | "chown"
433            | "touch"
434            | "find"
435            | "grep"
436            | "sed"
437            | "awk"
438            | "xargs"
439            | "tee"
440            | "sort"
441            | "uniq"
442            | "wc"
443            | "head"
444            | "tail"
445            | "sleep"
446            | "wait"
447            | "kill"
448            | "sh"
449            | "bash"
450            | "zsh"
451    )
452}
453
454/// Resolve a binary name to its npm package name.
455///
456/// Strategy:
457/// 1. Check known binary→package divergence map
458/// 2. Read `node_modules/.bin/<binary>` symlink target
459/// 3. Fall back: binary name = package name
460pub fn resolve_binary_to_package(binary: &str, root: &Path) -> String {
461    // 1. Known divergences
462    if let Some(&(_, pkg)) = BINARY_TO_PACKAGE.iter().find(|(bin, _)| *bin == binary) {
463        return pkg.to_string();
464    }
465
466    // 2. Try reading the symlink in node_modules/.bin/
467    let bin_link = root.join("node_modules/.bin").join(binary);
468    if let Ok(target) = std::fs::read_link(&bin_link)
469        && let Some(pkg_name) = extract_package_from_bin_path(&target)
470    {
471        return pkg_name;
472    }
473
474    // 3. Fallback: binary name = package name
475    binary.to_string()
476}
477
478/// Extract a package name from a `node_modules/.bin` symlink target path.
479///
480/// Typical symlink targets:
481/// - `../webpack/bin/webpack.js` → `webpack`
482/// - `../@babel/cli/bin/babel.js` → `@babel/cli`
483fn extract_package_from_bin_path(target: &std::path::Path) -> Option<String> {
484    let target_str = target.to_string_lossy();
485    let parts: Vec<&str> = target_str.split('/').collect();
486
487    for (i, part) in parts.iter().enumerate() {
488        if *part == ".." {
489            continue;
490        }
491        // Scoped package: @scope/name
492        if part.starts_with('@') && i + 1 < parts.len() {
493            return Some(format!("{}/{}", part, parts[i + 1]));
494        }
495        // Regular package
496        return Some(part.to_string());
497    }
498
499    None
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    // --- parse_script tests ---
507
508    #[test]
509    fn simple_binary() {
510        let cmds = parse_script("webpack");
511        assert_eq!(cmds.len(), 1);
512        assert_eq!(cmds[0].binary, "webpack");
513    }
514
515    #[test]
516    fn binary_with_args() {
517        let cmds = parse_script("eslint src --ext .ts,.tsx");
518        assert_eq!(cmds.len(), 1);
519        assert_eq!(cmds[0].binary, "eslint");
520    }
521
522    #[test]
523    fn chained_commands() {
524        let cmds = parse_script("tsc --noEmit && eslint src");
525        assert_eq!(cmds.len(), 2);
526        assert_eq!(cmds[0].binary, "tsc");
527        assert_eq!(cmds[1].binary, "eslint");
528    }
529
530    #[test]
531    fn semicolon_separator() {
532        let cmds = parse_script("tsc; eslint src");
533        assert_eq!(cmds.len(), 2);
534        assert_eq!(cmds[0].binary, "tsc");
535        assert_eq!(cmds[1].binary, "eslint");
536    }
537
538    #[test]
539    fn or_chain() {
540        let cmds = parse_script("tsc --noEmit || echo failed");
541        assert_eq!(cmds.len(), 2);
542        assert_eq!(cmds[0].binary, "tsc");
543        assert_eq!(cmds[1].binary, "echo");
544    }
545
546    #[test]
547    fn pipe_operator() {
548        let cmds = parse_script("jest --json | tee results.json");
549        assert_eq!(cmds.len(), 2);
550        assert_eq!(cmds[0].binary, "jest");
551        assert_eq!(cmds[1].binary, "tee");
552    }
553
554    #[test]
555    fn npx_prefix() {
556        let cmds = parse_script("npx eslint src");
557        assert_eq!(cmds.len(), 1);
558        assert_eq!(cmds[0].binary, "eslint");
559    }
560
561    #[test]
562    fn pnpx_prefix() {
563        let cmds = parse_script("pnpx vitest run");
564        assert_eq!(cmds.len(), 1);
565        assert_eq!(cmds[0].binary, "vitest");
566    }
567
568    #[test]
569    fn npx_with_flags() {
570        let cmds = parse_script("npx --yes --package @scope/tool eslint src");
571        assert_eq!(cmds.len(), 1);
572        assert_eq!(cmds[0].binary, "eslint");
573    }
574
575    #[test]
576    fn yarn_exec() {
577        let cmds = parse_script("yarn exec jest");
578        assert_eq!(cmds.len(), 1);
579        assert_eq!(cmds[0].binary, "jest");
580    }
581
582    #[test]
583    fn pnpm_exec() {
584        let cmds = parse_script("pnpm exec vitest run");
585        assert_eq!(cmds.len(), 1);
586        assert_eq!(cmds[0].binary, "vitest");
587    }
588
589    #[test]
590    fn pnpm_dlx() {
591        let cmds = parse_script("pnpm dlx create-react-app my-app");
592        assert_eq!(cmds.len(), 1);
593        assert_eq!(cmds[0].binary, "create-react-app");
594    }
595
596    #[test]
597    fn npm_run_skipped() {
598        let cmds = parse_script("npm run build");
599        assert!(cmds.is_empty());
600    }
601
602    #[test]
603    fn yarn_run_skipped() {
604        let cmds = parse_script("yarn run test");
605        assert!(cmds.is_empty());
606    }
607
608    #[test]
609    fn bare_yarn_skipped() {
610        // `yarn build` runs the "build" script
611        let cmds = parse_script("yarn build");
612        assert!(cmds.is_empty());
613    }
614
615    // --- env wrappers ---
616
617    #[test]
618    fn cross_env_prefix() {
619        let cmds = parse_script("cross-env NODE_ENV=production webpack");
620        assert_eq!(cmds.len(), 1);
621        assert_eq!(cmds[0].binary, "webpack");
622    }
623
624    #[test]
625    fn dotenv_prefix() {
626        let cmds = parse_script("dotenv -- next build");
627        assert_eq!(cmds.len(), 1);
628        assert_eq!(cmds[0].binary, "next");
629    }
630
631    #[test]
632    fn env_var_assignment_prefix() {
633        let cmds = parse_script("NODE_ENV=production webpack --mode production");
634        assert_eq!(cmds.len(), 1);
635        assert_eq!(cmds[0].binary, "webpack");
636    }
637
638    #[test]
639    fn multiple_env_vars() {
640        let cmds = parse_script("NODE_ENV=test CI=true jest");
641        assert_eq!(cmds.len(), 1);
642        assert_eq!(cmds[0].binary, "jest");
643    }
644
645    // --- node runners ---
646
647    #[test]
648    fn node_runner_file_args() {
649        let cmds = parse_script("node scripts/build.js");
650        assert_eq!(cmds.len(), 1);
651        assert_eq!(cmds[0].binary, "node");
652        assert_eq!(cmds[0].file_args, vec!["scripts/build.js"]);
653    }
654
655    #[test]
656    fn tsx_runner_file_args() {
657        let cmds = parse_script("tsx scripts/migrate.ts");
658        assert_eq!(cmds.len(), 1);
659        assert_eq!(cmds[0].binary, "tsx");
660        assert_eq!(cmds[0].file_args, vec!["scripts/migrate.ts"]);
661    }
662
663    #[test]
664    fn node_with_flags() {
665        let cmds = parse_script("node --experimental-specifier-resolution=node scripts/run.mjs");
666        assert_eq!(cmds.len(), 1);
667        assert_eq!(cmds[0].file_args, vec!["scripts/run.mjs"]);
668    }
669
670    #[test]
671    fn node_eval_no_file() {
672        let cmds = parse_script("node -e \"console.log('hi')\"");
673        assert_eq!(cmds.len(), 1);
674        assert_eq!(cmds[0].binary, "node");
675        assert!(cmds[0].file_args.is_empty());
676    }
677
678    #[test]
679    fn node_multiple_files() {
680        let cmds = parse_script("node --test file1.mjs file2.mjs");
681        assert_eq!(cmds.len(), 1);
682        assert_eq!(cmds[0].file_args, vec!["file1.mjs", "file2.mjs"]);
683    }
684
685    // --- config args ---
686
687    #[test]
688    fn config_equals() {
689        let cmds = parse_script("webpack --config=webpack.prod.js");
690        assert_eq!(cmds.len(), 1);
691        assert_eq!(cmds[0].binary, "webpack");
692        assert_eq!(cmds[0].config_args, vec!["webpack.prod.js"]);
693    }
694
695    #[test]
696    fn config_space() {
697        let cmds = parse_script("jest --config jest.config.ts");
698        assert_eq!(cmds.len(), 1);
699        assert_eq!(cmds[0].binary, "jest");
700        assert_eq!(cmds[0].config_args, vec!["jest.config.ts"]);
701    }
702
703    #[test]
704    fn config_short_flag() {
705        let cmds = parse_script("eslint -c .eslintrc.json src");
706        assert_eq!(cmds.len(), 1);
707        assert_eq!(cmds[0].binary, "eslint");
708        assert_eq!(cmds[0].config_args, vec![".eslintrc.json"]);
709    }
710
711    // --- binary → package mapping ---
712
713    #[test]
714    fn tsc_maps_to_typescript() {
715        let pkg = resolve_binary_to_package("tsc", Path::new("/nonexistent"));
716        assert_eq!(pkg, "typescript");
717    }
718
719    #[test]
720    fn ng_maps_to_angular_cli() {
721        let pkg = resolve_binary_to_package("ng", Path::new("/nonexistent"));
722        assert_eq!(pkg, "@angular/cli");
723    }
724
725    #[test]
726    fn biome_maps_to_biomejs() {
727        let pkg = resolve_binary_to_package("biome", Path::new("/nonexistent"));
728        assert_eq!(pkg, "@biomejs/biome");
729    }
730
731    #[test]
732    fn unknown_binary_is_identity() {
733        let pkg = resolve_binary_to_package("my-custom-tool", Path::new("/nonexistent"));
734        assert_eq!(pkg, "my-custom-tool");
735    }
736
737    #[test]
738    fn run_s_maps_to_npm_run_all() {
739        let pkg = resolve_binary_to_package("run-s", Path::new("/nonexistent"));
740        assert_eq!(pkg, "npm-run-all");
741    }
742
743    // --- extract_package_from_bin_path ---
744
745    #[test]
746    fn bin_path_regular_package() {
747        let path = std::path::Path::new("../webpack/bin/webpack.js");
748        assert_eq!(
749            extract_package_from_bin_path(path),
750            Some("webpack".to_string())
751        );
752    }
753
754    #[test]
755    fn bin_path_scoped_package() {
756        let path = std::path::Path::new("../@babel/cli/bin/babel.js");
757        assert_eq!(
758            extract_package_from_bin_path(path),
759            Some("@babel/cli".to_string())
760        );
761    }
762
763    // --- builtin commands ---
764
765    #[test]
766    fn builtin_commands_not_tracked() {
767        let scripts: HashMap<String, String> =
768            [("postinstall".to_string(), "echo done".to_string())]
769                .into_iter()
770                .collect();
771        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
772        assert!(result.used_packages.is_empty());
773    }
774
775    // --- analyze_scripts integration ---
776
777    #[test]
778    fn analyze_extracts_binaries() {
779        let scripts: HashMap<String, String> = [
780            ("build".to_string(), "tsc --noEmit && webpack".to_string()),
781            ("lint".to_string(), "eslint src".to_string()),
782            ("test".to_string(), "jest".to_string()),
783        ]
784        .into_iter()
785        .collect();
786        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
787        assert!(result.used_packages.contains("typescript"));
788        assert!(result.used_packages.contains("webpack"));
789        assert!(result.used_packages.contains("eslint"));
790        assert!(result.used_packages.contains("jest"));
791    }
792
793    #[test]
794    fn analyze_extracts_config_files() {
795        let scripts: HashMap<String, String> = [(
796            "build".to_string(),
797            "webpack --config webpack.prod.js".to_string(),
798        )]
799        .into_iter()
800        .collect();
801        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
802        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
803    }
804
805    #[test]
806    fn analyze_extracts_entry_files() {
807        let scripts: HashMap<String, String> =
808            [("seed".to_string(), "ts-node scripts/seed.ts".to_string())]
809                .into_iter()
810                .collect();
811        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
812        assert!(result.entry_files.contains(&"scripts/seed.ts".to_string()));
813        // ts-node should be tracked as a used package
814        assert!(result.used_packages.contains("ts-node"));
815    }
816
817    #[test]
818    fn analyze_cross_env_with_config() {
819        let scripts: HashMap<String, String> = [(
820            "build".to_string(),
821            "cross-env NODE_ENV=production webpack --config webpack.prod.js".to_string(),
822        )]
823        .into_iter()
824        .collect();
825        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
826        assert!(result.used_packages.contains("cross-env"));
827        assert!(result.used_packages.contains("webpack"));
828        assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
829    }
830
831    #[test]
832    fn analyze_complex_script() {
833        let scripts: HashMap<String, String> = [(
834            "ci".to_string(),
835            "cross-env CI=true npm run build && jest --config jest.ci.js --coverage".to_string(),
836        )]
837        .into_iter()
838        .collect();
839        let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
840        // cross-env is tracked, npm run is skipped, jest is tracked
841        assert!(result.used_packages.contains("cross-env"));
842        assert!(result.used_packages.contains("jest"));
843        assert!(!result.used_packages.contains("npm"));
844        assert!(result.config_files.contains(&"jest.ci.js".to_string()));
845    }
846
847    // --- is_env_assignment ---
848
849    #[test]
850    fn env_assignment_valid() {
851        assert!(is_env_assignment("NODE_ENV=production"));
852        assert!(is_env_assignment("CI=true"));
853        assert!(is_env_assignment("PORT=3000"));
854    }
855
856    #[test]
857    fn env_assignment_invalid() {
858        assert!(!is_env_assignment("--config"));
859        assert!(!is_env_assignment("webpack"));
860        assert!(!is_env_assignment("./scripts/build.js"));
861    }
862
863    // --- split_shell_operators ---
864
865    #[test]
866    fn split_respects_quotes() {
867        let segments = split_shell_operators("echo 'a && b' && jest");
868        assert_eq!(segments.len(), 2);
869        assert!(segments[1].trim() == "jest");
870    }
871
872    #[test]
873    fn split_double_quotes() {
874        let segments = split_shell_operators("echo \"a || b\" || jest");
875        assert_eq!(segments.len(), 2);
876        assert!(segments[1].trim() == "jest");
877    }
878
879    #[test]
880    fn background_operator_splits_commands() {
881        let cmds = parse_script("tsc --watch & webpack --watch");
882        assert_eq!(cmds.len(), 2);
883        assert_eq!(cmds[0].binary, "tsc");
884        assert_eq!(cmds[1].binary, "webpack");
885    }
886
887    #[test]
888    fn double_ampersand_still_works() {
889        let cmds = parse_script("tsc --watch && webpack --watch");
890        assert_eq!(cmds.len(), 2);
891        assert_eq!(cmds[0].binary, "tsc");
892        assert_eq!(cmds[1].binary, "webpack");
893    }
894
895    #[test]
896    fn multiple_background_operators() {
897        let cmds = parse_script("server & client & proxy");
898        assert_eq!(cmds.len(), 3);
899        assert_eq!(cmds[0].binary, "server");
900        assert_eq!(cmds[1].binary, "client");
901        assert_eq!(cmds[2].binary, "proxy");
902    }
903
904    // --- is_production_script ---
905
906    #[test]
907    fn production_script_start() {
908        assert!(super::is_production_script("start"));
909        assert!(super::is_production_script("prestart"));
910        assert!(super::is_production_script("poststart"));
911    }
912
913    #[test]
914    fn production_script_build() {
915        assert!(super::is_production_script("build"));
916        assert!(super::is_production_script("prebuild"));
917        assert!(super::is_production_script("postbuild"));
918        assert!(super::is_production_script("build:prod"));
919        assert!(super::is_production_script("build:esm"));
920    }
921
922    #[test]
923    fn production_script_serve_preview() {
924        assert!(super::is_production_script("serve"));
925        assert!(super::is_production_script("preview"));
926        assert!(super::is_production_script("prepare"));
927    }
928
929    #[test]
930    fn non_production_scripts() {
931        assert!(!super::is_production_script("test"));
932        assert!(!super::is_production_script("lint"));
933        assert!(!super::is_production_script("dev"));
934        assert!(!super::is_production_script("storybook"));
935        assert!(!super::is_production_script("typecheck"));
936        assert!(!super::is_production_script("format"));
937        assert!(!super::is_production_script("e2e"));
938    }
939
940    // --- mixed operator parsing ---
941
942    #[test]
943    fn mixed_operators_all_binaries_detected() {
944        let cmds = parse_script("build && serve & watch || fallback");
945        assert_eq!(cmds.len(), 4);
946        assert_eq!(cmds[0].binary, "build");
947        assert_eq!(cmds[1].binary, "serve");
948        assert_eq!(cmds[2].binary, "watch");
949        assert_eq!(cmds[3].binary, "fallback");
950    }
951
952    #[test]
953    fn background_with_env_vars() {
954        let cmds = parse_script("NODE_ENV=production server &");
955        assert_eq!(cmds.len(), 1);
956        assert_eq!(cmds[0].binary, "server");
957    }
958
959    #[test]
960    fn trailing_background_operator() {
961        let cmds = parse_script("webpack --watch &");
962        assert_eq!(cmds.len(), 1);
963        assert_eq!(cmds[0].binary, "webpack");
964    }
965
966    // --- filter_production_scripts ---
967
968    #[test]
969    fn filter_keeps_production_scripts() {
970        let scripts: HashMap<String, String> = [
971            ("build".to_string(), "webpack".to_string()),
972            ("start".to_string(), "node server.js".to_string()),
973            ("test".to_string(), "jest".to_string()),
974            ("lint".to_string(), "eslint src".to_string()),
975            ("dev".to_string(), "next dev".to_string()),
976        ]
977        .into_iter()
978        .collect();
979
980        let filtered = filter_production_scripts(&scripts);
981        assert!(filtered.contains_key("build"));
982        assert!(filtered.contains_key("start"));
983        assert!(!filtered.contains_key("test"));
984        assert!(!filtered.contains_key("lint"));
985        assert!(!filtered.contains_key("dev"));
986    }
987
988    mod proptests {
989        use super::*;
990        use proptest::prelude::*;
991
992        proptest! {
993            /// parse_script should never panic on arbitrary input.
994            #[test]
995            fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
996                let _ = parse_script(&s);
997            }
998
999            /// split_shell_operators should never panic on arbitrary input.
1000            #[test]
1001            fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
1002                let _ = split_shell_operators(&s);
1003            }
1004
1005            /// When parse_script returns commands, binary names should be non-empty.
1006            #[test]
1007            fn parsed_binaries_are_non_empty(
1008                binary in "[a-z][a-z0-9-]{0,20}",
1009                args in "[a-zA-Z0-9 _./=-]{0,50}",
1010            ) {
1011                let script = format!("{binary} {args}");
1012                let commands = parse_script(&script);
1013                for cmd in &commands {
1014                    prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
1015                }
1016            }
1017
1018            /// analyze_scripts should never panic on arbitrary script values.
1019            #[test]
1020            fn analyze_scripts_no_panic(
1021                name in "[a-z]{1,10}",
1022                value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
1023            ) {
1024                let scripts: HashMap<String, String> =
1025                    [(name, value)].into_iter().collect();
1026                let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
1027            }
1028
1029            /// is_env_assignment should never panic on arbitrary input.
1030            #[test]
1031            fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
1032                let _ = is_env_assignment(&s);
1033            }
1034
1035            /// resolve_binary_to_package should always return a non-empty string.
1036            #[test]
1037            fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
1038                let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
1039                prop_assert!(!result.is_empty(), "Package name should never be empty");
1040            }
1041
1042            /// Chained scripts should produce at least as many commands as operators + 1
1043            /// when each segment is a valid binary (excluding package managers and builtins).
1044            #[test]
1045            fn chained_binaries_produce_multiple_commands(
1046                bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
1047            ) {
1048                let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
1049                    "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
1050                prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
1051                let script = bins.join(" && ");
1052                let commands = parse_script(&script);
1053                prop_assert!(
1054                    commands.len() >= 2,
1055                    "Chained commands should produce multiple parsed commands, got {}",
1056                    commands.len()
1057                );
1058            }
1059        }
1060    }
1061}