use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;
use anyhow::{Context, Result};
use crate::{TsCategory, TsManifestChangeType, TypeScript};
use semver_analyzer_core::{
AnalysisReport, ApiChange, ApiChangeKind, ApiChangeType, BehavioralChange,
ChildComponentStatus, ComponentStatus, ComponentSummary, FileChanges, ManifestChange,
RemovalDisposition,
};
pub use semver_analyzer_konveyor_core::*;
type ConstantGroupEntries<'a> = Vec<(&'a ApiChange, Option<String>, FixStrategyEntry)>;
fn detect_collapsible_constant_groups<'a>(
report: &'a AnalysisReport<TypeScript>,
pkg_cache: &HashMap<String, String>,
rename_patterns: &RenamePatterns,
member_renames: &HashMap<String, String>,
) -> HashMap<ConstantGroupKey, ConstantGroupEntries<'a>> {
let mut groups: HashMap<ConstantGroupKey, ConstantGroupEntries<'a>> = HashMap::new();
for file_changes in &report.changes {
let from_pkg = resolve_npm_package(&file_changes.file.to_string_lossy(), pkg_cache);
let pkg_name = match &from_pkg {
Some(p) => p.clone(),
None => continue,
};
let file_path_str = file_changes.file.to_string_lossy();
for change in &file_changes.breaking_api_changes {
if change.kind != ApiChangeKind::Constant {
continue;
}
if change.symbol.contains('.') {
continue;
}
if change.migration_target.is_some() {
continue;
}
let strategy = match api_change_to_strategy(
change,
rename_patterns,
member_renames,
&file_path_str,
) {
Some(s) => s,
None => continue,
};
let key = ConstantGroupKey {
package: pkg_name.clone(),
change_type: change.change.clone(),
strategy: strategy.strategy.clone(),
};
groups
.entry(key)
.or_default()
.push((change, from_pkg.clone(), strategy));
}
}
groups.retain(|_, changes| changes.len() >= CONSTANT_COLLAPSE_THRESHOLD);
groups
}
fn derive_import_path(package: Option<&str>, qualified_name: &str) -> String {
let base = package.unwrap_or("unknown");
if qualified_name.contains("/deprecated/") {
format!("{}/deprecated", base)
} else if qualified_name.contains("/next/") {
format!("{}/next", base)
} else {
base.to_string()
}
}
fn extract_prop_name_from_signature(sig: &str) -> Option<&str> {
let after_kind = sig.split_once(": ")?.1;
let name = after_kind.split_once(": ").map(|(n, _)| n)?;
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn build_migration_message_v2(comp: &ComponentSummary<TypeScript>) -> String {
let component_name = &comp.name;
let removal_count = comp.member_summary.removed;
let total = comp.member_summary.total;
let mut msg = String::new();
if let Some(ref target) = comp.migration_target {
let replacement = target
.replacement_symbol
.strip_suffix("Props")
.unwrap_or(&target.replacement_symbol);
msg.push_str(&format!(
"MIGRATION: Replace <{}> with props on <{}>.\n\n",
component_name, replacement
));
{
let old_import = derive_import_path(
target.removed_package.as_deref(),
&target.removed_qualified_name,
);
let new_import = derive_import_path(
target.replacement_package.as_deref(),
&target.replacement_qualified_name,
);
if old_import != new_import {
msg.push_str(&format!(
"Import change:\n\
\x20 Replace: import {{ {} }} from '{}';\n\
\x20 With: import {{ {} }} from '{}';\n\n\
NOTE: The new <{}> may have a significantly different API.\n\
Review the property mapping below and update your usage accordingly.\n\n",
component_name, old_import, replacement, new_import, replacement
));
}
}
if !target.matching_members.is_empty() {
msg.push_str("Property mapping:\n");
for m in &target.matching_members {
if m.old_name == m.new_name {
msg.push_str(&format!(
" - {}.{} → {}.{}\n",
component_name, m.old_name, replacement, m.new_name
));
} else {
msg.push_str(&format!(
" - {}.{} → {}.{} (renamed)\n",
component_name, m.old_name, replacement, m.new_name
));
}
}
msg.push('\n');
}
if !target.removed_only_members.is_empty() {
msg.push_str(&format!(
"Removed with no direct equivalent: {}\n\n",
target.removed_only_members.join(", ")
));
}
} else if comp.status == ComponentStatus::Removed || (removal_count == total && total <= 2) {
msg.push_str(&format!(
"MIGRATION: <{}> was removed.\n\n\
This component has no detected direct replacement.\n\
Replace all <{}> usages with the recommended alternative.\n\n",
component_name, component_name,
));
} else {
msg.push_str(&format!(
"MIGRATION: <{}> has been restructured ({} of {} props removed).\n\n\
The component still exists but its API changed significantly.\n\
Props that were removed have moved to composed child components.\n\
Keep <{}> and restructure by replacing removed props with \
child components that provide the same functionality.\n\n",
component_name, removal_count, total, component_name,
));
if !comp.removed_members.is_empty() {
msg.push_str("Removed props (move to child components):\n");
for prop in &comp.removed_members {
msg.push_str(&format!(" - {}\n", prop.name));
}
msg.push('\n');
if !comp.child_components.is_empty() {
let prop_dispositions: HashMap<&str, &RemovalDisposition> = comp
.removed_members
.iter()
.filter_map(|rp| {
rp.removal_disposition
.as_ref()
.map(|d| (rp.name.as_str(), d))
})
.collect();
msg.push_str("Use these child components inside <");
msg.push_str(component_name);
msg.push_str(">:\n");
for child in &comp.child_components {
if !child.absorbed_members.is_empty() {
let mut as_props = Vec::new();
let mut as_children = Vec::new();
for prop_name in &child.absorbed_members {
match prop_dispositions.get(prop_name.as_str()) {
Some(RemovalDisposition::MovedToRelatedType {
mechanism, ..
}) if mechanism == "children" => {
as_children.push(prop_name.as_str());
}
_ => {
if child.known_members.contains(prop_name) {
as_props.push(prop_name.as_str());
} else {
as_children.push(prop_name.as_str());
}
}
}
}
let mut parts = Vec::new();
if !as_props.is_empty() {
parts.push(format!("pass as props: {}", as_props.join(", ")));
}
if !as_children.is_empty() {
parts.push(format!("pass as children: {}", as_children.join(", ")));
}
msg.push_str(&format!(" - <{}> — {}\n", child.name, parts.join("; ")));
} else {
msg.push_str(&format!(
" - <{}> — wrap relevant content as children\n",
child.name,
));
}
}
let absorbed: HashSet<&str> = comp
.child_components
.iter()
.flat_map(|c| c.absorbed_members.iter().map(|s| s.as_str()))
.collect();
let unmapped: Vec<&str> = comp
.removed_members
.iter()
.map(|rp| rp.name.as_str())
.filter(|n| !absorbed.contains(n))
.collect();
if !unmapped.is_empty() {
let truly_removed: Vec<&str> = unmapped
.iter()
.filter(|n| {
matches!(
prop_dispositions.get(*n),
Some(RemovalDisposition::TrulyRemoved)
| Some(RemovalDisposition::MadeAutomatic)
)
})
.copied()
.collect();
let unknown: Vec<&str> = unmapped
.iter()
.filter(|n| !truly_removed.contains(n))
.copied()
.collect();
if !truly_removed.is_empty() {
msg.push_str(&format!(
"\nRemoved with no replacement (safe to delete): {}\n",
truly_removed.join(", ")
));
}
if !unknown.is_empty() {
msg.push_str(&format!(
"\nProps with no direct child component match (handle manually): {}\n",
unknown.join(", ")
));
}
}
msg.push('\n');
}
}
}
if !comp.type_changes.is_empty() {
msg.push_str("Type changes:\n");
for tc in &comp.type_changes {
match (&tc.before, &tc.after) {
(Some(b), Some(a)) => {
msg.push_str(&format!(" - {}: {} → {}\n", tc.property, b, a));
}
(Some(b), None) => {
msg.push_str(&format!(" - {}: {} (removed)\n", tc.property, b));
}
(None, Some(a)) => {
msg.push_str(&format!(" - {}: → {} (added)\n", tc.property, a));
}
(None, None) => {
msg.push_str(&format!(" - {}: type changed\n", tc.property));
}
}
}
msg.push('\n');
}
if !comp.behavioral_changes.is_empty() {
let mut seen = BTreeSet::new();
let mut deduped: Vec<(&BehavioralChange<TypeScript>, usize)> = Vec::new();
for b in &comp.behavioral_changes {
let key = format!(
"{}:{}",
b.category
.as_ref()
.map(|c| behavioral_category_label(c))
.unwrap_or("change"),
b.description
);
if seen.insert(key.clone()) {
let count = comp
.behavioral_changes
.iter()
.filter(|b2| b2.description == b.description && b2.category == b.category)
.count();
deduped.push((b, count));
}
}
msg.push_str("Behavioral changes:\n");
for (b, count) in &deduped {
let cat = b
.category
.as_ref()
.map(|c| behavioral_category_label(c))
.unwrap_or("change");
if *count > 1 {
msg.push_str(&format!(" - {}: {} (×{})\n", cat, b.description, count));
} else {
msg.push_str(&format!(" - {}: {}\n", cat, b.description));
}
}
msg.push('\n');
}
if let Some(ref target) = comp.migration_target {
let replacement = target
.replacement_symbol
.strip_suffix("Props")
.unwrap_or(&target.replacement_symbol);
msg.push_str(&format!(
"Remove <{}> from JSX and move its props to <{}>.\n\
Also remove {} from the import statement.",
component_name, replacement, component_name
));
} else if comp.status == ComponentStatus::Removed || (removal_count == total && total <= 2) {
msg.push_str(&format!(
"Remove {} from the import statement.",
component_name,
));
} else {
msg.push_str(&format!(
"Keep {} in the import statement. Restructure JSX to use \
composed children instead of the removed props.",
component_name,
));
}
msg
}
pub fn generate_rules(
report: &AnalysisReport<TypeScript>,
file_pattern: &str,
pkg_cache: &HashMap<String, String>,
rename_patterns: &RenamePatterns,
member_renames: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let mut rules = Vec::new();
let mut id_counts: HashMap<String, usize> = HashMap::new();
let composition_required_components: HashSet<String> = report
.changes
.iter()
.flat_map(|fc| &fc.container_changes)
.filter(|c| c.new_container.is_some())
.map(|c| c.symbol.clone())
.collect();
struct ChildrenToPropMigration {
child_components: Vec<String>,
from_pkg: Option<String>,
}
let mut children_to_prop: BTreeMap<(String, String), ChildrenToPropMigration> = BTreeMap::new();
let mut consolidated_composition_keys: HashSet<String> = HashSet::new();
for file_changes in &report.changes {
let file_str = file_changes.file.to_string_lossy();
let from_pkg = resolve_npm_package(&file_str, pkg_cache);
for comp_change in &file_changes.container_changes {
let (old_parent, new_parent) =
match (&comp_change.old_container, &comp_change.new_container) {
(Some(old), Some(new)) => (old.as_str(), new.as_str()),
_ => continue,
};
let old_name = old_parent.split(" (").next().unwrap_or(old_parent).trim();
let new_name = new_parent.split(" (").next().unwrap_or(new_parent).trim();
if !old_name.eq_ignore_ascii_case(new_name) {
continue;
}
let old_is_children = old_parent.contains("children");
let target_prop = extract_target_prop(new_parent);
if !old_is_children {
continue;
}
let target_prop = match target_prop {
Some(p) => p.to_string(),
None => continue,
};
let key = (old_name.to_string(), target_prop.clone());
let entry = children_to_prop
.entry(key)
.or_insert_with(|| ChildrenToPropMigration {
child_components: Vec::new(),
from_pkg: from_pkg.clone(),
});
let child = &comp_change.symbol;
if !entry.child_components.iter().any(|c| c == child) {
entry.child_components.push(child.clone());
}
let dedup_key = format!("{}|{}|{}", comp_change.symbol, old_parent, new_parent,);
consolidated_composition_keys.insert(dedup_key);
}
}
for ((parent, prop), migration) in &children_to_prop {
let child_list = migration.child_components.join(", ");
let base_id = format!(
"semver-composition-{}-children-to-{}-prop",
sanitize_id(parent),
sanitize_id(prop),
);
let rule_id = unique_id(base_id, &mut id_counts);
let msg = format!(
"MIGRATION: Children that serve as the `{prop}` of <{parent}> should be \
passed via the `{prop}` prop instead of as children.\n\n\
Change: <{parent}><SomeIcon /></{parent}> → <{parent} {prop}={{<SomeIcon />}} />\n\n\
This applies to ALL components that represent the `{prop}`, including \
custom/app-level components. The library internally migrated {count} \
components to this pattern: {children}.\n\n\
For non-plain variants, the `{prop}` prop wraps the content in a styled \
<span> with proper spacing. Passing it as children bypasses this styling.",
parent = parent,
prop = prop,
count = migration.child_components.len(),
children = child_list,
);
let common_suffix = derive_common_suffix(&migration.child_components);
let (pattern, location, parent_field, parent_from_field) =
if let Some(ref suffix) = common_suffix {
(
format!("{}$", regex_escape(suffix)),
"JSX_COMPONENT".to_string(),
Some(format!("^{}$", regex_escape(parent))),
migration.from_pkg.clone(),
)
} else {
(
format!("^{}$", regex_escape(parent)),
"IMPORT".to_string(),
None,
None,
)
};
tracing::debug!(
children = migration.child_components.len(),
prop = %prop,
parent = %parent,
rule_id = %rule_id,
pattern = %pattern,
parent_field = ?parent_field,
"Consolidated composition changes"
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=composition".to_string(),
"has-codemod=false".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: format!(
"Children serving as the `{}` of <{}> should use the `{}` prop instead",
prop, parent, prop,
),
message: msg,
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern,
location,
component: None,
parent: parent_field,
parent_from: parent_from_field,
not_parent: None,
not_child: None,
value: None,
from: None,
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
}
let mut covered_components: HashSet<String> = HashSet::new();
let mut covered_props: HashSet<(String, String)> = HashSet::new();
for pkg in &report.packages {
for comp in &pkg.type_summaries {
let qualifies = comp.status == ComponentStatus::Removed
|| (comp.member_summary.removed >= 3 && comp.member_summary.removal_ratio > 0.5)
|| comp.member_summary.removed >= 5;
if !qualifies {
continue;
}
covered_components.insert(comp.name.clone());
covered_components.insert(comp.definition_name.clone());
for rp in &comp.removed_members {
covered_props.insert((comp.definition_name.clone(), rp.name.clone()));
covered_props.insert((comp.name.clone(), rp.name.clone()));
}
}
}
if !covered_components.is_empty() {
tracing::debug!(
components = covered_components.len(),
covered_props = covered_props.len(),
"P0-C coverage computed"
);
}
let public_symbols: HashSet<&str> = report
.packages
.iter()
.flat_map(|pkg| {
pkg.type_summaries.iter().flat_map(|comp| {
std::iter::once(comp.name.as_str())
.chain(std::iter::once(comp.definition_name.as_str()))
})
})
.collect();
let mut collapsed_keys: HashSet<(String, ApiChangeType, String)> = HashSet::new();
let mut collapsed_symbols: HashSet<(String, String)> = HashSet::new();
let mut renamed_constant_index: HashMap<&str, (&ApiChange, String)> = HashMap::new();
for fc in &report.changes {
let file_path = fc.file.to_string_lossy().to_string();
for change in &fc.breaking_api_changes {
if change.kind == ApiChangeKind::Constant && change.change == ApiChangeType::Renamed {
renamed_constant_index
.entry(change.symbol.as_str())
.or_insert((change, file_path.clone()));
}
}
}
let has_package_constants = report.packages.iter().any(|pkg| !pkg.constants.is_empty());
if has_package_constants {
for pkg in &report.packages {
for cg in &pkg.constants {
if cg.count < CONSTANT_COLLAPSE_THRESHOLD {
continue;
}
let symbol_names: Vec<&str> = cg.symbols.iter().map(|s| s.as_str()).collect();
let pattern = build_token_prefix_pattern(&symbol_names);
let strategy_name = if cg.strategy_hint.is_empty() {
"Manual".to_string()
} else {
cg.strategy_hint.clone()
};
let change_type_str = api_change_type_label(&cg.change_type);
let kind_str = api_kind_label(&ApiChangeKind::Constant);
let slug = pkg.name.replace('@', "").replace(['/', '.'], "-");
let strategy_slug = strategy_name.to_lowercase().replace(' ', "-");
let base_id = format!(
"semver-{}-constant-{}-{}-combined",
slug, change_type_str, strategy_slug
);
let rule_id = unique_id(base_id, &mut id_counts);
let mut message = format!(
"{} constants from `{}` had breaking changes ({}).\n",
cg.count, pkg.name, change_type_str,
);
let sample_count = 5.min(symbol_names.len());
if !symbol_names.is_empty() {
message.push_str(&format!(
"Affected constants include: {}",
symbol_names[..sample_count].join(", ")
));
if symbol_names.len() > sample_count {
message
.push_str(&format!(" and {} more.", symbol_names.len() - sample_count));
}
}
let strategy = if cg.change_type == ApiChangeType::Renamed {
let mut rename_strat = FixStrategyEntry::new("Rename");
for sym_name in &cg.symbols {
if covered_components.contains(sym_name) {
continue;
}
if let Some((change, file_path)) =
renamed_constant_index.get(sym_name.as_str())
{
let is_path_relocation = change
.before
.as_deref()
.is_some_and(|b| b.contains("packages/"))
|| change
.after
.as_deref()
.is_some_and(|a| a.contains("packages/"));
if is_path_relocation {
continue;
}
if let Some(s) = api_change_to_strategy(
change,
rename_patterns,
member_renames,
file_path,
) {
if s.strategy == "Rename" {
rename_strat.mappings.push(MappingEntry {
from: s.from,
to: s.to,
component: None,
prop: None,
});
}
}
}
}
tracing::debug!(
mappings = rename_strat.mappings.len(),
symbols = cg.symbols.len(),
"Built per-token Rename mappings for constantgroup"
);
rename_strat
} else {
let mut s = FixStrategyEntry::new(&strategy_name);
if !cg.suffix_renames.is_empty() {
s.mappings = cg
.suffix_renames
.iter()
.map(|sr| MappingEntry {
from: Some(sr.from.clone()),
to: Some(sr.to.clone()),
component: None,
prop: None,
})
.collect();
}
s
};
tracing::debug!(
count = cg.count,
change_type = %change_type_str,
strategy = %strategy_name,
package = %pkg.name,
rule_id = %rule_id,
"Collapsed constant rules into single rule"
);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
format!("change-type={}", change_type_str),
format!("kind={}", kind_str),
"has-codemod=true".to_string(),
format!("package={}", pkg.name),
],
effort: 3,
category: "mandatory".to_string(),
description: format!(
"{} constants from {} have breaking changes",
cg.count, pkg.name
),
message,
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern,
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some(pkg.name.clone()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(strategy),
});
let suppression_strategy = if cg.change_type == ApiChangeType::Renamed {
"Rename".to_string()
} else {
strategy_name
};
collapsed_keys.insert((
pkg.name.clone(),
cg.change_type.clone(),
suppression_strategy,
));
for sym in &cg.symbols {
collapsed_symbols.insert((pkg.name.clone(), sym.clone()));
}
}
}
} else {
let collapsible_groups =
detect_collapsible_constant_groups(report, pkg_cache, rename_patterns, member_renames);
for (key, changes) in &collapsible_groups {
let combined_rule = build_combined_constant_rule(key, changes, &mut id_counts);
tracing::debug!(
count = changes.len(),
change_type = %api_change_type_label(&key.change_type),
strategy = %key.strategy,
package = %key.package,
rule_id = %combined_rule.rule_id,
"Collapsed constant rules into single rule (legacy path)"
);
rules.push(combined_rule);
collapsed_keys.insert((
key.package.clone(),
key.change_type.clone(),
key.strategy.clone(),
));
for (change, _, _) in changes {
collapsed_symbols.insert((key.package.clone(), change.symbol.clone()));
}
}
}
for delta in &report.hierarchy_deltas {
covered_components.insert(delta.component.clone());
covered_components.insert(format!("{}Props", delta.component));
if let Some(comp) = report
.packages
.iter()
.flat_map(|pkg| &pkg.type_summaries)
.find(|c| c.name == delta.component)
{
for rp in &comp.removed_members {
covered_props.insert((comp.name.clone(), rp.name.clone()));
covered_props.insert((comp.definition_name.clone(), rp.name.clone()));
}
}
for child_name in &delta.removed_children {
covered_components.insert(child_name.clone());
covered_components.insert(format!("{}Props", child_name));
}
for child in &delta.added_children {
covered_components.insert(child.name.clone());
covered_components.insert(format!("{}Props", child.name));
}
if delta.source_package.is_some() {
let deprecated_dir = format!("/deprecated/components/{}/", delta.component);
for fc in &report.changes {
let file_str = fc.file.to_string_lossy();
if !file_str.contains(&deprecated_dir) {
continue;
}
for api in &fc.breaking_api_changes {
if !api.symbol.contains('.') {
covered_components.insert(api.symbol.clone());
}
}
}
}
}
for file_changes in &report.changes {
let from_pkg = resolve_npm_package(&file_changes.file.to_string_lossy(), pkg_cache);
for api_change in &file_changes.breaking_api_changes {
if api_change.kind == ApiChangeKind::Constant && !api_change.symbol.contains('.') {
if let Some(ref pkg) = from_pkg {
if collapsed_symbols.contains(&(pkg.clone(), api_change.symbol.clone())) {
continue;
}
let file_path_str = file_changes.file.to_string_lossy();
if let Some(strat) = api_change_to_strategy(
api_change,
rename_patterns,
member_renames,
&file_path_str,
) {
if collapsed_keys.contains(&(
pkg.clone(),
api_change.change.clone(),
strat.strategy,
)) {
continue;
}
}
}
}
if api_change.symbol.contains('.') {
let parts: Vec<&str> = api_change.symbol.splitn(2, '.').collect();
let interface_name = parts[0];
let prop_name = parts[1];
if covered_props.contains(&(interface_name.to_string(), prop_name.to_string())) {
continue;
}
} else if covered_components.contains(&api_change.symbol) {
if matches!(
api_change.change,
ApiChangeType::Removed | ApiChangeType::Renamed
) {
continue;
}
}
if api_change.change == ApiChangeType::Renamed {
let is_path_relocation = api_change
.before
.as_deref()
.is_some_and(|b| b.contains("packages/"))
|| api_change
.after
.as_deref()
.is_some_and(|a| a.contains("packages/"));
if is_path_relocation {
continue;
}
}
let new_rules = api_change_to_rules(
api_change,
file_changes,
from_pkg.as_deref(),
&mut id_counts,
rename_patterns,
member_renames,
);
rules.extend(new_rules);
}
let file_path_str = file_changes.file.to_string_lossy();
let is_test_demo_file = file_path_str.contains("/demo")
|| file_path_str.contains("/test")
|| file_path_str.contains("/testdata/")
|| file_path_str.contains("/integration/")
|| file_path_str.contains("/examples/")
|| file_path_str.contains("/stories/");
if !is_test_demo_file {
for behavioral in &file_changes.breaking_behavioral_changes {
if covered_components.contains(&behavioral.symbol) {
continue;
}
let beh_leaf = extract_leaf_symbol(&behavioral.symbol);
if !public_symbols.is_empty() && !public_symbols.contains(beh_leaf) {
continue;
}
if let Some(rule) = behavioral_change_to_rule(
behavioral,
file_changes,
file_pattern,
from_pkg.as_deref(),
&mut id_counts,
) {
rules.push(rule);
}
}
}
}
let hierarchy_covered_components: HashSet<String> = report
.hierarchy_deltas
.iter()
.filter(|d| !d.added_children.is_empty())
.map(|d| d.component.clone())
.collect();
if !hierarchy_covered_components.is_empty() {
tracing::debug!(
count = hierarchy_covered_components.len(),
"Hierarchy covers components — P0-C will skip those"
);
}
{
let has_package_components = report
.packages
.iter()
.any(|pkg| !pkg.type_summaries.is_empty());
if has_package_components {
for pkg in &report.packages {
for comp in &pkg.type_summaries {
let qualifies = comp.status == ComponentStatus::Removed
|| (comp.member_summary.removed >= 3
&& comp.member_summary.removal_ratio > 0.5)
|| comp.member_summary.removed >= 5;
if !qualifies {
continue;
}
if hierarchy_covered_components.contains(&comp.name) {
tracing::debug!(
component = %comp.name,
"Skipping P0-C for component (covered by hierarchy delta)"
);
continue;
}
let component_name = &comp.name;
let base_id = format!(
"semver-{}-component-import-deprecated",
sanitize_id(component_name)
);
let rule_id = unique_id(base_id, &mut id_counts);
let mut message = build_migration_message_v2(comp);
if comp.migration_target.is_none()
&& (comp.status == ComponentStatus::Removed
|| comp.member_summary.total <= 2)
{
if let Some(ref sd) = report.sd_result {
let source_family = comp.source_files.iter().find_map(|f| {
let f_str = f.to_string_lossy();
let parts: Vec<&str> = f_str.split('/').collect();
parts.iter().rev().find_map(|part| {
sd.composition_trees
.iter()
.find(|t| t.root.eq_ignore_ascii_case(part))
.map(|t| t.root.clone())
})
});
if let Some(family_root) = source_family {
if let Some(tree) =
sd.composition_trees.iter().find(|t| t.root == family_root)
{
let new_pkg = sd
.component_packages
.get(&family_root)
.cloned()
.unwrap_or_else(|| pkg.name.clone());
message.push_str(&format!(
"This type was part of the deprecated {} API.\n\
The new {} is in '{}' and uses a composition-based structure:\n\n",
family_root, family_root, new_pkg,
));
let children: Vec<&str> = {
let mut seen = HashSet::new();
tree.edges
.iter()
.filter(|e| seen.insert(e.child.as_str()))
.map(|e| e.child.as_str())
.collect()
};
if !children.is_empty() {
message.push_str(&format!(" <{}>\n", family_root));
for child in &children {
message.push_str(&format!(" <{} />\n", child));
}
message.push_str(&format!(" </{}>\n\n", family_root));
}
message.push_str(&format!(
"The {} interface was removed and has no direct replacement.\n\n",
component_name,
));
}
}
}
}
let is_from_deprecated = report.changes.iter().any(|fc| {
let file_str = fc.file.to_string_lossy();
file_str.contains("/deprecated/")
&& fc
.breaking_api_changes
.iter()
.any(|api| api.symbol == *component_name)
});
let pattern = format!("^{}$", regex_escape(component_name));
let when = if is_from_deprecated {
KonveyorCondition::Or {
or: vec![
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: pattern.clone(),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some(format!("{}/deprecated", pkg.name)),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: pattern.clone(),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some(pkg.name.clone()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
],
}
} else {
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern,
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some(pkg.name.clone()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
}
};
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=component-removal".to_string(),
"kind=interface".to_string(),
"has-codemod=false".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: format!(
"{} has significant breaking changes — {} of {} props removed",
component_name, comp.member_summary.removed, comp.member_summary.total
),
message,
links: Vec::new(),
when,
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
}
}
}
}
for manifest in &report.manifest_changes {
let rule = manifest_change_to_rule(manifest, file_pattern, &mut id_counts);
rules.push(rule);
}
let css_prefix_changes = detect_css_prefix_changes(report);
{
let mut broad_prefix: Option<(String, String)> = None;
for (_, old_var, new_var) in &css_prefix_changes {
static VER_RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"^(--[a-zA-Z]+-v\d+-)").unwrap());
if let (Some(old_base), Some(new_base)) = (
VER_RE.captures(old_var).map(|c| c[1].to_string()),
VER_RE.captures(new_var).map(|c| c[1].to_string()),
) {
if old_base != new_base {
broad_prefix = Some((old_base, new_base));
break;
}
}
}
if let Some((old_base, new_base)) = broad_prefix {
let old_class = old_base.trim_start_matches('-').to_string();
let new_class = new_base.trim_start_matches('-').to_string();
rules.push(KonveyorRule {
rule_id: format!(
"semver-consumer-css-stale-class-{}",
sanitize_id(&old_class)
),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=css-class".to_string(),
"has-codemod=true".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: format!("Consumer CSS contains stale '{}' class prefix", old_class),
message: format!(
"CSS/SCSS files reference '{}' class names which have been renamed to '{}'.",
old_class, new_class
),
links: Vec::new(),
when: KonveyorCondition::FrontendCssClass {
cssclass: FrontendPatternFields {
pattern: old_class.clone(),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry::with_from_to(
"CssVariablePrefix",
&old_class,
&new_class,
)),
});
}
}
for (_old_class_prefix, old_var_prefix, new_var_prefix) in &css_prefix_changes {
rules.push(KonveyorRule {
rule_id: format!(
"semver-consumer-css-stale-var-{}-to-{}",
sanitize_id(old_var_prefix),
sanitize_id(new_var_prefix),
),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=css-variable".to_string(),
"has-codemod=true".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: format!(
"CSS variables '{}' renamed to '{}'",
old_var_prefix, new_var_prefix
),
message: format!(
"CSS/SCSS files reference '{}' CSS variables which have been renamed to '{}'.",
old_var_prefix, new_var_prefix
),
links: Vec::new(),
when: KonveyorCondition::FrontendCssVar {
cssvar: FrontendPatternFields {
pattern: old_var_prefix.clone(),
file_pattern: None,
},
},
fix_strategy: Some(FixStrategyEntry::with_from_to(
"CssVariablePrefix",
old_var_prefix,
new_var_prefix,
)),
});
}
{
let mut suffix_renames: BTreeMap<String, String> = BTreeMap::new();
let effective_renames: &HashMap<String, String> = if member_renames.is_empty() {
&report.member_renames
} else {
member_renames
};
for (old_name, new_name) in effective_renames {
let old_suffix = extract_trailing_suffix(old_name);
let new_suffix = extract_trailing_suffix(new_name);
if let (Some(old_s), Some(new_s)) = (old_suffix, new_suffix) {
if old_s != new_s {
suffix_renames
.entry(old_s.to_string())
.or_insert_with(|| new_s.to_string());
}
}
}
if !suffix_renames.is_empty() {
tracing::debug!(
suffix_rename_count = suffix_renames.len(),
"Generating combined CSS logical property rule"
);
let suffix_alts: Vec<String> = suffix_renames.keys().map(|s| regex_escape(s)).collect();
let combined_pattern = format!("--({})", suffix_alts.join("|"));
let mappings: Vec<MappingEntry> = suffix_renames
.iter()
.map(|(old_s, new_s)| MappingEntry {
from: Some(format!("--{}", old_s)),
to: Some(format!("--{}", new_s)),
component: None,
prop: None,
})
.collect();
let mut message = format!(
"MIGRATION: {} CSS custom property suffixes have been renamed.\n\n\
Rename mappings:\n",
suffix_renames.len()
);
for (old_s, new_s) in &suffix_renames {
message.push_str(&format!(" - --{} → --{}\n", old_s, new_s));
}
message.push_str("\nUpdate all CSS variable references to use the new suffixes.");
let rule_id = unique_id(
"semver-css-logical-property-renames".to_string(),
&mut id_counts,
);
let mut strategy = FixStrategyEntry::new("Rename");
strategy.mappings = mappings;
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=css-variable".to_string(),
"has-codemod=true".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: format!("{} CSS variable suffixes renamed", suffix_renames.len()),
message,
links: Vec::new(),
when: KonveyorCondition::FrontendCssVar {
cssvar: FrontendPatternFields {
pattern: combined_pattern,
file_pattern: None,
},
},
fix_strategy: Some(strategy),
});
}
}
for entry in &rename_patterns.composition_rules {
let base_id = format!(
"semver-composition-{}-in-{}",
sanitize_id(&entry.child_pattern),
sanitize_id(&entry.parent),
);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=composition".to_string(),
"has-codemod=true".to_string(),
],
effort: entry.effort,
category: entry.category.clone(),
description: entry.description.clone(),
message: entry.description.clone(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: entry.child_pattern.clone(),
location: "JSX_COMPONENT".to_string(),
component: None,
parent: Some(entry.parent.clone()),
value: None,
from: entry.package.clone(),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
}
for entry in &rename_patterns.prop_renames {
let desc = entry.description.clone().unwrap_or_else(|| {
format!(
"'{}' prop renamed to '{}' — update all usages",
entry.old_prop, entry.new_prop
)
});
let base_id = format!(
"semver-prop-rename-{}-to-{}",
sanitize_id(&entry.old_prop),
sanitize_id(&entry.new_prop),
);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=prop-rename".to_string(),
"has-codemod=true".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: desc.clone(),
message: desc,
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(&entry.old_prop)),
location: "JSX_PROP".to_string(),
component: Some(entry.components.clone()),
parent: None,
value: None,
from: entry.package.clone(),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::rename(&entry.old_prop, &entry.new_prop)),
});
}
for entry in &rename_patterns.value_reviews {
let base_id = format!(
"semver-value-review-{}-{}-{}",
sanitize_id(&entry.component),
sanitize_id(&entry.prop),
sanitize_id(&entry.value),
);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=prop-value-review".to_string(),
"has-codemod=true".to_string(),
],
effort: entry.effort,
category: entry.category.clone(),
description: entry.description.clone(),
message: entry.description.clone(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(&entry.prop)),
location: "JSX_PROP".to_string(),
component: Some(entry.component.clone()),
parent: None,
value: Some(entry.value.clone()),
from: entry.package.clone(),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("Manual")),
});
}
for entry in &rename_patterns.component_warnings {
let base_id = format!("semver-component-warning-{}", sanitize_id(&entry.pattern),);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=component-warning".to_string(),
"impact=frontend-testing".to_string(),
"has-codemod=false".to_string(),
],
effort: entry.effort,
category: entry.category.clone(),
description: entry.description.clone(),
message: entry.description.clone(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: entry.pattern.clone(),
location: "JSX_COMPONENT".to_string(),
component: None,
parent: None,
value: None,
from: entry.package.clone(),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("Manual")),
});
}
for entry in &rename_patterns.missing_imports {
let base_id = format!(
"semver-missing-import-{}",
sanitize_id(&entry.missing_pattern),
);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=missing-import".to_string(),
"has-codemod=false".to_string(),
],
effort: entry.effort,
category: entry.category.clone(),
description: entry.description.clone(),
message: entry.description.clone(),
links: Vec::new(),
when: KonveyorCondition::And {
and: vec![
KonveyorCondition::FileContent {
filecontent: FileContentFields {
pattern: entry.has_pattern.clone(),
file_pattern: entry.file_pattern.clone(),
},
},
KonveyorCondition::FileContentNegated {
negated: true,
filecontent: FileContentFields {
pattern: entry.missing_pattern.clone(),
file_pattern: entry.file_pattern.clone(),
},
},
],
},
fix_strategy: Some(FixStrategyEntry::new("Manual")),
});
}
{
let has_child_components = report.packages.iter().any(|pkg| {
pkg.type_summaries
.iter()
.any(|comp| !comp.child_components.is_empty())
});
if has_child_components {
for pkg in &report.packages {
for comp in &pkg.type_summaries {
for child in &comp.child_components {
if child.status != ChildComponentStatus::Added {
continue;
}
let component_name = &comp.name;
let new_component = &child.name;
let mut msg = format!(
"MIGRATION: Use <{}> inside <{}>.\n\n",
new_component, component_name,
);
if !child.absorbed_members.is_empty() {
let prop_dispositions: HashMap<&str, &RemovalDisposition> = comp
.removed_members
.iter()
.filter_map(|rp| {
rp.removal_disposition
.as_ref()
.map(|d| (rp.name.as_str(), d))
})
.collect();
let mut as_props = Vec::new();
let mut as_children = Vec::new();
for prop_name in &child.absorbed_members {
match prop_dispositions.get(prop_name.as_str()) {
Some(RemovalDisposition::MovedToRelatedType {
mechanism,
..
}) if mechanism == "children" => {
as_children.push(prop_name.as_str());
}
_ => {
if child.known_members.contains(prop_name) {
as_props.push(prop_name.as_str());
} else {
as_children.push(prop_name.as_str());
}
}
}
}
msg.push_str(&format!(
"These props were removed from <{}> and moved to <{}>:\n",
component_name, new_component,
));
for prop in &as_props {
msg.push_str(&format!(
" - {} → <{} {}={{...}}>\n",
prop, new_component, prop,
));
}
for prop in &as_children {
msg.push_str(&format!(
" - {} → <{}>{{{}value}}</{}> (pass as children)\n",
prop, new_component, prop, new_component,
));
}
msg.push('\n');
} else {
msg.push_str(&format!(
"<{}> is a new child component of <{}>.\n\
Wrap relevant content inside <{}>.\n\n",
new_component, component_name, new_component,
));
}
msg.push_str(&format!(
"Add {} to your import statement from the same package.",
new_component,
));
let base_id = format!(
"semver-new-sibling-{}-in-{}",
sanitize_id(new_component),
sanitize_id(component_name),
);
let rule_id = unique_id(base_id, &mut id_counts);
let is_mandatory = !child.absorbed_members.is_empty()
|| composition_required_components.contains(new_component);
if !is_mandatory {
tracing::debug!(
new_component = %new_component,
parent = %component_name,
"Skipping optional new-sibling rule (no absorbed props, not composition-required)"
);
continue;
}
let category = "mandatory";
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=new-sibling-component".to_string(),
"has-codemod=false".to_string(),
],
effort: 3,
category: category.to_string(),
description: format!(
"<{}> is required inside <{}> — absorbs removed props",
new_component, component_name
),
message: msg,
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(component_name)),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some(pkg.name.clone()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
tracing::debug!(
new_component = %new_component,
parent = %component_name,
"Detected new sibling from packages data"
);
}
}
}
} else if !report.added_files.is_empty() {
let mut dir_to_added: HashMap<String, Vec<String>> = HashMap::new();
for added_path in &report.added_files {
let path_str = added_path.to_string_lossy();
if let (Some(dir), Some(file_stem)) = (
added_path.parent().map(|p| p.to_string_lossy().to_string()),
added_path
.file_stem()
.map(|s| s.to_string_lossy().to_string()),
) {
if file_stem.chars().next().is_some_and(|c| c.is_uppercase())
&& !path_str.contains(".d.ts")
{
dir_to_added.entry(dir).or_default().push(file_stem);
}
}
}
let behavioral_added_refs: BTreeSet<String> = report
.changes
.iter()
.flat_map(|fc| &fc.breaking_behavioral_changes)
.filter_map(|b| {
let desc = &b.description;
if desc.contains("element added") || desc.contains("added to render output") {
let start = desc.find('<')? + 1;
let end = desc[start..].find('>')? + start;
Some(desc[start..end].to_string())
} else {
None
}
})
.collect();
for file_changes in &report.changes {
let file_str = file_changes.file.to_string_lossy();
let dir = file_changes
.file
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if file_changes.breaking_api_changes.is_empty() {
continue;
}
let component_name = file_changes
.file
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if !component_name
.chars()
.next()
.is_some_and(|c| c.is_uppercase())
{
continue;
}
if file_str.contains(".d.ts") {
continue;
}
if let Some(added_siblings) = dir_to_added.get(&dir) {
for new_component in added_siblings {
if !behavioral_added_refs.contains(new_component.as_str()) {
continue;
}
let mut msg = format!(
"MIGRATION: <{}> may need to be used alongside <{}>.\n\n\
<{}> is a new component added in this version. \
Consumer code in examples and demos now uses <{}> \
within <{}>.\n\n",
new_component,
component_name,
new_component,
new_component,
component_name,
);
let breaking_summary: Vec<String> = file_changes
.breaking_api_changes
.iter()
.take(5)
.map(|c| format!(" - {}: {}", c.symbol, c.description))
.collect();
if !breaking_summary.is_empty() {
msg.push_str(&format!(
"Breaking changes on <{}>:\n{}\n\n",
component_name,
breaking_summary.join("\n"),
));
}
msg.push_str(&format!(
"Consider wrapping children of <{}> in <{}>.\n\
Add {} to your import statement from the same package.",
component_name, new_component, new_component,
));
let from_pkg = resolve_npm_package(&file_str, pkg_cache);
let base_id = format!(
"semver-new-sibling-{}-in-{}",
sanitize_id(new_component),
sanitize_id(&component_name),
);
let rule_id = unique_id(base_id, &mut id_counts);
rules.push(KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=new-sibling-component".to_string(),
"has-codemod=false".to_string(),
],
effort: 3,
category: "optional".to_string(),
description: format!(
"New component <{}> may be needed alongside <{}>",
new_component, component_name
),
message: msg,
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(&component_name)),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: from_pkg,
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
});
tracing::debug!(
new_component = %new_component,
parent = %component_name,
"Detected new sibling (behavioral evidence found)"
);
}
}
}
}
}
rules
}
pub fn generate_dependency_update_rules(
report: &AnalysisReport<TypeScript>,
pkg_info_cache: &HashMap<String, PackageInfo>,
) -> (Vec<KonveyorRule>, HashMap<String, FixStrategyEntry>) {
let mut rules = Vec::new();
let mut strategies = HashMap::new();
let mut packages_with_changes: HashMap<String, &PackageInfo> = HashMap::new();
for file_changes in &report.changes {
let file_str = file_changes.file.to_string_lossy();
let parts: Vec<&str> = file_str.split('/').collect();
if let Some(pkg_idx) = parts.iter().position(|&p| p == "packages") {
if let Some(pkg_dir_name) = parts.get(pkg_idx + 1) {
if let Some(info) = pkg_info_cache.get(*pkg_dir_name) {
if info.version.is_some()
&& (!file_changes.breaking_api_changes.is_empty()
|| !file_changes.breaking_behavioral_changes.is_empty())
{
packages_with_changes
.entry(info.name.clone())
.or_insert(info);
}
}
}
}
}
let from_major = report
.comparison
.from_ref
.trim_start_matches('v')
.split('.')
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
for info in pkg_info_cache.values() {
if packages_with_changes.contains_key(&info.name) {
continue;
}
if let Some(ref ver) = info.version {
let new_major = ver
.split('.')
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if new_major > from_major {
packages_with_changes
.entry(info.name.clone())
.or_insert(info);
}
}
}
for (npm_name, info) in &packages_with_changes {
let version = match &info.version {
Some(v) => v,
None => continue,
};
let slug = npm_name.replace('@', "").replace(['/', '.'], "-");
let rule_id = format!("semver-dep-update-{}", slug);
let new_version = format!("^{}", version);
let condition = KonveyorCondition::FrontendDependency {
dependency: FrontendDependencyFields {
name: Some(npm_name.clone()),
nameregex: None,
upperbound: {
let from_ref = &report.comparison.from_ref;
let major = from_ref
.trim_start_matches('v')
.split('.')
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
Some(format!("{}.99.99", major))
},
lowerbound: None,
},
};
rules.push(KonveyorRule {
rule_id: rule_id.clone(),
description: format!("Update {} to v{}", npm_name, version),
labels: vec![
"change-type=dependency-update".into(),
"has-codemod=true".into(),
"source=semver-analyzer".into(),
],
effort: 1,
category: "mandatory".into(),
links: Vec::new(),
when: condition,
message: format!(
"Update {} from current version to {}. \
This package has breaking changes between {} and {}.\n\n\
After updating package.json, regenerate your lockfile:\n\
- npm: npm install\n\
- yarn: yarn install\n\
- pnpm: pnpm install",
npm_name, new_version, report.comparison.from_ref, report.comparison.to_ref,
),
fix_strategy: Some(FixStrategyEntry::update_dependency(
npm_name.clone(),
new_version.clone(),
)),
});
strategies.insert(
rule_id,
FixStrategyEntry::update_dependency(npm_name.clone(), new_version),
);
}
if !rules.is_empty() {
tracing::debug!(
count = rules.len(),
packages = %packages_with_changes
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", "),
"Generated dependency update rules"
);
}
(rules, strategies)
}
fn extract_compound_tokens(
report: &AnalysisReport<TypeScript>,
rename_patterns: &RenamePatterns,
) -> (
BTreeSet<String>,
Vec<CompoundToken>,
HashMap<String, String>,
) {
let re = member_key_re();
let mut covered_symbols: BTreeSet<String> = BTreeSet::new();
let mut member_renames: HashMap<String, String> = HashMap::new();
let mut compound_tokens: Vec<CompoundToken> = Vec::new();
for file_changes in &report.changes {
for api_change in &file_changes.breaking_api_changes {
if api_change.change != ApiChangeType::TypeChanged {
continue;
}
let before = match &api_change.before {
Some(b) if b.contains("[\"") => b,
_ => continue,
};
let after = match &api_change.after {
Some(a) if a.contains("[\"") => a,
_ => continue,
};
let old_keys: BTreeSet<String> = re
.captures_iter(before)
.map(|c| c[1].to_string())
.filter(|k| k != "name" && k != "value" && k != "values" && k != "var")
.collect();
let new_keys: BTreeSet<String> = re
.captures_iter(after)
.map(|c| c[1].to_string())
.filter(|k| k != "name" && k != "value" && k != "values" && k != "var")
.collect();
if old_keys.len() < 3 || new_keys.len() < 3 {
continue;
}
for key in &old_keys {
covered_symbols.insert(key.clone());
}
let removed: BTreeSet<String> = old_keys.difference(&new_keys).cloned().collect();
let added: BTreeSet<String> = new_keys.difference(&old_keys).cloned().collect();
for old_key in &removed {
if let Some(expected_new) = rename_patterns.find_replacement(old_key) {
if added.contains(&expected_new) {
member_renames.insert(old_key.clone(), expected_new);
}
}
}
compound_tokens.push(CompoundToken { removed, added });
}
}
(covered_symbols, compound_tokens, member_renames)
}
pub fn extract_suffix_inventory(
report: &AnalysisReport<TypeScript>,
) -> (BTreeSet<String>, BTreeSet<String>) {
let re = member_key_re();
let mut removed_suffixes: BTreeSet<String> = BTreeSet::new();
let mut added_suffixes: BTreeSet<String> = BTreeSet::new();
for file_changes in &report.changes {
for api_change in &file_changes.breaking_api_changes {
if api_change.change != ApiChangeType::TypeChanged {
continue;
}
let before = match &api_change.before {
Some(b) if b.contains("[\"") => b,
_ => continue,
};
let after = match &api_change.after {
Some(a) if a.contains("[\"") => a,
_ => continue,
};
let old_keys: BTreeSet<String> = re
.captures_iter(before)
.map(|c| c[1].to_string())
.filter(|k| k != "name" && k != "value" && k != "values" && k != "var")
.collect();
let new_keys: BTreeSet<String> = re
.captures_iter(after)
.map(|c| c[1].to_string())
.filter(|k| k != "name" && k != "value" && k != "values" && k != "var")
.collect();
if old_keys.len() < 3 || new_keys.len() < 3 {
continue;
}
for key in old_keys.difference(&new_keys) {
if let Some(suffix) = extract_trailing_suffix(key) {
removed_suffixes.insert(suffix.to_string());
}
}
for key in new_keys.difference(&old_keys) {
if let Some(suffix) = extract_trailing_suffix(key) {
added_suffixes.insert(suffix.to_string());
}
}
}
}
(removed_suffixes, added_suffixes)
}
pub fn analyze_token_members(
report: &AnalysisReport<TypeScript>,
rename_patterns: &RenamePatterns,
) -> (BTreeSet<String>, HashMap<String, String>) {
let (covered_symbols, _compound_tokens, member_renames) =
extract_compound_tokens(report, rename_patterns);
(covered_symbols, member_renames)
}
pub fn apply_suffix_renames(
report: &AnalysisReport<TypeScript>,
suffix_renames: &HashMap<String, String>,
) -> HashMap<String, String> {
let (_covered, compound_tokens, mut member_renames) =
extract_compound_tokens(report, &RenamePatterns::empty());
for ct in &compound_tokens {
for old_key in &ct.removed {
if member_renames.contains_key(old_key) {
continue;
}
if let Some(old_suffix) = extract_trailing_suffix(old_key) {
if let Some(new_suffix) = suffix_renames.get(old_suffix) {
let prefix = &old_key[..old_key.len() - old_suffix.len()];
let expected_new = format!("{}{}", prefix, new_suffix);
if ct.added.contains(&expected_new) {
member_renames.insert(old_key.clone(), expected_new);
}
}
}
}
}
member_renames
}
pub fn build_package_name_cache(report: &AnalysisReport<TypeScript>) -> HashMap<String, String> {
let full_cache = build_package_info_cache(report);
full_cache
.into_iter()
.map(|(dir, info)| (dir, info.name))
.collect()
}
pub fn build_package_info_cache(
report: &AnalysisReport<TypeScript>,
) -> HashMap<String, PackageInfo> {
let mut cache: HashMap<String, PackageInfo> = HashMap::new();
let repo_path = &report.repository;
let to_ref = &report.comparison.to_ref;
for file_changes in &report.changes {
let file_str = file_changes.file.to_string_lossy();
let parts: Vec<&str> = file_str.split('/').collect();
if let Some(pkg_idx) = parts.iter().position(|&p| p == "packages") {
if let Some(pkg_dir_name) = parts.get(pkg_idx + 1) {
if cache.contains_key(*pkg_dir_name) {
continue;
}
let pkg_json_git_path = format!("packages/{}/package.json", pkg_dir_name);
let (npm_name, npm_version) =
read_package_json_at_ref(repo_path, to_ref, &pkg_json_git_path)
.or_else(|| {
let pkg_json_path = repo_path
.join("packages")
.join(pkg_dir_name)
.join("package.json");
read_package_json_from_file(&pkg_json_path)
})
.unwrap_or((None, None));
let info = PackageInfo {
name: npm_name.unwrap_or_else(|| pkg_dir_name.to_string()),
version: npm_version,
};
cache.insert(pkg_dir_name.to_string(), info);
}
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["ls-tree", "--name-only", to_ref, "packages/"])
.current_dir(repo_path)
.output()
{
if output.status.success() {
let listing = String::from_utf8_lossy(&output.stdout);
for line in listing.lines() {
let dir_name = line.trim_start_matches("packages/");
if dir_name.is_empty() || cache.contains_key(dir_name) {
continue;
}
let pkg_json_git_path = format!("{}/package.json", line);
if let Some((npm_name, npm_version)) =
read_package_json_at_ref(repo_path, to_ref, &pkg_json_git_path)
{
let info = PackageInfo {
name: npm_name.unwrap_or_else(|| dir_name.to_string()),
version: npm_version,
};
cache.insert(dir_name.to_string(), info);
}
}
}
}
for pkg in &report.packages {
let dir_name = pkg.name.rsplit('/').next().unwrap_or(&pkg.name);
let entry = cache
.entry(dir_name.to_string())
.or_insert_with(|| PackageInfo {
name: dir_name.to_string(),
version: None,
});
if !pkg.name.starts_with('@') || entry.name.starts_with('@') {
continue;
}
entry.name = pkg.name.clone();
}
if !cache.is_empty() {
tracing::debug!(
entries = ?cache
.iter()
.map(|(k, v)| format!(
"{}: {} ({})",
k,
v.name,
v.version.as_deref().unwrap_or("?")
))
.collect::<Vec<_>>(),
"Package info cache built"
);
}
cache
}
fn extract_css_var_prefix(css_var: &str) -> Option<String> {
static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^(--[a-zA-Z]+-(?:v\d+-|[a-zA-Z]+-{2})[a-zA-Z]+-{1,2})").unwrap()
});
RE.captures(css_var).map(|cap| cap[1].to_string())
}
fn extract_css_var_name(type_annotation: &str) -> Option<String> {
static RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r#"\["name"\]:\s*"([^"]+)""#).unwrap());
RE.captures(type_annotation).map(|cap| cap[1].to_string())
}
fn detect_css_prefix_changes(report: &AnalysisReport<TypeScript>) -> Vec<(String, String, String)> {
let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
for api_change in report
.changes
.iter()
.flat_map(|fc| &fc.breaking_api_changes)
.filter(|a| {
a.kind == ApiChangeKind::Constant
&& matches!(
a.change,
ApiChangeType::TypeChanged | ApiChangeType::Renamed
)
})
{
if let (Some(op), Some(np)) = (
api_change
.before
.as_deref()
.and_then(extract_css_var_name)
.and_then(|n| extract_css_var_prefix(&n)),
api_change
.after
.as_deref()
.and_then(extract_css_var_name)
.and_then(|n| extract_css_var_prefix(&n)),
) {
if op != np {
*pair_counts.entry((op, np)).or_insert(0) += 1;
}
}
}
static BASE_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^--[a-zA-Z]+-(?:v\d+-|[a-zA-Z]+-{2})").unwrap()
});
pair_counts
.iter()
.filter(|((old_p, new_p), _count)| {
let old_seg = BASE_RE.replace(old_p, "");
let new_seg = BASE_RE.replace(new_p, "");
old_seg == new_seg && !old_seg.is_empty()
})
.map(|((old_p, new_p), _)| {
let class_prefix = old_p.trim_start_matches('-').to_string();
(class_prefix, old_p.clone(), new_p.clone())
})
.collect()
}
pub fn generate_fix_guidance(
report: &AnalysisReport<TypeScript>,
rules: &[KonveyorRule],
file_pattern: &str,
) -> FixGuidanceDoc {
let mut fixes = Vec::new();
let mut rule_idx = 0;
for file_changes in &report.changes {
for api_change in &file_changes.breaking_api_changes {
if rule_idx < rules.len() {
let fix = api_change_to_fix(
api_change,
file_changes,
&rules[rule_idx].rule_id,
file_pattern,
);
fixes.push(fix);
rule_idx += 1;
}
}
for behavioral in &file_changes.breaking_behavioral_changes {
if rule_idx < rules.len() {
let fix =
behavioral_change_to_fix(behavioral, file_changes, &rules[rule_idx].rule_id);
fixes.push(fix);
rule_idx += 1;
}
}
}
for manifest in &report.manifest_changes {
if rule_idx < rules.len() {
let fix = manifest_change_to_fix(manifest, &rules[rule_idx].rule_id);
fixes.push(fix);
rule_idx += 1;
}
}
let auto_fixable = fixes
.iter()
.filter(|f| matches!(f.confidence, FixConfidence::Exact | FixConfidence::High))
.count();
let manual_only = fixes
.iter()
.filter(|f| matches!(f.source, FixSource::Manual))
.count();
let needs_review = fixes.len() - auto_fixable - manual_only;
FixGuidanceDoc {
migration: MigrationInfo {
from_ref: report.comparison.from_ref.clone(),
to_ref: report.comparison.to_ref.clone(),
generated_by: format!("semver-analyzer v{}", report.metadata.tool_version),
},
summary: FixSummary {
total_fixes: fixes.len(),
auto_fixable,
needs_review,
manual_only,
},
fixes,
}
}
pub fn write_ruleset_dir(
output_dir: &Path,
ruleset_name: &str,
report: &AnalysisReport<TypeScript>,
rules: &[KonveyorRule],
) -> Result<()> {
std::fs::create_dir_all(output_dir)
.with_context(|| format!("Failed to create output directory {}", output_dir.display()))?;
let from_ref = &report.comparison.from_ref;
let to_ref = &report.comparison.to_ref;
let ruleset = KonveyorRuleset {
name: ruleset_name.to_string(),
description: format!(
"Breaking changes detected between {} and {} by semver-analyzer v{}",
from_ref, to_ref, report.metadata.tool_version
),
labels: vec!["source=semver-analyzer".to_string()],
};
let ruleset_path = output_dir.join("ruleset.yaml");
let ruleset_yaml = serde_yaml::to_string(&ruleset).context("Failed to serialize ruleset")?;
std::fs::write(&ruleset_path, &ruleset_yaml)
.with_context(|| format!("Failed to write {}", ruleset_path.display()))?;
let rules_path = output_dir.join("breaking-changes.yaml");
let rules_yaml = serde_yaml::to_string(&rules).context("Failed to serialize rules")?;
std::fs::write(&rules_path, &rules_yaml)
.with_context(|| format!("Failed to write {}", rules_path.display()))?;
Ok(())
}
fn api_change_to_rules(
change: &ApiChange,
file_changes: &FileChanges<TypeScript>,
from_pkg: Option<&str>,
id_counts: &mut HashMap<String, usize>,
rename_patterns: &RenamePatterns,
member_renames: &HashMap<String, String>,
) -> Vec<KonveyorRule> {
let file_path = file_changes.file.display().to_string();
let leaf_symbol = extract_leaf_symbol(&change.symbol);
let effort = effort_for_api_change(&change.change);
let change_type_label = api_change_type_label(&change.change);
let base_id = format!(
"semver-{}-{}-{}",
sanitize_id(&file_path),
sanitize_id(&change.symbol),
change_type_label,
);
let rule_id = unique_id(base_id.clone(), id_counts);
let mut message = build_api_message(change, &file_path);
if change.change == ApiChangeType::Removed {
if let Some(ref disp) = change.removal_disposition {
match disp {
RemovalDisposition::ReplacedByMember { new_member } => {
let new_type = file_changes
.breaking_api_changes
.iter()
.find(|a| {
let leaf = a.symbol.split('.').next_back().unwrap_or("");
leaf == new_member.as_str()
&& matches!(
a.change,
ApiChangeType::SignatureChanged | ApiChangeType::TypeChanged
)
})
.and_then(|a| a.after.as_ref());
message.push_str(&format!("\n\nReplacement: use '{}' instead.", new_member));
if let Some(new_type_str) = new_type {
message.push_str(&format!("\nNew type: {}", new_type_str));
}
}
RemovalDisposition::MadeAutomatic => {
message.push_str("\n\nThis prop is now automatic — remove it.");
}
RemovalDisposition::TrulyRemoved => {
message.push_str("\n\nThis prop has been removed with no replacement.");
}
RemovalDisposition::MovedToRelatedType {
target_type,
mechanism,
..
} => {
message.push_str(&format!(
"\n\nThis prop moved to <{}>. Pass it as a {} on <{}>.",
target_type, mechanism, target_type
));
}
}
}
}
let component_symbol = if change.symbol.contains('.') {
change.symbol.split('.').next().unwrap_or(leaf_symbol)
} else {
leaf_symbol
};
let component_base = component_symbol
.strip_suffix("Props")
.unwrap_or(component_symbol);
let behavioral_context: Vec<String> = file_changes
.breaking_behavioral_changes
.iter()
.filter(|b| {
b.symbol == component_symbol
|| b.symbol == component_base
|| b.symbol.starts_with(&format!("{}.", component_symbol))
|| b.symbol.starts_with(&format!("{}.", component_base))
})
.map(|b| {
let cat = b
.category
.as_ref()
.map(|c| behavioral_category_label(c))
.unwrap_or("change");
format!("{}: {}", cat, b.description)
})
.collect();
if !behavioral_context.is_empty() {
message.push_str("\n\nBehavioral changes:\n");
for desc in &behavioral_context {
message.push_str(&format!(" - {}\n", desc));
}
}
let mut value_mappings: Vec<MappingEntry> = Vec::new();
if change.change == ApiChangeType::TypeChanged {
let removed = extract_removed_union_values(change);
let added = extract_added_union_values(change);
if !removed.is_empty() {
message.push_str("\n\nValue changes:");
if removed.len() == 1 && added.len() == 1 {
message.push_str(&format!(
"\n '{}' → '{}' (direct replacement)",
removed[0], added[0],
));
let parent_component = if change.symbol.contains('.') {
change.symbol.split('.').next().map(|s| s.to_string())
} else {
None
};
value_mappings.push(MappingEntry {
from: Some(removed[0].clone()),
to: Some(added[0].clone()),
component: parent_component,
prop: Some(extract_leaf_symbol(&change.symbol).to_string()),
});
} else {
message.push_str("\n Removed values:");
for v in &removed {
message.push_str(&format!("\n - '{}'", v));
}
if !added.is_empty() {
message.push_str("\n New values available:");
for v in &added {
message.push_str(&format!("\n - '{}'", v));
}
}
}
}
}
let mut labels = vec![
"source=semver-analyzer".to_string(),
format!("change-type={}", change_type_label),
format!("kind={}", api_kind_label(&change.kind)),
];
let has_codemod = if matches!(
change.change,
ApiChangeType::SignatureChanged | ApiChangeType::TypeChanged
) {
let name_changed = match (change.before.as_deref(), change.after.as_deref()) {
(Some(before), Some(after)) => {
let old_name = extract_prop_name_from_signature(before);
let new_name = extract_prop_name_from_signature(after);
match (old_name, new_name) {
(Some(o), Some(n)) => o != n,
_ => false,
}
}
_ => false,
};
!name_changed
} else {
matches!(change.change, ApiChangeType::Renamed)
|| matches!(
change.removal_disposition,
Some(RemovalDisposition::ReplacedByMember { .. })
)
};
labels.push(format!("has-codemod={}", has_codemod));
if let Some(pkg) = from_pkg {
labels.push(format!("package={}", pkg));
}
if is_additive_change(change) {
labels.push("change-scope=additive".to_string());
}
let condition = build_frontend_condition(change, leaf_symbol, from_pkg);
let mut fix_strategy =
api_change_to_strategy(change, rename_patterns, member_renames, &file_path);
if !value_mappings.is_empty() {
if let Some(ref mut strat) = fix_strategy {
strat.mappings.extend(value_mappings.clone());
} else {
let mut strat = FixStrategyEntry::new("PropValueChange");
strat.mappings = value_mappings.clone();
fix_strategy = Some(strat);
}
}
let mut rules = vec![KonveyorRule {
rule_id,
labels: labels.clone(),
effort,
category: "mandatory".to_string(),
description: change.description.clone(),
message,
links: Vec::new(),
when: condition,
fix_strategy,
}];
if matches!(change.kind, ApiChangeKind::Property | ApiChangeKind::Field)
&& change.change == ApiChangeType::TypeChanged
{
let removed_values = extract_removed_union_values(change);
if !removed_values.is_empty() {
let added_values = extract_added_union_values(change);
let value_map: HashMap<&str, &str> =
if removed_values.len() == 1 && added_values.len() == 1 {
let mut m = HashMap::new();
m.insert(removed_values[0].as_str(), added_values[0].as_str());
m
} else {
HashMap::new()
};
let parent_component = if change.symbol.contains('.') {
let parts: Vec<&str> = change.symbol.splitn(2, '.').collect();
Some(format!("^{}$", regex_escape(parts[0])))
} else {
None
};
let from = from_pkg.map(|s| s.to_string());
for value in &removed_values {
let migration_hint = if let Some(replacement) = value_map.get(value.as_str()) {
format!(
"The value '{}' is no longer accepted for '{}'. \
Replace with '{}'.",
value, change.symbol, replacement,
)
} else if !added_values.is_empty() {
let options: Vec<String> =
added_values.iter().map(|v| format!("'{}'", v)).collect();
format!(
"The value '{}' is no longer accepted for '{}'. \
Replace with one of the new values: {}.",
value,
change.symbol,
options.join(", "),
)
} else {
format!(
"The value '{}' is no longer accepted for '{}'. \
This value has been removed with no direct replacement.",
value, change.symbol,
)
};
let val_id =
unique_id(format!("{}-val-{}", base_id, sanitize_id(value)), id_counts);
let fix_strategy = if let Some(replacement) = value_map.get(value.as_str()) {
let mut strat = FixStrategyEntry::new("PropValueChange");
strat.mappings = vec![MappingEntry {
from: Some(value.clone()),
to: Some(replacement.to_string()),
component: parent_component
.as_ref()
.map(|p| p.trim_matches('^').trim_matches('$').to_string()),
prop: Some(extract_leaf_symbol(&change.symbol).to_string()),
}];
strat
} else {
FixStrategyEntry::new("PropValueChange")
};
rules.push(KonveyorRule {
rule_id: val_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=prop-value-change".to_string(),
format!("kind={}", api_kind_label(&change.kind)),
"has-codemod=true".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: format!("Value '{}' removed from '{}'", value, change.symbol),
message: format!("{}\n\nFile: {}", migration_hint, file_path),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!(
"^{}$",
regex_escape(extract_leaf_symbol(&change.symbol))
),
location: "JSX_PROP".to_string(),
component: parent_component.clone(),
parent: None,
value: Some(format!("^{}$", regex_escape(value))),
from: from.clone(),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(fix_strategy),
});
}
}
}
rules
}
fn behavioral_change_to_rule(
change: &BehavioralChange<TypeScript>,
file_changes: &FileChanges<TypeScript>,
file_pattern: &str,
from_pkg: Option<&str>,
id_counts: &mut HashMap<String, usize>,
) -> Option<KonveyorRule> {
if change.is_internal_only == Some(true) {
return None;
}
let file_path = file_changes.file.display().to_string();
let leaf_symbol = if change.symbol.contains('.') {
change.symbol.split('.').next().unwrap_or(&change.symbol)
} else {
extract_leaf_symbol(&change.symbol)
};
let base_id = format!(
"semver-{}-{}-behavioral",
sanitize_id(&file_path),
sanitize_id(&change.symbol),
);
let rule_id = unique_id(base_id, id_counts);
let message = format!(
"Behavioral change in '{}': {}\n\nFile: {}\nReview all usages to ensure compatibility with the new behavior.",
change.symbol, change.description, file_path,
);
let mut labels = vec![
"source=semver-analyzer".to_string(),
"ai-generated".to_string(),
];
if let Some(ref cat) = change.category {
labels.push(format!("change-type={}", behavioral_category_label(cat)));
if matches!(
cat,
TsCategory::DomStructure
| TsCategory::CssClass
| TsCategory::CssVariable
| TsCategory::Accessibility
| TsCategory::DataAttribute
) {
labels.push("impact=frontend-testing".to_string());
}
} else {
labels.push("change-type=behavioral".to_string());
}
if let Some(pkg) = from_pkg {
labels.push(format!("package={}", pkg));
}
let from = from_pkg.map(|s| s.to_string());
let condition = if from.is_some() {
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: format!("^{}$", regex_escape(leaf_symbol)),
location: "JSX_COMPONENT".to_string(),
component: None,
parent: None,
parent_from: None,
not_parent: None,
not_child: None,
value: None,
from,
file_pattern: None,
},
}
} else {
let pattern = format!(r"\b{}\b", regex_escape(leaf_symbol));
KonveyorCondition::FileContent {
filecontent: FileContentFields {
pattern,
file_pattern: file_pattern.to_string(),
},
}
};
let is_propagated = change.description.contains("propagated through call chain");
let is_test_assertion = change.description.contains("Test assertions changed")
|| change
.description
.to_lowercase()
.contains("test assertions");
let strategy = if is_propagated || is_test_assertion {
"Manual"
} else {
"LlmAssisted"
};
if is_test_assertion {
labels.push("source=test-diff".to_string());
}
Some(KonveyorRule {
rule_id,
labels,
effort: 3,
category: "mandatory".to_string(),
description: change.description.clone(),
message,
links: Vec::new(),
when: condition,
fix_strategy: Some(FixStrategyEntry::new(strategy)),
})
}
fn manifest_change_to_rule(
change: &ManifestChange<TypeScript>,
file_pattern: &str,
id_counts: &mut HashMap<String, usize>,
) -> KonveyorRule {
let change_type_label = manifest_change_type_label(&change.change_type);
let base_id = format!(
"semver-manifest-{}-{}",
sanitize_id(&change.field),
change_type_label,
);
let rule_id = unique_id(base_id, id_counts);
let category = if change.is_breaking {
"mandatory"
} else {
"optional"
};
let effort = manifest_effort(&change.change_type);
let (condition, message) =
build_manifest_condition_and_message(change, file_pattern, change_type_label);
KonveyorRule {
rule_id,
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=manifest".to_string(),
format!("manifest-field={}", change.field),
],
effort,
category: category.to_string(),
description: change.description.clone(),
message,
links: Vec::new(),
when: condition,
fix_strategy: Some(FixStrategyEntry::new("Manual")),
}
}
fn api_change_to_fix(
change: &ApiChange,
file_changes: &FileChanges<TypeScript>,
rule_id: &str,
file_pattern: &str,
) -> FixGuidanceEntry {
let file_path = file_changes.file.display().to_string();
let leaf_symbol = extract_leaf_symbol(&change.symbol);
let search_pattern = build_pattern(&change.kind, &change.change, leaf_symbol, &change.before);
let (strategy, confidence, source, fix_description, replacement) = match change.change {
ApiChangeType::Renamed => {
let old_name = change
.before
.as_deref()
.map(|b| extract_leaf_symbol(b).to_string())
.unwrap_or_else(|| change.symbol.clone());
let new_name = change
.after
.as_deref()
.map(|a| extract_leaf_symbol(a).to_string())
.unwrap_or_else(|| change.symbol.clone());
let desc = format!(
"Rename all occurrences of '{}' to '{}'.\n\
This is a mechanical find-and-replace that can be auto-applied.\n\
Search pattern: {} (in {} files)",
old_name, new_name, search_pattern, file_pattern,
);
(
FixStrategy::Rename,
FixConfidence::Exact,
FixSource::Pattern,
desc,
Some(new_name),
)
}
ApiChangeType::SignatureChanged => {
let desc = if let (Some(ref before), Some(ref after)) = (&change.before, &change.after)
{
format!(
"Update all call sites of '{}' to match the new signature.\n\n\
Old signature: {}\n\
New signature: {}\n\n\
Review each call site and adjust arguments accordingly.\n\
{}",
change.symbol, before, after, change.description,
)
} else {
format!(
"Update all call sites of '{}' to match the new signature.\n\
{}\n\n\
Review each usage and adjust arguments, type parameters, or \
modifiers as described above.",
change.symbol, change.description,
)
};
(
FixStrategy::UpdateSignature,
FixConfidence::High,
FixSource::Pattern,
desc,
None,
)
}
ApiChangeType::TypeChanged => {
let desc = if let (Some(ref before), Some(ref after)) = (&change.before, &change.after)
{
format!(
"Update type annotations from '{}' to '{}'.\n\n\
Old type: {}\n\
New type: {}\n\n\
Check all locations where this type is used in assignments, \
function parameters, return types, and generic type arguments.\n\
{}",
change.symbol, change.symbol, before, after, change.description,
)
} else {
format!(
"Update type references for '{}'.\n\
{}\n\n\
Check all locations where this type is used and update accordingly.",
change.symbol, change.description,
)
};
(
FixStrategy::UpdateType,
FixConfidence::High,
FixSource::Pattern,
desc,
None,
)
}
ApiChangeType::Removed => {
let kind_label = api_kind_label(&change.kind);
let desc = format!(
"The {} '{}' has been removed.\n\n\
Action required:\n\
1. Find all usages of '{}' in your codebase\n\
2. Identify an appropriate replacement (check the library's \
migration guide or changelog)\n\
3. Update each usage to use the replacement\n\
4. Remove any imports of '{}'\n\n\
{}",
kind_label, change.symbol, change.symbol, change.symbol, change.description,
);
(
FixStrategy::FindAlternative,
FixConfidence::Low,
FixSource::Manual,
desc,
None,
)
}
ApiChangeType::VisibilityChanged => {
let desc = format!(
"The visibility of '{}' has been reduced.\n\n\
If you are importing or using '{}' from outside its module, \
you need to find a public alternative.\n\
{}\n\n\
Check if there is a new public API that exposes the same functionality, \
or refactor your code to avoid depending on this internal symbol.",
change.symbol, change.symbol, change.description,
);
(
FixStrategy::FindAlternative,
FixConfidence::Medium,
FixSource::Pattern,
desc,
None,
)
}
};
FixGuidanceEntry {
rule_id: rule_id.to_string(),
strategy,
confidence,
source,
symbol: change.symbol.clone(),
file: file_path,
fix_description,
before: change.before.clone(),
after: change.after.clone(),
search_pattern,
replacement,
}
}
fn behavioral_change_to_fix(
change: &BehavioralChange<TypeScript>,
file_changes: &FileChanges<TypeScript>,
rule_id: &str,
) -> FixGuidanceEntry {
let file_path = file_changes.file.display().to_string();
let leaf_symbol = extract_leaf_symbol(&change.symbol);
let search_pattern = format!(r"\b{}\b", regex_escape(leaf_symbol));
let fix_description = format!(
"Behavioral change detected in '{}' (AI-generated finding).\n\n\
What changed: {}\n\n\
Action required:\n\
1. Review all usages of '{}' in your codebase\n\
2. Verify that your code handles the new behavior correctly\n\
3. Update tests that depend on the old behavior\n\
4. Pay special attention to edge cases and error handling\n\n\
This finding was generated by LLM analysis and should be \
verified by a developer.",
change.symbol, change.description, change.symbol,
);
FixGuidanceEntry {
rule_id: rule_id.to_string(),
strategy: FixStrategy::ManualReview,
confidence: FixConfidence::Medium,
source: FixSource::Llm,
symbol: change.symbol.clone(),
file: file_path,
fix_description,
before: None,
after: None,
search_pattern,
replacement: None,
}
}
fn manifest_change_to_fix(change: &ManifestChange<TypeScript>, rule_id: &str) -> FixGuidanceEntry {
let (strategy, confidence, source, fix_description, search, replacement) = match change
.change_type
{
TsManifestChangeType::ModuleSystemChanged => {
let is_cjs_to_esm = change
.after
.as_deref()
.map(|a| a == "module")
.unwrap_or(false);
if is_cjs_to_esm {
(
FixStrategy::UpdateImport,
FixConfidence::High,
FixSource::Pattern,
format!(
"The package has changed from CommonJS to ESM.\n\n\
Action required:\n\
1. Convert all require() calls to import statements:\n\
\n\
Before: const {{ foo }} = require('package')\n\
After: import {{ foo }} from 'package'\n\
\n\
2. Convert module.exports to export statements:\n\
\n\
Before: module.exports = {{ foo }}\n\
After: export {{ foo }}\n\
\n\
3. Update your package.json \"type\" field if needed\n\
4. Rename .js files to .mjs if mixing module systems\n\n\
{}",
change.description,
),
r"\brequire\s*\(".to_string(),
Some("import".to_string()),
)
} else {
(
FixStrategy::UpdateImport,
FixConfidence::High,
FixSource::Pattern,
format!(
"The package has changed from ESM to CommonJS.\n\n\
Action required:\n\
1. Convert all import statements to require() calls:\n\
\n\
Before: import {{ foo }} from 'package'\n\
After: const {{ foo }} = require('package')\n\
\n\
2. Convert export statements to module.exports\n\
3. Update your package.json \"type\" field if needed\n\n\
{}",
change.description,
),
r"\bimport\s+".to_string(),
Some("require".to_string()),
)
}
}
TsManifestChangeType::PeerDependencyAdded => (
FixStrategy::UpdateDependency,
FixConfidence::Exact,
FixSource::Pattern,
format!(
"A new peer dependency has been added: '{}'\n\n\
Action required:\n\
1. Install the peer dependency: npm install {}\n\
2. Verify version compatibility with your existing dependencies\n\n\
{}",
change.field, change.field, change.description,
),
change.field.clone(),
change.after.clone(),
),
TsManifestChangeType::PeerDependencyRemoved => (
FixStrategy::UpdateDependency,
FixConfidence::High,
FixSource::Pattern,
format!(
"Peer dependency '{}' has been removed.\n\n\
Action required:\n\
1. Check if you still need '{}' as a direct dependency\n\
2. If it was only required by this package, you may be able \
to remove it\n\
3. Verify that removing it doesn't break other dependencies\n\n\
{}",
change.field, change.field, change.description,
),
change.field.clone(),
None,
),
TsManifestChangeType::PeerDependencyRangeChanged => (
FixStrategy::UpdateDependency,
FixConfidence::High,
FixSource::Pattern,
format!(
"Peer dependency '{}' version range changed.\n\n\
Before: {}\n\
After: {}\n\n\
Action required:\n\
1. Update '{}' to a version that satisfies the new range\n\
2. Test for compatibility with the new version\n\n\
{}",
change.field,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
change.field,
change.description,
),
change.field.clone(),
change.after.clone(),
),
TsManifestChangeType::EntryPointChanged | TsManifestChangeType::ExportsEntryRemoved => (
FixStrategy::UpdateImport,
FixConfidence::Medium,
FixSource::Pattern,
format!(
"Package entry point or export map changed for '{}'.\n\n\
Before: {}\n\
After: {}\n\n\
Action required:\n\
1. Update all import paths that reference the old entry point\n\
2. Check the package's export map for the new path\n\n\
{}",
change.field,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
change.description,
),
change.field.clone(),
change.after.clone(),
),
_ => (
FixStrategy::ManualReview,
FixConfidence::Medium,
FixSource::Pattern,
format!(
"Package manifest field '{}' changed.\n\n\
Before: {}\n\
After: {}\n\n\
Review the change and update your configuration accordingly.\n\n\
{}",
change.field,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
change.description,
),
change.field.clone(),
None,
),
};
FixGuidanceEntry {
rule_id: rule_id.to_string(),
strategy,
confidence,
source,
symbol: change.field.clone(),
file: "package.json".to_string(),
fix_description,
before: change.before.clone(),
after: change.after.clone(),
search_pattern: search,
replacement,
}
}
fn build_manifest_condition_and_message(
change: &ManifestChange<TypeScript>,
file_pattern: &str,
change_type_label: &str,
) -> (KonveyorCondition, String) {
match change.change_type {
TsManifestChangeType::ModuleSystemChanged => {
let is_cjs_to_esm = change
.after
.as_deref()
.map(|a| a == "module")
.unwrap_or(false);
let (pattern, hint) = if is_cjs_to_esm {
(
r"\brequire\s*\(".to_string(),
"Convert require() calls to ESM import statements.",
)
} else {
(
r"\bimport\s+".to_string(),
"Convert ESM import statements to require() calls.",
)
};
let message = format!(
"Module system changed: {}\n\nBefore: {}\nAfter: {}\n{}",
change.description,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
hint,
);
(
KonveyorCondition::FileContent {
filecontent: FileContentFields {
pattern,
file_pattern: file_pattern.to_string(),
},
},
message,
)
}
TsManifestChangeType::PeerDependencyAdded
| TsManifestChangeType::PeerDependencyRemoved
| TsManifestChangeType::PeerDependencyRangeChanged => {
let message = format!(
"Peer dependency change ({}): {}\n\nField: {}\nBefore: {}\nAfter: {}",
change_type_label,
change.description,
change.field,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
);
(
KonveyorCondition::FileContent {
filecontent: FileContentFields {
pattern: format!(
"\"{}\"\\s*:",
change.field.replace('/', r"\/").replace('@', r"\@")
),
file_pattern: "package\\.json$".to_string(),
},
},
message,
)
}
_ => {
let message = format!(
"Package manifest change ({}): {}\n\nField: {}\nBefore: {}\nAfter: {}",
change_type_label,
change.description,
change.field,
change.before.as_deref().unwrap_or("(none)"),
change.after.as_deref().unwrap_or("(none)"),
);
(
KonveyorCondition::FileContent {
filecontent: FileContentFields {
pattern: format!(
"\"{}\"\\s*:",
change.field.replace('/', r"\/").replace('@', r"\@")
),
file_pattern: "package\\.json$".to_string(),
},
},
message,
)
}
}
}
fn manifest_effort(change_type: &TsManifestChangeType) -> u32 {
match change_type {
TsManifestChangeType::ModuleSystemChanged => 7,
TsManifestChangeType::EntryPointChanged => 5,
TsManifestChangeType::ExportsEntryRemoved => 5,
TsManifestChangeType::ExportsConditionRemoved => 3,
TsManifestChangeType::BinEntryRemoved => 3,
_ => 3,
}
}
fn behavioral_category_label(cat: &TsCategory) -> &'static str {
match cat {
TsCategory::DomStructure => "dom-structure",
TsCategory::CssClass => "css-class",
TsCategory::CssVariable => "css-variable",
TsCategory::Accessibility => "accessibility",
TsCategory::DefaultValue => "default-value",
TsCategory::LogicChange => "logic-change",
TsCategory::DataAttribute => "data-attribute",
TsCategory::RenderOutput => "render-output",
}
}
fn manifest_change_type_label(change_type: &TsManifestChangeType) -> &'static str {
match change_type {
TsManifestChangeType::EntryPointChanged => "entry-point-changed",
TsManifestChangeType::ExportsEntryRemoved => "exports-entry-removed",
TsManifestChangeType::ExportsEntryAdded => "exports-entry-added",
TsManifestChangeType::ExportsConditionRemoved => "exports-condition-removed",
TsManifestChangeType::ModuleSystemChanged => "module-system-changed",
TsManifestChangeType::PeerDependencyAdded => "peer-dependency-added",
TsManifestChangeType::PeerDependencyRemoved => "peer-dependency-removed",
TsManifestChangeType::PeerDependencyRangeChanged => "peer-dependency-range-changed",
TsManifestChangeType::EngineConstraintChanged => "engine-constraint-changed",
TsManifestChangeType::BinEntryRemoved => "bin-entry-removed",
}
}
#[cfg(test)]
mod tests {
use super::*;
use semver_analyzer_core::*;
use std::path::PathBuf;
fn make_report(
changes: Vec<FileChanges<TypeScript>>,
manifest_changes: Vec<ManifestChange<TypeScript>>,
) -> AnalysisReport<TypeScript> {
AnalysisReport {
repository: PathBuf::from("/tmp/test-repo"),
comparison: Comparison {
from_ref: "v1.0.0".to_string(),
to_ref: "v2.0.0".to_string(),
from_sha: "abc123".to_string(),
to_sha: "def456".to_string(),
commit_count: 10,
analysis_timestamp: "2026-03-16T00:00:00Z".to_string(),
},
summary: Summary {
total_breaking_changes: 0,
breaking_api_changes: 0,
breaking_behavioral_changes: 0,
files_with_breaking_changes: 0,
},
changes,
manifest_changes,
added_files: Vec::new(),
packages: vec![],
member_renames: HashMap::new(),
inferred_rename_patterns: None,
hierarchy_deltas: Vec::new(),
sd_result: None,
metadata: AnalysisMetadata {
call_graph_analysis: "none".to_string(),
tool_version: "0.1.0".to_string(),
llm_usage: None,
},
}
}
#[test]
fn test_extract_leaf_symbol() {
assert_eq!(extract_leaf_symbol("Card.isFlat"), "isFlat");
assert_eq!(extract_leaf_symbol("createUser"), "createUser");
assert_eq!(extract_leaf_symbol("a.b.c"), "c");
}
#[test]
fn test_sanitize_id() {
assert_eq!(sanitize_id("src/api/users.d.ts"), "src-api-users-d-ts");
assert_eq!(sanitize_id("Card.isFlat"), "card-isflat");
assert_eq!(sanitize_id("foo///bar"), "foo-bar");
}
#[test]
fn test_unique_id() {
let mut counts = HashMap::new();
assert_eq!(unique_id("foo".to_string(), &mut counts), "foo");
assert_eq!(unique_id("foo".to_string(), &mut counts), "foo-2");
assert_eq!(unique_id("foo".to_string(), &mut counts), "foo-3");
assert_eq!(unique_id("bar".to_string(), &mut counts), "bar");
}
#[test]
fn test_regex_escape() {
assert_eq!(regex_escape("foo"), "foo");
assert_eq!(regex_escape("foo.bar"), "foo\\.bar");
assert_eq!(regex_escape("a*b+c?"), "a\\*b\\+c\\?");
}
#[test]
fn test_build_pattern_function_removed() {
let pattern = build_pattern(
&ApiChangeKind::Function,
&ApiChangeType::Removed,
"createUser",
&None,
);
assert_eq!(pattern, r"\bcreateUser\s*\(");
}
#[test]
fn test_build_pattern_property_removed() {
let pattern = build_pattern(
&ApiChangeKind::Property,
&ApiChangeType::Removed,
"isFlat",
&None,
);
assert_eq!(pattern, r"\.isFlat\b");
}
#[test]
fn test_build_pattern_class_removed() {
let pattern = build_pattern(
&ApiChangeKind::Class,
&ApiChangeType::Removed,
"Card",
&None,
);
assert_eq!(pattern, r"\bCard\b");
}
#[test]
fn test_build_pattern_renamed_uses_before() {
let pattern = build_pattern(
&ApiChangeKind::Function,
&ApiChangeType::Renamed,
"newName",
&Some("oldName".to_string()),
);
assert_eq!(pattern, r"\boldName\s*\(");
}
#[test]
fn test_generate_rules_api_change() {
let changes = vec![FileChanges {
file: PathBuf::from("src/api/users.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "createUser".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Exported function 'createUser' was removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx,js,jsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
assert_eq!(
rules[0].rule_id,
"semver-src-api-users-d-ts-createuser-removed"
);
assert_eq!(rules[0].category, "mandatory");
assert_eq!(rules[0].effort, 5);
assert!(rules[0]
.labels
.contains(&"source=semver-analyzer".to_string()));
assert!(rules[0].labels.contains(&"change-type=removed".to_string()));
assert!(rules[0].labels.contains(&"kind=function".to_string()));
}
#[test]
fn test_generate_rules_behavioral_change() {
let changes = vec![FileChanges {
file: PathBuf::from("src/api/users.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![],
breaking_behavioral_changes: vec![BehavioralChange {
symbol: "validateEmail".to_string(),
kind: BehavioralChangeKind::Function,
category: None,
description: "Now rejects emails with '+' aliases".to_string(),
source_file: Some("src/api/users.ts".to_string()),
confidence: None,
evidence_type: None,
referenced_symbols: vec![],
is_internal_only: None,
}],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
assert!(rules[0].rule_id.contains("behavioral"));
assert_eq!(rules[0].category, "mandatory");
assert!(rules[0].labels.contains(&"ai-generated".to_string()));
assert!(rules[0]
.labels
.contains(&"change-type=behavioral".to_string()));
}
#[test]
fn test_generate_rules_manifest_module_system() {
let manifest = vec![ManifestChange {
field: "type".to_string(),
change_type: TsManifestChangeType::ModuleSystemChanged,
before: Some("commonjs".to_string()),
after: Some("module".to_string()),
description: "CJS to ESM".to_string(),
is_breaking: true,
}];
let report = make_report(vec![], manifest);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx,js,jsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
assert!(rules[0].rule_id.contains("manifest"));
assert!(rules[0].rule_id.contains("module-system-changed"));
assert_eq!(rules[0].category, "mandatory");
assert_eq!(rules[0].effort, 7);
match &rules[0].when {
KonveyorCondition::FileContent { filecontent } => {
assert!(filecontent.pattern.contains("require"));
}
_ => panic!("Expected FileContent condition for module system change"),
}
}
#[test]
fn test_generate_rules_manifest_peer_dep() {
let manifest = vec![ManifestChange {
field: "react".to_string(),
change_type: TsManifestChangeType::PeerDependencyRemoved,
before: Some("^17.0.0".to_string()),
after: None,
description: "Peer dependency 'react' was removed".to_string(),
is_breaking: true,
}];
let report = make_report(vec![], manifest);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx,js,jsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
match &rules[0].when {
KonveyorCondition::FileContent { filecontent } => {
assert!(filecontent.pattern.contains("react"));
assert!(filecontent.file_pattern.contains("package"));
}
_ => panic!("Expected FileContent condition for peer dependency change"),
}
}
#[test]
fn test_duplicate_rule_ids_get_suffix() {
let changes = vec![FileChanges {
file: PathBuf::from("test.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![
ApiChange {
symbol: "foo".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Removed foo".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
ApiChange {
symbol: "foo".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Removed foo overload".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 2);
assert_ne!(rules[0].rule_id, rules[1].rule_id);
assert!(rules[1].rule_id.ends_with("-2"));
}
#[test]
fn test_write_ruleset_dir() {
let base = std::env::temp_dir().join("semver-konveyor-test-out");
let dir = base.join("rules");
let _ = std::fs::remove_dir_all(&base);
let report = make_report(vec![], vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let fix_guidance = generate_fix_guidance(&report, &rules, "*.ts");
write_ruleset_dir(&dir, "test-ruleset", &report, &rules).unwrap();
let fix_dir = write_fix_guidance_dir(&dir, &fix_guidance).unwrap();
assert!(dir.join("ruleset.yaml").exists());
assert!(dir.join("breaking-changes.yaml").exists());
assert!(!dir.join("fix-guidance.yaml").exists());
assert_eq!(fix_dir, base.join("fix-guidance"));
assert!(fix_dir.join("fix-guidance.yaml").exists());
let ruleset_content = std::fs::read_to_string(dir.join("ruleset.yaml")).unwrap();
assert!(ruleset_content.contains("test-ruleset"));
assert!(ruleset_content.contains("source=semver-analyzer"));
let fix_content = std::fs::read_to_string(fix_dir.join("fix-guidance.yaml")).unwrap();
assert!(fix_content.contains("migration"));
assert!(fix_content.contains("total_fixes"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn test_full_roundtrip_yaml_output() {
let changes = vec![FileChanges {
file: PathBuf::from("src/components/Button.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Button.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'primary' | 'secondary'".to_string()),
after: Some("'primary' | 'danger'".to_string()),
description: "Removed 'secondary' variant, added 'danger'".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let yaml = serde_yaml::to_string(&rules).unwrap();
assert!(yaml.contains("ruleID"));
assert!(yaml.contains("frontend.referenced"));
assert!(yaml.contains("variant"));
}
#[test]
fn test_fix_guidance_renamed_is_exact() {
let changes = vec![FileChanges {
file: PathBuf::from("src/lib.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Chip".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Class,
change: ApiChangeType::Renamed,
before: Some("Chip".to_string()),
after: Some("Label".to_string()),
description: "Chip renamed to Label".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.{ts,tsx}");
assert_eq!(guidance.fixes.len(), 1);
let fix = &guidance.fixes[0];
assert!(matches!(fix.strategy, FixStrategy::Rename));
assert!(matches!(fix.confidence, FixConfidence::Exact));
assert!(matches!(fix.source, FixSource::Pattern));
assert_eq!(fix.replacement.as_deref(), Some("Label"));
assert!(fix.fix_description.contains("Rename all occurrences"));
assert!(fix.fix_description.contains("'Chip'"));
assert!(fix.fix_description.contains("'Label'"));
}
#[test]
fn test_fix_guidance_removed_is_manual() {
let changes = vec![FileChanges {
file: PathBuf::from("src/api.d.ts"),
status: FileStatus::Deleted,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "createUser".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Function createUser was removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.ts");
assert_eq!(guidance.fixes.len(), 1);
let fix = &guidance.fixes[0];
assert!(matches!(fix.strategy, FixStrategy::FindAlternative));
assert!(matches!(fix.confidence, FixConfidence::Low));
assert!(matches!(fix.source, FixSource::Manual));
assert!(fix.replacement.is_none());
assert!(fix.fix_description.contains("has been removed"));
}
#[test]
fn test_fix_guidance_signature_changed() {
let changes = vec![FileChanges {
file: PathBuf::from("src/utils.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "formatDate".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::SignatureChanged,
before: Some("formatDate(d: Date): string".to_string()),
after: Some("formatDate(d: Date, locale: string): string".to_string()),
description: "Added required 'locale' parameter".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.ts");
assert_eq!(guidance.fixes.len(), 1);
let fix = &guidance.fixes[0];
assert!(matches!(fix.strategy, FixStrategy::UpdateSignature));
assert!(matches!(fix.confidence, FixConfidence::High));
assert!(fix.fix_description.contains("Old signature:"));
assert!(fix.fix_description.contains("New signature:"));
assert_eq!(fix.before.as_deref(), Some("formatDate(d: Date): string"));
assert_eq!(
fix.after.as_deref(),
Some("formatDate(d: Date, locale: string): string")
);
}
#[test]
fn test_fix_guidance_behavioral_is_llm_source() {
let changes = vec![FileChanges {
file: PathBuf::from("src/auth.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![],
breaking_behavioral_changes: vec![BehavioralChange {
symbol: "validateToken".to_string(),
kind: BehavioralChangeKind::Function,
category: None,
description: "Now throws on expired tokens instead of returning null".to_string(),
source_file: Some("src/auth.ts".to_string()),
confidence: None,
evidence_type: None,
referenced_symbols: vec![],
is_internal_only: None,
}],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.ts");
assert_eq!(guidance.fixes.len(), 1);
let fix = &guidance.fixes[0];
assert!(matches!(fix.strategy, FixStrategy::ManualReview));
assert!(matches!(fix.confidence, FixConfidence::Medium));
assert!(matches!(fix.source, FixSource::Llm));
assert!(fix.fix_description.contains("AI-generated"));
assert!(fix.fix_description.contains("throws on expired tokens"));
}
#[test]
fn test_fix_guidance_manifest_cjs_to_esm() {
let manifest = vec![ManifestChange {
field: "type".to_string(),
change_type: TsManifestChangeType::ModuleSystemChanged,
before: Some("commonjs".to_string()),
after: Some("module".to_string()),
description: "CJS to ESM migration".to_string(),
is_breaking: true,
}];
let report = make_report(vec![], manifest);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.ts");
assert_eq!(guidance.fixes.len(), 1);
let fix = &guidance.fixes[0];
assert!(matches!(fix.strategy, FixStrategy::UpdateImport));
assert!(matches!(fix.confidence, FixConfidence::High));
assert!(fix.fix_description.contains("require()"));
assert!(fix.fix_description.contains("import"));
assert_eq!(fix.replacement.as_deref(), Some("import"));
}
#[test]
fn test_fix_guidance_summary_counts() {
let changes = vec![FileChanges {
file: PathBuf::from("src/lib.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![
ApiChange {
symbol: "Chip".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Class,
change: ApiChangeType::Renamed,
before: Some("Chip".to_string()),
after: Some("Label".to_string()),
description: "Renamed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
ApiChange {
symbol: "oldFn".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
],
breaking_behavioral_changes: vec![BehavioralChange {
symbol: "process".to_string(),
kind: BehavioralChangeKind::Function,
category: None,
description: "Changed behavior".to_string(),
source_file: Some("src/lib.ts".to_string()),
confidence: None,
evidence_type: None,
referenced_symbols: vec![],
is_internal_only: None,
}],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.ts");
assert_eq!(guidance.summary.total_fixes, 3);
assert_eq!(guidance.summary.auto_fixable, 1); assert_eq!(guidance.summary.manual_only, 1); assert_eq!(guidance.summary.needs_review, 1); }
#[test]
fn test_fix_guidance_yaml_roundtrip() {
let changes = vec![FileChanges {
file: PathBuf::from("src/index.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![
ApiChange {
symbol: "Foo".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Class,
change: ApiChangeType::Renamed,
before: Some("Foo".to_string()),
after: Some("Bar".to_string()),
description: "Renamed Foo to Bar".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
ApiChange {
symbol: "baz".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::SignatureChanged,
before: Some("baz(): void".to_string()),
after: Some("baz(x: number): void".to_string()),
description: "Added required param".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
},
],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let manifest = vec![ManifestChange {
field: "type".to_string(),
change_type: TsManifestChangeType::ModuleSystemChanged,
before: Some("commonjs".to_string()),
after: Some("module".to_string()),
description: "CJS to ESM".to_string(),
is_breaking: true,
}];
let report = make_report(changes, manifest);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.{ts,tsx}",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let guidance = generate_fix_guidance(&report, &rules, "*.{ts,tsx}");
let yaml = serde_yaml::to_string(&guidance).unwrap();
assert!(yaml.contains("strategy"));
assert!(yaml.contains("confidence"));
assert!(yaml.contains("fix_description"));
assert!(yaml.contains("search_pattern"));
assert!(yaml.contains("replacement"));
assert!(yaml.contains("rename"));
assert!(yaml.contains("update_signature"));
assert!(yaml.contains("update_import"));
assert!(yaml.contains("auto_fixable"));
assert!(yaml.contains("needs_review"));
assert!(yaml.contains("manual_only"));
}
#[test]
fn test_frontend_provider_class_rename_generates_or_condition() {
let changes = vec![FileChanges {
file: PathBuf::from("src/components/Chip.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Chip".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Class,
change: ApiChangeType::Renamed,
before: Some("Chip".to_string()),
after: Some("Label".to_string()),
description: "Chip renamed to Label".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
let yaml = serde_yaml::to_string(&rules[0]).unwrap();
assert!(yaml.contains("frontend.referenced"));
assert!(yaml.contains("JSX_COMPONENT"));
assert!(yaml.contains("IMPORT"));
assert!(yaml.contains("^Chip$")); assert!(yaml.contains("has-codemod=true"));
}
#[test]
fn test_frontend_provider_prop_removed_scoped_to_component() {
let changes = vec![FileChanges {
file: PathBuf::from("src/components/Card.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Card.isFlat".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "Card.isFlat prop removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(
rules.len(),
1,
"Single prop removal should not trigger P0-C, got {} rules",
rules.len()
);
let yaml0 = serde_yaml::to_string(&rules[0]).unwrap();
assert!(yaml0.contains("JSX_PROP"));
assert!(yaml0.contains("^isFlat$"));
assert!(yaml0.contains("^Card$")); }
#[test]
fn test_frontend_provider_function_uses_function_call() {
let changes = vec![FileChanges {
file: PathBuf::from("src/utils.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "createUser".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Function,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "createUser removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
let yaml = serde_yaml::to_string(&rules[0]).unwrap();
assert!(yaml.contains("FUNCTION_CALL"));
assert!(yaml.contains("^createUser$"));
}
#[test]
fn test_frontend_provider_type_alias_uses_type_reference() {
let changes = vec![FileChanges {
file: PathBuf::from("src/types.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "UserRole".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::TypeAlias,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "UserRole type removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
let yaml = serde_yaml::to_string(&rules[0]).unwrap();
assert!(yaml.contains("TYPE_REFERENCE"));
assert!(yaml.contains("^UserRole$"));
}
fn make_api_change(
symbol: &str,
kind: ApiChangeKind,
change: ApiChangeType,
description: &str,
) -> ApiChange {
ApiChange {
symbol: symbol.to_string(),
qualified_name: String::new(),
kind,
change,
before: None,
after: None,
description: description.to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}
}
fn make_file_changes(
file: &str,
api: Vec<ApiChange>,
behavioral: Vec<BehavioralChange<TypeScript>>,
) -> FileChanges<TypeScript> {
FileChanges {
file: PathBuf::from(file),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: api,
breaking_behavioral_changes: behavioral,
container_changes: vec![],
}
}
fn make_behavioral(
symbol: &str,
category: Option<TsCategory>,
description: &str,
) -> BehavioralChange<TypeScript> {
BehavioralChange {
symbol: symbol.to_string(),
kind: BehavioralChangeKind::Function,
category,
description: description.to_string(),
source_file: None,
confidence: None,
evidence_type: None,
referenced_symbols: vec![],
is_internal_only: None,
}
}
fn make_report_with_added(
changes: Vec<FileChanges<TypeScript>>,
added_files: Vec<PathBuf>,
) -> AnalysisReport<TypeScript> {
let mut report = make_report(changes, vec![]);
report.added_files = added_files;
report
}
#[test]
fn test_p0c_skips_minor_prop_removals() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Button/Button.tsx",
vec![
make_api_change(
"ButtonProps.isActive",
ApiChangeKind::Property,
ApiChangeType::Removed,
"isActive prop removed",
),
make_api_change(
"ButtonProps.variant",
ApiChangeKind::Property,
ApiChangeType::TypeChanged,
"variant type changed",
),
],
vec![],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let p0c_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("component-import-deprecated"))
.collect();
assert!(
p0c_rules.is_empty(),
"Button with 1/2 removed props should NOT get a P0-C rule. Got: {:?}",
p0c_rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn test_p0c_skips_type_alias_removals() {
let changes = vec![make_file_changes(
"src/types.d.ts",
vec![make_api_change(
"UserRole",
ApiChangeKind::TypeAlias,
ApiChangeType::Removed,
"UserRole type removed",
)],
vec![],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
assert!(!rules[0].rule_id.contains("component-import-deprecated"));
}
#[test]
fn test_suppress_redundant_prop_rules_modal_scenario() {
let rules = vec![
KonveyorRule {
rule_id: "semver-modal-component-import-deprecated".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=component-removal".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: "Modal has significant breaking changes".to_string(),
message: "MIGRATION: Modal restructured".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^Modal$".to_string(),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("LlmAssisted")),
},
KonveyorRule {
rule_id: "semver-modal-title-removed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=removed".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: "Modal.title removed".to_string(),
message: "title prop removed".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^title$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Modal$".to_string()),
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Modal".to_string()),
..Default::default()
}),
},
KonveyorRule {
rule_id: "semver-modal-actions-removed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=removed".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: "Modal.actions removed".to_string(),
message: "actions prop removed".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^actions$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Modal$".to_string()),
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Modal".to_string()),
..Default::default()
}),
},
KonveyorRule {
rule_id: "semver-card-isflat-removed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=removed".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: "Card.isFlat removed".to_string(),
message: "isFlat prop removed".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^isFlat$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Card$".to_string()),
parent: None,
value: None,
from: None,
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Card".to_string()),
..Default::default()
}),
},
];
let result = suppress_redundant_prop_rules(rules);
assert_eq!(
result.len(),
2,
"Expected 2 rules after suppression, got {}",
result.len()
);
assert!(result
.iter()
.any(|r| r.rule_id == "semver-modal-component-import-deprecated"));
assert!(result
.iter()
.any(|r| r.rule_id == "semver-card-isflat-removed"));
assert!(!result.iter().any(|r| r.rule_id.contains("modal-title")));
assert!(!result.iter().any(|r| r.rule_id.contains("modal-actions")));
}
#[test]
fn test_css_logical_property_suffix_renames() {
let member_renames: HashMap<String, String> = vec![
(
"c_table__caption_PaddingTop".to_string(),
"c_table__caption_PaddingBlockStart".to_string(),
),
(
"c_table__caption_PaddingBottom".to_string(),
"c_table__caption_PaddingBlockEnd".to_string(),
),
(
"c_nav_PaddingLeft".to_string(),
"c_nav_PaddingInlineStart".to_string(),
),
(
"c_button_MarginTop".to_string(),
"c_button_MarginBlockStart".to_string(),
),
]
.into_iter()
.collect();
let report = make_report(vec![], vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&member_renames,
);
let css_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("css-logical"))
.collect();
assert_eq!(
css_rules.len(),
1,
"Expected 1 combined CSS logical property rule, got {}",
css_rules.len()
);
let rule = css_rules[0];
let strat = rule.fix_strategy.as_ref().unwrap();
assert_eq!(strat.strategy, "Rename");
assert!(
strat.mappings.len() >= 4,
"Expected at least 4 suffix mappings, got {}",
strat.mappings.len()
);
let has_padding_top = strat.mappings.iter().any(|m| {
m.from.as_deref() == Some("--PaddingTop")
&& m.to.as_deref() == Some("--PaddingBlockStart")
});
assert!(
has_padding_top,
"Missing PaddingTop→PaddingBlockStart mapping"
);
let has_margin_top = strat.mappings.iter().any(|m| {
m.from.as_deref() == Some("--MarginTop")
&& m.to.as_deref() == Some("--MarginBlockStart")
});
assert!(has_margin_top, "Missing MarginTop→MarginBlockStart mapping");
assert!(rule.message.contains("PaddingTop"));
assert!(rule.message.contains("PaddingBlockStart"));
match &rule.when {
KonveyorCondition::FrontendCssVar { cssvar } => {
assert!(cssvar.pattern.contains("PaddingTop"));
assert!(cssvar.pattern.contains("MarginTop"));
}
_ => panic!("Expected FrontendCssVar condition"),
}
}
#[test]
fn test_constant_collapse_threshold() {
let mut api_changes = Vec::new();
for i in 0..15 {
api_changes.push(ApiChange {
symbol: format!("c_component_token_{}", i),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Removed,
before: None,
after: None,
description: format!("Token {} removed", i),
migration_target: None,
removal_disposition: None,
renders_element: None,
});
}
let changes = vec![make_file_changes(
"packages/react-tokens/dist/esm/tokens.d.ts",
api_changes,
vec![],
)];
let mut pkg_cache = HashMap::new();
pkg_cache.insert(
"react-tokens".to_string(),
"@patternfly/react-tokens".to_string(),
);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let combined_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("combined"))
.collect();
assert!(
!combined_rules.is_empty(),
"Expected at least one combined rule from 15 constants"
);
let individual_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("c-component-token"))
.collect();
assert_eq!(
individual_rules.len(),
0,
"Expected 0 individual token rules (all collapsed), got {}",
individual_rules.len()
);
}
#[test]
fn test_constant_collapse_below_threshold() {
let mut api_changes = Vec::new();
for i in 0..5 {
api_changes.push(ApiChange {
symbol: format!("c_component_token_{}", i),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Removed,
before: None,
after: None,
description: format!("Token {} removed", i),
migration_target: None,
removal_disposition: None,
renders_element: None,
});
}
let changes = vec![make_file_changes(
"packages/react-tokens/dist/esm/tokens.d.ts",
api_changes,
vec![],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let combined_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("combined"))
.collect();
assert_eq!(
combined_rules.len(),
0,
"Should not collapse 5 constants (below threshold)"
);
}
#[test]
fn test_constant_collapse_renamed_gets_rename_mappings() {
let mut api_changes = Vec::new();
for i in 0..15 {
api_changes.push(ApiChange {
symbol: format!("global_token_{}", i),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Renamed,
before: Some(format!("constant: global_token_{}", i)),
after: Some(format!("variable: t_global_token_{}", i)),
description: format!("Exported constant `global_token_{}` was renamed", i),
migration_target: None,
removal_disposition: None,
renders_element: None,
});
}
let changes = vec![make_file_changes(
"packages/react-tokens/dist/esm/index.d.ts",
api_changes,
vec![],
)];
let mut pkg_cache = HashMap::new();
pkg_cache.insert(
"react-tokens".to_string(),
"@patternfly/react-tokens".to_string(),
);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let combined_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.description.contains("constants from"))
.collect();
assert!(
!combined_rules.is_empty(),
"Expected at least one combined rule for 15 renamed constants"
);
let rule = combined_rules[0];
let strat = rule
.fix_strategy
.as_ref()
.expect("combined rule should have fix_strategy");
assert_eq!(
strat.strategy, "Rename",
"Renamed constant group should have Rename strategy, got {}",
strat.strategy
);
assert_eq!(
strat.mappings.len(),
15,
"Expected 15 per-token mappings, got {}",
strat.mappings.len()
);
let m0 = strat
.mappings
.iter()
.find(|m| m.from.as_deref() == Some("global_token_0"))
.expect("Should have mapping for global_token_0");
assert_eq!(m0.to.as_deref(), Some("t_global_token_0"));
for m in &strat.mappings {
let from = m.from.as_deref().unwrap_or("");
let to = m.to.as_deref().unwrap_or("");
assert!(
!from.contains("constant: ") && !from.contains("variable: "),
"from contains symbol_summary: {}",
from
);
assert!(
!to.contains("constant: ") && !to.contains("variable: "),
"to contains symbol_summary: {}",
to
);
}
let individual_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("global-token"))
.collect();
assert_eq!(
individual_rules.len(),
0,
"Individual rules should be suppressed, found {}",
individual_rules.len()
);
}
#[test]
fn test_constant_collapse_renamed_with_token_mappings_override() {
let mut api_changes = Vec::new();
for i in 0..15 {
api_changes.push(ApiChange {
symbol: format!("global_token_{}", i),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Renamed,
before: Some(format!("constant: global_token_{}", i)),
after: Some(format!("variable: wrong_target_{}", i)),
description: format!("Exported constant `global_token_{}` was renamed", i),
migration_target: None,
removal_disposition: None,
renders_element: None,
});
}
let changes = vec![make_file_changes(
"packages/react-tokens/dist/esm/index.d.ts",
api_changes,
vec![],
)];
let mut pkg_cache = HashMap::new();
pkg_cache.insert(
"react-tokens".to_string(),
"@patternfly/react-tokens".to_string(),
);
let mut patterns = RenamePatterns::empty();
patterns
.token_mappings
.insert("global_token_0".into(), "correct_target_0".into());
patterns
.token_mappings
.insert("global_token_5".into(), "correct_target_5".into());
let report = make_report(changes, vec![]);
let rules = generate_rules(&report, "*.ts", &pkg_cache, &patterns, &HashMap::new());
let combined_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.description.contains("constants from"))
.collect();
assert!(!combined_rules.is_empty());
let strat = combined_rules[0]
.fix_strategy
.as_ref()
.expect("should have strategy");
assert_eq!(strat.strategy, "Rename");
let m0 = strat
.mappings
.iter()
.find(|m| m.from.as_deref() == Some("global_token_0"))
.expect("Should have mapping for global_token_0");
assert_eq!(
m0.to.as_deref(),
Some("correct_target_0"),
"User token_mapping should override algorithmic target"
);
let m5 = strat
.mappings
.iter()
.find(|m| m.from.as_deref() == Some("global_token_5"))
.expect("Should have mapping for global_token_5");
assert_eq!(
m5.to.as_deref(),
Some("correct_target_5"),
"User token_mapping should override algorithmic target"
);
let m3 = strat
.mappings
.iter()
.find(|m| m.from.as_deref() == Some("global_token_3"))
.expect("Should have mapping for global_token_3");
assert_eq!(
m3.to.as_deref(),
Some("wrong_target_3"),
"Token without user mapping should use algorithm's result"
);
}
#[test]
fn test_new_sibling_component_detection_with_behavioral_evidence() {
let changes = vec![
make_file_changes(
"packages/react-core/src/components/Masthead/MastheadBrand.tsx",
vec![
make_api_change(
"MastheadBrandProps",
ApiChangeKind::Interface,
ApiChangeType::SignatureChanged,
"Now extends HTMLDivElement instead of HTMLAnchorElement",
),
make_api_change(
"MastheadBrandProps.component",
ApiChangeKind::Property,
ApiChangeType::Removed,
"component prop removed",
),
],
vec![make_behavioral(
"MastheadBrand",
Some(TsCategory::LogicChange),
"href no longer creates a clickable link",
)],
),
make_file_changes(
"packages/react-core/src/components/Masthead/examples/MastheadBasic.tsx",
vec![],
vec![make_behavioral(
"MastheadBasic",
Some(TsCategory::DomStructure),
"<MastheadLogo> element added to render output (1 instance)",
)],
),
];
let report = make_report_with_added(
changes,
vec![PathBuf::from(
"packages/react-core/src/components/Masthead/MastheadLogo.tsx",
)],
);
let mut pkg_cache = HashMap::new();
pkg_cache.insert(
"react-core".to_string(),
"@patternfly/react-core".to_string(),
);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("new-sibling"))
.collect();
assert_eq!(
sibling_rules.len(),
1,
"Expected 1 new-sibling rule, got {}",
sibling_rules.len()
);
let rule = sibling_rules[0];
assert!(rule.message.contains("MastheadLogo"));
assert!(rule.message.contains("MastheadBrand"));
assert!(rule.message.contains("Consider wrapping"));
assert_eq!(rule.fix_strategy.as_ref().unwrap().strategy, "LlmAssisted");
assert_eq!(rule.category, "optional");
}
#[test]
fn test_new_sibling_without_behavioral_evidence_is_skipped() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Masthead/MastheadBrand.tsx",
vec![make_api_change(
"MastheadBrandProps.component",
ApiChangeKind::Property,
ApiChangeType::Removed,
"component prop removed",
)],
vec![],
)];
let report = make_report_with_added(
changes,
vec![PathBuf::from(
"packages/react-core/src/components/Masthead/MastheadLogo.tsx",
)],
);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("new-sibling"))
.collect();
assert_eq!(
sibling_rules.len(),
0,
"Should not generate sibling rule without behavioral evidence"
);
}
#[test]
fn test_behavioral_rule_dedup_when_p0c_covers_component() {
let changes = vec![
make_file_changes(
"packages/react-core/src/components/EmptyState/EmptyStateHeader.tsx",
vec![make_api_change(
"EmptyStateHeader",
ApiChangeKind::Constant,
ApiChangeType::Removed,
"EmptyStateHeader component removed",
)],
vec![make_behavioral(
"EmptyStateHeader",
Some(TsCategory::RenderOutput),
"<EmptyStateHeader> element removed from render output",
)],
),
];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let behavioral_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| {
r.labels
.iter()
.any(|l| l.starts_with("change-type=behavioral"))
&& r.rule_id.contains("emptystateheader")
})
.collect();
for rule in &behavioral_rules {
let strat = rule.fix_strategy.as_ref().unwrap();
assert_eq!(
strat.strategy, "Manual",
"Behavioral rule for EmptyStateHeader should be Manual (covered by P0-C), got {}",
strat.strategy
);
}
}
#[test]
fn test_strategy_priority_llm_with_member_mappings_wins() {
let rules = vec![
KonveyorRule {
rule_id: "semver-modal-actions-removed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=removed".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: "actions prop removed from Modal".to_string(),
message: "actions removed".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^actions$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Modal$".to_string()),
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Modal".to_string()),
..Default::default()
}),
},
KonveyorRule {
rule_id: "semver-modal-structural-migration".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=removed".to_string(),
],
effort: 5,
category: "mandatory".to_string(),
description: "Modal decomposed into ModalHeader/ModalBody/ModalFooter".to_string(),
message: "Modal restructured".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^actions$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Modal$".to_string()),
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry {
strategy: "LlmAssisted".to_string(),
member_mappings: vec![MemberMappingEntry {
old_name: "title".to_string(),
new_name: "ModalHeader.title".to_string(),
}],
..Default::default()
}),
},
];
let (consolidated, _) = consolidate_rules(rules);
let merged = consolidated.iter().find(|r| r.rule_id.contains("modal"));
assert!(merged.is_some(), "Expected a merged modal rule");
let strat = merged.unwrap().fix_strategy.as_ref().unwrap();
assert_eq!(
strat.strategy, "LlmAssisted",
"LlmAssisted with member_mappings should win over RemoveProp, got {}",
strat.strategy
);
}
fn make_rule_with_labels(rule_id: &str, labels: Vec<&str>) -> KonveyorRule {
KonveyorRule {
rule_id: rule_id.to_string(),
labels: labels.into_iter().map(|l| l.to_string()).collect(),
effort: 1,
category: "mandatory".to_string(),
description: "test rule".to_string(),
message: "test message".to_string(),
links: Vec::new(),
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^Test$".to_string(),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: None,
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: Some(FixStrategyEntry::new("Manual")),
}
}
#[test]
fn test_consolidation_css_variable_rules_stay_separate() {
let css_prefix_rule = make_rule_with_labels(
"semver-consumer-css-stale-var-pf-v5",
vec!["source=semver-analyzer", "change-type=css-variable"],
);
let css_logical_rule = make_rule_with_labels(
"semver-css-logical-property-renames",
vec![
"source=semver-analyzer",
"change-type=css-variable",
"has-codemod=true",
],
);
let key1 = consolidation_key(&css_prefix_rule);
let key2 = consolidation_key(&css_logical_rule);
assert_ne!(
key1, key2,
"CSS prefix and CSS logical property rules should have different consolidation keys"
);
}
#[test]
fn test_consolidation_sibling_rules_stay_separate() {
let sibling_a = make_rule_with_labels(
"semver-new-sibling-mastheadlogo-in-mastheadbrand",
vec![
"source=semver-analyzer",
"change-type=new-sibling-component",
],
);
let sibling_b = make_rule_with_labels(
"semver-new-sibling-drawerdescription-in-drawer",
vec![
"source=semver-analyzer",
"change-type=new-sibling-component",
],
);
let key_a = consolidation_key(&sibling_a);
let key_b = consolidation_key(&sibling_b);
assert_ne!(
key_a, key_b,
"Different sibling rules should have different consolidation keys"
);
}
#[test]
fn test_consolidation_component_removal_rules_stay_separate() {
let modal_rule = make_rule_with_labels(
"semver-modal-component-import-deprecated",
vec!["source=semver-analyzer", "change-type=component-removal"],
);
let emptystate_rule = make_rule_with_labels(
"semver-emptystateheader-component-import-deprecated",
vec!["source=semver-analyzer", "change-type=component-removal"],
);
let key_modal = consolidation_key(&modal_rule);
let key_empty = consolidation_key(&emptystate_rule);
assert_ne!(
key_modal, key_empty,
"P0-C rules for different components should NOT be consolidated together"
);
}
#[test]
fn test_consolidation_dependency_update_rules_stay_separate() {
let dep_a = make_rule_with_labels(
"semver-dep-update-patternfly-react-core",
vec!["source=semver-analyzer", "change-type=dependency-update"],
);
let dep_b = make_rule_with_labels(
"semver-dep-update-patternfly-react-tokens",
vec!["source=semver-analyzer", "change-type=dependency-update"],
);
let key_a = consolidation_key(&dep_a);
let key_b = consolidation_key(&dep_b);
assert_ne!(
key_a, key_b,
"Dependency update rules for different packages should NOT be consolidated"
);
}
#[test]
fn test_consolidation_regular_api_rules_still_merge() {
let mut rule_a = make_rule_with_labels(
"semver-modal-title-removed",
vec![
"source=semver-analyzer",
"change-type=removed",
"kind=property",
],
);
rule_a.message =
"title was removed\nFile: packages/react-core/src/components/Modal/Modal.d.ts"
.to_string();
let mut rule_b = make_rule_with_labels(
"semver-modal-actions-removed",
vec![
"source=semver-analyzer",
"change-type=removed",
"kind=property",
],
);
rule_b.message =
"actions was removed\nFile: packages/react-core/src/components/Modal/Modal.d.ts"
.to_string();
let key_a = consolidation_key(&rule_a);
let key_b = consolidation_key(&rule_b);
assert_eq!(
key_a, key_b,
"Regular API rules from the same file should still consolidate"
);
}
#[test]
fn test_consolidation_e2e_protected_rules_survive() {
let rules = vec![
{
let mut r = make_rule_with_labels(
"semver-modal-component-import-deprecated",
vec!["source=semver-analyzer", "change-type=component-removal"],
);
r.fix_strategy = Some(FixStrategyEntry::new("LlmAssisted"));
r
},
{
let mut r = make_rule_with_labels(
"semver-emptystateheader-component-import-deprecated",
vec!["source=semver-analyzer", "change-type=component-removal"],
);
r.fix_strategy = Some(FixStrategyEntry::new("LlmAssisted"));
r
},
{
let mut r = make_rule_with_labels(
"semver-css-logical-property-renames",
vec![
"source=semver-analyzer",
"change-type=css-variable",
"has-codemod=true",
],
);
r.fix_strategy = Some(FixStrategyEntry {
strategy: "Rename".to_string(),
mappings: vec![MappingEntry {
from: Some("--PaddingTop".to_string()),
to: Some("--PaddingBlockStart".to_string()),
component: None,
prop: None,
}],
..Default::default()
});
r
},
make_rule_with_labels(
"semver-new-sibling-mastheadlogo-in-mastheadbrand",
vec![
"source=semver-analyzer",
"change-type=new-sibling-component",
],
),
];
let (consolidated, _) = consolidate_rules(rules);
assert_eq!(
consolidated.len(),
4,
"Expected 4 rules after consolidation (all protected), got {}. IDs: {:?}",
consolidated.len(),
consolidated.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
assert!(
consolidated
.iter()
.any(|r| r.rule_id.contains("modal-component")),
"Modal P0-C rule lost in consolidation"
);
assert!(
consolidated
.iter()
.any(|r| r.rule_id.contains("emptystateheader-component")),
"EmptyStateHeader P0-C rule lost in consolidation"
);
assert!(
consolidated
.iter()
.any(|r| r.rule_id.contains("css-logical")),
"CSS logical property rule lost in consolidation"
);
assert!(
consolidated
.iter()
.any(|r| r.rule_id.contains("mastheadlogo")),
"MastheadLogo sibling rule lost in consolidation"
);
let css_rule = consolidated
.iter()
.find(|r| r.rule_id.contains("css-logical"))
.unwrap();
let strat = css_rule.fix_strategy.as_ref().unwrap();
assert_eq!(strat.strategy, "Rename");
assert!(
!strat.mappings.is_empty(),
"CSS rule lost its mappings during consolidation"
);
}
#[test]
fn test_suppress_works_with_individual_p0c_rules() {
let rules = vec![
{
let mut r = make_rule_with_labels(
"semver-modal-component-import-deprecated",
vec!["source=semver-analyzer", "change-type=component-removal"],
);
r.when = KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^Modal$".to_string(),
location: "IMPORT".to_string(),
component: None,
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
};
r.fix_strategy = Some(FixStrategyEntry::new("LlmAssisted"));
r
},
{
let mut r = make_rule_with_labels(
"semver-modal-title-removed",
vec!["source=semver-analyzer", "change-type=removed"],
);
r.fix_strategy = Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Modal".to_string()),
..Default::default()
});
r
},
{
let mut r = make_rule_with_labels(
"semver-modal-actions-removed",
vec!["source=semver-analyzer", "change-type=removed"],
);
r.fix_strategy = Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Modal".to_string()),
..Default::default()
});
r
},
{
let mut r = make_rule_with_labels(
"semver-modalprops-footer-removed",
vec!["source=semver-analyzer", "change-type=removed"],
);
r.fix_strategy = Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("ModalProps".to_string()),
..Default::default()
});
r
},
{
let mut r = make_rule_with_labels(
"semver-card-isflat-removed",
vec!["source=semver-analyzer", "change-type=removed"],
);
r.fix_strategy = Some(FixStrategyEntry {
strategy: "RemoveProp".to_string(),
component: Some("Card".to_string()),
..Default::default()
});
r
},
];
let result = suppress_redundant_prop_rules(rules);
assert_eq!(
result.len(),
2,
"Expected 2 rules after suppression (Modal P0-C + Card), got {}. IDs: {:?}",
result.len(),
result.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
assert!(result
.iter()
.any(|r| r.rule_id.contains("modal-component-import")));
assert!(result.iter().any(|r| r.rule_id.contains("card-isflat")));
}
#[test]
fn test_extract_trailing_suffix() {
assert_eq!(
extract_trailing_suffix("c_table__caption_PaddingTop"),
Some("PaddingTop")
);
assert_eq!(
extract_trailing_suffix("c_nav_PaddingInlineStart"),
Some("PaddingInlineStart")
);
assert_eq!(extract_trailing_suffix("global_Color_100"), None); assert_eq!(extract_trailing_suffix("c_button"), None); assert_eq!(
extract_trailing_suffix("c_about_modal_box__brand_PaddingBlockEnd"),
Some("PaddingBlockEnd")
);
}
fn make_token_object(keys: &[&str]) -> String {
let members: Vec<String> = keys
.iter()
.map(|k| {
format!("[\"{k}\"]: {{ [\"name\"]: \"--pf-test--{k}\"; [\"value\"]: \"1rem\" }}")
})
.collect();
format!("{{ {} }}", members.join("; "))
}
fn make_token_type_changed(symbol: &str, old_keys: &[&str], new_keys: &[&str]) -> ApiChange {
ApiChange {
symbol: symbol.to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::TypeChanged,
before: Some(make_token_object(old_keys)),
after: Some(make_token_object(new_keys)),
description: format!("{} type changed", symbol),
migration_target: None,
removal_disposition: None,
renders_element: None,
}
}
#[test]
fn test_apply_suffix_renames_maps_members() {
let changes = vec![make_file_changes(
"packages/react-tokens/src/c_alert.d.ts",
vec![make_token_type_changed(
"c_alert",
&[
"c_alert__description_PaddingTop",
"c_alert__icon_MarginLeft",
"c_alert_Color",
],
&[
"c_alert__description_PaddingBlockStart",
"c_alert__icon_MarginInlineStart",
"c_alert_Color",
],
)],
vec![],
)];
let report = make_report(changes, vec![]);
let suffix_map: HashMap<String, String> = [
("PaddingTop".to_string(), "PaddingBlockStart".to_string()),
("MarginLeft".to_string(), "MarginInlineStart".to_string()),
]
.into_iter()
.collect();
let renames = apply_suffix_renames(&report, &suffix_map);
assert_eq!(
renames.get("c_alert__description_PaddingTop"),
Some(&"c_alert__description_PaddingBlockStart".to_string()),
);
assert_eq!(
renames.get("c_alert__icon_MarginLeft"),
Some(&"c_alert__icon_MarginInlineStart".to_string()),
);
assert!(!renames.contains_key("c_alert_Color"));
}
#[test]
fn test_apply_suffix_renames_skips_missing_target() {
let changes = vec![make_file_changes(
"packages/react-tokens/src/c_alert.d.ts",
vec![make_token_type_changed(
"c_alert",
&["c_alert__body_PaddingTop", "c_alert_Size", "c_alert_Width"],
&[
"c_alert_Size",
"c_alert_Width",
],
)],
vec![],
)];
let report = make_report(changes, vec![]);
let suffix_map: HashMap<String, String> =
[("PaddingTop".to_string(), "PaddingBlockStart".to_string())]
.into_iter()
.collect();
let renames = apply_suffix_renames(&report, &suffix_map);
assert!(
renames.is_empty(),
"No rename should be produced when target key doesn't exist in added set"
);
}
#[test]
fn test_extract_suffix_inventory() {
let changes = vec![make_file_changes(
"packages/react-tokens/src/c_alert.d.ts",
vec![make_token_type_changed(
"c_alert",
&[
"c_alert__body_PaddingTop",
"c_alert__body_MarginLeft",
"c_alert_Color",
],
&[
"c_alert__body_PaddingBlockStart",
"c_alert__body_MarginInlineStart",
"c_alert_Color",
],
)],
vec![],
)];
let report = make_report(changes, vec![]);
let (removed, added) = extract_suffix_inventory(&report);
assert!(removed.contains("PaddingTop"));
assert!(removed.contains("MarginLeft"));
assert!(!removed.contains("Color"));
assert!(added.contains("PaddingBlockStart"));
assert!(added.contains("MarginInlineStart"));
}
#[test]
fn test_api_rule_message_includes_behavioral_context() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Modal/Modal.tsx",
vec![make_api_change(
"ModalProps.title",
ApiChangeKind::Property,
ApiChangeType::Removed,
"title prop removed from ModalProps",
)],
vec![
make_behavioral(
"Modal",
Some(TsCategory::RenderOutput),
"title prop no longer renders ModalBoxHeader",
),
make_behavioral(
"Modal",
Some(TsCategory::DomStructure),
"ModalBoxCloseButton no longer rendered inside ModalBoxHeader",
),
],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let api_rule = rules
.iter()
.find(|r| r.rule_id.contains("modalprops") && r.rule_id.contains("title"));
assert!(api_rule.is_some(), "Missing API rule for ModalProps.title");
let msg = &api_rule.unwrap().message;
assert!(
msg.contains("Behavioral changes"),
"Missing behavioral changes section"
);
assert!(
msg.contains("title prop no longer renders ModalBoxHeader"),
"Missing behavioral description"
);
}
fn make_pkg_cache(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn test_api_rule_has_from_package() {
let changes = vec![make_file_changes(
"packages/react-core/dist/esm/components/Modal/Modal.d.ts",
vec![make_api_change(
"Modal.title",
ApiChangeKind::Property,
ApiChangeType::Removed,
"Modal.title removed",
)],
vec![],
)];
let pkg_cache = make_pkg_cache(&[("react-core", "@patternfly/react-core")]);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
for rule in &rules {
match &rule.when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert!(
referenced.from.is_some(),
"Rule {} has from=None — should be scoped to @patternfly/react-core",
rule.rule_id
);
assert!(
referenced
.from
.as_ref()
.unwrap()
.contains("@patternfly/react-core"),
"Rule {} has wrong from: {:?}",
rule.rule_id,
referenced.from
);
}
KonveyorCondition::Or { or } => {
for cond in or {
if let KonveyorCondition::FrontendReferenced { referenced } = cond {
assert!(
referenced.from.is_some(),
"Rule {} has Or branch with from=None",
rule.rule_id
);
}
}
}
_ => {} }
}
}
#[test]
fn test_constant_collapse_has_from_package() {
let mut api_changes = Vec::new();
for i in 0..15 {
api_changes.push(ApiChange {
symbol: format!("c_component_token_{}", i),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Removed,
before: None,
after: None,
description: format!("Token {} removed", i),
migration_target: None,
removal_disposition: None,
renders_element: None,
});
}
let changes = vec![make_file_changes(
"packages/react-tokens/dist/esm/tokens.d.ts",
api_changes,
vec![],
)];
let pkg_cache = make_pkg_cache(&[("react-tokens", "@patternfly/react-tokens")]);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let combined = rules.iter().find(|r| r.rule_id.contains("combined"));
assert!(combined.is_some(), "Expected a combined constant rule");
match &combined.unwrap().when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert_eq!(
referenced.from.as_deref(),
Some("@patternfly/react-tokens"),
"Combined constant rule should be scoped to @patternfly/react-tokens"
);
}
_ => panic!("Combined constant rule should use FrontendReferenced condition"),
}
}
#[test]
fn test_new_sibling_rule_has_from_package() {
let changes = vec![
make_file_changes(
"packages/react-core/src/components/Masthead/MastheadBrand.tsx",
vec![make_api_change(
"MastheadBrandProps.component",
ApiChangeKind::Property,
ApiChangeType::Removed,
"component prop removed",
)],
vec![],
),
make_file_changes(
"packages/react-core/src/components/Masthead/examples/Demo.tsx",
vec![],
vec![make_behavioral(
"Demo",
Some(TsCategory::DomStructure),
"<MastheadLogo> element added to render output",
)],
),
];
let pkg_cache = make_pkg_cache(&[("react-core", "@patternfly/react-core")]);
let report = make_report_with_added(
changes,
vec![PathBuf::from(
"packages/react-core/src/components/Masthead/MastheadLogo.tsx",
)],
);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling = rules.iter().find(|r| r.rule_id.contains("new-sibling"));
assert!(sibling.is_some(), "Expected a new-sibling rule");
match &sibling.unwrap().when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert_eq!(
referenced.from.as_deref(),
Some("@patternfly/react-core"),
"Sibling rule should be scoped to @patternfly/react-core"
);
}
_ => panic!("Sibling rule should use FrontendReferenced condition"),
}
}
#[test]
fn test_rules_from_different_packages_have_distinct_from() {
let changes = vec![
make_file_changes(
"packages/react-core/dist/esm/components/Button/Button.d.ts",
vec![make_api_change(
"Button.isActive",
ApiChangeKind::Property,
ApiChangeType::Removed,
"isActive prop removed",
)],
vec![],
),
make_file_changes(
"packages/react-icons/dist/esm/icons/CheckIcon.d.ts",
vec![make_api_change(
"CheckIcon",
ApiChangeKind::Constant,
ApiChangeType::Removed,
"CheckIcon removed",
)],
vec![],
),
];
let pkg_cache = make_pkg_cache(&[
("react-core", "@patternfly/react-core"),
("react-icons", "@patternfly/react-icons"),
]);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let core_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("button"))
.collect();
let icon_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("checkicon"))
.collect();
assert!(!core_rules.is_empty(), "Expected Button rules");
assert!(!icon_rules.is_empty(), "Expected CheckIcon rules");
for rule in &core_rules {
if let KonveyorCondition::FrontendReferenced { referenced } = &rule.when {
assert_eq!(
referenced.from.as_deref(),
Some("@patternfly/react-core"),
"Button rule should be from react-core, got {:?}",
referenced.from
);
}
}
for rule in &icon_rules {
if let KonveyorCondition::FrontendReferenced { referenced } = &rule.when {
assert_eq!(
referenced.from.as_deref(),
Some("@patternfly/react-icons"),
"CheckIcon rule should be from react-icons, got {:?}",
referenced.from
);
}
}
}
#[test]
fn test_deprecated_subpath_uses_anchored_from() {
let changes = vec![make_file_changes(
"packages/react-core/src/deprecated/components/Wizard/Wizard.d.ts",
vec![make_api_change(
"Wizard",
ApiChangeKind::Constant,
ApiChangeType::Removed,
"Deprecated Wizard removed",
)],
vec![],
)];
let pkg_cache = make_pkg_cache(&[("react-core", "@patternfly/react-core")]);
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&pkg_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
let wizard_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.to_lowercase().contains("wizard"))
.collect();
assert!(!wizard_rules.is_empty(), "Expected Wizard rules");
let has_deprecated_from = wizard_rules.iter().any(|r| match &r.when {
KonveyorCondition::FrontendReferenced { referenced } => referenced
.from
.as_ref()
.map_or(false, |f| f.contains("deprecated")),
KonveyorCondition::Or { or } => or.iter().any(|c| {
if let KonveyorCondition::FrontendReferenced { referenced } = c {
referenced
.from
.as_ref()
.map_or(false, |f| f.contains("deprecated"))
} else {
false
}
}),
_ => false,
});
assert!(
has_deprecated_from,
"Deprecated Wizard rules should have from containing 'deprecated'"
);
}
#[test]
fn test_frontend_provider_constant_uses_import() {
let changes = vec![FileChanges {
file: PathBuf::from("src/config.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "DEFAULT_TIMEOUT".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "DEFAULT_TIMEOUT removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let empty_cache = HashMap::new();
let rules = generate_rules(
&report,
"*.ts",
&empty_cache,
&RenamePatterns::empty(),
&HashMap::new(),
);
assert_eq!(rules.len(), 1);
let yaml = serde_yaml::to_string(&rules[0]).unwrap();
assert!(yaml.contains("IMPORT"));
assert!(yaml.contains("^DEFAULT_TIMEOUT$"));
}
#[test]
fn test_p0c_v2_triggers_for_heavily_removed_components() {
let mut api_changes = Vec::new();
for i in 0..10 {
api_changes.push(make_api_change(
&format!("ModalProps.prop{}", i),
ApiChangeKind::Property,
ApiChangeType::Removed,
&format!("prop{} removed", i),
));
}
for i in 10..14 {
api_changes.push(make_api_change(
&format!("ModalProps.prop{}", i),
ApiChangeKind::Property,
ApiChangeType::TypeChanged,
&format!("prop{} type changed", i),
));
}
let changes = vec![make_file_changes(
"packages/react-core/src/components/Modal/Modal.tsx",
api_changes,
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: Some("5.0.0".to_string()),
new_version: Some("6.0.0".to_string()),
type_summaries: vec![ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 14,
removed: 10,
renamed: 0,
type_changed: 4,
added: 0,
removal_ratio: 10.0 / 14.0,
},
removed_members: (0..10)
.map(|i| RemovedMember {
name: format!("prop{}", i),
old_type: None,
removal_disposition: None,
})
.collect(),
type_changes: (10..14)
.map(|i| TypeChange {
property: format!("prop{}", i),
before: None,
after: None,
})
.collect(),
migration_target: None,
behavioral_changes: vec![],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let p0c_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("component-import-deprecated"))
.collect();
assert!(
!p0c_rules.is_empty(),
"Modal with 10/14 removed props (v2 path) should get a P0-C rule"
);
assert_eq!(
p0c_rules[0].fix_strategy.as_ref().unwrap().strategy,
"LlmAssisted"
);
match &p0c_rules[0].when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert_eq!(
referenced.from.as_deref(),
Some("@patternfly/react-core"),
"from should come from pkg.name"
);
}
_ => panic!("Expected FrontendReferenced condition"),
}
assert!(
p0c_rules[0].message.contains("MIGRATION"),
"Message should contain MIGRATION header"
);
}
#[test]
fn test_p0c_v2_skips_minor_prop_removals() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Button/Button.tsx",
vec![
make_api_change(
"ButtonProps.isActive",
ApiChangeKind::Property,
ApiChangeType::Removed,
"isActive prop removed",
),
make_api_change(
"ButtonProps.variant",
ApiChangeKind::Property,
ApiChangeType::TypeChanged,
"variant type changed",
),
],
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Button".to_string(),
definition_name: "ButtonProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 10,
removed: 1,
renamed: 0,
type_changed: 1,
added: 0,
removal_ratio: 0.1,
},
removed_members: vec![RemovedMember {
name: "isActive".to_string(),
old_type: None,
removal_disposition: None,
}],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let p0c_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("component-import-deprecated"))
.collect();
assert!(
p0c_rules.is_empty(),
"Button with 1/10 removed props (v2 path) should NOT get a P0-C rule. Got: {:?}",
p0c_rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn test_new_sibling_v2_detection_from_child_components() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Masthead/MastheadBrand.tsx",
vec![
make_api_change(
"MastheadBrandProps",
ApiChangeKind::Interface,
ApiChangeType::SignatureChanged,
"Now extends HTMLDivElement instead of HTMLAnchorElement",
),
make_api_change(
"MastheadBrandProps.component",
ApiChangeKind::Property,
ApiChangeType::Removed,
"component prop removed",
),
],
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "MastheadBrand".to_string(),
definition_name: "MastheadBrandProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 5,
removed: 1,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 0.2,
},
removed_members: vec![RemovedMember {
name: "component".to_string(),
old_type: None,
removal_disposition: None,
}],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![ChildComponent {
name: "MastheadLogo".to_string(),
status: ChildComponentStatus::Added,
known_members: vec!["href".to_string()],
absorbed_members: vec![],
}],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("new-sibling"))
.collect();
assert_eq!(
sibling_rules.len(),
0,
"Expected 0 new-sibling rules (MastheadLogo has no absorbed_props, should be skipped), got {}",
sibling_rules.len()
);
}
#[test]
fn test_new_sibling_v2_mandatory_with_absorbed_props() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Modal/Modal.tsx",
vec![make_api_change(
"ModalProps.title",
ApiChangeKind::Property,
ApiChangeType::Removed,
"title prop removed",
)],
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 20,
removed: 1,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 0.05,
},
removed_members: vec![RemovedMember {
name: "title".to_string(),
old_type: None,
removal_disposition: None,
}],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![ChildComponent {
name: "ModalHeader".to_string(),
status: ChildComponentStatus::Added,
known_members: vec!["title".to_string()],
absorbed_members: vec!["title".to_string()],
}],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("new-sibling"))
.collect();
assert_eq!(
sibling_rules.len(),
1,
"Expected 1 new-sibling rule (ModalHeader absorbs title), got {}",
sibling_rules.len()
);
assert_eq!(sibling_rules[0].category, "mandatory");
assert!(sibling_rules[0].message.contains("ModalHeader"));
assert!(sibling_rules[0].message.contains("title"));
}
#[test]
fn test_new_sibling_v2_skips_modified_children() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/Modal/Modal.tsx",
vec![make_api_change(
"ModalProps.title",
ApiChangeKind::Property,
ApiChangeType::Removed,
"title prop removed",
)],
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary::default(),
removed_members: vec![],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![ChildComponent {
name: "ModalHeader".to_string(),
status: ChildComponentStatus::Modified, known_members: vec![],
absorbed_members: vec![],
}],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let sibling_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("new-sibling"))
.collect();
assert_eq!(
sibling_rules.len(),
0,
"Modified children should not generate sibling rules"
);
}
#[test]
fn test_p0c_v2_removed_component_status_triggers() {
let changes = vec![make_file_changes(
"packages/react-core/src/components/EmptyState/EmptyStateHeader.tsx",
vec![make_api_change(
"EmptyStateHeader",
ApiChangeKind::Constant,
ApiChangeType::Removed,
"EmptyStateHeader component removed",
)],
vec![],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "EmptyStateHeader".to_string(),
definition_name: "EmptyStateHeaderProps".to_string(),
status: ComponentStatus::Removed,
member_summary: MemberSummary {
total: 5,
removed: 5,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 1.0,
},
removed_members: vec![],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let p0c_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("component-import-deprecated"))
.collect();
assert!(
!p0c_rules.is_empty(),
"Removed component (v2 path) should get a P0-C rule"
);
assert!(
p0c_rules[0].message.contains("MIGRATION"),
"Message should contain MIGRATION header"
);
assert!(
p0c_rules[0].message.contains("was removed"),
"Message should indicate component was removed"
);
}
#[test]
fn test_build_migration_message_v2_with_migration_target() {
let comp = ComponentSummary {
name: "EmptyStateHeader".to_string(),
definition_name: "EmptyStateHeaderProps".to_string(),
status: ComponentStatus::Removed,
member_summary: MemberSummary {
total: 5,
removed: 5,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 1.0,
},
removed_members: vec![],
type_changes: vec![],
migration_target: Some(MigrationTarget {
removed_symbol: "EmptyStateHeaderProps".to_string(),
removed_qualified_name: "EmptyStateHeader.EmptyStateHeaderProps".to_string(),
removed_package: None,
replacement_symbol: "EmptyStateProps".to_string(),
replacement_qualified_name: "EmptyState.EmptyStateProps".to_string(),
replacement_package: None,
matching_members: vec![
MemberMapping {
old_name: "titleText".to_string(),
new_name: "titleText".to_string(),
},
MemberMapping {
old_name: "icon".to_string(),
new_name: "icon".to_string(),
},
],
removed_only_members: vec!["className".to_string()],
overlap_ratio: 0.67,
old_extends: None,
new_extends: None,
}),
behavioral_changes: vec![make_behavioral(
"EmptyStateHeader",
Some(TsCategory::RenderOutput),
"<EmptyStateHeader> element removed from render output",
)],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
};
let msg = build_migration_message_v2(&comp);
assert!(
msg.contains("Replace <EmptyStateHeader>"),
"Should have migration header"
);
assert!(
msg.contains("EmptyState"),
"Should reference replacement component"
);
assert!(msg.contains("titleText"), "Should include property mapping");
assert!(msg.contains("icon"), "Should include icon in mapping");
assert!(
msg.contains("className"),
"Should include removed-only members"
);
assert!(
msg.contains("Behavioral changes"),
"Should include behavioral section"
);
assert!(
msg.contains("element removed from render output"),
"Should include behavioral description"
);
}
#[test]
fn test_build_migration_message_v2_restructured_with_children() {
let comp = ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 14,
removed: 10,
renamed: 0,
type_changed: 4,
added: 0,
removal_ratio: 10.0 / 14.0,
},
removed_members: vec![
RemovedMember {
name: "title".to_string(),
old_type: Some("string".to_string()),
removal_disposition: None,
},
RemovedMember {
name: "actions".to_string(),
old_type: None,
removal_disposition: None,
},
],
type_changes: vec![TypeChange {
property: "variant".to_string(),
before: Some("'default' | 'large'".to_string()),
after: Some("'default' | 'medium' | 'large'".to_string()),
}],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![
ChildComponent {
name: "ModalHeader".to_string(),
status: ChildComponentStatus::Added,
known_members: vec!["title".to_string(), "description".to_string()],
absorbed_members: vec!["title".to_string(), "description".to_string()],
},
ChildComponent {
name: "ModalFooter".to_string(),
status: ChildComponentStatus::Added,
known_members: vec![],
absorbed_members: vec![],
},
],
expected_children: vec![],
source_files: vec![],
};
let msg = build_migration_message_v2(&comp);
assert!(msg.contains("restructured"), "Should mention restructured");
assert!(
msg.contains("10 of 14 props removed"),
"Should show removal counts"
);
assert!(msg.contains("Removed props"), "Should list removed props");
assert!(
msg.contains(" - title"),
"Should include title in removed list"
);
assert!(
msg.contains(" - actions"),
"Should include actions in removed list"
);
assert!(
msg.contains("ModalHeader"),
"Should include ModalHeader child. Msg:\n{msg}"
);
assert!(
msg.contains("ModalFooter"),
"Should include ModalFooter child. Msg:\n{msg}"
);
assert!(
msg.contains("pass as props: title, description"),
"Should show absorbed props mapping for ModalHeader. Msg:\n{msg}"
);
assert!(
msg.contains("Type changes"),
"Should include type changes section"
);
assert!(
msg.contains("variant"),
"Should include variant type change"
);
}
#[test]
fn test_migration_message_with_removal_dispositions() {
use semver_analyzer_core::RemovalDisposition;
let comp = ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 20,
removed: 8,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 8.0 / 20.0,
},
removed_members: vec![
RemovedMember {
name: "title".to_string(),
old_type: Some("string".to_string()),
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalHeader".to_string(),
mechanism: "prop".to_string(),
}),
},
RemovedMember {
name: "actions".to_string(),
old_type: None,
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalFooter".to_string(),
mechanism: "children".to_string(),
}),
},
RemovedMember {
name: "footer".to_string(),
old_type: None,
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalFooter".to_string(),
mechanism: "children".to_string(),
}),
},
RemovedMember {
name: "showClose".to_string(),
old_type: None,
removal_disposition: Some(RemovalDisposition::TrulyRemoved),
},
RemovedMember {
name: "hasNoBodyWrapper".to_string(),
old_type: None,
removal_disposition: Some(RemovalDisposition::MadeAutomatic),
},
],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![
ChildComponent {
name: "ModalHeader".to_string(),
status: ChildComponentStatus::Added,
known_members: vec!["title".to_string(), "description".to_string()],
absorbed_members: vec!["title".to_string()],
},
ChildComponent {
name: "ModalFooter".to_string(),
status: ChildComponentStatus::Added,
known_members: vec!["children".to_string(), "className".to_string()],
absorbed_members: vec!["actions".to_string(), "footer".to_string()],
},
],
expected_children: vec![],
source_files: vec![],
};
let msg = build_migration_message_v2(&comp);
assert!(
msg.contains("pass as props: title"),
"ModalHeader should show 'pass as props' for title. Msg:\n{msg}"
);
assert!(
msg.contains("pass as children: actions, footer"),
"ModalFooter should show 'pass as children' for actions, footer. Msg:\n{msg}"
);
assert!(
msg.contains("safe to delete"),
"Should mention 'safe to delete' for truly removed props. Msg:\n{msg}"
);
assert!(
msg.contains("showClose"),
"showClose should be in safe to delete list. Msg:\n{msg}"
);
assert!(
msg.contains("hasNoBodyWrapper"),
"hasNoBodyWrapper should be in safe to delete list. Msg:\n{msg}"
);
}
#[test]
fn test_p0c_suppression_covers_enriched_props() {
use semver_analyzer_core::RemovalDisposition;
let prop_names = [
"title",
"actions",
"footer",
"description",
"header",
"help",
];
let changes = vec![FileChanges {
file: "packages/react-core/src/components/Modal/Modal.ModalProps.d.ts".into(),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: prop_names
.iter()
.map(|name| ApiChange {
symbol: format!("ModalProps.{}", name),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: None,
after: None,
description: format!("{} removed", name),
migration_target: None,
removal_disposition: None,
renders_element: None,
})
.collect(),
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Modal".to_string(),
definition_name: "ModalProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 20,
removed: 6,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 6.0 / 20.0,
},
removed_members: vec![
RemovedMember {
name: "title".into(),
old_type: None,
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalHeader".into(),
mechanism: "prop".into(),
}),
},
RemovedMember {
name: "actions".into(),
old_type: None,
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalFooter".into(),
mechanism: "children".into(),
}),
},
RemovedMember {
name: "footer".into(),
old_type: None,
removal_disposition: None,
},
RemovedMember {
name: "description".into(),
old_type: None,
removal_disposition: None,
},
RemovedMember {
name: "header".into(),
old_type: None,
removal_disposition: None,
},
RemovedMember {
name: "help".into(),
old_type: None,
removal_disposition: None,
},
],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![
ChildComponent {
name: "ModalHeader".into(),
status: ChildComponentStatus::Added,
known_members: vec!["title".into()],
absorbed_members: vec!["title".into()],
},
ChildComponent {
name: "ModalFooter".into(),
status: ChildComponentStatus::Added,
known_members: vec!["children".into()],
absorbed_members: vec!["actions".into()],
},
],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let p0c_rule = rules
.iter()
.find(|r| r.rule_id.contains("component-import-deprecated"));
assert!(
p0c_rule.is_some(),
"Should generate P0-C rule for Modal. Rule IDs: {:?}",
rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
let prop_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| {
r.rule_id.contains("modalprops-title")
|| r.rule_id.contains("modalprops-actions")
|| r.rule_id.contains("modalprops-footer")
})
.collect();
assert!(
prop_rules.is_empty(),
"Per-prop removal rules should be suppressed by P0-C. Found: {:?}",
prop_rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
let header_rule = rules
.iter()
.find(|r| r.rule_id.contains("new-sibling-modalheader"));
assert!(
header_rule.is_some(),
"Should have enriched new-sibling rule for ModalHeader. IDs: {:?}",
rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
let header_msg = &header_rule.unwrap().message;
assert!(
header_msg.contains("title"),
"ModalHeader rule should mention title. Msg:\n{header_msg}"
);
assert!(
header_msg.contains("<ModalHeader title="),
"ModalHeader rule should show how to pass title as prop. Msg:\n{header_msg}"
);
let footer_rule = rules
.iter()
.find(|r| r.rule_id.contains("new-sibling-modalfooter"));
assert!(
footer_rule.is_some(),
"Should have enriched new-sibling rule for ModalFooter. IDs: {:?}",
rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
let footer_msg = &footer_rule.unwrap().message;
assert!(
footer_msg.contains("actions"),
"ModalFooter rule should mention actions. Msg:\n{footer_msg}"
);
assert!(
footer_msg.contains("pass as children"),
"ModalFooter rule should show 'pass as children' for actions. Msg:\n{footer_msg}"
);
}
#[test]
fn test_is_internal_only_behavioral_skipped() {
let mut internal_beh = make_behavioral(
"ModalBox",
Some(TsCategory::DomStructure),
"Internal wrapper now uses div instead of section",
);
internal_beh.is_internal_only = Some(true);
let changes = vec![make_file_changes(
"packages/react-core/src/components/Modal/ModalBox.tsx",
vec![],
vec![internal_beh],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let modalbox_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("modalbox"))
.collect();
assert!(
modalbox_rules.is_empty(),
"is_internal_only=true should suppress rule. Found: {:?}",
modalbox_rules
.iter()
.map(|r| &r.rule_id)
.collect::<Vec<_>>()
);
}
#[test]
fn test_non_public_behavioral_skipped_when_packages_present() {
let internal_beh = make_behavioral(
"MenuBase",
Some(TsCategory::DomStructure),
"Internal base component changed",
);
let changes = vec![make_file_changes(
"packages/react-core/src/components/Menu/MenuBase.tsx",
vec![],
vec![internal_beh],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Menu".to_string(),
definition_name: "MenuProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 5,
removed: 0,
renamed: 0,
type_changed: 1,
added: 0,
removal_ratio: 0.0,
},
removed_members: vec![],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let menubase_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("menubase"))
.collect();
assert!(
menubase_rules.is_empty(),
"Non-public symbol 'MenuBase' should not produce a rule. Found: {:?}",
menubase_rules
.iter()
.map(|r| &r.rule_id)
.collect::<Vec<_>>()
);
}
#[test]
fn test_suppress_redundant_prop_value_rules() {
let type_changed_rule = KonveyorRule {
rule_id: "semver-label-color-type-changed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=type-changed".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: "Type of color changed".to_string(),
message: "Full union type change".to_string(),
links: vec![],
when: KonveyorCondition::Or {
or: vec![
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^color$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Label$".to_string()),
parent: None,
value: Some("^cyan$".to_string()),
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^color$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Label$".to_string()),
parent: None,
value: Some("^gold$".to_string()),
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
],
},
fix_strategy: None,
};
let prop_value_rule = KonveyorRule {
rule_id: "semver-label-color-prop-value-change".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=prop-value-change".to_string(),
],
effort: 1,
category: "mandatory".to_string(),
description: "Prop value removed".to_string(),
message: "Value cyan removed from color".to_string(),
links: vec![],
when: KonveyorCondition::Or {
or: vec![
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^color$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Label$".to_string()),
parent: None,
value: Some("^cyan$".to_string()),
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^color$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Label$".to_string()),
parent: None,
value: Some("^gold$".to_string()),
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
],
},
fix_strategy: None,
};
let unrelated_rule = KonveyorRule {
rule_id: "semver-button-variant-type-changed".to_string(),
labels: vec![
"source=semver-analyzer".to_string(),
"change-type=type-changed".to_string(),
],
effort: 3,
category: "mandatory".to_string(),
description: "Button variant changed".to_string(),
message: "Variant type narrowed".to_string(),
links: vec![],
when: KonveyorCondition::FrontendReferenced {
referenced: FrontendReferencedFields {
pattern: "^variant$".to_string(),
location: "JSX_PROP".to_string(),
component: Some("^Button$".to_string()),
parent: None,
value: None,
from: Some("@patternfly/react-core".to_string()),
file_pattern: None,
parent_from: None,
not_parent: None,
not_child: None,
},
},
fix_strategy: None,
};
let rules = vec![type_changed_rule, prop_value_rule, unrelated_rule];
let result = suppress_redundant_prop_value_rules(rules);
assert_eq!(
result.len(),
2,
"Should suppress 1 prop-value-change rule, keeping 2. IDs: {:?}",
result.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
assert!(
result
.iter()
.any(|r| r.rule_id == "semver-label-color-type-changed"),
"type-changed rule should survive"
);
assert!(
!result
.iter()
.any(|r| r.rule_id == "semver-label-color-prop-value-change"),
"prop-value-change rule should be suppressed"
);
assert!(
result
.iter()
.any(|r| r.rule_id == "semver-button-variant-type-changed"),
"Unrelated rule should be kept"
);
}
#[test]
fn test_public_behavioral_not_skipped() {
let beh = make_behavioral(
"Menu",
Some(TsCategory::DomStructure),
"Menu now renders nav element",
);
let changes = vec![make_file_changes(
"packages/react-core/src/components/Menu/Menu.tsx",
vec![],
vec![beh],
)];
let mut report = make_report(changes, vec![]);
report.packages = vec![PackageChanges {
name: "@patternfly/react-core".to_string(),
old_version: None,
new_version: None,
type_summaries: vec![ComponentSummary {
name: "Menu".to_string(),
definition_name: "MenuProps".to_string(),
status: ComponentStatus::Modified,
member_summary: MemberSummary {
total: 5,
removed: 0,
renamed: 0,
type_changed: 0,
added: 0,
removal_ratio: 0.0,
},
removed_members: vec![],
type_changes: vec![],
migration_target: None,
behavioral_changes: vec![],
child_components: vec![],
expected_children: vec![],
source_files: vec![],
}],
constants: vec![],
added_exports: vec![],
}];
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let menu_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.rule_id.contains("menu") && r.rule_id.contains("behavioral"))
.collect();
assert!(
!menu_rules.is_empty(),
"Public symbol 'Menu' should produce a behavioral rule. IDs: {:?}",
rules.iter().map(|r| &r.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn test_extract_target_prop_as_pattern() {
assert_eq!(extract_target_prop("Button (as icon prop)"), Some("icon"));
}
#[test]
fn test_extract_target_prop_via_pattern() {
assert_eq!(extract_target_prop("Button (via icon prop)"), Some("icon"));
}
#[test]
fn test_extract_target_prop_with_wrapper() {
assert_eq!(
extract_target_prop("Button (as icon prop via Icon wrapper)"),
Some("icon")
);
}
#[test]
fn test_extract_target_prop_no_parens() {
assert_eq!(extract_target_prop("MastheadMain"), None);
}
#[test]
fn test_extract_target_prop_children_context() {
assert_eq!(extract_target_prop("Button (as children)"), None);
}
#[test]
fn test_extract_target_prop_children_via_wrapper() {
assert_eq!(
extract_target_prop("Button (as children via div wrapper)"),
None
);
}
fn make_composition_changes(
file: &str,
changes: Vec<ContainerChange>,
) -> FileChanges<TypeScript> {
FileChanges {
file: PathBuf::from(file),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![],
breaking_behavioral_changes: vec![],
container_changes: changes,
}
}
#[test]
fn test_children_to_prop_consolidated_into_parent_rule() {
let changes = vec![make_composition_changes(
"packages/react-core/src/components/Button/CloseButton.tsx",
vec![
ContainerChange {
symbol: "TimesIcon".to_string(),
old_container: Some("Button (as children)".to_string()),
new_container: Some("Button (as icon prop)".to_string()),
description: "TimesIcon moved to icon prop".to_string(),
},
ContainerChange {
symbol: "CopyIcon".to_string(),
old_container: Some("Button (as children)".to_string()),
new_container: Some("Button (as icon prop)".to_string()),
description: "CopyIcon moved to icon prop".to_string(),
},
],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let composition_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.labels.iter().any(|l| l == "change-type=composition"))
.collect();
assert_eq!(
composition_rules.len(),
1,
"Expected 1 consolidated rule, got {}: {:?}",
composition_rules.len(),
composition_rules
.iter()
.map(|r| &r.rule_id)
.collect::<Vec<_>>()
);
let rule = composition_rules[0];
assert!(
rule.rule_id.contains("children-to-icon-prop"),
"Rule ID should indicate children→prop consolidation: {}",
rule.rule_id,
);
match &rule.when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert_eq!(
referenced.pattern, "Icon$",
"Should derive common suffix 'Icon' from child names"
);
assert_eq!(referenced.location, "JSX_COMPONENT");
assert_eq!(
referenced.parent,
Some("^Button$".to_string()),
"Should match children of Button"
);
assert!(
referenced.from.is_none(),
"from should be None to catch app-level icons"
);
}
other => panic!("Expected FrontendReferenced, got {:?}", other),
}
assert!(
rule.message.contains("TimesIcon"),
"Message should list TimesIcon"
);
assert!(
rule.message.contains("CopyIcon"),
"Message should list CopyIcon"
);
assert!(
rule.message.contains("icon"),
"Message should mention the icon prop"
);
}
#[test]
fn test_single_children_to_prop_still_consolidated() {
let changes = vec![make_composition_changes(
"packages/react-core/src/components/MenuToggle/MenuToggle.tsx",
vec![ContainerChange {
symbol: "EllipsisVIcon".to_string(),
old_container: Some("MenuToggle (as children)".to_string()),
new_container: Some("MenuToggle (as icon prop)".to_string()),
description: "EllipsisVIcon moved to icon prop".to_string(),
}],
)];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let composition_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.labels.iter().any(|l| l == "change-type=composition"))
.collect();
assert_eq!(composition_rules.len(), 1);
let rule = composition_rules[0];
match &rule.when {
KonveyorCondition::FrontendReferenced { referenced } => {
assert_eq!(referenced.pattern, "^MenuToggle$");
assert_eq!(referenced.location, "IMPORT");
}
other => panic!("Expected FrontendReferenced, got {:?}", other),
}
}
#[test]
fn test_children_to_prop_deduplicates_across_files() {
let changes = vec![
make_composition_changes(
"packages/react-core/src/components/Modal/CloseButton.tsx",
vec![ContainerChange {
symbol: "TimesIcon".to_string(),
old_container: Some("Button (as children)".to_string()),
new_container: Some("Button (as icon prop)".to_string()),
description: "TimesIcon in CloseButton".to_string(),
}],
),
make_composition_changes(
"packages/react-core/src/components/Popover/PopoverClose.tsx",
vec![ContainerChange {
symbol: "TimesIcon".to_string(),
old_container: Some("Button (as children)".to_string()),
new_container: Some("Button (as icon prop)".to_string()),
description: "TimesIcon in PopoverClose".to_string(),
}],
),
];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let composition_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.labels.iter().any(|l| l == "change-type=composition"))
.collect();
assert_eq!(composition_rules.len(), 1);
let times_count = composition_rules[0].message.matches("TimesIcon").count();
assert_eq!(
times_count, 1,
"TimesIcon should be deduplicated in the message, found {} occurrences",
times_count,
);
}
#[test]
fn test_value_diff_tier1_one_to_one_mapping() {
let changes = vec![FileChanges {
file: PathBuf::from("packages/react-core/src/components/Tabs/Tabs.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Tabs.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'default' | 'light300'".to_string()),
after: Some("'default' | 'secondary'".to_string()),
description: "light300 renamed to secondary".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let val_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| {
r.labels
.iter()
.any(|l| l == "change-type=prop-value-change")
})
.collect();
assert_eq!(val_rules.len(), 1, "Expected 1 per-value rule");
let rule = val_rules[0];
assert!(
rule.message.contains("Replace with 'secondary'"),
"Tier 1 rule should have explicit mapping. Message: {}",
rule.message,
);
assert!(
!rule.message.contains("one of the new values"),
"Tier 1 rule should NOT use generic 'one of' phrasing",
);
let strat = rule.fix_strategy.as_ref().unwrap();
assert_eq!(strat.mappings.len(), 1);
assert_eq!(strat.mappings[0].from.as_deref(), Some("light300"));
assert_eq!(strat.mappings[0].to.as_deref(), Some("secondary"));
}
#[test]
fn test_value_diff_tier3_lists_new_values() {
let changes = vec![FileChanges {
file: PathBuf::from("packages/react-core/src/components/Toolbar/ToolbarGroup.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "ToolbarGroup.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'button-group' | 'filter-group' | 'icon-button-group'".to_string()),
after: Some("'action-group' | 'action-group-plain' | 'filter-group'".to_string()),
description: "variant values changed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let val_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| {
r.labels
.iter()
.any(|l| l == "change-type=prop-value-change")
})
.collect();
assert_eq!(val_rules.len(), 2, "Expected 2 per-value rules");
for rule in &val_rules {
assert!(
rule.message.contains("action-group")
&& rule.message.contains("action-group-plain"),
"Tier 3 rule should list available replacements. Message: {}",
rule.message,
);
assert!(
rule.message.contains("one of the new values"),
"Tier 3 rule should use 'one of the new values' phrasing",
);
}
}
#[test]
fn test_value_diff_no_added_values() {
let changes = vec![FileChanges {
file: PathBuf::from("packages/react-core/src/components/Page/PageSection.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "PageSection.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'dark' | 'darker' | 'default' | 'light'".to_string()),
after: Some("'default'".to_string()),
description: "variant simplified".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let val_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| {
r.labels
.iter()
.any(|l| l == "change-type=prop-value-change")
})
.collect();
assert_eq!(val_rules.len(), 3);
for rule in &val_rules {
assert!(
rule.message.contains("no direct replacement"),
"Rule with no added values should say no replacement. Message: {}",
rule.message,
);
}
}
#[test]
fn test_extract_added_union_values() {
let change = ApiChange {
symbol: "Foo.bar".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'a' | 'b' | 'c'".to_string()),
after: Some("'b' | 'c' | 'd' | 'e'".to_string()),
description: "values changed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
};
let removed = extract_removed_union_values(&change);
let added = extract_added_union_values(&change);
assert_eq!(removed, vec!["a"]);
assert_eq!(added, vec!["d", "e"]);
}
#[test]
fn test_type_changed_rule_tier1_message_has_direct_mapping() {
let changes = vec![FileChanges {
file: PathBuf::from("packages/react-core/src/components/Tabs/Tabs.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "Tabs.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'default' | 'light300'".to_string()),
after: Some("'default' | 'secondary'".to_string()),
description: "variant values changed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let tc_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.labels.iter().any(|l| l == "change-type=type-changed"))
.collect();
assert!(!tc_rules.is_empty(), "Should have a type-changed rule");
let rule = tc_rules[0];
assert!(
rule.message.contains("'light300' → 'secondary'"),
"Type-changed message should contain Tier 1 mapping. Message:\n{}",
rule.message,
);
assert!(
rule.message.contains("direct replacement"),
"Tier 1 should indicate direct replacement",
);
let strat = rule.fix_strategy.as_ref().unwrap();
assert!(
strat
.mappings
.iter()
.any(|m| m.from.as_deref() == Some("light300")
&& m.to.as_deref() == Some("secondary")),
"Fix strategy should contain value mapping. Mappings: {:?}",
strat.mappings,
);
}
#[test]
fn test_type_changed_rule_tier3_message_lists_values() {
let changes = vec![FileChanges {
file: PathBuf::from("packages/react-core/src/components/Toolbar/ToolbarGroup.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "ToolbarGroup.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::TypeChanged,
before: Some("'button-group' | 'filter-group' | 'icon-button-group'".to_string()),
after: Some("'action-group' | 'action-group-plain' | 'filter-group'".to_string()),
description: "variant values changed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
}];
let report = make_report(changes, vec![]);
let rules = generate_rules(
&report,
"*.ts",
&HashMap::new(),
&RenamePatterns::empty(),
&HashMap::new(),
);
let tc_rules: Vec<&KonveyorRule> = rules
.iter()
.filter(|r| r.labels.iter().any(|l| l == "change-type=type-changed"))
.collect();
assert!(!tc_rules.is_empty());
let rule = tc_rules[0];
assert!(
rule.message.contains("Removed values:")
&& rule.message.contains("'button-group'")
&& rule.message.contains("'icon-button-group'"),
"Should list removed values. Message:\n{}",
rule.message,
);
assert!(
rule.message.contains("New values available:")
&& rule.message.contains("'action-group'")
&& rule.message.contains("'action-group-plain'"),
"Should list new values. Message:\n{}",
rule.message,
);
}
fn empty_rename_patterns() -> RenamePatterns {
RenamePatterns::empty()
}
#[test]
fn test_removed_prop_with_replaced_by_prop_becomes_rename() {
use semver_analyzer_core::RemovalDisposition;
let change = ApiChange {
symbol: "ToolbarFilterProps.chips".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some("property: chips: (ToolbarChip | string)[]".to_string()),
after: None,
description: "chips removed".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::ReplacedByMember {
new_member: "labels".to_string(),
}),
renders_element: None,
};
let rename_patterns = empty_rename_patterns();
let member_renames = HashMap::new();
let strat = api_change_to_strategy(&change, &rename_patterns, &member_renames, "test.ts");
let strat = strat.expect("should produce a strategy");
assert_eq!(
strat.strategy, "Rename",
"ReplacedByMember should produce Rename, not RemoveProp"
);
assert_eq!(strat.from.as_deref(), Some("chips"));
assert_eq!(strat.to.as_deref(), Some("labels"));
}
#[test]
fn test_removed_prop_with_replaced_by_prop_dotted_symbol() {
use semver_analyzer_core::RemovalDisposition;
let change = ApiChange {
symbol: "ToolbarFilterProps.deleteChip".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some("property: deleteChip: (category: string) => void".to_string()),
after: None,
description: "deleteChip removed".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::ReplacedByMember {
new_member: "deleteLabel".to_string(),
}),
renders_element: None,
};
let rename_patterns = empty_rename_patterns();
let member_renames = HashMap::new();
let strat = api_change_to_strategy(&change, &rename_patterns, &member_renames, "test.ts");
let strat = strat.expect("should produce a strategy");
assert_eq!(strat.strategy, "Rename");
assert_eq!(strat.from.as_deref(), Some("deleteChip"));
assert_eq!(strat.to.as_deref(), Some("deleteLabel"));
}
#[test]
fn test_removed_prop_without_disposition_stays_remove_prop() {
let change = ApiChange {
symbol: "ToolbarFilterProps.expandableChipContainerRef".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some(
"property: expandableChipContainerRef: RefObject<HTMLDivElement>".to_string(),
),
after: None,
description: "expandableChipContainerRef removed".to_string(),
migration_target: None,
removal_disposition: None,
renders_element: None,
};
let rename_patterns = empty_rename_patterns();
let member_renames = HashMap::new();
let strat = api_change_to_strategy(&change, &rename_patterns, &member_renames, "test.ts");
let strat = strat.expect("should produce a strategy");
assert_eq!(
strat.strategy, "RemoveProp",
"No disposition should stay RemoveProp"
);
}
#[test]
fn test_removed_prop_with_truly_removed_stays_remove_prop() {
use semver_analyzer_core::RemovalDisposition;
let change = ApiChange {
symbol: "ModalProps.showClose".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some("property: showClose: boolean".to_string()),
after: None,
description: "showClose removed".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::TrulyRemoved),
renders_element: None,
};
let rename_patterns = empty_rename_patterns();
let member_renames = HashMap::new();
let strat = api_change_to_strategy(&change, &rename_patterns, &member_renames, "test.ts");
let strat = strat.expect("should produce a strategy");
assert_eq!(
strat.strategy, "RemoveProp",
"TrulyRemoved should stay RemoveProp"
);
}
#[test]
fn test_removed_prop_with_moved_to_child_stays_remove_prop() {
use semver_analyzer_core::RemovalDisposition;
let change = ApiChange {
symbol: "ModalProps.title".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some("property: title: string".to_string()),
after: None,
description: "title moved to ModalHeader".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::MovedToRelatedType {
target_type: "ModalHeader".to_string(),
mechanism: "prop".to_string(),
}),
renders_element: None,
};
let rename_patterns = empty_rename_patterns();
let member_renames = HashMap::new();
let strat = api_change_to_strategy(&change, &rename_patterns, &member_renames, "test.ts");
let strat = strat.expect("should produce a strategy");
assert_eq!(
strat.strategy, "RemoveProp",
"MovedToRelatedType should stay RemoveProp (handled by hierarchy rule)"
);
}
#[test]
fn test_extract_css_var_prefix_versioned() {
assert_eq!(
extract_css_var_prefix("--pf-v5-c-button--Color"),
Some("--pf-v5-c-".to_string())
);
}
#[test]
fn test_extract_css_var_prefix_global() {
assert_eq!(
extract_css_var_prefix("--pf-v5-global--spacer--sm"),
Some("--pf-v5-global--".to_string())
);
}
#[test]
fn test_extract_css_var_prefix_theming() {
assert_eq!(
extract_css_var_prefix("--pf-t--global--spacer--sm"),
Some("--pf-t--global--".to_string())
);
}
#[test]
fn test_extract_css_var_prefix_v6_component() {
assert_eq!(
extract_css_var_prefix("--pf-v6-c-alert--BoxShadow"),
Some("--pf-v6-c-".to_string())
);
}
#[test]
fn test_extract_css_var_name_from_type_annotation() {
let annotation = r#"{ ["name"]: "--pf-v5-global--spacer--sm"; ["value"]: "0.5rem" }"#;
assert_eq!(
extract_css_var_name(annotation),
Some("--pf-v5-global--spacer--sm".to_string())
);
}
#[test]
fn test_detect_css_prefix_changes_filters_noise() {
let mut report = make_report(vec![], vec![]);
report.changes.push(FileChanges {
file: PathBuf::from("tokens/c_button_Color.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "c_button_Color".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::TypeChanged,
before: Some(
"constant: c_button_Color: { [\"name\"]: \"--pf-v5-c-button--Color\"; [\"value\"]: \"#151515\" }"
.to_string(),
),
after: Some(
"constant: c_button_Color: { [\"name\"]: \"--pf-v6-c-button--Color\"; [\"value\"]: \"#151515\" }"
.to_string(),
),
description: String::new(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
});
report.changes.push(FileChanges {
file: PathBuf::from("tokens/global_spacer_sm.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "global_spacer_sm".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Renamed,
before: Some(
"constant: global_spacer_sm: { [\"name\"]: \"--pf-v5-global--spacer--sm\"; [\"value\"]: \"0.5rem\" }"
.to_string(),
),
after: Some(
"variable: t_global_spacer_sm: { [\"name\"]: \"--pf-t--global--spacer--sm\"; [\"value\"]: \"0.5rem\" }"
.to_string(),
),
description: String::new(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
});
report.changes.push(FileChanges {
file: PathBuf::from("tokens/c_noise.d.ts"),
status: FileStatus::Modified,
renamed_from: None,
breaking_api_changes: vec![ApiChange {
symbol: "c_noise".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Constant,
change: ApiChangeType::Renamed,
before: Some(
"constant: c_noise: { [\"name\"]: \"--pf-v5-c-noise--val\"; [\"value\"]: \"1px\" }"
.to_string(),
),
after: Some(
"constant: t_global_something: { [\"name\"]: \"--pf-t--global--something\"; [\"value\"]: \"1px\" }"
.to_string(),
),
description: String::new(),
migration_target: None,
removal_disposition: None,
renders_element: None,
}],
breaking_behavioral_changes: vec![],
container_changes: vec![],
});
let prefixes = detect_css_prefix_changes(&report);
let old_prefixes: Vec<&str> = prefixes.iter().map(|(_, old, _)| old.as_str()).collect();
let new_prefixes: Vec<&str> = prefixes.iter().map(|(_, _, new)| new.as_str()).collect();
assert!(
old_prefixes.contains(&"--pf-v5-c-"),
"Should detect --pf-v5-c- prefix. Got: {:?}",
prefixes
);
assert!(
old_prefixes.contains(&"--pf-v5-global--"),
"Should detect --pf-v5-global-- prefix. Got: {:?}",
prefixes
);
assert!(
new_prefixes.contains(&"--pf-v6-c-"),
"Should map --pf-v5-c- to --pf-v6-c-. Got: {:?}",
prefixes
);
assert!(
new_prefixes.contains(&"--pf-t--global--"),
"Should map --pf-v5-global-- to --pf-t--global--. Got: {:?}",
prefixes
);
let has_noise = prefixes
.iter()
.any(|(_, old, new)| old == "--pf-v5-c-" && new == "--pf-t--global--");
assert!(
!has_noise,
"Should filter out noise pair --pf-v5-c- → --pf-t--global--"
);
}
#[test]
fn test_enum_value_removal_is_not_codemod() {
let change = ApiChange {
symbol: "PageSection.variant.variant".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: Some("'light'".to_string()),
after: None,
description: "Value 'light' removed".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::ReplacedByMember {
new_member: "secondary".to_string(),
}),
renders_element: None,
};
let is_enum_value = change.change == ApiChangeType::Removed
&& change
.before
.as_deref()
.is_some_and(|b| b.starts_with('\'') && b.ends_with('\''));
assert!(
is_enum_value,
"Should detect enum value removal from quoted before"
);
assert!(
!is_enum_value || true, "Enum value removals should not be codemod"
);
}
#[test]
fn test_prop_rename_is_codemod() {
let change = ApiChange {
symbol: "ToolbarGroup.spaceItems".to_string(),
qualified_name: String::new(),
kind: ApiChangeKind::Property,
change: ApiChangeType::Removed,
before: None,
after: None,
description: "spaceItems removed".to_string(),
migration_target: None,
removal_disposition: Some(RemovalDisposition::ReplacedByMember {
new_member: "gap".to_string(),
}),
renders_element: None,
};
let is_enum_value = change.change == ApiChangeType::Removed
&& change
.before
.as_deref()
.is_some_and(|b| b.starts_with('\'') && b.ends_with('\''));
assert!(
!is_enum_value,
"Regular prop rename should NOT be detected as enum value"
);
}
}