1mod lockfile;
13
14use fix_engine::language::LanguageFixProvider;
15use fix_engine_core::*;
16use konveyor_core::incident::Incident;
17use std::path::{Path, PathBuf};
18
19pub struct JsFixProvider;
21
22impl JsFixProvider {
23 pub fn new() -> Self {
24 Self
25 }
26}
27
28impl Default for JsFixProvider {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl LanguageFixProvider for JsFixProvider {
35 fn should_skip_path(&self, path: &Path) -> bool {
36 path.components().any(|c| c.as_os_str() == "node_modules")
42 }
43
44 fn post_process_lines(&self, lines: &mut [String]) {
45 dedup_import_specifiers(lines);
46 }
47
48 fn plan_remove_attribute(
49 &self,
50 rule_id: &str,
51 incident: &Incident,
52 file_path: &Path,
53 ) -> Option<PlannedFix> {
54 plan_remove_prop(rule_id, incident, file_path)
55 }
56
57 fn plan_ensure_dependency(
58 &self,
59 rule_id: &str,
60 incident: &Incident,
61 package: &str,
62 new_version: &str,
63 file_path: &Path,
64 ) -> Vec<PlannedFix> {
65 plan_ensure_npm_dependency(rule_id, incident, package, new_version, file_path)
66 }
67
68 fn get_matched_text(&self, incident: &Incident) -> String {
69 get_matched_text_from_incident(incident)
70 }
71
72 fn get_matched_text_for_rename(
73 &self,
74 incident: &Incident,
75 mappings: &[RenameMapping],
76 ) -> String {
77 get_matched_text_for_rename_from_incident(incident, mappings)
78 }
79
80 fn is_whole_file_rename(&self, incident: &Incident) -> bool {
81 incident.variables.contains_key("importedName")
85 }
86
87 fn pre_apply(&self, project_root: &Path) -> Option<Box<dyn std::any::Any>> {
88 if !project_root.join("yarn.lock").exists() {
94 return None;
95 }
96
97 tracing::info!("Capturing baseline peer dependency warnings before applying fixes");
98 let baseline = capture_yarn_missing_peer_names(project_root);
99 tracing::info!(
100 count = baseline.len(),
101 peers = ?baseline,
102 "Baseline unmet peer dependencies captured"
103 );
104 Some(Box::new(baseline))
105 }
106
107 fn post_apply(
108 &self,
109 project_root: &Path,
110 modified_files: &[std::path::PathBuf],
111 pre_state: Option<Box<dyn std::any::Any>>,
112 ) -> anyhow::Result<()> {
113 let any_package_json = modified_files
116 .iter()
117 .any(|p| p.file_name().and_then(|f| f.to_str()) == Some("package.json"));
118
119 if !any_package_json {
120 return Ok(());
121 }
122
123 tracing::info!("package.json was modified, running install to sync lockfile");
124
125 if project_root.join("yarn.lock").exists() {
126 let baseline = pre_state
128 .and_then(|s| s.downcast::<std::collections::HashSet<String>>().ok())
129 .map(|b| *b)
130 .unwrap_or_default();
131 run_yarn_install_and_resolve_peers(project_root, &baseline);
132 } else if project_root.join("pnpm-lock.yaml").exists() {
133 run_pnpm_install(project_root);
134 } else {
135 run_npm_install(project_root);
136 }
137
138 Ok(())
139 }
140}
141
142fn capture_yarn_missing_peer_names(project_root: &Path) -> std::collections::HashSet<String> {
148 let output = std::process::Command::new("yarn")
149 .args(["install"])
150 .env("YARN_ENABLE_SCRIPTS", "false")
151 .current_dir(project_root)
152 .output();
153
154 match output {
155 Ok(o) => {
156 let stdout = String::from_utf8_lossy(&o.stdout);
157 parse_yarn_missing_peer_deps(&stdout)
158 .into_iter()
159 .map(|p| p.peer_name)
160 .collect()
161 }
162 Err(e) => {
163 tracing::warn!(
164 "yarn install could not be executed for baseline capture: {}",
165 e
166 );
167 std::collections::HashSet::new()
168 }
169 }
170}
171
172fn run_yarn_install_and_resolve_peers(
186 project_root: &Path,
187 baseline_peers: &std::collections::HashSet<String>,
188) {
189 tracing::info!("Running yarn install (scripts disabled)");
190
191 let output = std::process::Command::new("yarn")
194 .args(["install"])
195 .env("YARN_ENABLE_SCRIPTS", "false")
196 .current_dir(project_root)
197 .output();
198
199 let output = match output {
200 Ok(o) => o,
201 Err(e) => {
202 tracing::warn!("yarn install could not be executed: {}", e);
203 return;
204 }
205 };
206
207 if !output.status.success() {
208 let stderr = String::from_utf8_lossy(&output.stderr);
209 tracing::warn!("yarn install failed: {}", stderr.trim());
210 }
211
212 let stdout = String::from_utf8_lossy(&output.stdout);
214 let all_missing_peers = parse_yarn_missing_peer_deps(&stdout);
215
216 let missing_peers: Vec<_> = all_missing_peers
220 .into_iter()
221 .filter(|p| !baseline_peers.contains(&p.peer_name))
222 .collect();
223
224 if missing_peers.is_empty() {
225 tracing::info!("yarn install completed, no newly-introduced missing peer dependencies");
226 return;
227 }
228
229 tracing::info!(
230 new_count = missing_peers.len(),
231 baseline_count = baseline_peers.len(),
232 "Filtered peer deps: {} new (out of {} total warnings, {} were pre-existing)",
233 missing_peers.len(),
234 missing_peers.len() + baseline_peers.len(),
235 baseline_peers.len(),
236 );
237
238 let mut install_specs: Vec<String> = Vec::new();
242 let mut seen = std::collections::HashSet::new();
243
244 for peer in &missing_peers {
245 if !seen.insert(peer.peer_name.clone()) {
246 continue;
247 }
248
249 match lookup_peer_dep_version(project_root, &peer.requested_by, &peer.peer_name) {
250 Some(version_range) => {
251 tracing::info!(
252 peer = %peer.peer_name,
253 version = %version_range,
254 requested_by = %peer.requested_by,
255 "Resolved peer dep version range from requesting package"
256 );
257 install_specs.push(format!("{}@{}", peer.peer_name, version_range));
258 }
259 None => {
260 tracing::warn!(
261 peer = %peer.peer_name,
262 requested_by = %peer.requested_by,
263 "Could not resolve peer dep version; skipping to avoid installing incompatible version"
264 );
265 }
266 }
267 }
268
269 if install_specs.is_empty() {
270 tracing::info!("No peer dependencies with resolved versions to install");
271 return;
272 }
273
274 tracing::info!(
275 count = install_specs.len(),
276 specs = ?install_specs,
277 "Installing missing peer dependencies with resolved versions"
278 );
279
280 let add_result = std::process::Command::new("yarn")
285 .args(["add", "-D"])
286 .args(&install_specs)
287 .env("YARN_ENABLE_SCRIPTS", "false")
288 .current_dir(project_root)
289 .output();
290
291 match add_result {
292 Ok(o) if o.status.success() => {
293 tracing::info!("Successfully installed missing peer dependencies");
294 }
295 Ok(o) => {
296 let stderr = String::from_utf8_lossy(&o.stderr);
297 tracing::warn!("yarn add for peer dependencies failed: {}", stderr.trim());
298 }
299 Err(e) => {
300 tracing::warn!("yarn add could not be executed: {}", e);
301 }
302 }
303}
304
305#[derive(Debug, Clone)]
307struct MissingPeerDep {
308 peer_name: String,
310 requested_by: String,
312}
313
314fn parse_yarn_missing_peer_deps(output: &str) -> Vec<MissingPeerDep> {
325 let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("valid regex");
327 let stripped = ansi_re.replace_all(output, "");
328
329 let peer_re = regex::Regex::new(
342 r"YN0002: (?:│ )?(@?[^@\s]+)@\S+ doesn't provide (@?[^\s(]+) \([^)]+\),? ?(?:requested by (@?[^\s.]+))?",
343 )
344 .expect("valid regex");
345
346 let mut seen = std::collections::HashSet::new();
347 peer_re
348 .captures_iter(&stripped)
349 .filter_map(|cap| {
350 let provider = cap[1].to_string();
351 let peer_name = cap[2].to_string();
352 let requested_by = cap
357 .get(3)
358 .map(|m| m.as_str().to_string())
359 .unwrap_or(provider);
360 seen.insert(peer_name.clone()).then_some(MissingPeerDep {
362 peer_name,
363 requested_by,
364 })
365 })
366 .collect()
367}
368
369fn lookup_peer_dep_version(
374 project_root: &Path,
375 requested_by: &str,
376 peer_name: &str,
377) -> Option<String> {
378 let pkg_json_path = project_root
379 .join("node_modules")
380 .join(requested_by)
381 .join("package.json");
382
383 let content = std::fs::read_to_string(&pkg_json_path).ok()?;
384 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
385
386 parsed
387 .get("peerDependencies")?
388 .get(peer_name)?
389 .as_str()
390 .map(|s| s.to_string())
391}
392
393fn run_pnpm_install(project_root: &Path) {
398 tracing::info!("Running pnpm install --ignore-scripts (with auto-install-peers)");
399
400 let output = std::process::Command::new("pnpm")
401 .args(["install", "--ignore-scripts"])
402 .env("npm_config_auto_install_peers", "true")
403 .current_dir(project_root)
404 .output();
405
406 match output {
407 Ok(o) if o.status.success() => {
408 tracing::info!("pnpm install completed successfully");
409 }
410 Ok(o) => {
411 let stderr = String::from_utf8_lossy(&o.stderr);
412 tracing::warn!("pnpm install failed: {}", stderr.trim());
413 }
414 Err(e) => {
415 tracing::warn!("pnpm install could not be executed: {}", e);
416 }
417 }
418}
419
420fn run_npm_install(project_root: &Path) {
422 tracing::info!("Running npm install --ignore-scripts --no-audit --no-fund");
423
424 let output = std::process::Command::new("npm")
425 .args(["install", "--ignore-scripts", "--no-audit", "--no-fund"])
426 .current_dir(project_root)
427 .output();
428
429 match output {
430 Ok(o) if o.status.success() => {
431 tracing::info!("npm install completed successfully");
432 }
433 Ok(o) => {
434 let stderr = String::from_utf8_lossy(&o.stderr);
435 tracing::warn!("npm install failed: {}", stderr.trim());
436 }
437 Err(e) => {
438 tracing::warn!("npm install could not be executed: {}", e);
439 }
440 }
441}
442
443fn plan_remove_prop(rule_id: &str, incident: &Incident, file_path: &Path) -> Option<PlannedFix> {
446 let line = incident.line_number?;
447 let prop_name = incident
448 .variables
449 .get("propName")
450 .and_then(|v| v.as_str())?;
451
452 let source = std::fs::read_to_string(file_path).ok()?;
454 let all_lines: Vec<&str> = source.lines().collect();
455 let line_idx = (line as usize).saturating_sub(1);
456 let file_line = all_lines.get(line_idx)?;
457 let trimmed = file_line.trim();
458
459 if trimmed.starts_with(prop_name) {
461 let depth = bracket_depth(file_line);
462 if depth == 0 {
463 Some(PlannedFix {
465 edits: vec![TextEdit {
466 line,
467 old_text: file_line.to_string(),
468 new_text: String::new(),
469 rule_id: rule_id.to_string(),
470 description: format!("Remove prop '{}' (entire line)", prop_name),
471 replace_all: false,
472 }],
473 confidence: FixConfidence::High,
474 source: FixSource::Pattern,
475 rule_id: rule_id.to_string(),
476 file_uri: incident.file_uri.clone(),
477 line,
478 description: format!("Remove prop '{}'", prop_name),
479 })
480 } else {
481 let mut cumulative_depth = depth;
483 let mut end_idx = line_idx;
484 for (i, subsequent_line) in all_lines.iter().enumerate().skip(line_idx + 1) {
485 cumulative_depth += bracket_depth(subsequent_line);
486 end_idx = i;
487 if cumulative_depth <= 0 {
488 break;
489 }
490 }
491
492 if cumulative_depth > 0 {
493 return Some(PlannedFix {
494 edits: vec![],
495 confidence: FixConfidence::Low,
496 source: FixSource::Pattern,
497 rule_id: rule_id.to_string(),
498 file_uri: incident.file_uri.clone(),
499 line,
500 description: format!(
501 "Remove prop '{}' (unbalanced brackets, manual)",
502 prop_name
503 ),
504 });
505 }
506
507 let mut edits = Vec::new();
509 for i in line_idx..=end_idx {
510 if let Some(l) = all_lines.get(i) {
511 edits.push(TextEdit {
512 line: (i + 1) as u32,
513 old_text: l.to_string(),
514 new_text: String::new(),
515 rule_id: rule_id.to_string(),
516 description: format!(
517 "Remove prop '{}' (line {} of multi-line)",
518 prop_name,
519 i - line_idx + 1
520 ),
521 replace_all: false,
522 });
523 }
524 }
525
526 Some(PlannedFix {
527 edits,
528 confidence: FixConfidence::High,
529 source: FixSource::Pattern,
530 rule_id: rule_id.to_string(),
531 file_uri: incident.file_uri.clone(),
532 line,
533 description: format!(
534 "Remove prop '{}' ({} lines)",
535 prop_name,
536 end_idx - line_idx + 1
537 ),
538 })
539 }
540 } else {
541 let prop_re = regex::Regex::new(&format!(
543 r#"\s+{prop_name}(?:=\{{[^}}]*\}}|="[^"]*"|='[^']*'|=\{{.*?\}})?"#
544 ))
545 .ok()?;
546
547 if let Some(m) = prop_re.find(file_line) {
548 if bracket_depth(m.as_str()) != 0 {
549 return Some(PlannedFix {
550 edits: vec![],
551 confidence: FixConfidence::Low,
552 source: FixSource::Pattern,
553 rule_id: rule_id.to_string(),
554 file_uri: incident.file_uri.clone(),
555 line,
556 description: format!("Remove prop '{}' (multi-line inline, manual)", prop_name),
557 });
558 }
559
560 Some(PlannedFix {
561 edits: vec![TextEdit {
562 line,
563 old_text: m.as_str().to_string(),
564 new_text: String::new(),
565 rule_id: rule_id.to_string(),
566 description: format!("Remove prop '{}'", prop_name),
567 replace_all: false,
568 }],
569 confidence: FixConfidence::High,
570 source: FixSource::Pattern,
571 rule_id: rule_id.to_string(),
572 file_uri: incident.file_uri.clone(),
573 line,
574 description: format!("Remove prop '{}'", prop_name),
575 })
576 } else {
577 Some(PlannedFix {
578 edits: vec![],
579 confidence: FixConfidence::Low,
580 source: FixSource::Pattern,
581 rule_id: rule_id.to_string(),
582 file_uri: incident.file_uri.clone(),
583 line,
584 description: format!("Remove prop '{}' (manual)", prop_name),
585 })
586 }
587 }
588}
589
590fn dedup_import_specifiers(lines: &mut [String]) {
594 let import_re = regex::Regex::new(r"^(\s*import\s+\{)([^}]+)(\}\s*from\s+.*)$").unwrap();
595
596 for line in lines.iter_mut() {
597 if let Some(caps) = import_re.captures(line) {
598 let prefix = caps.get(1).unwrap().as_str();
599 let specifiers_str = caps.get(2).unwrap().as_str();
600 let suffix = caps.get(3).unwrap().as_str();
601
602 let specifiers: Vec<&str> = specifiers_str
603 .split(',')
604 .map(|s| s.trim())
605 .filter(|s| !s.is_empty())
606 .collect();
607
608 let mut seen = std::collections::HashSet::new();
609 let deduped: Vec<&str> = specifiers
610 .into_iter()
611 .filter(|s| seen.insert(s.to_string()))
612 .collect();
613
614 let new_specifiers = format!(" {} ", deduped.join(", "));
615 let new_line = format!("{}{}{}", prefix, new_specifiers, suffix);
616
617 if new_line != *line {
618 *line = new_line;
619 }
620 }
621 }
622}
623
624fn bracket_depth(line: &str) -> i32 {
628 let mut depth: i32 = 0;
629 let mut in_single_quote = false;
630 let mut in_double_quote = false;
631 let mut in_backtick = false;
632 let mut prev = '\0';
633 for ch in line.chars() {
634 match ch {
635 '\'' if !in_double_quote && !in_backtick && prev != '\\' => {
636 in_single_quote = !in_single_quote
637 }
638 '"' if !in_single_quote && !in_backtick && prev != '\\' => {
639 in_double_quote = !in_double_quote
640 }
641 '`' if !in_single_quote && !in_double_quote && prev != '\\' => {
642 in_backtick = !in_backtick
643 }
644 '(' | '{' | '[' if !in_single_quote && !in_double_quote && !in_backtick => depth += 1,
645 ')' | '}' | ']' if !in_single_quote && !in_double_quote && !in_backtick => depth -= 1,
646 _ => {}
647 }
648 prev = ch;
649 }
650 depth
651}
652
653fn get_matched_text_from_incident(incident: &Incident) -> String {
657 for key in &[
658 "propName",
659 "componentName",
660 "importedName",
661 "className",
662 "variableName",
663 ] {
664 if let Some(serde_json::Value::String(s)) = incident.variables.get(*key) {
665 return s.clone();
666 }
667 }
668 String::new()
669}
670
671fn get_matched_text_for_rename_from_incident(
673 incident: &Incident,
674 mappings: &[RenameMapping],
675) -> String {
676 let prop_name = get_matched_text_from_incident(incident);
677
678 if mappings.iter().any(|m| m.old == prop_name) {
679 return prop_name;
680 }
681
682 if let Some(serde_json::Value::String(val)) = incident.variables.get("propValue") {
683 if mappings.iter().any(|m| m.old == val.as_str()) {
684 return val.clone();
685 }
686 }
687
688 if let Some(serde_json::Value::Array(vals)) = incident.variables.get("propObjectValues") {
689 for v in vals {
690 if let serde_json::Value::String(s) = v {
691 if mappings.iter().any(|m| m.old == s.as_str()) {
692 return s.clone();
693 }
694 }
695 }
696 }
697
698 prop_name
699}
700
701fn find_nearest_package_json(path: &Path) -> Option<PathBuf> {
705 let mut dir = if path.is_file() { path.parent()? } else { path };
706 loop {
707 let candidate = dir.join("package.json");
708 if candidate.exists() {
709 return Some(candidate);
710 }
711 dir = dir.parent()?;
712 }
713}
714
715fn plan_ensure_npm_dependency(
731 rule_id: &str,
732 incident: &Incident,
733 package: &str,
734 new_version: &str,
735 file_path: &Path,
736) -> Vec<PlannedFix> {
737 if lockfile::is_lockfile(file_path) {
748 tracing::info!(
749 package = %package,
750 lockfile = %file_path.display(),
751 "Lockfile incident: resolving parent packages for transitive dependency"
752 );
753
754 let pkg_json = match find_nearest_package_json(file_path) {
756 Some(p) => p,
757 None => {
758 tracing::warn!(
759 lockfile = %file_path.display(),
760 "No package.json found near lockfile; skipping"
761 );
762 return Vec::new();
763 }
764 };
765
766 if let Some(current_version) = read_dep_version_from_package_json(&pkg_json, package) {
775 if is_range_already_compatible(¤t_version, new_version) {
776 tracing::info!(
777 package = %package,
778 current = %current_version,
779 required = %new_version,
780 "Lockfile path: consumer's version already satisfies required range; skipping parent resolution"
781 );
782 return Vec::new();
783 }
784 }
785
786 let direct_deps = lockfile::parse_direct_dep_names(&pkg_json);
788
789 let all_parents = lockfile::find_transitive_ancestor_packages(file_path, package);
793
794 let actionable_parents: Vec<&String> = all_parents
796 .iter()
797 .filter(|name| direct_deps.contains(name.as_str()))
798 .collect();
799
800 if actionable_parents.is_empty() {
801 tracing::debug!(
802 package = %package,
803 "No direct-dep parents found for transitive lockfile dep; skipping"
804 );
805 return Vec::new();
806 }
807
808 let target_major = extract_major(new_version);
809 let mut fixes = Vec::new();
810
811 for parent in &actionable_parents {
812 tracing::info!(
813 parent = %parent,
814 compatible_with = %package,
815 target_major = target_major,
816 "Resolving npm-compatible version for lockfile parent"
817 );
818
819 let resolved = resolve_npm_compatible_version(parent, package, target_major);
820
821 match resolved {
822 Some(ref ver) => {
823 tracing::info!(
824 parent = %parent,
825 resolved_version = %ver,
826 "Resolved npm-compatible version for lockfile parent"
827 );
828 if let Some(fix) =
829 plan_ensure_npm_dependency_inner(rule_id, &pkg_json, parent, ver)
830 {
831 fixes.push(fix);
832 }
833 }
834 None => {
835 tracing::warn!(
836 parent = %parent,
837 compatible_with = %package,
838 "Could not resolve compatible version from npm; skipping parent"
839 );
840 }
841 }
842 }
843
844 tracing::info!(
845 package = %package,
846 parents = actionable_parents.len(),
847 fixes = fixes.len(),
848 "Lockfile incident resolved"
849 );
850
851 return fixes;
852 }
853
854 if let Some(serde_json::Value::String(depends_on)) = incident.variables.get("isDependentOf") {
858 let actual_package = match incident
859 .variables
860 .get("dependencyName")
861 .and_then(|v| v.as_str())
862 {
863 Some(p) => p,
864 None => return Vec::new(),
865 };
866
867 if let Some(current) = read_dep_version_from_package_json(file_path, depends_on) {
870 if is_range_already_compatible(¤t, new_version) {
871 tracing::info!(
872 package = %depends_on,
873 current = %current,
874 required = %new_version,
875 "Dependent path: consumer's version already satisfies required range; skipping"
876 );
877 return Vec::new();
878 }
879 }
880
881 let target_major = extract_major(new_version);
882
883 tracing::info!(
884 dependent = %actual_package,
885 depends_on = %depends_on,
886 target_major = target_major,
887 "Resolving compatible version from npm for dependent package"
888 );
889
890 let resolved = resolve_npm_compatible_version(actual_package, depends_on, target_major);
891
892 return match resolved {
893 Some(ref ver) => {
894 tracing::info!(
895 package = %actual_package,
896 resolved_version = %ver,
897 "Resolved npm-compatible version for dependent"
898 );
899 plan_ensure_npm_dependency_inner(rule_id, file_path, actual_package, ver)
900 .into_iter()
901 .collect()
902 }
903 None => {
904 tracing::warn!(
905 package = %actual_package,
906 depends_on = %depends_on,
907 "Could not resolve compatible version from npm; skipping"
908 );
909 Vec::new()
910 }
911 };
912 }
913
914 plan_ensure_npm_dependency_inner(rule_id, file_path, package, new_version)
916 .into_iter()
917 .collect()
918}
919
920fn plan_ensure_npm_dependency_inner(
922 rule_id: &str,
923 file_path: &Path,
924 package: &str,
925 new_version: &str,
926) -> Option<PlannedFix> {
927 let pkg_json = if file_path.file_name().is_some_and(|f| f == "package.json") {
929 file_path.to_path_buf()
930 } else {
931 find_nearest_package_json(file_path)?
932 };
933
934 let source = std::fs::read_to_string(&pkg_json).ok()?;
935 let pkg_json_uri = format!("file://{}", pkg_json.display());
936
937 let lines: Vec<&str> = source.lines().collect();
942 let top_level_dep_ranges = find_top_level_dep_blocks(&lines);
943
944 let package_quoted = format!("\"{}\"", package);
946 let version_re = regex::Regex::new(r#"("[\^~><=]*[0-9][^"]*")"#).ok()?;
947
948 for (idx, file_line) in source.lines().enumerate() {
949 if !file_line.contains(&package_quoted) {
950 continue;
951 }
952 if !top_level_dep_ranges
954 .iter()
955 .any(|r| idx >= r.start && idx < r.end)
956 {
957 continue;
958 }
959 if let Some(m) = version_re.find(file_line) {
960 let line = (idx + 1) as u32;
961 let old_version = m.as_str();
962
963 let old_ver_raw = old_version.trim_matches('"');
965
966 if is_range_already_compatible(old_ver_raw, new_version) {
972 tracing::info!(
973 package = %package,
974 current = %old_ver_raw,
975 required = %new_version,
976 "Skipping update: consumer's version range already satisfies the required range"
977 );
978 return None;
979 }
980
981 let new_ver_quoted = format!("\"{}\"", new_version);
982
983 return Some(PlannedFix {
984 edits: vec![TextEdit {
985 line,
986 old_text: old_version.to_string(),
987 new_text: new_ver_quoted.clone(),
988 rule_id: rule_id.to_string(),
989 description: format!(
990 "Update {} from {} to {}",
991 package, old_version, new_ver_quoted
992 ),
993 replace_all: false,
994 }],
995 confidence: FixConfidence::Exact,
996 source: FixSource::Pattern,
997 rule_id: rule_id.to_string(),
998 file_uri: pkg_json_uri,
999 line,
1000 description: format!("Update {} to {}", package, new_version),
1001 });
1002 }
1003 }
1004
1005 let target_block = top_level_dep_ranges
1009 .iter()
1010 .find(|r| r.name == "devDependencies")
1011 .or_else(|| {
1012 top_level_dep_ranges
1013 .iter()
1014 .find(|r| r.name == "dependencies")
1015 });
1016
1017 let target_block = target_block?;
1018 let mut last_entry_line: Option<usize> = None;
1019 let mut closing_brace_line: Option<usize> = None;
1020
1021 for (idx, line) in lines
1022 .iter()
1023 .enumerate()
1024 .take(target_block.end)
1025 .skip(target_block.start)
1026 {
1027 let trimmed = line.trim();
1028 if trimmed == "}" || trimmed == "}," {
1029 closing_brace_line = Some(idx);
1030 break;
1031 }
1032 if !trimmed.is_empty()
1033 && !trimmed.starts_with("\"dependencies\"")
1034 && !trimmed.starts_with("\"devDependencies\"")
1035 && trimmed != "{"
1036 {
1037 last_entry_line = Some(idx);
1038 }
1039 }
1040
1041 let closing_idx = closing_brace_line?;
1042 let closing_line_num = (closing_idx + 1) as u32;
1043
1044 let entry_indent = if let Some(last_idx) = last_entry_line {
1045 let last = lines[last_idx];
1046 let indent_len = last.len() - last.trim_start().len();
1047 &last[..indent_len]
1048 } else {
1049 " "
1050 };
1051
1052 let mut edits = Vec::new();
1053
1054 if let Some(last_idx) = last_entry_line {
1055 let last = lines[last_idx];
1056 if !last.trim_end().ends_with(',') {
1057 let last_line_num = (last_idx + 1) as u32;
1058 let trimmed_last = last.trim_end().to_string();
1059 edits.push(TextEdit {
1060 line: last_line_num,
1061 old_text: trimmed_last.clone(),
1062 new_text: format!("{},", trimmed_last),
1063 rule_id: rule_id.to_string(),
1064 description: format!("Add trailing comma before new dependency {}", package),
1065 replace_all: false,
1066 });
1067 }
1068 }
1069
1070 let closing_line_text = lines[closing_idx].to_string();
1071 let new_entry = format!(
1072 "{}\"{}\": \"{}\"\n{}",
1073 entry_indent, package, new_version, closing_line_text
1074 );
1075 edits.push(TextEdit {
1076 line: closing_line_num,
1077 old_text: closing_line_text,
1078 new_text: new_entry,
1079 rule_id: rule_id.to_string(),
1080 description: format!("Add {} {} to dependencies", package, new_version),
1081 replace_all: false,
1082 });
1083
1084 Some(PlannedFix {
1085 edits,
1086 confidence: FixConfidence::Exact,
1087 source: FixSource::Pattern,
1088 rule_id: rule_id.to_string(),
1089 file_uri: pkg_json_uri,
1090 line: closing_line_num,
1091 description: format!("Add {} {} to dependencies", package, new_version),
1092 })
1093}
1094
1095struct DepBlockRange {
1099 name: &'static str,
1101 start: usize,
1103 end: usize,
1105}
1106
1107fn find_top_level_dep_blocks(lines: &[&str]) -> Vec<DepBlockRange> {
1113 let mut results = Vec::new();
1114 let mut root_depth: i32 = 0;
1115
1116 let mut i = 0;
1117 while i < lines.len() {
1118 let trimmed = lines[i].trim();
1119
1120 for ch in trimmed.chars() {
1122 match ch {
1123 '{' => root_depth += 1,
1124 '}' => root_depth -= 1,
1125 _ => {}
1126 }
1127 }
1128
1129 let is_dep_key = (root_depth == 2 || root_depth == 1)
1134 && (trimmed.starts_with("\"dependencies\"")
1135 || trimmed.starts_with("\"devDependencies\""));
1136
1137 if !is_dep_key {
1138 i += 1;
1139 continue;
1140 }
1141
1142 let name = if trimmed.starts_with("\"devDependencies\"") {
1143 "devDependencies"
1144 } else {
1145 "dependencies"
1146 };
1147
1148 let start = i;
1149 let mut block_depth: i32 = 0;
1151 for ch in trimmed.chars() {
1152 match ch {
1153 '{' => block_depth += 1,
1154 '}' => block_depth -= 1,
1155 _ => {}
1156 }
1157 }
1158
1159 i += 1;
1160 while i < lines.len() && block_depth > 0 {
1161 let t = lines[i].trim();
1162 for ch in t.chars() {
1163 match ch {
1164 '{' => {
1165 block_depth += 1;
1166 root_depth += 1;
1167 }
1168 '}' => {
1169 block_depth -= 1;
1170 root_depth -= 1;
1171 }
1172 _ => {}
1173 }
1174 }
1175 i += 1;
1176 }
1177
1178 results.push(DepBlockRange {
1179 name,
1180 start,
1181 end: i,
1182 });
1183 }
1184
1185 results
1186}
1187
1188fn read_dep_version_from_package_json(pkg_json: &Path, package: &str) -> Option<String> {
1195 let pkg_json = if pkg_json.file_name().is_some_and(|f| f == "package.json") {
1196 pkg_json.to_path_buf()
1197 } else {
1198 find_nearest_package_json(pkg_json)?
1199 };
1200
1201 let source = std::fs::read_to_string(&pkg_json).ok()?;
1202 let parsed: serde_json::Value = serde_json::from_str(&source).ok()?;
1203
1204 for section in ["dependencies", "devDependencies"] {
1206 if let Some(version) = parsed
1207 .get(section)
1208 .and_then(|deps| deps.get(package))
1209 .and_then(|v| v.as_str())
1210 {
1211 return Some(version.to_string());
1212 }
1213 }
1214
1215 None
1216}
1217
1218fn is_range_already_compatible(consumer_range: &str, required_range: &str) -> bool {
1238 let consumer = match consumer_range.parse::<node_semver::Range>() {
1239 Ok(r) => r,
1240 Err(_) => return false,
1241 };
1242 let required = match required_range.parse::<node_semver::Range>() {
1243 Ok(r) => r,
1244 Err(_) => return false,
1245 };
1246
1247 required.allows_all(&consumer)
1250}
1251
1252fn extract_major(version: &str) -> u64 {
1257 let stripped = version
1258 .trim()
1259 .trim_start_matches('^')
1260 .trim_start_matches('~')
1261 .trim_start_matches(">=")
1262 .trim_start_matches("<=")
1263 .trim_start_matches('>')
1264 .trim_start_matches('<')
1265 .trim_start_matches('=');
1266 stripped
1267 .split('.')
1268 .next()
1269 .and_then(|s| s.parse().ok())
1270 .unwrap_or(0)
1271}
1272
1273fn resolve_npm_compatible_version(
1283 package: &str,
1284 compatible_with: &str,
1285 target_major: u64,
1286) -> Option<String> {
1287 let url = format!("https://registry.npmjs.org/{}", package);
1288
1289 let mut response = match ureq::get(&url).call() {
1290 Ok(resp) => resp,
1291 Err(e) => {
1292 tracing::warn!(
1293 package = %package,
1294 error = %e,
1295 "npm registry query failed"
1296 );
1297 return None;
1298 }
1299 };
1300
1301 let body: serde_json::Value = match response.body_mut().read_json() {
1302 Ok(v) => v,
1303 Err(e) => {
1304 tracing::warn!(
1305 package = %package,
1306 error = %e,
1307 "Failed to parse npm registry response"
1308 );
1309 return None;
1310 }
1311 };
1312
1313 let versions = body.get("versions")?.as_object()?;
1314
1315 let mut candidates: Vec<(u64, u64, u64, &str)> = Vec::new();
1318
1319 for (ver_str, ver_data) in versions {
1320 if ver_str.contains("alpha")
1322 || ver_str.contains("prerelease")
1323 || ver_str.contains("rc")
1324 || ver_str.contains("beta")
1325 {
1326 continue;
1327 }
1328
1329 let dep_constraint = ["dependencies", "peerDependencies"]
1338 .iter()
1339 .find_map(|section| {
1340 ver_data
1341 .get(*section)
1342 .and_then(|deps| deps.get(compatible_with))
1343 .and_then(|c| c.as_str())
1344 });
1345
1346 let is_compatible = match dep_constraint {
1347 Some(c) => extract_major(c) >= target_major,
1348 None => true, };
1350
1351 if !is_compatible {
1352 continue;
1353 }
1354
1355 if let Some(parsed) = parse_semver_tuple(ver_str) {
1357 candidates.push((parsed.0, parsed.1, parsed.2, ver_str.as_str()));
1358 }
1359 }
1360
1361 candidates.sort();
1363 let latest = candidates.last()?;
1364
1365 Some(format!("^{}", latest.3))
1366}
1367
1368fn parse_semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
1370 let s = s.trim();
1371 let version_part = s.split('-').next().unwrap_or(s);
1372 let parts: Vec<&str> = version_part.split('.').collect();
1373 let major = parts.first()?.parse().ok()?;
1374 let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
1375 let patch = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
1376 Some((major, minor, patch))
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 use super::*;
1382 use std::collections::BTreeMap;
1383
1384 fn make_test_incident(
1386 uri: &str,
1387 line: u32,
1388 variables: BTreeMap<String, serde_json::Value>,
1389 ) -> Incident {
1390 Incident {
1391 file_uri: uri.to_string(),
1392 line_number: Some(line),
1393 code_location: None,
1394 message: String::new(),
1395 code_snip: None,
1396 variables,
1397 effort: None,
1398 links: Vec::new(),
1399 is_dependency_incident: false,
1400 }
1401 }
1402
1403 #[test]
1406 fn test_skip_node_modules() {
1407 let provider = JsFixProvider::new();
1408 assert!(provider.should_skip_path(Path::new(
1409 "/project/node_modules/@patternfly/react-core/index.js"
1410 )));
1411 }
1412
1413 #[test]
1414 fn test_skip_nested_node_modules() {
1415 let provider = JsFixProvider::new();
1416 assert!(
1417 provider.should_skip_path(Path::new("/project/packages/app/node_modules/foo/bar.ts"))
1418 );
1419 }
1420
1421 #[test]
1422 fn test_does_not_skip_src() {
1423 let provider = JsFixProvider::new();
1424 assert!(!provider.should_skip_path(Path::new("/project/src/App.tsx")));
1425 }
1426
1427 #[test]
1428 fn test_does_not_skip_vendor() {
1429 let provider = JsFixProvider::new();
1430 assert!(!provider.should_skip_path(Path::new("/project/src/vendor/lib.ts")));
1431 }
1432
1433 #[test]
1436 fn test_whole_file_rename_with_imported_name() {
1437 let provider = JsFixProvider::new();
1438 let mut vars = BTreeMap::new();
1439 vars.insert(
1440 "importedName".to_string(),
1441 serde_json::Value::String("Chip".to_string()),
1442 );
1443 let incident = make_test_incident("file:///test.tsx", 1, vars);
1444 assert!(provider.is_whole_file_rename(&incident));
1445 }
1446
1447 #[test]
1448 fn test_not_whole_file_rename_without_imported_name() {
1449 let provider = JsFixProvider::new();
1450 let mut vars = BTreeMap::new();
1451 vars.insert(
1452 "propName".to_string(),
1453 serde_json::Value::String("isActive".to_string()),
1454 );
1455 let incident = make_test_incident("file:///test.tsx", 1, vars);
1456 assert!(!provider.is_whole_file_rename(&incident));
1457 }
1458
1459 #[test]
1462 fn test_bracket_depth_balanced() {
1463 assert_eq!(bracket_depth("{ foo: bar }"), 0);
1464 assert_eq!(bracket_depth("foo()"), 0);
1465 assert_eq!(bracket_depth("[1, 2, 3]"), 0);
1466 assert_eq!(bracket_depth("{ foo: [1, 2] }"), 0);
1467 }
1468
1469 #[test]
1470 fn test_bracket_depth_open() {
1471 assert_eq!(bracket_depth("actions={["), 2);
1472 assert_eq!(bracket_depth(" <Button"), 0);
1473 assert_eq!(bracket_depth("foo(bar, {"), 2);
1474 }
1475
1476 #[test]
1477 fn test_bracket_depth_close() {
1478 assert_eq!(bracket_depth("]}"), -2);
1479 assert_eq!(bracket_depth(")"), -1);
1480 }
1481
1482 #[test]
1483 fn test_bracket_depth_ignores_string_literals() {
1484 assert_eq!(bracket_depth(r#" foo="{not a bracket}""#), 0);
1485 assert_eq!(bracket_depth(" foo='[still not]'"), 0);
1486 }
1487
1488 #[test]
1491 fn test_dedup_import_removes_duplicates() {
1492 let mut lines =
1493 vec!["import { Content, Content, Content } from '@patternfly/react-core';".to_string()];
1494 dedup_import_specifiers(&mut lines);
1495 let count = lines[0].matches("Content").count();
1496 assert_eq!(count, 1);
1497 }
1498
1499 #[test]
1500 fn test_dedup_import_preserves_different_specifiers() {
1501 let mut lines = vec!["import { Foo, Bar, Foo, Baz, Bar } from '@pkg';".to_string()];
1502 dedup_import_specifiers(&mut lines);
1503 assert_eq!(lines[0].matches("Foo").count(), 1);
1504 assert_eq!(lines[0].matches("Bar").count(), 1);
1505 assert_eq!(lines[0].matches("Baz").count(), 1);
1506 }
1507
1508 #[test]
1511 fn test_get_matched_text_prop_name_first() {
1512 let mut vars = BTreeMap::new();
1513 vars.insert(
1514 "propName".to_string(),
1515 serde_json::Value::String("isActive".to_string()),
1516 );
1517 vars.insert(
1518 "componentName".to_string(),
1519 serde_json::Value::String("Button".to_string()),
1520 );
1521 let incident = make_test_incident("file:///test.tsx", 1, vars);
1522 assert_eq!(get_matched_text_from_incident(&incident), "isActive");
1523 }
1524
1525 #[test]
1526 fn test_get_matched_text_empty_when_no_known_vars() {
1527 let incident = make_test_incident("", 1, BTreeMap::new());
1528 assert_eq!(get_matched_text_from_incident(&incident), "");
1529 }
1530
1531 #[test]
1534 fn test_get_matched_text_for_rename_prefers_prop_name() {
1535 let mut vars = BTreeMap::new();
1536 vars.insert(
1537 "propName".into(),
1538 serde_json::Value::String("spaceItems".into()),
1539 );
1540 let incident = make_test_incident("file:///test.tsx", 1, vars);
1541 let mappings = vec![RenameMapping {
1542 old: "spaceItems".into(),
1543 new: "gap".into(),
1544 }];
1545 assert_eq!(
1546 get_matched_text_for_rename_from_incident(&incident, &mappings),
1547 "spaceItems"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_get_matched_text_for_rename_falls_back_to_prop_value() {
1553 let mut vars = BTreeMap::new();
1554 vars.insert(
1555 "propName".into(),
1556 serde_json::Value::String("variant".into()),
1557 );
1558 vars.insert(
1559 "propValue".into(),
1560 serde_json::Value::String("light".into()),
1561 );
1562 let incident = make_test_incident("file:///test.tsx", 1, vars);
1563 let mappings = vec![RenameMapping {
1564 old: "light".into(),
1565 new: "secondary".into(),
1566 }];
1567 assert_eq!(
1568 get_matched_text_for_rename_from_incident(&incident, &mappings),
1569 "light"
1570 );
1571 }
1572
1573 #[test]
1576 fn test_post_process_deduplicates_imports() {
1577 let provider = JsFixProvider::new();
1578 let mut lines = vec![
1579 "import { Content, Content } from '@patternfly/react-core';".to_string(),
1580 "const x = 1;".to_string(),
1581 ];
1582 provider.post_process_lines(&mut lines);
1583 assert_eq!(lines[0].matches("Content").count(), 1);
1584 assert_eq!(lines[1], "const x = 1;");
1585 }
1586
1587 #[test]
1590 fn test_extract_major() {
1591 assert_eq!(extract_major("^6.4.1"), 6);
1592 assert_eq!(extract_major("~5.0.0"), 5);
1593 assert_eq!(extract_major("6.4.1"), 6);
1594 assert_eq!(extract_major(">=7.0.0"), 7);
1595 assert_eq!(extract_major("^6.0.0-alpha.1"), 6);
1596 }
1597
1598 #[test]
1599 fn test_parse_semver_tuple() {
1600 assert_eq!(parse_semver_tuple("6.4.1"), Some((6, 4, 1)));
1601 assert_eq!(parse_semver_tuple("5.0.0"), Some((5, 0, 0)));
1602 assert_eq!(parse_semver_tuple("6.0.0-alpha.1"), Some((6, 0, 0)));
1603 }
1604
1605 #[test]
1608 fn test_dependent_incident_updates_correct_package() {
1609 let dir = tempfile::tempdir().unwrap();
1610 let pkg_json = dir.path().join("package.json");
1611 std::fs::write(
1612 &pkg_json,
1613 r#"{
1614 "devDependencies": {
1615 "@patternfly/react-core": "^6.4.1",
1616 "@patternfly/react-topology": "5.2.1"
1617 }
1618}"#,
1619 )
1620 .unwrap();
1621
1622 let mut vars = BTreeMap::new();
1624 vars.insert(
1625 "dependencyName".into(),
1626 serde_json::Value::String("@patternfly/react-topology".into()),
1627 );
1628 vars.insert(
1629 "dependencyVersion".into(),
1630 serde_json::Value::String("5.2.1".into()),
1631 );
1632 vars.insert(
1633 "dependencyType".into(),
1634 serde_json::Value::String("devDependencies".into()),
1635 );
1636 vars.insert(
1637 "isDependentOf".into(),
1638 serde_json::Value::String("@patternfly/react-core".into()),
1639 );
1640 vars.insert(
1641 "dependentConstraint".into(),
1642 serde_json::Value::String("^5.1.1".into()),
1643 );
1644
1645 let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 4, vars);
1646
1647 let result = plan_ensure_npm_dependency(
1651 "semver-dep-update-patternfly-react-core",
1652 &incident,
1653 "@patternfly/react-core",
1654 "^6.4.1",
1655 &pkg_json,
1656 );
1657
1658 if let Some(fix) = result.first() {
1661 assert!(
1662 fix.description.contains("react-topology"),
1663 "Fix should target react-topology, got: {}",
1664 fix.description
1665 );
1666 assert!(
1667 !fix.description.contains("react-core"),
1668 "Fix should NOT mention react-core as the package to update"
1669 );
1670 }
1671 }
1673
1674 #[test]
1677 fn test_non_dependent_incident_uses_provided_version() {
1678 let dir = tempfile::tempdir().unwrap();
1679 let pkg_json = dir.path().join("package.json");
1680 std::fs::write(
1681 &pkg_json,
1682 r#"{
1683 "dependencies": {
1684 "@patternfly/react-core": "5.3.4"
1685 }
1686}"#,
1687 )
1688 .unwrap();
1689
1690 let vars = BTreeMap::new();
1691 let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 3, vars);
1692
1693 let result = plan_ensure_npm_dependency(
1694 "semver-dep-update-patternfly-react-core",
1695 &incident,
1696 "@patternfly/react-core",
1697 "^6.4.1",
1698 &pkg_json,
1699 );
1700
1701 assert_eq!(
1702 result.len(),
1703 1,
1704 "Should produce exactly one fix for primary dep update"
1705 );
1706 let fix = &result[0];
1707 assert_eq!(fix.edits.len(), 1);
1708 assert!(fix.edits[0].new_text.contains("6.4.1"));
1709 assert!(fix.description.contains("react-core"));
1710 }
1711
1712 #[test]
1713 fn parse_yarn_missing_peers_basic() {
1714 let output = "\
1715➤ YN0000: · Yarn 4.6.0
1716➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
1717➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory-core (p4d5e6), requested by some-dep.
1718➤ YN0000: · Done in 1.5s
1719";
1720 let peers = super::parse_yarn_missing_peer_deps(output);
1721 assert_eq!(peers.len(), 2);
1722 assert_eq!(peers[0].peer_name, "victory");
1723 assert_eq!(peers[0].requested_by, "@patternfly/react-charts");
1725 assert_eq!(peers[1].peer_name, "victory-core");
1726 assert_eq!(peers[1].requested_by, "some-dep");
1728 }
1729
1730 #[test]
1731 fn parse_yarn_missing_peers_with_ansi() {
1732 let output = "\x1b[33m➤\x1b[0m \x1b[33mYN0002\x1b[0m: \x1b[38;5;173mfoo@npm:1.0.0\x1b[0m doesn't provide \x1b[38;5;111mbar\x1b[0m (p7g8h9), requested by baz.\n";
1734 let peers = super::parse_yarn_missing_peer_deps(output);
1735 assert_eq!(peers.len(), 1);
1736 assert_eq!(peers[0].peer_name, "bar");
1737 assert_eq!(peers[0].requested_by, "baz");
1739 }
1740
1741 #[test]
1742 fn parse_yarn_missing_peers_scoped_package() {
1743 let output = "➤ YN0002: @scope/some-pkg@npm:2.0.0 doesn't provide @scope/peer-pkg (pabcde), requested by other-dep.\n";
1744 let peers = super::parse_yarn_missing_peer_deps(output);
1745 assert_eq!(peers.len(), 1);
1746 assert_eq!(peers[0].peer_name, "@scope/peer-pkg");
1747 assert_eq!(peers[0].requested_by, "other-dep");
1749 }
1750
1751 #[test]
1752 fn parse_yarn_missing_peers_deduplicates() {
1753 let output = "\
1754➤ YN0002: pkg-a@npm:1.0.0 doesn't provide victory (p11111), requested by dep-a.
1755➤ YN0002: pkg-b@npm:2.0.0 doesn't provide victory (p22222), requested by dep-b.
1756➤ YN0002: pkg-c@npm:3.0.0 doesn't provide victory (p33333), requested by dep-c.
1757";
1758 let peers = super::parse_yarn_missing_peer_deps(output);
1759 assert_eq!(peers.len(), 1);
1760 assert_eq!(peers[0].peer_name, "victory");
1761 assert_eq!(peers[0].requested_by, "dep-a");
1763 }
1764
1765 #[test]
1766 fn parse_yarn_missing_peers_workspace_box_separator() {
1767 let output = "\
1773➤ YN0000: · Yarn 4.6.0
1774➤ YN0000: ┌ Resolution step
1775➤ YN0000: └ Completed
1776➤ YN0000: ┌ Post-resolution validation
1777➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1778➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
1779➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
1780➤ YN0086: │ Some peer dependencies are incorrectly met by your project; run yarn explain peer-requirements <hash> for details, where <hash> is the six-letter p-prefixed code.
1781➤ YN0000: └ Completed
1782➤ YN0000: · Done with warnings in 1s 4ms
1783";
1784 let peers = super::parse_yarn_missing_peer_deps(output);
1785 assert_eq!(peers.len(), 3);
1786 assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
1787 assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
1788 assert_eq!(peers[1].peer_name, "axe-core");
1789 assert_eq!(peers[1].requested_by, "cypress-axe");
1790 assert_eq!(peers[2].peer_name, "i18next");
1791 assert_eq!(peers[2].requested_by, "react-i18next");
1792 }
1793
1794 #[test]
1795 fn parse_yarn_missing_peers_mixed_formats() {
1796 let output = "\
1800➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1801➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
1802";
1803 let peers = super::parse_yarn_missing_peer_deps(output);
1804 assert_eq!(peers.len(), 2);
1805 assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
1806 assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
1807 assert_eq!(peers[1].peer_name, "victory");
1808 assert_eq!(peers[1].requested_by, "@patternfly/react-charts");
1809 }
1810
1811 #[test]
1812 fn parse_yarn_missing_peers_no_warnings() {
1813 let output = "➤ YN0000: · Yarn 4.6.0\n➤ YN0000: · Done in 0.5s\n";
1814 let peers = super::parse_yarn_missing_peer_deps(output);
1815 assert!(peers.is_empty());
1816 }
1817
1818 #[test]
1819 fn lookup_peer_dep_version_finds_version() {
1820 let dir = tempfile::tempdir().unwrap();
1821 let pkg_dir = dir.path().join("node_modules/@patternfly/react-charts");
1822 std::fs::create_dir_all(&pkg_dir).unwrap();
1823 std::fs::write(
1824 pkg_dir.join("package.json"),
1825 r#"{
1826 "name": "@patternfly/react-charts",
1827 "peerDependencies": {
1828 "victory-core": "^37.3.6",
1829 "echarts": "^5.6.0 || ^6.0.0"
1830 }
1831}"#,
1832 )
1833 .unwrap();
1834
1835 assert_eq!(
1836 super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "victory-core"),
1837 Some("^37.3.6".to_string())
1838 );
1839 assert_eq!(
1840 super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "echarts"),
1841 Some("^5.6.0 || ^6.0.0".to_string())
1842 );
1843 assert_eq!(
1844 super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "nonexistent"),
1845 None
1846 );
1847 }
1848
1849 #[test]
1850 fn lookup_peer_dep_version_missing_package() {
1851 let dir = tempfile::tempdir().unwrap();
1852 assert_eq!(
1853 super::lookup_peer_dep_version(dir.path(), "nonexistent-pkg", "some-peer"),
1854 None
1855 );
1856 }
1857
1858 #[test]
1861 fn range_compatible_caret_subset_of_or_range() {
1862 assert!(super::is_range_already_compatible(
1864 "^17.0.1",
1865 "^17 || ^18 || ^19"
1866 ));
1867 }
1868
1869 #[test]
1870 fn range_compatible_identical_ranges() {
1871 assert!(super::is_range_already_compatible("^6.4.1", "^6.4.1"));
1872 }
1873
1874 #[test]
1875 fn range_incompatible_different_majors() {
1876 assert!(!super::is_range_already_compatible("^11.7.3", "^17.0.3"));
1878 }
1879
1880 #[test]
1881 fn range_compatible_exact_within_caret() {
1882 assert!(super::is_range_already_compatible("1.2.3", "^1.0.0"));
1884 }
1885
1886 #[test]
1887 fn range_compatible_tilde_within_caret() {
1888 assert!(super::is_range_already_compatible("~1.2.3", "^1.0.0"));
1890 }
1891
1892 #[test]
1893 fn range_incompatible_caret_not_in_tilde() {
1894 assert!(!super::is_range_already_compatible("^1.0.0", "~1.2.3"));
1896 }
1897
1898 #[test]
1899 fn range_compatible_or_range_within_star() {
1900 assert!(super::is_range_already_compatible("^17 || ^18", ">=0.0.0"));
1902 }
1903
1904 #[test]
1905 fn range_unparseable_returns_false() {
1906 assert!(!super::is_range_already_compatible("latest", "^1.0.0"));
1908 assert!(!super::is_range_already_compatible(
1909 "^1.0.0",
1910 "git+ssh://foo"
1911 ));
1912 }
1913
1914 #[test]
1915 fn range_compatible_prerelease() {
1916 assert!(super::is_range_already_compatible(
1918 "^6.30.3-pre-v6.0",
1919 "^6.0.0"
1920 ));
1921 }
1922
1923 #[test]
1924 fn range_compatible_higher_patch_not_downgraded() {
1925 assert!(super::is_range_already_compatible("^6.4.2", "^6.4.1"));
1929 }
1930
1931 #[test]
1932 fn range_compatible_higher_minor_not_downgraded() {
1933 assert!(super::is_range_already_compatible("^6.5.0", "^6.4.1"));
1935 }
1936
1937 #[test]
1938 fn baseline_diff_filters_preexisting_peers() {
1939 let baseline: std::collections::HashSet<String> = [
1942 "@patternfly/react-styles",
1943 "axe-core",
1944 "i18next",
1945 "mocha",
1946 "react-redux",
1947 "react-router",
1948 "react-router-dom",
1949 "redux",
1950 "redux-thunk",
1951 ]
1952 .iter()
1953 .map(|s| s.to_string())
1954 .collect();
1955
1956 let after_output = "\
1958➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
1959➤ YN0002: │ my-app@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
1960➤ YN0002: │ my-app@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
1961➤ YN0002: │ my-app@workspace:. doesn't provide mocha (p7ec1d), requested by cypress-multi-reporters and other dependencies.
1962➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
1963➤ YN0002: │ my-app@workspace:. doesn't provide react-router-dom (p7d47e), requested by react-router-dom-v5-compat.
1964➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
1965➤ YN0002: │ my-app@workspace:. doesn't provide redux-thunk (pd9e06), requested by @openshift/dynamic-plugin-sdk-utils.
1966";
1967 let all_missing = super::parse_yarn_missing_peer_deps(after_output);
1968
1969 let new_peers: Vec<_> = all_missing
1971 .into_iter()
1972 .filter(|p| !baseline.contains(&p.peer_name))
1973 .collect();
1974
1975 assert_eq!(new_peers.len(), 1);
1977 assert_eq!(new_peers[0].peer_name, "@patternfly/react-drag-drop");
1978 assert_eq!(
1979 new_peers[0].requested_by,
1980 "@patternfly/react-component-groups"
1981 );
1982 }
1983
1984 #[test]
1985 fn baseline_diff_installs_nothing_when_no_new_peers() {
1986 let baseline: std::collections::HashSet<String> = ["react-redux", "redux"]
1988 .iter()
1989 .map(|s| s.to_string())
1990 .collect();
1991
1992 let after_output = "\
1993➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by some-pkg.
1994➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by some-pkg.
1995";
1996 let all_missing = super::parse_yarn_missing_peer_deps(after_output);
1997 let new_peers: Vec<_> = all_missing
1998 .into_iter()
1999 .filter(|p| !baseline.contains(&p.peer_name))
2000 .collect();
2001
2002 assert!(new_peers.is_empty());
2003 }
2004}