1mod resolve;
13mod shell;
14
15#[expect(clippy::disallowed_types)]
16use std::collections::HashMap;
17use std::path::Path;
18
19use rustc_hash::FxHashSet;
20
21pub use resolve::resolve_binary_to_package;
22
23const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
25
26const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
28
29#[derive(Debug, Default)]
31pub struct ScriptAnalysis {
32 pub used_packages: FxHashSet<String>,
34 pub config_files: Vec<String>,
36 pub entry_files: Vec<String>,
38}
39
40#[derive(Debug, PartialEq, Eq)]
42pub struct ScriptCommand {
43 pub binary: String,
45 pub config_args: Vec<String>,
47 pub file_args: Vec<String>,
49}
50
51#[expect(clippy::implicit_hasher, clippy::disallowed_types)]
56pub fn filter_production_scripts(scripts: &HashMap<String, String>) -> HashMap<String, String> {
57 scripts
58 .iter()
59 .filter(|(name, _)| is_production_script(name))
60 .map(|(k, v)| (k.clone(), v.clone()))
61 .collect()
62}
63
64fn is_production_script(name: &str) -> bool {
69 let root_name = name.split(':').next().unwrap_or(name);
71
72 if matches!(
74 root_name,
75 "start" | "build" | "serve" | "preview" | "prepare" | "prepublishOnly" | "postinstall"
76 ) {
77 return true;
78 }
79
80 let base = root_name
82 .strip_prefix("pre")
83 .or_else(|| root_name.strip_prefix("post"));
84
85 base.is_some_and(|base| matches!(base, "start" | "build" | "serve" | "install"))
86}
87
88#[expect(clippy::implicit_hasher, clippy::disallowed_types)]
93pub fn analyze_scripts(scripts: &HashMap<String, String>, root: &Path) -> ScriptAnalysis {
94 let mut result = ScriptAnalysis::default();
95
96 for script_value in scripts.values() {
97 for wrapper in ENV_WRAPPERS {
99 if script_value
100 .split_whitespace()
101 .any(|token| token == *wrapper)
102 {
103 let pkg = resolve_binary_to_package(wrapper, root);
104 if !is_builtin_command(wrapper) {
105 result.used_packages.insert(pkg);
106 }
107 }
108 }
109
110 let commands = parse_script(script_value);
111
112 for cmd in commands {
113 if !cmd.binary.is_empty() && !is_builtin_command(&cmd.binary) {
115 if NODE_RUNNERS.contains(&cmd.binary.as_str()) {
116 if cmd.binary != "node" && cmd.binary != "bun" {
118 let pkg = resolve_binary_to_package(&cmd.binary, root);
119 result.used_packages.insert(pkg);
120 }
121 } else {
122 let pkg = resolve_binary_to_package(&cmd.binary, root);
123 result.used_packages.insert(pkg);
124 }
125 }
126
127 result.config_files.extend(cmd.config_args);
128 result.entry_files.extend(cmd.file_args);
129 }
130 }
131
132 result
133}
134
135pub fn parse_script(script: &str) -> Vec<ScriptCommand> {
139 let mut commands = Vec::new();
140
141 for segment in shell::split_shell_operators(script) {
142 let segment = segment.trim();
143 if segment.is_empty() {
144 continue;
145 }
146 if let Some(cmd) = parse_command_segment(segment) {
147 commands.push(cmd);
148 }
149 }
150
151 commands
152}
153
154fn extract_args_for_binary(
158 tokens: &[&str],
159 mut idx: usize,
160 is_node_runner: bool,
161) -> (Vec<String>, Vec<String>) {
162 let mut file_args = Vec::new();
163 let mut config_args = Vec::new();
164
165 while idx < tokens.len() {
166 let token = tokens[idx];
167
168 if is_node_runner
170 && matches!(
171 token,
172 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
173 )
174 {
175 idx += 2;
176 continue;
177 }
178
179 if let Some(config) = extract_config_arg(token, tokens.get(idx + 1).copied()) {
180 config_args.push(config);
181 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
182 idx += 1;
183 } else {
184 idx += 2;
185 }
186 continue;
187 }
188
189 if token.starts_with('-') {
190 idx += 1;
191 continue;
192 }
193
194 if looks_like_file_path(token) {
195 file_args.push(token.to_string());
196 }
197 idx += 1;
198 }
199
200 (file_args, config_args)
201}
202
203fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
205 let tokens: Vec<&str> = segment.split_whitespace().collect();
206 if tokens.is_empty() {
207 return None;
208 }
209
210 let idx = shell::skip_initial_wrappers(&tokens, 0)?;
211 let idx = shell::advance_past_package_manager(&tokens, idx)?;
212
213 let binary = tokens[idx].to_string();
214 let is_node_runner = NODE_RUNNERS.contains(&binary.as_str());
215 let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
216
217 Some(ScriptCommand {
218 binary,
219 config_args,
220 file_args,
221 })
222}
223
224fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
226 if let Some(value) = token.strip_prefix("--config=")
228 && !value.is_empty()
229 {
230 return Some(value.to_string());
231 }
232 if let Some(value) = token.strip_prefix("-c=")
234 && !value.is_empty()
235 {
236 return Some(value.to_string());
237 }
238 if matches!(token, "--config" | "-c")
240 && let Some(next_token) = next
241 && !next_token.starts_with('-')
242 {
243 return Some(next_token.to_string());
244 }
245 None
246}
247
248fn is_env_assignment(token: &str) -> bool {
250 token.find('=').is_some_and(|eq_pos| {
251 let name = &token[..eq_pos];
252 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
253 })
254}
255
256fn looks_like_file_path(token: &str) -> bool {
258 const EXTENSIONS: &[&str] = &[
259 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
260 ".toml",
261 ];
262 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
263 return true;
264 }
265 token.starts_with("./")
266 || token.starts_with("../")
267 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
268}
269
270fn is_builtin_command(cmd: &str) -> bool {
272 matches!(
273 cmd,
274 "echo"
275 | "cat"
276 | "cp"
277 | "mv"
278 | "rm"
279 | "mkdir"
280 | "rmdir"
281 | "ls"
282 | "cd"
283 | "pwd"
284 | "test"
285 | "true"
286 | "false"
287 | "exit"
288 | "export"
289 | "source"
290 | "which"
291 | "chmod"
292 | "chown"
293 | "touch"
294 | "find"
295 | "grep"
296 | "sed"
297 | "awk"
298 | "xargs"
299 | "tee"
300 | "sort"
301 | "uniq"
302 | "wc"
303 | "head"
304 | "tail"
305 | "sleep"
306 | "wait"
307 | "kill"
308 | "sh"
309 | "bash"
310 | "zsh"
311 )
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
321 fn simple_binary() {
322 let cmds = parse_script("webpack");
323 assert_eq!(cmds.len(), 1);
324 assert_eq!(cmds[0].binary, "webpack");
325 }
326
327 #[test]
328 fn binary_with_args() {
329 let cmds = parse_script("eslint src --ext .ts,.tsx");
330 assert_eq!(cmds.len(), 1);
331 assert_eq!(cmds[0].binary, "eslint");
332 }
333
334 #[test]
335 fn chained_commands() {
336 let cmds = parse_script("tsc --noEmit && eslint src");
337 assert_eq!(cmds.len(), 2);
338 assert_eq!(cmds[0].binary, "tsc");
339 assert_eq!(cmds[1].binary, "eslint");
340 }
341
342 #[test]
343 fn semicolon_separator() {
344 let cmds = parse_script("tsc; eslint src");
345 assert_eq!(cmds.len(), 2);
346 assert_eq!(cmds[0].binary, "tsc");
347 assert_eq!(cmds[1].binary, "eslint");
348 }
349
350 #[test]
351 fn or_chain() {
352 let cmds = parse_script("tsc --noEmit || echo failed");
353 assert_eq!(cmds.len(), 2);
354 assert_eq!(cmds[0].binary, "tsc");
355 assert_eq!(cmds[1].binary, "echo");
356 }
357
358 #[test]
359 fn pipe_operator() {
360 let cmds = parse_script("jest --json | tee results.json");
361 assert_eq!(cmds.len(), 2);
362 assert_eq!(cmds[0].binary, "jest");
363 assert_eq!(cmds[1].binary, "tee");
364 }
365
366 #[test]
367 fn npx_prefix() {
368 let cmds = parse_script("npx eslint src");
369 assert_eq!(cmds.len(), 1);
370 assert_eq!(cmds[0].binary, "eslint");
371 }
372
373 #[test]
374 fn pnpx_prefix() {
375 let cmds = parse_script("pnpx vitest run");
376 assert_eq!(cmds.len(), 1);
377 assert_eq!(cmds[0].binary, "vitest");
378 }
379
380 #[test]
381 fn npx_with_flags() {
382 let cmds = parse_script("npx --yes --package @scope/tool eslint src");
383 assert_eq!(cmds.len(), 1);
384 assert_eq!(cmds[0].binary, "eslint");
385 }
386
387 #[test]
388 fn yarn_exec() {
389 let cmds = parse_script("yarn exec jest");
390 assert_eq!(cmds.len(), 1);
391 assert_eq!(cmds[0].binary, "jest");
392 }
393
394 #[test]
395 fn pnpm_exec() {
396 let cmds = parse_script("pnpm exec vitest run");
397 assert_eq!(cmds.len(), 1);
398 assert_eq!(cmds[0].binary, "vitest");
399 }
400
401 #[test]
402 fn pnpm_dlx() {
403 let cmds = parse_script("pnpm dlx create-react-app my-app");
404 assert_eq!(cmds.len(), 1);
405 assert_eq!(cmds[0].binary, "create-react-app");
406 }
407
408 #[test]
409 fn npm_run_skipped() {
410 let cmds = parse_script("npm run build");
411 assert!(cmds.is_empty());
412 }
413
414 #[test]
415 fn yarn_run_skipped() {
416 let cmds = parse_script("yarn run test");
417 assert!(cmds.is_empty());
418 }
419
420 #[test]
421 fn bare_yarn_skipped() {
422 let cmds = parse_script("yarn build");
424 assert!(cmds.is_empty());
425 }
426
427 #[test]
430 fn cross_env_prefix() {
431 let cmds = parse_script("cross-env NODE_ENV=production webpack");
432 assert_eq!(cmds.len(), 1);
433 assert_eq!(cmds[0].binary, "webpack");
434 }
435
436 #[test]
437 fn dotenv_prefix() {
438 let cmds = parse_script("dotenv -- next build");
439 assert_eq!(cmds.len(), 1);
440 assert_eq!(cmds[0].binary, "next");
441 }
442
443 #[test]
444 fn env_var_assignment_prefix() {
445 let cmds = parse_script("NODE_ENV=production webpack --mode production");
446 assert_eq!(cmds.len(), 1);
447 assert_eq!(cmds[0].binary, "webpack");
448 }
449
450 #[test]
451 fn multiple_env_vars() {
452 let cmds = parse_script("NODE_ENV=test CI=true jest");
453 assert_eq!(cmds.len(), 1);
454 assert_eq!(cmds[0].binary, "jest");
455 }
456
457 #[test]
460 fn node_runner_file_args() {
461 let cmds = parse_script("node scripts/build.js");
462 assert_eq!(cmds.len(), 1);
463 assert_eq!(cmds[0].binary, "node");
464 assert_eq!(cmds[0].file_args, vec!["scripts/build.js"]);
465 }
466
467 #[test]
468 fn tsx_runner_file_args() {
469 let cmds = parse_script("tsx scripts/migrate.ts");
470 assert_eq!(cmds.len(), 1);
471 assert_eq!(cmds[0].binary, "tsx");
472 assert_eq!(cmds[0].file_args, vec!["scripts/migrate.ts"]);
473 }
474
475 #[test]
476 fn node_with_flags() {
477 let cmds = parse_script("node --experimental-specifier-resolution=node scripts/run.mjs");
478 assert_eq!(cmds.len(), 1);
479 assert_eq!(cmds[0].file_args, vec!["scripts/run.mjs"]);
480 }
481
482 #[test]
483 fn node_eval_no_file() {
484 let cmds = parse_script("node -e \"console.log('hi')\"");
485 assert_eq!(cmds.len(), 1);
486 assert_eq!(cmds[0].binary, "node");
487 assert!(cmds[0].file_args.is_empty());
488 }
489
490 #[test]
491 fn node_multiple_files() {
492 let cmds = parse_script("node --test file1.mjs file2.mjs");
493 assert_eq!(cmds.len(), 1);
494 assert_eq!(cmds[0].file_args, vec!["file1.mjs", "file2.mjs"]);
495 }
496
497 #[test]
500 fn config_equals() {
501 let cmds = parse_script("webpack --config=webpack.prod.js");
502 assert_eq!(cmds.len(), 1);
503 assert_eq!(cmds[0].binary, "webpack");
504 assert_eq!(cmds[0].config_args, vec!["webpack.prod.js"]);
505 }
506
507 #[test]
508 fn config_space() {
509 let cmds = parse_script("jest --config jest.config.ts");
510 assert_eq!(cmds.len(), 1);
511 assert_eq!(cmds[0].binary, "jest");
512 assert_eq!(cmds[0].config_args, vec!["jest.config.ts"]);
513 }
514
515 #[test]
516 fn config_short_flag() {
517 let cmds = parse_script("eslint -c .eslintrc.json src");
518 assert_eq!(cmds.len(), 1);
519 assert_eq!(cmds[0].binary, "eslint");
520 assert_eq!(cmds[0].config_args, vec![".eslintrc.json"]);
521 }
522
523 #[test]
526 fn tsc_maps_to_typescript() {
527 let pkg = resolve_binary_to_package("tsc", Path::new("/nonexistent"));
528 assert_eq!(pkg, "typescript");
529 }
530
531 #[test]
532 fn ng_maps_to_angular_cli() {
533 let pkg = resolve_binary_to_package("ng", Path::new("/nonexistent"));
534 assert_eq!(pkg, "@angular/cli");
535 }
536
537 #[test]
538 fn biome_maps_to_biomejs() {
539 let pkg = resolve_binary_to_package("biome", Path::new("/nonexistent"));
540 assert_eq!(pkg, "@biomejs/biome");
541 }
542
543 #[test]
544 fn unknown_binary_is_identity() {
545 let pkg = resolve_binary_to_package("my-custom-tool", Path::new("/nonexistent"));
546 assert_eq!(pkg, "my-custom-tool");
547 }
548
549 #[test]
550 fn run_s_maps_to_npm_run_all() {
551 let pkg = resolve_binary_to_package("run-s", Path::new("/nonexistent"));
552 assert_eq!(pkg, "npm-run-all");
553 }
554
555 #[test]
558 fn bin_path_regular_package() {
559 let path = std::path::Path::new("../webpack/bin/webpack.js");
560 assert_eq!(
561 resolve::extract_package_from_bin_path(path),
562 Some("webpack".to_string())
563 );
564 }
565
566 #[test]
567 fn bin_path_scoped_package() {
568 let path = std::path::Path::new("../@babel/cli/bin/babel.js");
569 assert_eq!(
570 resolve::extract_package_from_bin_path(path),
571 Some("@babel/cli".to_string())
572 );
573 }
574
575 #[test]
578 fn builtin_commands_not_tracked() {
579 let scripts: HashMap<String, String> =
580 [("postinstall".to_string(), "echo done".to_string())]
581 .into_iter()
582 .collect();
583 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
584 assert!(result.used_packages.is_empty());
585 }
586
587 #[test]
590 fn analyze_extracts_binaries() {
591 let scripts: HashMap<String, String> = [
592 ("build".to_string(), "tsc --noEmit && webpack".to_string()),
593 ("lint".to_string(), "eslint src".to_string()),
594 ("test".to_string(), "jest".to_string()),
595 ]
596 .into_iter()
597 .collect();
598 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
599 assert!(result.used_packages.contains("typescript"));
600 assert!(result.used_packages.contains("webpack"));
601 assert!(result.used_packages.contains("eslint"));
602 assert!(result.used_packages.contains("jest"));
603 }
604
605 #[test]
606 fn analyze_extracts_config_files() {
607 let scripts: HashMap<String, String> = [(
608 "build".to_string(),
609 "webpack --config webpack.prod.js".to_string(),
610 )]
611 .into_iter()
612 .collect();
613 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
614 assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
615 }
616
617 #[test]
618 fn analyze_extracts_entry_files() {
619 let scripts: HashMap<String, String> =
620 [("seed".to_string(), "ts-node scripts/seed.ts".to_string())]
621 .into_iter()
622 .collect();
623 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
624 assert!(result.entry_files.contains(&"scripts/seed.ts".to_string()));
625 assert!(result.used_packages.contains("ts-node"));
627 }
628
629 #[test]
630 fn analyze_cross_env_with_config() {
631 let scripts: HashMap<String, String> = [(
632 "build".to_string(),
633 "cross-env NODE_ENV=production webpack --config webpack.prod.js".to_string(),
634 )]
635 .into_iter()
636 .collect();
637 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
638 assert!(result.used_packages.contains("cross-env"));
639 assert!(result.used_packages.contains("webpack"));
640 assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
641 }
642
643 #[test]
644 fn analyze_complex_script() {
645 let scripts: HashMap<String, String> = [(
646 "ci".to_string(),
647 "cross-env CI=true npm run build && jest --config jest.ci.js --coverage".to_string(),
648 )]
649 .into_iter()
650 .collect();
651 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
652 assert!(result.used_packages.contains("cross-env"));
654 assert!(result.used_packages.contains("jest"));
655 assert!(!result.used_packages.contains("npm"));
656 assert!(result.config_files.contains(&"jest.ci.js".to_string()));
657 }
658
659 #[test]
662 fn env_assignment_valid() {
663 assert!(is_env_assignment("NODE_ENV=production"));
664 assert!(is_env_assignment("CI=true"));
665 assert!(is_env_assignment("PORT=3000"));
666 }
667
668 #[test]
669 fn env_assignment_invalid() {
670 assert!(!is_env_assignment("--config"));
671 assert!(!is_env_assignment("webpack"));
672 assert!(!is_env_assignment("./scripts/build.js"));
673 }
674
675 #[test]
678 fn split_respects_quotes() {
679 let segments = shell::split_shell_operators("echo 'a && b' && jest");
680 assert_eq!(segments.len(), 2);
681 assert!(segments[1].trim() == "jest");
682 }
683
684 #[test]
685 fn split_double_quotes() {
686 let segments = shell::split_shell_operators("echo \"a || b\" || jest");
687 assert_eq!(segments.len(), 2);
688 assert!(segments[1].trim() == "jest");
689 }
690
691 #[test]
692 fn background_operator_splits_commands() {
693 let cmds = parse_script("tsc --watch & webpack --watch");
694 assert_eq!(cmds.len(), 2);
695 assert_eq!(cmds[0].binary, "tsc");
696 assert_eq!(cmds[1].binary, "webpack");
697 }
698
699 #[test]
700 fn double_ampersand_still_works() {
701 let cmds = parse_script("tsc --watch && webpack --watch");
702 assert_eq!(cmds.len(), 2);
703 assert_eq!(cmds[0].binary, "tsc");
704 assert_eq!(cmds[1].binary, "webpack");
705 }
706
707 #[test]
708 fn multiple_background_operators() {
709 let cmds = parse_script("server & client & proxy");
710 assert_eq!(cmds.len(), 3);
711 assert_eq!(cmds[0].binary, "server");
712 assert_eq!(cmds[1].binary, "client");
713 assert_eq!(cmds[2].binary, "proxy");
714 }
715
716 #[test]
719 fn production_script_start() {
720 assert!(super::is_production_script("start"));
721 assert!(super::is_production_script("prestart"));
722 assert!(super::is_production_script("poststart"));
723 }
724
725 #[test]
726 fn production_script_build() {
727 assert!(super::is_production_script("build"));
728 assert!(super::is_production_script("prebuild"));
729 assert!(super::is_production_script("postbuild"));
730 assert!(super::is_production_script("build:prod"));
731 assert!(super::is_production_script("build:esm"));
732 }
733
734 #[test]
735 fn production_script_serve_preview() {
736 assert!(super::is_production_script("serve"));
737 assert!(super::is_production_script("preview"));
738 assert!(super::is_production_script("prepare"));
739 }
740
741 #[test]
742 fn non_production_scripts() {
743 assert!(!super::is_production_script("test"));
744 assert!(!super::is_production_script("lint"));
745 assert!(!super::is_production_script("dev"));
746 assert!(!super::is_production_script("storybook"));
747 assert!(!super::is_production_script("typecheck"));
748 assert!(!super::is_production_script("format"));
749 assert!(!super::is_production_script("e2e"));
750 }
751
752 #[test]
755 fn mixed_operators_all_binaries_detected() {
756 let cmds = parse_script("build && serve & watch || fallback");
757 assert_eq!(cmds.len(), 4);
758 assert_eq!(cmds[0].binary, "build");
759 assert_eq!(cmds[1].binary, "serve");
760 assert_eq!(cmds[2].binary, "watch");
761 assert_eq!(cmds[3].binary, "fallback");
762 }
763
764 #[test]
765 fn background_with_env_vars() {
766 let cmds = parse_script("NODE_ENV=production server &");
767 assert_eq!(cmds.len(), 1);
768 assert_eq!(cmds[0].binary, "server");
769 }
770
771 #[test]
772 fn trailing_background_operator() {
773 let cmds = parse_script("webpack --watch &");
774 assert_eq!(cmds.len(), 1);
775 assert_eq!(cmds[0].binary, "webpack");
776 }
777
778 #[test]
781 fn filter_keeps_production_scripts() {
782 let scripts: HashMap<String, String> = [
783 ("build".to_string(), "webpack".to_string()),
784 ("start".to_string(), "node server.js".to_string()),
785 ("test".to_string(), "jest".to_string()),
786 ("lint".to_string(), "eslint src".to_string()),
787 ("dev".to_string(), "next dev".to_string()),
788 ]
789 .into_iter()
790 .collect();
791
792 let filtered = filter_production_scripts(&scripts);
793 assert!(filtered.contains_key("build"));
794 assert!(filtered.contains_key("start"));
795 assert!(!filtered.contains_key("test"));
796 assert!(!filtered.contains_key("lint"));
797 assert!(!filtered.contains_key("dev"));
798 }
799
800 mod proptests {
801 use super::*;
802 use proptest::prelude::*;
803
804 proptest! {
805 #[test]
807 fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
808 let _ = parse_script(&s);
809 }
810
811 #[test]
813 fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
814 let _ = shell::split_shell_operators(&s);
815 }
816
817 #[test]
819 fn parsed_binaries_are_non_empty(
820 binary in "[a-z][a-z0-9-]{0,20}",
821 args in "[a-zA-Z0-9 _./=-]{0,50}",
822 ) {
823 let script = format!("{binary} {args}");
824 let commands = parse_script(&script);
825 for cmd in &commands {
826 prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
827 }
828 }
829
830 #[test]
832 fn analyze_scripts_no_panic(
833 name in "[a-z]{1,10}",
834 value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
835 ) {
836 let scripts: HashMap<String, String> =
837 [(name, value)].into_iter().collect();
838 let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
839 }
840
841 #[test]
843 fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
844 let _ = is_env_assignment(&s);
845 }
846
847 #[test]
849 fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
850 let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
851 prop_assert!(!result.is_empty(), "Package name should never be empty");
852 }
853
854 #[test]
857 fn chained_binaries_produce_multiple_commands(
858 bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
859 ) {
860 let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
861 "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
862 prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
863 let script = bins.join(" && ");
864 let commands = parse_script(&script);
865 prop_assert!(
866 commands.len() >= 2,
867 "Chained commands should produce multiple parsed commands, got {}",
868 commands.len()
869 );
870 }
871 }
872 }
873}