mod lockfile;
use fix_engine::language::LanguageFixProvider;
use fix_engine_core::*;
use konveyor_core::incident::Incident;
use std::path::{Path, PathBuf};
pub struct JsFixProvider;
impl JsFixProvider {
pub fn new() -> Self {
Self
}
}
impl Default for JsFixProvider {
fn default() -> Self {
Self::new()
}
}
impl LanguageFixProvider for JsFixProvider {
fn should_skip_path(&self, path: &Path) -> bool {
path.components().any(|c| c.as_os_str() == "node_modules")
}
fn post_process_lines(&self, lines: &mut [String]) {
dedup_import_specifiers(lines);
}
fn plan_remove_attribute(
&self,
rule_id: &str,
incident: &Incident,
file_path: &Path,
) -> Option<PlannedFix> {
plan_remove_prop(rule_id, incident, file_path)
}
fn plan_ensure_dependency(
&self,
rule_id: &str,
incident: &Incident,
package: &str,
new_version: &str,
file_path: &Path,
) -> Vec<PlannedFix> {
plan_ensure_npm_dependency(rule_id, incident, package, new_version, file_path)
}
fn get_matched_text(&self, incident: &Incident) -> String {
get_matched_text_from_incident(incident)
}
fn get_matched_text_for_rename(
&self,
incident: &Incident,
mappings: &[RenameMapping],
) -> String {
get_matched_text_for_rename_from_incident(incident, mappings)
}
fn is_whole_file_rename(&self, incident: &Incident) -> bool {
incident.variables.contains_key("importedName")
}
fn pre_apply(&self, project_root: &Path) -> Option<Box<dyn std::any::Any>> {
if !project_root.join("yarn.lock").exists() {
return None;
}
tracing::info!("Capturing baseline peer dependency warnings before applying fixes");
let baseline = capture_yarn_missing_peer_names(project_root);
tracing::info!(
count = baseline.len(),
peers = ?baseline,
"Baseline unmet peer dependencies captured"
);
Some(Box::new(baseline))
}
fn post_apply(
&self,
project_root: &Path,
modified_files: &[std::path::PathBuf],
pre_state: Option<Box<dyn std::any::Any>>,
) -> anyhow::Result<()> {
let any_package_json = modified_files
.iter()
.any(|p| p.file_name().and_then(|f| f.to_str()) == Some("package.json"));
if !any_package_json {
return Ok(());
}
tracing::info!("package.json was modified, running install to sync lockfile");
if project_root.join("yarn.lock").exists() {
let baseline = pre_state
.and_then(|s| s.downcast::<std::collections::HashSet<String>>().ok())
.map(|b| *b)
.unwrap_or_default();
run_yarn_install_and_resolve_peers(project_root, &baseline);
} else if project_root.join("pnpm-lock.yaml").exists() {
run_pnpm_install(project_root);
} else {
run_npm_install(project_root);
}
Ok(())
}
}
fn capture_yarn_missing_peer_names(project_root: &Path) -> std::collections::HashSet<String> {
let output = std::process::Command::new("yarn")
.args(["install"])
.env("YARN_ENABLE_SCRIPTS", "false")
.current_dir(project_root)
.output();
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_yarn_missing_peer_deps(&stdout)
.into_iter()
.map(|p| p.peer_name)
.collect()
}
Err(e) => {
tracing::warn!(
"yarn install could not be executed for baseline capture: {}",
e
);
std::collections::HashSet::new()
}
}
}
fn run_yarn_install_and_resolve_peers(
project_root: &Path,
baseline_peers: &std::collections::HashSet<String>,
) {
tracing::info!("Running yarn install (scripts disabled)");
let output = std::process::Command::new("yarn")
.args(["install"])
.env("YARN_ENABLE_SCRIPTS", "false")
.current_dir(project_root)
.output();
let output = match output {
Ok(o) => o,
Err(e) => {
tracing::warn!("yarn install could not be executed: {}", e);
return;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("yarn install failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let all_missing_peers = parse_yarn_missing_peer_deps(&stdout);
let missing_peers: Vec<_> = all_missing_peers
.into_iter()
.filter(|p| !baseline_peers.contains(&p.peer_name))
.collect();
if missing_peers.is_empty() {
tracing::info!("yarn install completed, no newly-introduced missing peer dependencies");
return;
}
tracing::info!(
new_count = missing_peers.len(),
baseline_count = baseline_peers.len(),
"Filtered peer deps: {} new (out of {} total warnings, {} were pre-existing)",
missing_peers.len(),
missing_peers.len() + baseline_peers.len(),
baseline_peers.len(),
);
let mut install_specs: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for peer in &missing_peers {
if !seen.insert(peer.peer_name.clone()) {
continue;
}
match lookup_peer_dep_version(project_root, &peer.requested_by, &peer.peer_name) {
Some(version_range) => {
tracing::info!(
peer = %peer.peer_name,
version = %version_range,
requested_by = %peer.requested_by,
"Resolved peer dep version range from requesting package"
);
install_specs.push(format!("{}@{}", peer.peer_name, version_range));
}
None => {
tracing::warn!(
peer = %peer.peer_name,
requested_by = %peer.requested_by,
"Could not resolve peer dep version; skipping to avoid installing incompatible version"
);
}
}
}
if install_specs.is_empty() {
tracing::info!("No peer dependencies with resolved versions to install");
return;
}
tracing::info!(
count = install_specs.len(),
specs = ?install_specs,
"Installing missing peer dependencies with resolved versions"
);
let add_result = std::process::Command::new("yarn")
.args(["add", "-D"])
.args(&install_specs)
.env("YARN_ENABLE_SCRIPTS", "false")
.current_dir(project_root)
.output();
match add_result {
Ok(o) if o.status.success() => {
tracing::info!("Successfully installed missing peer dependencies");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!("yarn add for peer dependencies failed: {}", stderr.trim());
}
Err(e) => {
tracing::warn!("yarn add could not be executed: {}", e);
}
}
}
#[derive(Debug, Clone)]
struct MissingPeerDep {
peer_name: String,
requested_by: String,
}
fn parse_yarn_missing_peer_deps(output: &str) -> Vec<MissingPeerDep> {
let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("valid regex");
let stripped = ansi_re.replace_all(output, "");
let peer_re = regex::Regex::new(
r"YN0002: (?:│ )?(@?[^@\s]+)@\S+ doesn't provide (@?[^\s(]+) \([^)]+\),? ?(?:requested by (@?[^\s.]+))?",
)
.expect("valid regex");
let mut seen = std::collections::HashSet::new();
peer_re
.captures_iter(&stripped)
.filter_map(|cap| {
let provider = cap[1].to_string();
let peer_name = cap[2].to_string();
let requested_by = cap
.get(3)
.map(|m| m.as_str().to_string())
.unwrap_or(provider);
seen.insert(peer_name.clone()).then_some(MissingPeerDep {
peer_name,
requested_by,
})
})
.collect()
}
fn lookup_peer_dep_version(
project_root: &Path,
requested_by: &str,
peer_name: &str,
) -> Option<String> {
let pkg_json_path = project_root
.join("node_modules")
.join(requested_by)
.join("package.json");
let content = std::fs::read_to_string(&pkg_json_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
parsed
.get("peerDependencies")?
.get(peer_name)?
.as_str()
.map(|s| s.to_string())
}
fn run_pnpm_install(project_root: &Path) {
tracing::info!("Running pnpm install --ignore-scripts (with auto-install-peers)");
let output = std::process::Command::new("pnpm")
.args(["install", "--ignore-scripts"])
.env("npm_config_auto_install_peers", "true")
.current_dir(project_root)
.output();
match output {
Ok(o) if o.status.success() => {
tracing::info!("pnpm install completed successfully");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!("pnpm install failed: {}", stderr.trim());
}
Err(e) => {
tracing::warn!("pnpm install could not be executed: {}", e);
}
}
}
fn run_npm_install(project_root: &Path) {
tracing::info!("Running npm install --ignore-scripts --no-audit --no-fund");
let output = std::process::Command::new("npm")
.args(["install", "--ignore-scripts", "--no-audit", "--no-fund"])
.current_dir(project_root)
.output();
match output {
Ok(o) if o.status.success() => {
tracing::info!("npm install completed successfully");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!("npm install failed: {}", stderr.trim());
}
Err(e) => {
tracing::warn!("npm install could not be executed: {}", e);
}
}
}
fn plan_remove_prop(rule_id: &str, incident: &Incident, file_path: &Path) -> Option<PlannedFix> {
let line = incident.line_number?;
let prop_name = incident
.variables
.get("propName")
.and_then(|v| v.as_str())?;
let source = std::fs::read_to_string(file_path).ok()?;
let all_lines: Vec<&str> = source.lines().collect();
let line_idx = (line as usize).saturating_sub(1);
let file_line = all_lines.get(line_idx)?;
let trimmed = file_line.trim();
if trimmed.starts_with(prop_name) {
let depth = bracket_depth(file_line);
if depth == 0 {
Some(PlannedFix {
edits: vec![TextEdit {
line,
old_text: file_line.to_string(),
new_text: String::new(),
rule_id: rule_id.to_string(),
description: format!("Remove prop '{}' (entire line)", prop_name),
replace_all: false,
}],
confidence: FixConfidence::High,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!("Remove prop '{}'", prop_name),
})
} else {
let mut cumulative_depth = depth;
let mut end_idx = line_idx;
for (i, subsequent_line) in all_lines.iter().enumerate().skip(line_idx + 1) {
cumulative_depth += bracket_depth(subsequent_line);
end_idx = i;
if cumulative_depth <= 0 {
break;
}
}
if cumulative_depth > 0 {
return Some(PlannedFix {
edits: vec![],
confidence: FixConfidence::Low,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!(
"Remove prop '{}' (unbalanced brackets, manual)",
prop_name
),
});
}
let mut edits = Vec::new();
for i in line_idx..=end_idx {
if let Some(l) = all_lines.get(i) {
edits.push(TextEdit {
line: (i + 1) as u32,
old_text: l.to_string(),
new_text: String::new(),
rule_id: rule_id.to_string(),
description: format!(
"Remove prop '{}' (line {} of multi-line)",
prop_name,
i - line_idx + 1
),
replace_all: false,
});
}
}
Some(PlannedFix {
edits,
confidence: FixConfidence::High,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!(
"Remove prop '{}' ({} lines)",
prop_name,
end_idx - line_idx + 1
),
})
}
} else {
let prop_re = regex::Regex::new(&format!(
r#"\s+{prop_name}(?:=\{{[^}}]*\}}|="[^"]*"|='[^']*'|=\{{.*?\}})?"#
))
.ok()?;
if let Some(m) = prop_re.find(file_line) {
if bracket_depth(m.as_str()) != 0 {
return Some(PlannedFix {
edits: vec![],
confidence: FixConfidence::Low,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!("Remove prop '{}' (multi-line inline, manual)", prop_name),
});
}
Some(PlannedFix {
edits: vec![TextEdit {
line,
old_text: m.as_str().to_string(),
new_text: String::new(),
rule_id: rule_id.to_string(),
description: format!("Remove prop '{}'", prop_name),
replace_all: false,
}],
confidence: FixConfidence::High,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!("Remove prop '{}'", prop_name),
})
} else {
Some(PlannedFix {
edits: vec![],
confidence: FixConfidence::Low,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: incident.file_uri.clone(),
line,
description: format!("Remove prop '{}' (manual)", prop_name),
})
}
}
}
fn dedup_import_specifiers(lines: &mut [String]) {
let import_re = regex::Regex::new(r"^(\s*import\s+\{)([^}]+)(\}\s*from\s+.*)$").unwrap();
for line in lines.iter_mut() {
if let Some(caps) = import_re.captures(line) {
let prefix = caps.get(1).unwrap().as_str();
let specifiers_str = caps.get(2).unwrap().as_str();
let suffix = caps.get(3).unwrap().as_str();
let specifiers: Vec<&str> = specifiers_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut seen = std::collections::HashSet::new();
let deduped: Vec<&str> = specifiers
.into_iter()
.filter(|s| seen.insert(s.to_string()))
.collect();
let new_specifiers = format!(" {} ", deduped.join(", "));
let new_line = format!("{}{}{}", prefix, new_specifiers, suffix);
if new_line != *line {
*line = new_line;
}
}
}
}
fn bracket_depth(line: &str) -> i32 {
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_backtick = false;
let mut prev = '\0';
for ch in line.chars() {
match ch {
'\'' if !in_double_quote && !in_backtick && prev != '\\' => {
in_single_quote = !in_single_quote
}
'"' if !in_single_quote && !in_backtick && prev != '\\' => {
in_double_quote = !in_double_quote
}
'`' if !in_single_quote && !in_double_quote && prev != '\\' => {
in_backtick = !in_backtick
}
'(' | '{' | '[' if !in_single_quote && !in_double_quote && !in_backtick => depth += 1,
')' | '}' | ']' if !in_single_quote && !in_double_quote && !in_backtick => depth -= 1,
_ => {}
}
prev = ch;
}
depth
}
fn get_matched_text_from_incident(incident: &Incident) -> String {
for key in &[
"propName",
"componentName",
"importedName",
"className",
"variableName",
] {
if let Some(serde_json::Value::String(s)) = incident.variables.get(*key) {
return s.clone();
}
}
String::new()
}
fn get_matched_text_for_rename_from_incident(
incident: &Incident,
mappings: &[RenameMapping],
) -> String {
let prop_name = get_matched_text_from_incident(incident);
if mappings.iter().any(|m| m.old == prop_name) {
return prop_name;
}
if let Some(serde_json::Value::String(val)) = incident.variables.get("propValue") {
if mappings.iter().any(|m| m.old == val.as_str()) {
return val.clone();
}
}
if let Some(serde_json::Value::Array(vals)) = incident.variables.get("propObjectValues") {
for v in vals {
if let serde_json::Value::String(s) = v {
if mappings.iter().any(|m| m.old == s.as_str()) {
return s.clone();
}
}
}
}
prop_name
}
fn find_nearest_package_json(path: &Path) -> Option<PathBuf> {
let mut dir = if path.is_file() { path.parent()? } else { path };
loop {
let candidate = dir.join("package.json");
if candidate.exists() {
return Some(candidate);
}
dir = dir.parent()?;
}
}
fn plan_ensure_npm_dependency(
rule_id: &str,
incident: &Incident,
package: &str,
new_version: &str,
file_path: &Path,
) -> Vec<PlannedFix> {
if lockfile::is_lockfile(file_path) {
tracing::info!(
package = %package,
lockfile = %file_path.display(),
"Lockfile incident: resolving parent packages for transitive dependency"
);
let pkg_json = match find_nearest_package_json(file_path) {
Some(p) => p,
None => {
tracing::warn!(
lockfile = %file_path.display(),
"No package.json found near lockfile; skipping"
);
return Vec::new();
}
};
if let Some(current_version) = read_dep_version_from_package_json(&pkg_json, package) {
if is_range_already_compatible(¤t_version, new_version) {
tracing::info!(
package = %package,
current = %current_version,
required = %new_version,
"Lockfile path: consumer's version already satisfies required range; skipping parent resolution"
);
return Vec::new();
}
}
let direct_deps = lockfile::parse_direct_dep_names(&pkg_json);
let all_parents = lockfile::find_transitive_ancestor_packages(file_path, package);
let actionable_parents: Vec<&String> = all_parents
.iter()
.filter(|name| direct_deps.contains(name.as_str()))
.collect();
if actionable_parents.is_empty() {
tracing::debug!(
package = %package,
"No direct-dep parents found for transitive lockfile dep; skipping"
);
return Vec::new();
}
let target_major = extract_major(new_version);
let mut fixes = Vec::new();
for parent in &actionable_parents {
tracing::info!(
parent = %parent,
compatible_with = %package,
target_major = target_major,
"Resolving npm-compatible version for lockfile parent"
);
let resolved = resolve_npm_compatible_version(parent, package, target_major);
match resolved {
Some(ref ver) => {
tracing::info!(
parent = %parent,
resolved_version = %ver,
"Resolved npm-compatible version for lockfile parent"
);
if let Some(fix) =
plan_ensure_npm_dependency_inner(rule_id, &pkg_json, parent, ver)
{
fixes.push(fix);
}
}
None => {
tracing::warn!(
parent = %parent,
compatible_with = %package,
"Could not resolve compatible version from npm; skipping parent"
);
}
}
}
tracing::info!(
package = %package,
parents = actionable_parents.len(),
fixes = fixes.len(),
"Lockfile incident resolved"
);
return fixes;
}
if let Some(serde_json::Value::String(depends_on)) = incident.variables.get("isDependentOf") {
let actual_package = match incident
.variables
.get("dependencyName")
.and_then(|v| v.as_str())
{
Some(p) => p,
None => return Vec::new(),
};
if let Some(current) = read_dep_version_from_package_json(file_path, depends_on) {
if is_range_already_compatible(¤t, new_version) {
tracing::info!(
package = %depends_on,
current = %current,
required = %new_version,
"Dependent path: consumer's version already satisfies required range; skipping"
);
return Vec::new();
}
}
let target_major = extract_major(new_version);
tracing::info!(
dependent = %actual_package,
depends_on = %depends_on,
target_major = target_major,
"Resolving compatible version from npm for dependent package"
);
let resolved = resolve_npm_compatible_version(actual_package, depends_on, target_major);
return match resolved {
Some(ref ver) => {
tracing::info!(
package = %actual_package,
resolved_version = %ver,
"Resolved npm-compatible version for dependent"
);
plan_ensure_npm_dependency_inner(rule_id, file_path, actual_package, ver)
.into_iter()
.collect()
}
None => {
tracing::warn!(
package = %actual_package,
depends_on = %depends_on,
"Could not resolve compatible version from npm; skipping"
);
Vec::new()
}
};
}
plan_ensure_npm_dependency_inner(rule_id, file_path, package, new_version)
.into_iter()
.collect()
}
fn plan_ensure_npm_dependency_inner(
rule_id: &str,
file_path: &Path,
package: &str,
new_version: &str,
) -> Option<PlannedFix> {
let pkg_json = if file_path.file_name().is_some_and(|f| f == "package.json") {
file_path.to_path_buf()
} else {
find_nearest_package_json(file_path)?
};
let source = std::fs::read_to_string(&pkg_json).ok()?;
let pkg_json_uri = format!("file://{}", pkg_json.display());
let lines: Vec<&str> = source.lines().collect();
let top_level_dep_ranges = find_top_level_dep_blocks(&lines);
let package_quoted = format!("\"{}\"", package);
let version_re = regex::Regex::new(r#"("[\^~><=]*[0-9][^"]*")"#).ok()?;
for (idx, file_line) in source.lines().enumerate() {
if !file_line.contains(&package_quoted) {
continue;
}
if !top_level_dep_ranges
.iter()
.any(|r| idx >= r.start && idx < r.end)
{
continue;
}
if let Some(m) = version_re.find(file_line) {
let line = (idx + 1) as u32;
let old_version = m.as_str();
let old_ver_raw = old_version.trim_matches('"');
if is_range_already_compatible(old_ver_raw, new_version) {
tracing::info!(
package = %package,
current = %old_ver_raw,
required = %new_version,
"Skipping update: consumer's version range already satisfies the required range"
);
return None;
}
let new_ver_quoted = format!("\"{}\"", new_version);
return Some(PlannedFix {
edits: vec![TextEdit {
line,
old_text: old_version.to_string(),
new_text: new_ver_quoted.clone(),
rule_id: rule_id.to_string(),
description: format!(
"Update {} from {} to {}",
package, old_version, new_ver_quoted
),
replace_all: false,
}],
confidence: FixConfidence::Exact,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: pkg_json_uri,
line,
description: format!("Update {} to {}", package, new_version),
});
}
}
let target_block = top_level_dep_ranges
.iter()
.find(|r| r.name == "devDependencies")
.or_else(|| {
top_level_dep_ranges
.iter()
.find(|r| r.name == "dependencies")
});
let target_block = target_block?;
let mut last_entry_line: Option<usize> = None;
let mut closing_brace_line: Option<usize> = None;
for (idx, line) in lines
.iter()
.enumerate()
.take(target_block.end)
.skip(target_block.start)
{
let trimmed = line.trim();
if trimmed == "}" || trimmed == "}," {
closing_brace_line = Some(idx);
break;
}
if !trimmed.is_empty()
&& !trimmed.starts_with("\"dependencies\"")
&& !trimmed.starts_with("\"devDependencies\"")
&& trimmed != "{"
{
last_entry_line = Some(idx);
}
}
let closing_idx = closing_brace_line?;
let closing_line_num = (closing_idx + 1) as u32;
let entry_indent = if let Some(last_idx) = last_entry_line {
let last = lines[last_idx];
let indent_len = last.len() - last.trim_start().len();
&last[..indent_len]
} else {
" "
};
let mut edits = Vec::new();
if let Some(last_idx) = last_entry_line {
let last = lines[last_idx];
if !last.trim_end().ends_with(',') {
let last_line_num = (last_idx + 1) as u32;
let trimmed_last = last.trim_end().to_string();
edits.push(TextEdit {
line: last_line_num,
old_text: trimmed_last.clone(),
new_text: format!("{},", trimmed_last),
rule_id: rule_id.to_string(),
description: format!("Add trailing comma before new dependency {}", package),
replace_all: false,
});
}
}
let closing_line_text = lines[closing_idx].to_string();
let new_entry = format!(
"{}\"{}\": \"{}\"\n{}",
entry_indent, package, new_version, closing_line_text
);
edits.push(TextEdit {
line: closing_line_num,
old_text: closing_line_text,
new_text: new_entry,
rule_id: rule_id.to_string(),
description: format!("Add {} {} to dependencies", package, new_version),
replace_all: false,
});
Some(PlannedFix {
edits,
confidence: FixConfidence::Exact,
source: FixSource::Pattern,
rule_id: rule_id.to_string(),
file_uri: pkg_json_uri,
line: closing_line_num,
description: format!("Add {} {} to dependencies", package, new_version),
})
}
struct DepBlockRange {
name: &'static str,
start: usize,
end: usize,
}
fn find_top_level_dep_blocks(lines: &[&str]) -> Vec<DepBlockRange> {
let mut results = Vec::new();
let mut root_depth: i32 = 0;
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
for ch in trimmed.chars() {
match ch {
'{' => root_depth += 1,
'}' => root_depth -= 1,
_ => {}
}
}
let is_dep_key = (root_depth == 2 || root_depth == 1)
&& (trimmed.starts_with("\"dependencies\"")
|| trimmed.starts_with("\"devDependencies\""));
if !is_dep_key {
i += 1;
continue;
}
let name = if trimmed.starts_with("\"devDependencies\"") {
"devDependencies"
} else {
"dependencies"
};
let start = i;
let mut block_depth: i32 = 0;
for ch in trimmed.chars() {
match ch {
'{' => block_depth += 1,
'}' => block_depth -= 1,
_ => {}
}
}
i += 1;
while i < lines.len() && block_depth > 0 {
let t = lines[i].trim();
for ch in t.chars() {
match ch {
'{' => {
block_depth += 1;
root_depth += 1;
}
'}' => {
block_depth -= 1;
root_depth -= 1;
}
_ => {}
}
}
i += 1;
}
results.push(DepBlockRange {
name,
start,
end: i,
});
}
results
}
fn read_dep_version_from_package_json(pkg_json: &Path, package: &str) -> Option<String> {
let pkg_json = if pkg_json.file_name().is_some_and(|f| f == "package.json") {
pkg_json.to_path_buf()
} else {
find_nearest_package_json(pkg_json)?
};
let source = std::fs::read_to_string(&pkg_json).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&source).ok()?;
for section in ["dependencies", "devDependencies"] {
if let Some(version) = parsed
.get(section)
.and_then(|deps| deps.get(package))
.and_then(|v| v.as_str())
{
return Some(version.to_string());
}
}
None
}
fn is_range_already_compatible(consumer_range: &str, required_range: &str) -> bool {
let consumer = match consumer_range.parse::<node_semver::Range>() {
Ok(r) => r,
Err(_) => return false,
};
let required = match required_range.parse::<node_semver::Range>() {
Ok(r) => r,
Err(_) => return false,
};
required.allows_all(&consumer)
}
fn extract_major(version: &str) -> u64 {
let stripped = version
.trim()
.trim_start_matches('^')
.trim_start_matches('~')
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('=');
stripped
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn resolve_npm_compatible_version(
package: &str,
compatible_with: &str,
target_major: u64,
) -> Option<String> {
let url = format!("https://registry.npmjs.org/{}", package);
let mut response = match ureq::get(&url).call() {
Ok(resp) => resp,
Err(e) => {
tracing::warn!(
package = %package,
error = %e,
"npm registry query failed"
);
return None;
}
};
let body: serde_json::Value = match response.body_mut().read_json() {
Ok(v) => v,
Err(e) => {
tracing::warn!(
package = %package,
error = %e,
"Failed to parse npm registry response"
);
return None;
}
};
let versions = body.get("versions")?.as_object()?;
let mut candidates: Vec<(u64, u64, u64, &str)> = Vec::new();
for (ver_str, ver_data) in versions {
if ver_str.contains("alpha")
|| ver_str.contains("prerelease")
|| ver_str.contains("rc")
|| ver_str.contains("beta")
{
continue;
}
let dep_constraint = ["dependencies", "peerDependencies"]
.iter()
.find_map(|section| {
ver_data
.get(*section)
.and_then(|deps| deps.get(compatible_with))
.and_then(|c| c.as_str())
});
let is_compatible = match dep_constraint {
Some(c) => extract_major(c) >= target_major,
None => true, };
if !is_compatible {
continue;
}
if let Some(parsed) = parse_semver_tuple(ver_str) {
candidates.push((parsed.0, parsed.1, parsed.2, ver_str.as_str()));
}
}
candidates.sort();
let latest = candidates.last()?;
Some(format!("^{}", latest.3))
}
fn parse_semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
let s = s.trim();
let version_part = s.split('-').next().unwrap_or(s);
let parts: Vec<&str> = version_part.split('.').collect();
let major = parts.first()?.parse().ok()?;
let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
Some((major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn make_test_incident(
uri: &str,
line: u32,
variables: BTreeMap<String, serde_json::Value>,
) -> Incident {
Incident {
file_uri: uri.to_string(),
line_number: Some(line),
code_location: None,
message: String::new(),
code_snip: None,
variables,
effort: None,
links: Vec::new(),
is_dependency_incident: false,
}
}
#[test]
fn test_skip_node_modules() {
let provider = JsFixProvider::new();
assert!(provider.should_skip_path(Path::new(
"/project/node_modules/@patternfly/react-core/index.js"
)));
}
#[test]
fn test_skip_nested_node_modules() {
let provider = JsFixProvider::new();
assert!(
provider.should_skip_path(Path::new("/project/packages/app/node_modules/foo/bar.ts"))
);
}
#[test]
fn test_does_not_skip_src() {
let provider = JsFixProvider::new();
assert!(!provider.should_skip_path(Path::new("/project/src/App.tsx")));
}
#[test]
fn test_does_not_skip_vendor() {
let provider = JsFixProvider::new();
assert!(!provider.should_skip_path(Path::new("/project/src/vendor/lib.ts")));
}
#[test]
fn test_whole_file_rename_with_imported_name() {
let provider = JsFixProvider::new();
let mut vars = BTreeMap::new();
vars.insert(
"importedName".to_string(),
serde_json::Value::String("Chip".to_string()),
);
let incident = make_test_incident("file:///test.tsx", 1, vars);
assert!(provider.is_whole_file_rename(&incident));
}
#[test]
fn test_not_whole_file_rename_without_imported_name() {
let provider = JsFixProvider::new();
let mut vars = BTreeMap::new();
vars.insert(
"propName".to_string(),
serde_json::Value::String("isActive".to_string()),
);
let incident = make_test_incident("file:///test.tsx", 1, vars);
assert!(!provider.is_whole_file_rename(&incident));
}
#[test]
fn test_bracket_depth_balanced() {
assert_eq!(bracket_depth("{ foo: bar }"), 0);
assert_eq!(bracket_depth("foo()"), 0);
assert_eq!(bracket_depth("[1, 2, 3]"), 0);
assert_eq!(bracket_depth("{ foo: [1, 2] }"), 0);
}
#[test]
fn test_bracket_depth_open() {
assert_eq!(bracket_depth("actions={["), 2);
assert_eq!(bracket_depth(" <Button"), 0);
assert_eq!(bracket_depth("foo(bar, {"), 2);
}
#[test]
fn test_bracket_depth_close() {
assert_eq!(bracket_depth("]}"), -2);
assert_eq!(bracket_depth(")"), -1);
}
#[test]
fn test_bracket_depth_ignores_string_literals() {
assert_eq!(bracket_depth(r#" foo="{not a bracket}""#), 0);
assert_eq!(bracket_depth(" foo='[still not]'"), 0);
}
#[test]
fn test_dedup_import_removes_duplicates() {
let mut lines =
vec!["import { Content, Content, Content } from '@patternfly/react-core';".to_string()];
dedup_import_specifiers(&mut lines);
let count = lines[0].matches("Content").count();
assert_eq!(count, 1);
}
#[test]
fn test_dedup_import_preserves_different_specifiers() {
let mut lines = vec!["import { Foo, Bar, Foo, Baz, Bar } from '@pkg';".to_string()];
dedup_import_specifiers(&mut lines);
assert_eq!(lines[0].matches("Foo").count(), 1);
assert_eq!(lines[0].matches("Bar").count(), 1);
assert_eq!(lines[0].matches("Baz").count(), 1);
}
#[test]
fn test_get_matched_text_prop_name_first() {
let mut vars = BTreeMap::new();
vars.insert(
"propName".to_string(),
serde_json::Value::String("isActive".to_string()),
);
vars.insert(
"componentName".to_string(),
serde_json::Value::String("Button".to_string()),
);
let incident = make_test_incident("file:///test.tsx", 1, vars);
assert_eq!(get_matched_text_from_incident(&incident), "isActive");
}
#[test]
fn test_get_matched_text_empty_when_no_known_vars() {
let incident = make_test_incident("", 1, BTreeMap::new());
assert_eq!(get_matched_text_from_incident(&incident), "");
}
#[test]
fn test_get_matched_text_for_rename_prefers_prop_name() {
let mut vars = BTreeMap::new();
vars.insert(
"propName".into(),
serde_json::Value::String("spaceItems".into()),
);
let incident = make_test_incident("file:///test.tsx", 1, vars);
let mappings = vec![RenameMapping {
old: "spaceItems".into(),
new: "gap".into(),
}];
assert_eq!(
get_matched_text_for_rename_from_incident(&incident, &mappings),
"spaceItems"
);
}
#[test]
fn test_get_matched_text_for_rename_falls_back_to_prop_value() {
let mut vars = BTreeMap::new();
vars.insert(
"propName".into(),
serde_json::Value::String("variant".into()),
);
vars.insert(
"propValue".into(),
serde_json::Value::String("light".into()),
);
let incident = make_test_incident("file:///test.tsx", 1, vars);
let mappings = vec![RenameMapping {
old: "light".into(),
new: "secondary".into(),
}];
assert_eq!(
get_matched_text_for_rename_from_incident(&incident, &mappings),
"light"
);
}
#[test]
fn test_post_process_deduplicates_imports() {
let provider = JsFixProvider::new();
let mut lines = vec![
"import { Content, Content } from '@patternfly/react-core';".to_string(),
"const x = 1;".to_string(),
];
provider.post_process_lines(&mut lines);
assert_eq!(lines[0].matches("Content").count(), 1);
assert_eq!(lines[1], "const x = 1;");
}
#[test]
fn test_extract_major() {
assert_eq!(extract_major("^6.4.1"), 6);
assert_eq!(extract_major("~5.0.0"), 5);
assert_eq!(extract_major("6.4.1"), 6);
assert_eq!(extract_major(">=7.0.0"), 7);
assert_eq!(extract_major("^6.0.0-alpha.1"), 6);
}
#[test]
fn test_parse_semver_tuple() {
assert_eq!(parse_semver_tuple("6.4.1"), Some((6, 4, 1)));
assert_eq!(parse_semver_tuple("5.0.0"), Some((5, 0, 0)));
assert_eq!(parse_semver_tuple("6.0.0-alpha.1"), Some((6, 0, 0)));
}
#[test]
fn test_dependent_incident_updates_correct_package() {
let dir = tempfile::tempdir().unwrap();
let pkg_json = dir.path().join("package.json");
std::fs::write(
&pkg_json,
r#"{
"devDependencies": {
"@patternfly/react-core": "^6.4.1",
"@patternfly/react-topology": "5.2.1"
}
}"#,
)
.unwrap();
let mut vars = BTreeMap::new();
vars.insert(
"dependencyName".into(),
serde_json::Value::String("@patternfly/react-topology".into()),
);
vars.insert(
"dependencyVersion".into(),
serde_json::Value::String("5.2.1".into()),
);
vars.insert(
"dependencyType".into(),
serde_json::Value::String("devDependencies".into()),
);
vars.insert(
"isDependentOf".into(),
serde_json::Value::String("@patternfly/react-core".into()),
);
vars.insert(
"dependentConstraint".into(),
serde_json::Value::String("^5.1.1".into()),
);
let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 4, vars);
let result = plan_ensure_npm_dependency(
"semver-dep-update-patternfly-react-core",
&incident,
"@patternfly/react-core",
"^6.4.1",
&pkg_json,
);
if let Some(fix) = result.first() {
assert!(
fix.description.contains("react-topology"),
"Fix should target react-topology, got: {}",
fix.description
);
assert!(
!fix.description.contains("react-core"),
"Fix should NOT mention react-core as the package to update"
);
}
}
#[test]
fn test_non_dependent_incident_uses_provided_version() {
let dir = tempfile::tempdir().unwrap();
let pkg_json = dir.path().join("package.json");
std::fs::write(
&pkg_json,
r#"{
"dependencies": {
"@patternfly/react-core": "5.3.4"
}
}"#,
)
.unwrap();
let vars = BTreeMap::new();
let incident = make_test_incident(&format!("file://{}", pkg_json.display()), 3, vars);
let result = plan_ensure_npm_dependency(
"semver-dep-update-patternfly-react-core",
&incident,
"@patternfly/react-core",
"^6.4.1",
&pkg_json,
);
assert_eq!(
result.len(),
1,
"Should produce exactly one fix for primary dep update"
);
let fix = &result[0];
assert_eq!(fix.edits.len(), 1);
assert!(fix.edits[0].new_text.contains("6.4.1"));
assert!(fix.description.contains("react-core"));
}
#[test]
fn parse_yarn_missing_peers_basic() {
let output = "\
➤ YN0000: · Yarn 4.6.0
➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory-core (p4d5e6), requested by some-dep.
➤ YN0000: · Done in 1.5s
";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].peer_name, "victory");
assert_eq!(peers[0].requested_by, "@patternfly/react-charts");
assert_eq!(peers[1].peer_name, "victory-core");
assert_eq!(peers[1].requested_by, "some-dep");
}
#[test]
fn parse_yarn_missing_peers_with_ansi() {
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";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].peer_name, "bar");
assert_eq!(peers[0].requested_by, "baz");
}
#[test]
fn parse_yarn_missing_peers_scoped_package() {
let output = "➤ YN0002: @scope/some-pkg@npm:2.0.0 doesn't provide @scope/peer-pkg (pabcde), requested by other-dep.\n";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].peer_name, "@scope/peer-pkg");
assert_eq!(peers[0].requested_by, "other-dep");
}
#[test]
fn parse_yarn_missing_peers_deduplicates() {
let output = "\
➤ YN0002: pkg-a@npm:1.0.0 doesn't provide victory (p11111), requested by dep-a.
➤ YN0002: pkg-b@npm:2.0.0 doesn't provide victory (p22222), requested by dep-b.
➤ YN0002: pkg-c@npm:3.0.0 doesn't provide victory (p33333), requested by dep-c.
";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].peer_name, "victory");
assert_eq!(peers[0].requested_by, "dep-a");
}
#[test]
fn parse_yarn_missing_peers_workspace_box_separator() {
let output = "\
➤ YN0000: · Yarn 4.6.0
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed
➤ YN0000: ┌ Post-resolution validation
➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
➤ YN0002: │ pipelines-console-plugin@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
➤ 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.
➤ YN0000: └ Completed
➤ YN0000: · Done with warnings in 1s 4ms
";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 3);
assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
assert_eq!(peers[1].peer_name, "axe-core");
assert_eq!(peers[1].requested_by, "cypress-axe");
assert_eq!(peers[2].peer_name, "i18next");
assert_eq!(peers[2].requested_by, "react-i18next");
}
#[test]
fn parse_yarn_missing_peers_mixed_formats() {
let output = "\
➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
➤ YN0002: @patternfly/react-charts@npm:8.4.1 doesn't provide victory (p1a2b3), requested by @patternfly/react-charts.
";
let peers = super::parse_yarn_missing_peer_deps(output);
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].peer_name, "@patternfly/react-drag-drop");
assert_eq!(peers[0].requested_by, "@patternfly/react-component-groups");
assert_eq!(peers[1].peer_name, "victory");
assert_eq!(peers[1].requested_by, "@patternfly/react-charts");
}
#[test]
fn parse_yarn_missing_peers_no_warnings() {
let output = "➤ YN0000: · Yarn 4.6.0\n➤ YN0000: · Done in 0.5s\n";
let peers = super::parse_yarn_missing_peer_deps(output);
assert!(peers.is_empty());
}
#[test]
fn lookup_peer_dep_version_finds_version() {
let dir = tempfile::tempdir().unwrap();
let pkg_dir = dir.path().join("node_modules/@patternfly/react-charts");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{
"name": "@patternfly/react-charts",
"peerDependencies": {
"victory-core": "^37.3.6",
"echarts": "^5.6.0 || ^6.0.0"
}
}"#,
)
.unwrap();
assert_eq!(
super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "victory-core"),
Some("^37.3.6".to_string())
);
assert_eq!(
super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "echarts"),
Some("^5.6.0 || ^6.0.0".to_string())
);
assert_eq!(
super::lookup_peer_dep_version(dir.path(), "@patternfly/react-charts", "nonexistent"),
None
);
}
#[test]
fn lookup_peer_dep_version_missing_package() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
super::lookup_peer_dep_version(dir.path(), "nonexistent-pkg", "some-peer"),
None
);
}
#[test]
fn range_compatible_caret_subset_of_or_range() {
assert!(super::is_range_already_compatible(
"^17.0.1",
"^17 || ^18 || ^19"
));
}
#[test]
fn range_compatible_identical_ranges() {
assert!(super::is_range_already_compatible("^6.4.1", "^6.4.1"));
}
#[test]
fn range_incompatible_different_majors() {
assert!(!super::is_range_already_compatible("^11.7.3", "^17.0.3"));
}
#[test]
fn range_compatible_exact_within_caret() {
assert!(super::is_range_already_compatible("1.2.3", "^1.0.0"));
}
#[test]
fn range_compatible_tilde_within_caret() {
assert!(super::is_range_already_compatible("~1.2.3", "^1.0.0"));
}
#[test]
fn range_incompatible_caret_not_in_tilde() {
assert!(!super::is_range_already_compatible("^1.0.0", "~1.2.3"));
}
#[test]
fn range_compatible_or_range_within_star() {
assert!(super::is_range_already_compatible("^17 || ^18", ">=0.0.0"));
}
#[test]
fn range_unparseable_returns_false() {
assert!(!super::is_range_already_compatible("latest", "^1.0.0"));
assert!(!super::is_range_already_compatible(
"^1.0.0",
"git+ssh://foo"
));
}
#[test]
fn range_compatible_prerelease() {
assert!(super::is_range_already_compatible(
"^6.30.3-pre-v6.0",
"^6.0.0"
));
}
#[test]
fn range_compatible_higher_patch_not_downgraded() {
assert!(super::is_range_already_compatible("^6.4.2", "^6.4.1"));
}
#[test]
fn range_compatible_higher_minor_not_downgraded() {
assert!(super::is_range_already_compatible("^6.5.0", "^6.4.1"));
}
#[test]
fn baseline_diff_filters_preexisting_peers() {
let baseline: std::collections::HashSet<String> = [
"@patternfly/react-styles",
"axe-core",
"i18next",
"mocha",
"react-redux",
"react-router",
"react-router-dom",
"redux",
"redux-thunk",
]
.iter()
.map(|s| s.to_string())
.collect();
let after_output = "\
➤ YN0002: │ my-app@workspace:. doesn't provide @patternfly/react-drag-drop (pccfa6), requested by @patternfly/react-component-groups.
➤ YN0002: │ my-app@workspace:. doesn't provide axe-core (p27d9e), requested by cypress-axe.
➤ YN0002: │ my-app@workspace:. doesn't provide i18next (p374d5), requested by react-i18next.
➤ YN0002: │ my-app@workspace:. doesn't provide mocha (p7ec1d), requested by cypress-multi-reporters and other dependencies.
➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
➤ YN0002: │ my-app@workspace:. doesn't provide react-router-dom (p7d47e), requested by react-router-dom-v5-compat.
➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by @openshift/dynamic-plugin-sdk-extensions and other dependencies.
➤ YN0002: │ my-app@workspace:. doesn't provide redux-thunk (pd9e06), requested by @openshift/dynamic-plugin-sdk-utils.
";
let all_missing = super::parse_yarn_missing_peer_deps(after_output);
let new_peers: Vec<_> = all_missing
.into_iter()
.filter(|p| !baseline.contains(&p.peer_name))
.collect();
assert_eq!(new_peers.len(), 1);
assert_eq!(new_peers[0].peer_name, "@patternfly/react-drag-drop");
assert_eq!(
new_peers[0].requested_by,
"@patternfly/react-component-groups"
);
}
#[test]
fn baseline_diff_installs_nothing_when_no_new_peers() {
let baseline: std::collections::HashSet<String> = ["react-redux", "redux"]
.iter()
.map(|s| s.to_string())
.collect();
let after_output = "\
➤ YN0002: │ my-app@workspace:. doesn't provide react-redux (pa391f), requested by some-pkg.
➤ YN0002: │ my-app@workspace:. doesn't provide redux (pd1d52), requested by some-pkg.
";
let all_missing = super::parse_yarn_missing_peer_deps(after_output);
let new_peers: Vec<_> = all_missing
.into_iter()
.filter(|p| !baseline.contains(&p.peer_name))
.collect();
assert!(new_peers.is_empty());
}
}