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