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
224#[expect(clippy::cognitive_complexity)] fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
227 let tokens: Vec<&str> = segment.split_whitespace().collect();
228 if tokens.is_empty() {
229 return None;
230 }
231
232 let mut idx = 0;
233
234 while idx < tokens.len() && is_env_assignment(tokens[idx]) {
236 idx += 1;
237 }
238 if idx >= tokens.len() {
239 return None;
240 }
241
242 while idx < tokens.len() && ENV_WRAPPERS.contains(&tokens[idx]) {
244 idx += 1;
245 while idx < tokens.len() && is_env_assignment(tokens[idx]) {
247 idx += 1;
248 }
249 if idx < tokens.len() && tokens[idx] == "--" {
251 idx += 1;
252 }
253 }
254 if idx >= tokens.len() {
255 return None;
256 }
257
258 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 let binary = tokens[idx].to_string();
292 idx += 1;
293
294 if NODE_RUNNERS.contains(&binary.as_str()) {
296 let mut file_args = Vec::new();
297 let mut config_args = Vec::new();
298
299 while idx < tokens.len() {
300 let token = tokens[idx];
301
302 if matches!(
304 token,
305 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
306 ) {
307 idx += 2;
308 continue;
309 }
310
311 if token.starts_with('-') {
312 if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
313 config_args.push(config);
314 if !token.contains('=') {
315 idx += 1;
316 }
317 }
318 idx += 1;
319 continue;
320 }
321
322 if looks_like_file_path(token) {
323 file_args.push(token.to_string());
324 }
325 idx += 1;
326 }
327
328 return Some(ScriptCommand {
329 binary,
330 config_args,
331 file_args,
332 });
333 }
334
335 let mut config_args = Vec::new();
337 let mut file_args = Vec::new();
338
339 while idx < tokens.len() {
340 let token = tokens[idx];
341
342 if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
343 config_args.push(config);
344 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
345 idx += 1;
346 } else {
347 idx += 2;
348 }
349 continue;
350 }
351
352 if token.starts_with('-') {
353 idx += 1;
354 continue;
355 }
356
357 if looks_like_file_path(token) {
358 file_args.push(token.to_string());
359 }
360 idx += 1;
361 }
362
363 Some(ScriptCommand {
364 binary,
365 config_args,
366 file_args,
367 })
368}
369
370fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
372 if let Some(value) = token.strip_prefix("--config=")
374 && !value.is_empty()
375 {
376 return Some(value.to_string());
377 }
378 if let Some(value) = token.strip_prefix("-c=")
380 && !value.is_empty()
381 {
382 return Some(value.to_string());
383 }
384 if matches!(token, "--config" | "-c")
386 && let Some(next_token) = next
387 && !next_token.starts_with('-')
388 {
389 return Some(next_token.to_string());
390 }
391 None
392}
393
394fn is_env_assignment(token: &str) -> bool {
396 token.find('=').is_some_and(|eq_pos| {
397 let name = &token[..eq_pos];
398 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
399 })
400}
401
402fn looks_like_file_path(token: &str) -> bool {
404 const EXTENSIONS: &[&str] = &[
405 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
406 ".toml",
407 ];
408 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
409 return true;
410 }
411 token.starts_with("./")
412 || token.starts_with("../")
413 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
414}
415
416fn is_builtin_command(cmd: &str) -> bool {
418 matches!(
419 cmd,
420 "echo"
421 | "cat"
422 | "cp"
423 | "mv"
424 | "rm"
425 | "mkdir"
426 | "rmdir"
427 | "ls"
428 | "cd"
429 | "pwd"
430 | "test"
431 | "true"
432 | "false"
433 | "exit"
434 | "export"
435 | "source"
436 | "which"
437 | "chmod"
438 | "chown"
439 | "touch"
440 | "find"
441 | "grep"
442 | "sed"
443 | "awk"
444 | "xargs"
445 | "tee"
446 | "sort"
447 | "uniq"
448 | "wc"
449 | "head"
450 | "tail"
451 | "sleep"
452 | "wait"
453 | "kill"
454 | "sh"
455 | "bash"
456 | "zsh"
457 )
458}
459
460pub fn resolve_binary_to_package(binary: &str, root: &Path) -> String {
467 if let Some(&(_, pkg)) = BINARY_TO_PACKAGE.iter().find(|(bin, _)| *bin == binary) {
469 return pkg.to_string();
470 }
471
472 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 binary.to_string()
482}
483
484fn 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 if part.starts_with('@') && i + 1 < parts.len() {
499 return Some(format!("{}/{}", part, parts[i + 1]));
500 }
501 return Some(part.to_string());
503 }
504
505 None
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[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 let cmds = parse_script("yarn build");
618 assert!(cmds.is_empty());
619 }
620
621 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[test]
872 fn split_respects_quotes() {
873 let segments = split_shell_operators("echo 'a && b' && jest");
874 assert_eq!(segments.len(), 2);
875 assert!(segments[1].trim() == "jest");
876 }
877
878 #[test]
879 fn split_double_quotes() {
880 let segments = split_shell_operators("echo \"a || b\" || jest");
881 assert_eq!(segments.len(), 2);
882 assert!(segments[1].trim() == "jest");
883 }
884
885 #[test]
886 fn background_operator_splits_commands() {
887 let cmds = parse_script("tsc --watch & webpack --watch");
888 assert_eq!(cmds.len(), 2);
889 assert_eq!(cmds[0].binary, "tsc");
890 assert_eq!(cmds[1].binary, "webpack");
891 }
892
893 #[test]
894 fn double_ampersand_still_works() {
895 let cmds = parse_script("tsc --watch && webpack --watch");
896 assert_eq!(cmds.len(), 2);
897 assert_eq!(cmds[0].binary, "tsc");
898 assert_eq!(cmds[1].binary, "webpack");
899 }
900
901 #[test]
902 fn multiple_background_operators() {
903 let cmds = parse_script("server & client & proxy");
904 assert_eq!(cmds.len(), 3);
905 assert_eq!(cmds[0].binary, "server");
906 assert_eq!(cmds[1].binary, "client");
907 assert_eq!(cmds[2].binary, "proxy");
908 }
909
910 #[test]
913 fn production_script_start() {
914 assert!(super::is_production_script("start"));
915 assert!(super::is_production_script("prestart"));
916 assert!(super::is_production_script("poststart"));
917 }
918
919 #[test]
920 fn production_script_build() {
921 assert!(super::is_production_script("build"));
922 assert!(super::is_production_script("prebuild"));
923 assert!(super::is_production_script("postbuild"));
924 assert!(super::is_production_script("build:prod"));
925 assert!(super::is_production_script("build:esm"));
926 }
927
928 #[test]
929 fn production_script_serve_preview() {
930 assert!(super::is_production_script("serve"));
931 assert!(super::is_production_script("preview"));
932 assert!(super::is_production_script("prepare"));
933 }
934
935 #[test]
936 fn non_production_scripts() {
937 assert!(!super::is_production_script("test"));
938 assert!(!super::is_production_script("lint"));
939 assert!(!super::is_production_script("dev"));
940 assert!(!super::is_production_script("storybook"));
941 assert!(!super::is_production_script("typecheck"));
942 assert!(!super::is_production_script("format"));
943 assert!(!super::is_production_script("e2e"));
944 }
945
946 #[test]
949 fn mixed_operators_all_binaries_detected() {
950 let cmds = parse_script("build && serve & watch || fallback");
951 assert_eq!(cmds.len(), 4);
952 assert_eq!(cmds[0].binary, "build");
953 assert_eq!(cmds[1].binary, "serve");
954 assert_eq!(cmds[2].binary, "watch");
955 assert_eq!(cmds[3].binary, "fallback");
956 }
957
958 #[test]
959 fn background_with_env_vars() {
960 let cmds = parse_script("NODE_ENV=production server &");
961 assert_eq!(cmds.len(), 1);
962 assert_eq!(cmds[0].binary, "server");
963 }
964
965 #[test]
966 fn trailing_background_operator() {
967 let cmds = parse_script("webpack --watch &");
968 assert_eq!(cmds.len(), 1);
969 assert_eq!(cmds[0].binary, "webpack");
970 }
971
972 #[test]
975 fn filter_keeps_production_scripts() {
976 let scripts: HashMap<String, String> = [
977 ("build".to_string(), "webpack".to_string()),
978 ("start".to_string(), "node server.js".to_string()),
979 ("test".to_string(), "jest".to_string()),
980 ("lint".to_string(), "eslint src".to_string()),
981 ("dev".to_string(), "next dev".to_string()),
982 ]
983 .into_iter()
984 .collect();
985
986 let filtered = filter_production_scripts(&scripts);
987 assert!(filtered.contains_key("build"));
988 assert!(filtered.contains_key("start"));
989 assert!(!filtered.contains_key("test"));
990 assert!(!filtered.contains_key("lint"));
991 assert!(!filtered.contains_key("dev"));
992 }
993
994 mod proptests {
995 use super::*;
996 use proptest::prelude::*;
997
998 proptest! {
999 #[test]
1001 fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
1002 let _ = parse_script(&s);
1003 }
1004
1005 #[test]
1007 fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
1008 let _ = split_shell_operators(&s);
1009 }
1010
1011 #[test]
1013 fn parsed_binaries_are_non_empty(
1014 binary in "[a-z][a-z0-9-]{0,20}",
1015 args in "[a-zA-Z0-9 _./=-]{0,50}",
1016 ) {
1017 let script = format!("{binary} {args}");
1018 let commands = parse_script(&script);
1019 for cmd in &commands {
1020 prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
1021 }
1022 }
1023
1024 #[test]
1026 fn analyze_scripts_no_panic(
1027 name in "[a-z]{1,10}",
1028 value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
1029 ) {
1030 let scripts: HashMap<String, String> =
1031 [(name, value)].into_iter().collect();
1032 let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
1033 }
1034
1035 #[test]
1037 fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
1038 let _ = is_env_assignment(&s);
1039 }
1040
1041 #[test]
1043 fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
1044 let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
1045 prop_assert!(!result.is_empty(), "Package name should never be empty");
1046 }
1047
1048 #[test]
1051 fn chained_binaries_produce_multiple_commands(
1052 bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
1053 ) {
1054 let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
1055 "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
1056 prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
1057 let script = bins.join(" && ");
1058 let commands = parse_script(&script);
1059 prop_assert!(
1060 commands.len() >= 2,
1061 "Chained commands should produce multiple parsed commands, got {}",
1062 commands.len()
1063 );
1064 }
1065 }
1066 }
1067}