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