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