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)]
315#[expect(clippy::disallowed_types)]
316mod tests {
317 use super::*;
318
319 #[test]
322 fn simple_binary() {
323 let cmds = parse_script("webpack");
324 assert_eq!(cmds.len(), 1);
325 assert_eq!(cmds[0].binary, "webpack");
326 }
327
328 #[test]
329 fn binary_with_args() {
330 let cmds = parse_script("eslint src --ext .ts,.tsx");
331 assert_eq!(cmds.len(), 1);
332 assert_eq!(cmds[0].binary, "eslint");
333 }
334
335 #[test]
336 fn chained_commands() {
337 let cmds = parse_script("tsc --noEmit && eslint src");
338 assert_eq!(cmds.len(), 2);
339 assert_eq!(cmds[0].binary, "tsc");
340 assert_eq!(cmds[1].binary, "eslint");
341 }
342
343 #[test]
344 fn semicolon_separator() {
345 let cmds = parse_script("tsc; eslint src");
346 assert_eq!(cmds.len(), 2);
347 assert_eq!(cmds[0].binary, "tsc");
348 assert_eq!(cmds[1].binary, "eslint");
349 }
350
351 #[test]
352 fn or_chain() {
353 let cmds = parse_script("tsc --noEmit || echo failed");
354 assert_eq!(cmds.len(), 2);
355 assert_eq!(cmds[0].binary, "tsc");
356 assert_eq!(cmds[1].binary, "echo");
357 }
358
359 #[test]
360 fn pipe_operator() {
361 let cmds = parse_script("jest --json | tee results.json");
362 assert_eq!(cmds.len(), 2);
363 assert_eq!(cmds[0].binary, "jest");
364 assert_eq!(cmds[1].binary, "tee");
365 }
366
367 #[test]
368 fn npx_prefix() {
369 let cmds = parse_script("npx eslint src");
370 assert_eq!(cmds.len(), 1);
371 assert_eq!(cmds[0].binary, "eslint");
372 }
373
374 #[test]
375 fn pnpx_prefix() {
376 let cmds = parse_script("pnpx vitest run");
377 assert_eq!(cmds.len(), 1);
378 assert_eq!(cmds[0].binary, "vitest");
379 }
380
381 #[test]
382 fn npx_with_flags() {
383 let cmds = parse_script("npx --yes --package @scope/tool eslint src");
384 assert_eq!(cmds.len(), 1);
385 assert_eq!(cmds[0].binary, "eslint");
386 }
387
388 #[test]
389 fn yarn_exec() {
390 let cmds = parse_script("yarn exec jest");
391 assert_eq!(cmds.len(), 1);
392 assert_eq!(cmds[0].binary, "jest");
393 }
394
395 #[test]
396 fn pnpm_exec() {
397 let cmds = parse_script("pnpm exec vitest run");
398 assert_eq!(cmds.len(), 1);
399 assert_eq!(cmds[0].binary, "vitest");
400 }
401
402 #[test]
403 fn pnpm_dlx() {
404 let cmds = parse_script("pnpm dlx create-react-app my-app");
405 assert_eq!(cmds.len(), 1);
406 assert_eq!(cmds[0].binary, "create-react-app");
407 }
408
409 #[test]
410 fn npm_run_skipped() {
411 let cmds = parse_script("npm run build");
412 assert!(cmds.is_empty());
413 }
414
415 #[test]
416 fn yarn_run_skipped() {
417 let cmds = parse_script("yarn run test");
418 assert!(cmds.is_empty());
419 }
420
421 #[test]
422 fn bare_yarn_skipped() {
423 let cmds = parse_script("yarn build");
425 assert!(cmds.is_empty());
426 }
427
428 #[test]
431 fn cross_env_prefix() {
432 let cmds = parse_script("cross-env NODE_ENV=production webpack");
433 assert_eq!(cmds.len(), 1);
434 assert_eq!(cmds[0].binary, "webpack");
435 }
436
437 #[test]
438 fn dotenv_prefix() {
439 let cmds = parse_script("dotenv -- next build");
440 assert_eq!(cmds.len(), 1);
441 assert_eq!(cmds[0].binary, "next");
442 }
443
444 #[test]
445 fn env_var_assignment_prefix() {
446 let cmds = parse_script("NODE_ENV=production webpack --mode production");
447 assert_eq!(cmds.len(), 1);
448 assert_eq!(cmds[0].binary, "webpack");
449 }
450
451 #[test]
452 fn multiple_env_vars() {
453 let cmds = parse_script("NODE_ENV=test CI=true jest");
454 assert_eq!(cmds.len(), 1);
455 assert_eq!(cmds[0].binary, "jest");
456 }
457
458 #[test]
461 fn node_runner_file_args() {
462 let cmds = parse_script("node scripts/build.js");
463 assert_eq!(cmds.len(), 1);
464 assert_eq!(cmds[0].binary, "node");
465 assert_eq!(cmds[0].file_args, vec!["scripts/build.js"]);
466 }
467
468 #[test]
469 fn tsx_runner_file_args() {
470 let cmds = parse_script("tsx scripts/migrate.ts");
471 assert_eq!(cmds.len(), 1);
472 assert_eq!(cmds[0].binary, "tsx");
473 assert_eq!(cmds[0].file_args, vec!["scripts/migrate.ts"]);
474 }
475
476 #[test]
477 fn node_with_flags() {
478 let cmds = parse_script("node --experimental-specifier-resolution=node scripts/run.mjs");
479 assert_eq!(cmds.len(), 1);
480 assert_eq!(cmds[0].file_args, vec!["scripts/run.mjs"]);
481 }
482
483 #[test]
484 fn node_eval_no_file() {
485 let cmds = parse_script("node -e \"console.log('hi')\"");
486 assert_eq!(cmds.len(), 1);
487 assert_eq!(cmds[0].binary, "node");
488 assert!(cmds[0].file_args.is_empty());
489 }
490
491 #[test]
492 fn node_multiple_files() {
493 let cmds = parse_script("node --test file1.mjs file2.mjs");
494 assert_eq!(cmds.len(), 1);
495 assert_eq!(cmds[0].file_args, vec!["file1.mjs", "file2.mjs"]);
496 }
497
498 #[test]
501 fn config_equals() {
502 let cmds = parse_script("webpack --config=webpack.prod.js");
503 assert_eq!(cmds.len(), 1);
504 assert_eq!(cmds[0].binary, "webpack");
505 assert_eq!(cmds[0].config_args, vec!["webpack.prod.js"]);
506 }
507
508 #[test]
509 fn config_space() {
510 let cmds = parse_script("jest --config jest.config.ts");
511 assert_eq!(cmds.len(), 1);
512 assert_eq!(cmds[0].binary, "jest");
513 assert_eq!(cmds[0].config_args, vec!["jest.config.ts"]);
514 }
515
516 #[test]
517 fn config_short_flag() {
518 let cmds = parse_script("eslint -c .eslintrc.json src");
519 assert_eq!(cmds.len(), 1);
520 assert_eq!(cmds[0].binary, "eslint");
521 assert_eq!(cmds[0].config_args, vec![".eslintrc.json"]);
522 }
523
524 #[test]
527 fn tsc_maps_to_typescript() {
528 let pkg = resolve_binary_to_package("tsc", Path::new("/nonexistent"));
529 assert_eq!(pkg, "typescript");
530 }
531
532 #[test]
533 fn ng_maps_to_angular_cli() {
534 let pkg = resolve_binary_to_package("ng", Path::new("/nonexistent"));
535 assert_eq!(pkg, "@angular/cli");
536 }
537
538 #[test]
539 fn biome_maps_to_biomejs() {
540 let pkg = resolve_binary_to_package("biome", Path::new("/nonexistent"));
541 assert_eq!(pkg, "@biomejs/biome");
542 }
543
544 #[test]
545 fn unknown_binary_is_identity() {
546 let pkg = resolve_binary_to_package("my-custom-tool", Path::new("/nonexistent"));
547 assert_eq!(pkg, "my-custom-tool");
548 }
549
550 #[test]
551 fn run_s_maps_to_npm_run_all() {
552 let pkg = resolve_binary_to_package("run-s", Path::new("/nonexistent"));
553 assert_eq!(pkg, "npm-run-all");
554 }
555
556 #[test]
559 fn bin_path_regular_package() {
560 let path = std::path::Path::new("../webpack/bin/webpack.js");
561 assert_eq!(
562 resolve::extract_package_from_bin_path(path),
563 Some("webpack".to_string())
564 );
565 }
566
567 #[test]
568 fn bin_path_scoped_package() {
569 let path = std::path::Path::new("../@babel/cli/bin/babel.js");
570 assert_eq!(
571 resolve::extract_package_from_bin_path(path),
572 Some("@babel/cli".to_string())
573 );
574 }
575
576 #[test]
579 fn builtin_commands_not_tracked() {
580 let scripts: HashMap<String, String> =
581 std::iter::once(("postinstall".to_string(), "echo done".to_string())).collect();
582 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
583 assert!(result.used_packages.is_empty());
584 }
585
586 #[test]
589 fn analyze_extracts_binaries() {
590 let scripts: HashMap<String, String> = [
591 ("build".to_string(), "tsc --noEmit && webpack".to_string()),
592 ("lint".to_string(), "eslint src".to_string()),
593 ("test".to_string(), "jest".to_string()),
594 ]
595 .into_iter()
596 .collect();
597 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
598 assert!(result.used_packages.contains("typescript"));
599 assert!(result.used_packages.contains("webpack"));
600 assert!(result.used_packages.contains("eslint"));
601 assert!(result.used_packages.contains("jest"));
602 }
603
604 #[test]
605 fn analyze_extracts_config_files() {
606 let scripts: HashMap<String, String> = std::iter::once((
607 "build".to_string(),
608 "webpack --config webpack.prod.js".to_string(),
609 ))
610 .collect();
611 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
612 assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
613 }
614
615 #[test]
616 fn analyze_extracts_entry_files() {
617 let scripts: HashMap<String, String> =
618 std::iter::once(("seed".to_string(), "ts-node scripts/seed.ts".to_string())).collect();
619 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
620 assert!(result.entry_files.contains(&"scripts/seed.ts".to_string()));
621 assert!(result.used_packages.contains("ts-node"));
623 }
624
625 #[test]
626 fn analyze_cross_env_with_config() {
627 let scripts: HashMap<String, String> = std::iter::once((
628 "build".to_string(),
629 "cross-env NODE_ENV=production webpack --config webpack.prod.js".to_string(),
630 ))
631 .collect();
632 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
633 assert!(result.used_packages.contains("cross-env"));
634 assert!(result.used_packages.contains("webpack"));
635 assert!(result.config_files.contains(&"webpack.prod.js".to_string()));
636 }
637
638 #[test]
639 fn analyze_complex_script() {
640 let scripts: HashMap<String, String> = std::iter::once((
641 "ci".to_string(),
642 "cross-env CI=true npm run build && jest --config jest.ci.js --coverage".to_string(),
643 ))
644 .collect();
645 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
646 assert!(result.used_packages.contains("cross-env"));
648 assert!(result.used_packages.contains("jest"));
649 assert!(!result.used_packages.contains("npm"));
650 assert!(result.config_files.contains(&"jest.ci.js".to_string()));
651 }
652
653 #[test]
656 fn env_assignment_valid() {
657 assert!(is_env_assignment("NODE_ENV=production"));
658 assert!(is_env_assignment("CI=true"));
659 assert!(is_env_assignment("PORT=3000"));
660 }
661
662 #[test]
663 fn env_assignment_invalid() {
664 assert!(!is_env_assignment("--config"));
665 assert!(!is_env_assignment("webpack"));
666 assert!(!is_env_assignment("./scripts/build.js"));
667 }
668
669 #[test]
672 fn split_respects_quotes() {
673 let segments = shell::split_shell_operators("echo 'a && b' && jest");
674 assert_eq!(segments.len(), 2);
675 assert!(segments[1].trim() == "jest");
676 }
677
678 #[test]
679 fn split_double_quotes() {
680 let segments = shell::split_shell_operators("echo \"a || b\" || jest");
681 assert_eq!(segments.len(), 2);
682 assert!(segments[1].trim() == "jest");
683 }
684
685 #[test]
686 fn background_operator_splits_commands() {
687 let cmds = parse_script("tsc --watch & webpack --watch");
688 assert_eq!(cmds.len(), 2);
689 assert_eq!(cmds[0].binary, "tsc");
690 assert_eq!(cmds[1].binary, "webpack");
691 }
692
693 #[test]
694 fn double_ampersand_still_works() {
695 let cmds = parse_script("tsc --watch && webpack --watch");
696 assert_eq!(cmds.len(), 2);
697 assert_eq!(cmds[0].binary, "tsc");
698 assert_eq!(cmds[1].binary, "webpack");
699 }
700
701 #[test]
702 fn multiple_background_operators() {
703 let cmds = parse_script("server & client & proxy");
704 assert_eq!(cmds.len(), 3);
705 assert_eq!(cmds[0].binary, "server");
706 assert_eq!(cmds[1].binary, "client");
707 assert_eq!(cmds[2].binary, "proxy");
708 }
709
710 #[test]
713 fn production_script_start() {
714 assert!(super::is_production_script("start"));
715 assert!(super::is_production_script("prestart"));
716 assert!(super::is_production_script("poststart"));
717 }
718
719 #[test]
720 fn production_script_build() {
721 assert!(super::is_production_script("build"));
722 assert!(super::is_production_script("prebuild"));
723 assert!(super::is_production_script("postbuild"));
724 assert!(super::is_production_script("build:prod"));
725 assert!(super::is_production_script("build:esm"));
726 }
727
728 #[test]
729 fn production_script_serve_preview() {
730 assert!(super::is_production_script("serve"));
731 assert!(super::is_production_script("preview"));
732 assert!(super::is_production_script("prepare"));
733 }
734
735 #[test]
736 fn non_production_scripts() {
737 assert!(!super::is_production_script("test"));
738 assert!(!super::is_production_script("lint"));
739 assert!(!super::is_production_script("dev"));
740 assert!(!super::is_production_script("storybook"));
741 assert!(!super::is_production_script("typecheck"));
742 assert!(!super::is_production_script("format"));
743 assert!(!super::is_production_script("e2e"));
744 }
745
746 #[test]
749 fn mixed_operators_all_binaries_detected() {
750 let cmds = parse_script("build && serve & watch || fallback");
751 assert_eq!(cmds.len(), 4);
752 assert_eq!(cmds[0].binary, "build");
753 assert_eq!(cmds[1].binary, "serve");
754 assert_eq!(cmds[2].binary, "watch");
755 assert_eq!(cmds[3].binary, "fallback");
756 }
757
758 #[test]
759 fn background_with_env_vars() {
760 let cmds = parse_script("NODE_ENV=production server &");
761 assert_eq!(cmds.len(), 1);
762 assert_eq!(cmds[0].binary, "server");
763 }
764
765 #[test]
766 fn trailing_background_operator() {
767 let cmds = parse_script("webpack --watch &");
768 assert_eq!(cmds.len(), 1);
769 assert_eq!(cmds[0].binary, "webpack");
770 }
771
772 #[test]
775 fn filter_keeps_production_scripts() {
776 let scripts: HashMap<String, String> = [
777 ("build".to_string(), "webpack".to_string()),
778 ("start".to_string(), "node server.js".to_string()),
779 ("test".to_string(), "jest".to_string()),
780 ("lint".to_string(), "eslint src".to_string()),
781 ("dev".to_string(), "next dev".to_string()),
782 ]
783 .into_iter()
784 .collect();
785
786 let filtered = filter_production_scripts(&scripts);
787 assert!(filtered.contains_key("build"));
788 assert!(filtered.contains_key("start"));
789 assert!(!filtered.contains_key("test"));
790 assert!(!filtered.contains_key("lint"));
791 assert!(!filtered.contains_key("dev"));
792 }
793
794 #[test]
797 fn looks_like_file_path_with_known_extensions() {
798 assert!(super::looks_like_file_path("src/app.ts"));
799 assert!(super::looks_like_file_path("config.json"));
800 assert!(super::looks_like_file_path("setup.yaml"));
801 assert!(super::looks_like_file_path("rollup.config.mjs"));
802 assert!(super::looks_like_file_path("test.spec.tsx"));
803 assert!(super::looks_like_file_path("file.toml"));
804 }
805
806 #[test]
807 fn looks_like_file_path_with_relative_prefix() {
808 assert!(super::looks_like_file_path("./scripts/build"));
809 assert!(super::looks_like_file_path("../shared/utils"));
810 }
811
812 #[test]
813 fn looks_like_file_path_with_slash_but_not_scope() {
814 assert!(super::looks_like_file_path("src/components/Button"));
815 assert!(!super::looks_like_file_path("@scope/package")); }
817
818 #[test]
819 fn looks_like_file_path_url_not_file() {
820 assert!(!super::looks_like_file_path("https://example.com/path"));
821 }
822
823 #[test]
824 fn looks_like_file_path_bare_word_not_file() {
825 assert!(!super::looks_like_file_path("webpack"));
826 assert!(!super::looks_like_file_path("--mode"));
827 assert!(!super::looks_like_file_path("production"));
828 }
829
830 #[test]
833 fn extract_config_arg_with_equals() {
834 assert_eq!(
835 super::extract_config_arg("--config=webpack.prod.js", None),
836 Some("webpack.prod.js".to_string())
837 );
838 }
839
840 #[test]
841 fn extract_config_arg_short_with_equals() {
842 assert_eq!(
843 super::extract_config_arg("-c=.eslintrc.json", None),
844 Some(".eslintrc.json".to_string())
845 );
846 }
847
848 #[test]
849 fn extract_config_arg_with_next_token() {
850 assert_eq!(
851 super::extract_config_arg("--config", Some("jest.config.ts")),
852 Some("jest.config.ts".to_string())
853 );
854 }
855
856 #[test]
857 fn extract_config_arg_short_with_next_token() {
858 assert_eq!(
859 super::extract_config_arg("-c", Some(".eslintrc.json")),
860 Some(".eslintrc.json".to_string())
861 );
862 }
863
864 #[test]
865 fn extract_config_arg_next_is_flag_returns_none() {
866 assert_eq!(
867 super::extract_config_arg("--config", Some("--verbose")),
868 None
869 );
870 }
871
872 #[test]
873 fn extract_config_arg_no_match() {
874 assert_eq!(super::extract_config_arg("--verbose", None), None);
875 assert_eq!(super::extract_config_arg("src/index.ts", None), None);
876 }
877
878 #[test]
879 fn extract_config_arg_empty_equals_returns_none() {
880 assert_eq!(super::extract_config_arg("--config=", None), None);
881 assert_eq!(super::extract_config_arg("-c=", None), None);
882 }
883
884 #[test]
887 fn node_require_flag_skips_next_arg() {
888 let cmds = parse_script("node -r tsconfig-paths/register ./src/server.ts");
889 assert_eq!(cmds.len(), 1);
890 assert_eq!(cmds[0].binary, "node");
891 assert!(cmds[0].file_args.contains(&"./src/server.ts".to_string()));
894 assert!(
895 !cmds[0]
896 .file_args
897 .contains(&"tsconfig-paths/register".to_string())
898 );
899 }
900
901 #[test]
902 fn node_eval_skips_next_arg() {
903 let cmds = parse_script("node --eval \"console.log(1)\" scripts/run.js");
904 assert_eq!(cmds.len(), 1);
905 assert!(cmds[0].file_args.contains(&"scripts/run.js".to_string()));
907 }
908
909 #[test]
912 fn production_script_prepublish_only() {
913 assert!(super::is_production_script("prepublishOnly"));
914 }
915
916 #[test]
917 fn production_script_postinstall() {
918 assert!(super::is_production_script("postinstall"));
919 }
920
921 #[test]
922 fn production_script_preserve_is_not_production() {
923 assert!(super::is_production_script("preserve"));
926 }
927
928 #[test]
929 fn production_script_preinstall() {
930 assert!(super::is_production_script("preinstall"));
932 }
933
934 #[test]
935 fn production_script_namespaced() {
936 assert!(super::is_production_script("build:esm"));
937 assert!(super::is_production_script("start:dev"));
938 assert!(!super::is_production_script("test:unit"));
939 assert!(!super::is_production_script("lint:fix"));
940 }
941
942 #[test]
945 fn env_assignment_empty_value() {
946 assert!(is_env_assignment("KEY="));
947 }
948
949 #[test]
950 fn env_assignment_equals_at_start_is_not_assignment() {
951 assert!(!is_env_assignment("=value"));
952 }
953
954 #[test]
957 fn parse_empty_script() {
958 let cmds = parse_script("");
959 assert!(cmds.is_empty());
960 }
961
962 #[test]
963 fn parse_whitespace_only_script() {
964 let cmds = parse_script(" ");
965 assert!(cmds.is_empty());
966 }
967
968 #[test]
969 fn analyze_scripts_empty_scripts() {
970 let scripts: HashMap<String, String> = HashMap::new();
971 let result = analyze_scripts(&scripts, Path::new("/nonexistent"));
972 assert!(result.used_packages.is_empty());
973 assert!(result.config_files.is_empty());
974 assert!(result.entry_files.is_empty());
975 }
976
977 #[test]
980 fn bun_treated_as_package_manager() {
981 let cmds = parse_script("bun scripts/build.ts");
983 assert!(
984 cmds.is_empty(),
985 "bare `bun <arg>` should be treated as running a script (like yarn)"
986 );
987 }
988
989 #[test]
990 fn bun_exec_extracts_binary() {
991 let cmds = parse_script("bun exec vitest run");
992 assert_eq!(cmds.len(), 1);
993 assert_eq!(cmds[0].binary, "vitest");
994 }
995
996 mod proptests {
997 use super::*;
998 use proptest::prelude::*;
999
1000 proptest! {
1001 #[test]
1003 fn parse_script_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
1004 let _ = parse_script(&s);
1005 }
1006
1007 #[test]
1009 fn split_shell_operators_no_panic(s in "[a-zA-Z0-9 _./@&|;=\"'-]{1,200}") {
1010 let _ = shell::split_shell_operators(&s);
1011 }
1012
1013 #[test]
1015 fn parsed_binaries_are_non_empty(
1016 binary in "[a-z][a-z0-9-]{0,20}",
1017 args in "[a-zA-Z0-9 _./=-]{0,50}",
1018 ) {
1019 let script = format!("{binary} {args}");
1020 let commands = parse_script(&script);
1021 for cmd in &commands {
1022 prop_assert!(!cmd.binary.is_empty(), "Binary name should never be empty");
1023 }
1024 }
1025
1026 #[test]
1028 fn analyze_scripts_no_panic(
1029 name in "[a-z]{1,10}",
1030 value in "[a-zA-Z0-9 _./@&|;=-]{1,100}",
1031 ) {
1032 let scripts: HashMap<String, String> = std::iter::once((name, value)).collect();
1033 let _ = analyze_scripts(&scripts, Path::new("/nonexistent"));
1034 }
1035
1036 #[test]
1038 fn is_env_assignment_no_panic(s in "[a-zA-Z0-9_=./-]{1,50}") {
1039 let _ = is_env_assignment(&s);
1040 }
1041
1042 #[test]
1044 fn resolve_binary_always_non_empty(binary in "[a-z][a-z0-9-]{0,20}") {
1045 let result = resolve_binary_to_package(&binary, Path::new("/nonexistent"));
1046 prop_assert!(!result.is_empty(), "Package name should never be empty");
1047 }
1048
1049 #[test]
1052 fn chained_binaries_produce_multiple_commands(
1053 bins in prop::collection::vec("[a-z][a-z0-9]{0,10}", 2..5),
1054 ) {
1055 let reserved = ["npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx",
1056 "node", "env", "cross", "sh", "bash", "exec", "sudo", "nohup"];
1057 prop_assume!(!bins.iter().any(|b| reserved.contains(&b.as_str())));
1058 let script = bins.join(" && ");
1059 let commands = parse_script(&script);
1060 prop_assert!(
1061 commands.len() >= 2,
1062 "Chained commands should produce multiple parsed commands, got {}",
1063 commands.len()
1064 );
1065 }
1066 }
1067 }
1068}