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
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14
15/// Result of analyzing all package.json scripts.
16#[derive(Debug, Default)]
17pub struct ScriptAnalysis {
18    /// Package names used as binaries in scripts (mapped from binary → package name).
19    pub used_packages: HashSet<String>,
20    /// Config file paths extracted from `--config` / `-c` arguments.
21    pub config_files: Vec<String>,
22    /// File paths extracted as positional arguments (entry point candidates).
23    pub entry_files: Vec<String>,
24}
25
26/// A parsed command segment from a script value.
27#[derive(Debug, PartialEq)]
28struct ScriptCommand {
29    /// The binary/command name (e.g., "webpack", "eslint", "tsc").
30    binary: String,
31    /// Config file arguments (from `--config`, `-c`).
32    config_args: Vec<String>,
33    /// File path arguments (positional args that look like file paths).
34    file_args: Vec<String>,
35}
36
37/// Known binary-name → package-name mappings where they diverge.
38fn binary_to_package_map() -> HashMap<&'static str, &'static str> {
39    HashMap::from([
40        ("tsc", "typescript"),
41        ("tsserver", "typescript"),
42        ("ng", "@angular/cli"),
43        ("nuxi", "nuxt"),
44        ("run-s", "npm-run-all"),
45        ("run-p", "npm-run-all"),
46        ("run-s2", "npm-run-all2"),
47        ("run-p2", "npm-run-all2"),
48        ("sb", "storybook"),
49        ("biome", "@biomejs/biome"),
50        ("oxlint", "oxlint"),
51    ])
52}
53
54/// Environment variable wrapper commands to strip before the actual binary.
55const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
56
57/// Node.js runners whose first non-flag argument is a file path, not a binary name.
58const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
59
60/// Filter scripts to only production-relevant ones (start, build, and their pre/post hooks).
61///
62/// In production mode, dev/test/lint scripts are excluded since they only affect
63/// devDependency usage, not the production dependency graph.
64pub fn filter_production_scripts(scripts: &HashMap<String, String>) -> HashMap<String, String> {
65    scripts
66        .iter()
67        .filter(|(name, _)| is_production_script(name))
68        .map(|(k, v)| (k.clone(), v.clone()))
69        .collect()
70}
71
72/// Check if a script name is production-relevant.
73///
74/// Production scripts: `start`, `build`, `serve`, `preview`, `prepare`, `prepublishOnly`,
75/// and their `pre`/`post` lifecycle hooks, plus namespaced variants like `build:prod`.
76fn is_production_script(name: &str) -> bool {
77    // Check the root name (before any `:` namespace separator)
78    let root_name = name.split(':').next().unwrap_or(name);
79
80    // Direct match (including scripts that happen to start with pre/post like preview, prepare)
81    if matches!(
82        root_name,
83        "start" | "build" | "serve" | "preview" | "prepare" | "prepublishOnly" | "postinstall"
84    ) {
85        return true;
86    }
87
88    // Check lifecycle hooks: pre/post + production script name
89    let base = root_name
90        .strip_prefix("pre")
91        .or_else(|| root_name.strip_prefix("post"));
92
93    if let Some(base) = base {
94        matches!(base, "start" | "build" | "serve" | "install")
95    } else {
96        false
97    }
98}
99
100/// Analyze all scripts from a package.json `scripts` field.
101///
102/// For each script value, parses shell commands, extracts binary names (mapped to
103/// package names), `--config` file paths, and positional file path arguments.
104pub fn analyze_scripts(scripts: &HashMap<String, String>, root: &Path) -> ScriptAnalysis {
105    let mut result = ScriptAnalysis::default();
106
107    for script_value in scripts.values() {
108        // Track env wrapper packages (cross-env, dotenv) as used before parsing
109        for wrapper in ENV_WRAPPERS {
110            if script_value
111                .split_whitespace()
112                .any(|token| token == *wrapper)
113            {
114                let pkg = resolve_binary_to_package(wrapper, root);
115                if !is_builtin_command(wrapper) {
116                    result.used_packages.insert(pkg);
117                }
118            }
119        }
120
121        let commands = parse_script(script_value);
122
123        for cmd in commands {
124            // Map binary to package name and track as used
125            if !cmd.binary.is_empty() && !is_builtin_command(&cmd.binary) {
126                if NODE_RUNNERS.contains(&cmd.binary.as_str()) {
127                    // Node runners themselves are packages (node excluded)
128                    if cmd.binary != "node" && cmd.binary != "bun" {
129                        let pkg = resolve_binary_to_package(&cmd.binary, root);
130                        result.used_packages.insert(pkg);
131                    }
132                } else {
133                    let pkg = resolve_binary_to_package(&cmd.binary, root);
134                    result.used_packages.insert(pkg);
135                }
136            }
137
138            result.config_files.extend(cmd.config_args);
139            result.entry_files.extend(cmd.file_args);
140        }
141    }
142
143    result
144}
145
146/// Parse a single script value into one or more commands.
147///
148/// Splits on shell operators (`&&`, `||`, `;`, `|`) and parses each segment.
149fn parse_script(script: &str) -> Vec<ScriptCommand> {
150    let mut commands = Vec::new();
151
152    for segment in split_shell_operators(script) {
153        let segment = segment.trim();
154        if segment.is_empty() {
155            continue;
156        }
157        if let Some(cmd) = parse_command_segment(segment) {
158            commands.push(cmd);
159        }
160    }
161
162    commands
163}
164
165/// Split a script string on shell operators (`&&`, `||`, `;`, `|`).
166/// Respects single and double quotes.
167fn split_shell_operators(script: &str) -> Vec<&str> {
168    let mut segments = Vec::new();
169    let mut start = 0;
170    let bytes = script.as_bytes();
171    let len = bytes.len();
172    let mut i = 0;
173    let mut in_single_quote = false;
174    let mut in_double_quote = false;
175
176    while i < len {
177        let b = bytes[i];
178
179        if b == b'\'' && !in_double_quote {
180            in_single_quote = !in_single_quote;
181            i += 1;
182            continue;
183        }
184        if b == b'"' && !in_single_quote {
185            in_double_quote = !in_double_quote;
186            i += 1;
187            continue;
188        }
189        if in_single_quote || in_double_quote {
190            i += 1;
191            continue;
192        }
193
194        // && or ||
195        if i + 1 < len
196            && ((b == b'&' && bytes[i + 1] == b'&') || (b == b'|' && bytes[i + 1] == b'|'))
197        {
198            segments.push(&script[start..i]);
199            i += 2;
200            start = i;
201            continue;
202        }
203
204        // ; or single | (pipe — only first command provides the binary)
205        if b == b';' || (b == b'|' && (i + 1 >= len || bytes[i + 1] != b'|')) {
206            segments.push(&script[start..i]);
207            i += 1;
208            start = i;
209            continue;
210        }
211
212        i += 1;
213    }
214
215    if start < len {
216        segments.push(&script[start..]);
217    }
218
219    segments
220}
221
222/// Parse a single command segment (after splitting on shell operators).
223fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
224    let tokens: Vec<&str> = segment.split_whitespace().collect();
225    if tokens.is_empty() {
226        return None;
227    }
228
229    let mut idx = 0;
230
231    // Skip env var assignments (KEY=value pairs)
232    while idx < tokens.len() && is_env_assignment(tokens[idx]) {
233        idx += 1;
234    }
235    if idx >= tokens.len() {
236        return None;
237    }
238
239    // Skip env wrapper commands (cross-env, dotenv, env)
240    while idx < tokens.len() && ENV_WRAPPERS.contains(&tokens[idx]) {
241        idx += 1;
242        // Skip env var assignments after the wrapper
243        while idx < tokens.len() && is_env_assignment(tokens[idx]) {
244            idx += 1;
245        }
246        // dotenv uses -- as separator
247        if idx < tokens.len() && tokens[idx] == "--" {
248            idx += 1;
249        }
250    }
251    if idx >= tokens.len() {
252        return None;
253    }
254
255    // Handle package manager prefixes
256    let token = tokens[idx];
257    if matches!(token, "npx" | "pnpx" | "bunx") {
258        idx += 1;
259        // Skip npx flags (--yes, --no-install, -p, --package)
260        while idx < tokens.len() && tokens[idx].starts_with('-') {
261            let flag = tokens[idx];
262            idx += 1;
263            // --package <name> consumes the next argument
264            if matches!(flag, "--package" | "-p") && idx < tokens.len() {
265                idx += 1;
266            }
267        }
268    } else if matches!(token, "yarn" | "pnpm" | "npm" | "bun") {
269        if idx + 1 < tokens.len() {
270            let subcmd = tokens[idx + 1];
271            if subcmd == "exec" || subcmd == "dlx" {
272                idx += 2;
273            } else if matches!(subcmd, "run" | "run-script") {
274                // Delegates to a named script, not a binary invocation
275                return None;
276            } else {
277                // Bare `yarn <name>` runs a script — skip
278                return None;
279            }
280        } else {
281            return None;
282        }
283    }
284    if idx >= tokens.len() {
285        return None;
286    }
287
288    let binary = tokens[idx].to_string();
289    idx += 1;
290
291    // If the binary is a node runner, extract file paths from arguments
292    if NODE_RUNNERS.contains(&binary.as_str()) {
293        let mut file_args = Vec::new();
294        let mut config_args = Vec::new();
295
296        while idx < tokens.len() {
297            let token = tokens[idx];
298
299            // Skip flags that consume the next argument
300            if matches!(
301                token,
302                "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
303            ) {
304                idx += 2;
305                continue;
306            }
307
308            if token.starts_with('-') {
309                if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
310                    config_args.push(config);
311                    if !token.contains('=') {
312                        idx += 1;
313                    }
314                }
315                idx += 1;
316                continue;
317            }
318
319            if looks_like_file_path(token) {
320                file_args.push(token.to_string());
321            }
322            idx += 1;
323        }
324
325        return Some(ScriptCommand {
326            binary,
327            config_args,
328            file_args,
329        });
330    }
331
332    // For other binaries, extract config args and file args
333    let mut config_args = Vec::new();
334    let mut file_args = Vec::new();
335
336    while idx < tokens.len() {
337        let token = tokens[idx];
338
339        if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
340            config_args.push(config);
341            if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
342                idx += 1;
343            } else {
344                idx += 2;
345            }
346            continue;
347        }
348
349        if token.starts_with('-') {
350            idx += 1;
351            continue;
352        }
353
354        if looks_like_file_path(token) {
355            file_args.push(token.to_string());
356        }
357        idx += 1;
358    }
359
360    Some(ScriptCommand {
361        binary,
362        config_args,
363        file_args,
364    })
365}
366
367/// Extract a config file path from a `--config` or `-c` flag.
368fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
369    // --config=path/to/config.js
370    if let Some(value) = token.strip_prefix("--config=")
371        && !value.is_empty()
372    {
373        return Some(value.to_string());
374    }
375    // -c=path
376    if let Some(value) = token.strip_prefix("-c=")
377        && !value.is_empty()
378    {
379        return Some(value.to_string());
380    }
381    // --config path or -c path
382    if matches!(token, "--config" | "-c")
383        && let Some(next_token) = next
384        && !next_token.starts_with('-')
385    {
386        return Some(next_token.to_string());
387    }
388    None
389}
390
391/// Check if a token is an environment variable assignment (`KEY=value`).
392fn is_env_assignment(token: &str) -> bool {
393    if let Some(eq_pos) = token.find('=') {
394        let name = &token[..eq_pos];
395        !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
396    } else {
397        false
398    }
399}
400
401/// Check if a token looks like a file path (has a known extension or path separator).
402fn looks_like_file_path(token: &str) -> bool {
403    const EXTENSIONS: &[&str] = &[
404        ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
405        ".toml",
406    ];
407    if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
408        return true;
409    }
410    token.starts_with("./")
411        || token.starts_with("../")
412        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
413}
414
415/// Check if a command is a shell built-in (not an npm package).
416fn is_builtin_command(cmd: &str) -> bool {
417    matches!(
418        cmd,
419        "echo"
420            | "cat"
421            | "cp"
422            | "mv"
423            | "rm"
424            | "mkdir"
425            | "rmdir"
426            | "ls"
427            | "cd"
428            | "pwd"
429            | "test"
430            | "true"
431            | "false"
432            | "exit"
433            | "export"
434            | "source"
435            | "which"
436            | "chmod"
437            | "chown"
438            | "touch"
439            | "find"
440            | "grep"
441            | "sed"
442            | "awk"
443            | "xargs"
444            | "tee"
445            | "sort"
446            | "uniq"
447            | "wc"
448            | "head"
449            | "tail"
450            | "sleep"
451            | "wait"
452            | "kill"
453            | "sh"
454            | "bash"
455            | "zsh"
456    )
457}
458
459/// Resolve a binary name to its npm package name.
460///
461/// Strategy:
462/// 1. Check known binary→package divergence map
463/// 2. Read `node_modules/.bin/<binary>` symlink target
464/// 3. Fall back: binary name = package name
465pub fn resolve_binary_to_package(binary: &str, root: &Path) -> String {
466    // 1. Known divergences
467    let map = binary_to_package_map();
468    if let Some(&pkg) = map.get(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    // --- is_production_script ---
886
887    #[test]
888    fn production_script_start() {
889        assert!(super::is_production_script("start"));
890        assert!(super::is_production_script("prestart"));
891        assert!(super::is_production_script("poststart"));
892    }
893
894    #[test]
895    fn production_script_build() {
896        assert!(super::is_production_script("build"));
897        assert!(super::is_production_script("prebuild"));
898        assert!(super::is_production_script("postbuild"));
899        assert!(super::is_production_script("build:prod"));
900        assert!(super::is_production_script("build:esm"));
901    }
902
903    #[test]
904    fn production_script_serve_preview() {
905        assert!(super::is_production_script("serve"));
906        assert!(super::is_production_script("preview"));
907        assert!(super::is_production_script("prepare"));
908    }
909
910    #[test]
911    fn non_production_scripts() {
912        assert!(!super::is_production_script("test"));
913        assert!(!super::is_production_script("lint"));
914        assert!(!super::is_production_script("dev"));
915        assert!(!super::is_production_script("storybook"));
916        assert!(!super::is_production_script("typecheck"));
917        assert!(!super::is_production_script("format"));
918        assert!(!super::is_production_script("e2e"));
919    }
920
921    // --- filter_production_scripts ---
922
923    #[test]
924    fn filter_keeps_production_scripts() {
925        let scripts: HashMap<String, String> = [
926            ("build".to_string(), "webpack".to_string()),
927            ("start".to_string(), "node server.js".to_string()),
928            ("test".to_string(), "jest".to_string()),
929            ("lint".to_string(), "eslint src".to_string()),
930            ("dev".to_string(), "next dev".to_string()),
931        ]
932        .into_iter()
933        .collect();
934
935        let filtered = filter_production_scripts(&scripts);
936        assert!(filtered.contains_key("build"));
937        assert!(filtered.contains_key("start"));
938        assert!(!filtered.contains_key("test"));
939        assert!(!filtered.contains_key("lint"));
940        assert!(!filtered.contains_key("dev"));
941    }
942}