Skip to main content

fallow_core/scripts/
mod.rs

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