1#[expect(clippy::disallowed_types)]
13use std::collections::HashMap;
14use std::path::Path;
15
16use rustc_hash::FxHashSet;
17
18#[derive(Debug, Default)]
20pub struct ScriptAnalysis {
21 pub used_packages: FxHashSet<String>,
23 pub config_files: Vec<String>,
25 pub entry_files: Vec<String>,
27}
28
29#[derive(Debug, PartialEq, Eq)]
31pub struct ScriptCommand {
32 pub binary: String,
34 pub config_args: Vec<String>,
36 pub file_args: Vec<String>,
38}
39
40static 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
55const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
57
58const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
60
61#[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
74fn is_production_script(name: &str) -> bool {
79 let root_name = name.split(':').next().unwrap_or(name);
81
82 if matches!(
84 root_name,
85 "start" | "build" | "serve" | "preview" | "prepare" | "prepublishOnly" | "postinstall"
86 ) {
87 return true;
88 }
89
90 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#[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 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 if !cmd.binary.is_empty() && !is_builtin_command(&cmd.binary) {
125 if NODE_RUNNERS.contains(&cmd.binary.as_str()) {
126 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
145pub 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
164fn 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 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 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
224fn skip_initial_wrappers(tokens: &[&str], mut idx: usize) -> Option<usize> {
228 while idx < tokens.len() && is_env_assignment(tokens[idx]) {
230 idx += 1;
231 }
232 if idx >= tokens.len() {
233 return None;
234 }
235
236 while idx < tokens.len() && ENV_WRAPPERS.contains(&tokens[idx]) {
238 idx += 1;
239 while idx < tokens.len() && is_env_assignment(tokens[idx]) {
241 idx += 1;
242 }
243 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
255fn 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 while idx < tokens.len() && tokens[idx].starts_with('-') {
264 let flag = tokens[idx];
265 idx += 1;
266 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 return None;
279 } else {
280 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
294fn 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 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
343fn 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
364fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
366 if let Some(value) = token.strip_prefix("--config=")
368 && !value.is_empty()
369 {
370 return Some(value.to_string());
371 }
372 if let Some(value) = token.strip_prefix("-c=")
374 && !value.is_empty()
375 {
376 return Some(value.to_string());
377 }
378 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
388fn 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
396fn 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
410fn 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
454pub fn resolve_binary_to_package(binary: &str, root: &Path) -> String {
461 if let Some(&(_, pkg)) = BINARY_TO_PACKAGE.iter().find(|(bin, _)| *bin == binary) {
463 return pkg.to_string();
464 }
465
466 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 binary.to_string()
476}
477
478fn 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 if part.starts_with('@') && i + 1 < parts.len() {
493 return Some(format!("{}/{}", part, parts[i + 1]));
494 }
495 return Some(part.to_string());
497 }
498
499 None
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[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 let cmds = parse_script("yarn build");
612 assert!(cmds.is_empty());
613 }
614
615 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
995 fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
996 let _ = parse_script(&s);
997 }
998
999 #[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 #[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 #[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 #[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 #[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 #[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}